Response Sanitization
Refuse sensitive outbound content before dispatch and redact secrets and personal data from tool responses before the agent reads them.
On this page
An agent can message customers, call payment APIs, and read tool output while secrets and personal data stay out of what it sends and what it reads back.
ContentReviewGuard and ResponseSanitizationGuard check a call's outbound arguments and refuse it before dispatch. OutputSanitizer, which the default runtime's SanitizerHook runs after the tool returns, rewrites the result before the agent receives it.
Request checks refuse before dispatch; response checks rewrite what returns
The stage decides what a content check can prevent. The two guards sit in the pre-invocation GuardPipeline; the sanitizer runs as a post-invocation hook.
after the response: report.md exists in 2 of 3 calls; one response delivered.
write_report call; spacing has no duration meaning. An output check acts on the returned value, so blocking it cannot undo a write that already happened.The mediated callA refusal at input means the tool never runs, so a customer's email in a Slack message is stopped before Slack sees it. The output stage works on the returned value: it can redact or withhold what the agent reads, and an effect the tool already produced stays in place. Run a check at input whenever the content is in the request.
Read more in Guard Pipelines, where post-invocation hooks run beside pre-invocation guards.
The output sanitizer replaces each finding with its category's strategy
OutputSanitizer runs fifteen pattern detectors and an entropy check over every string in a tool result, assigns each finding a redaction strategy, and returns the rewritten value with its findings. Under the default configuration, each field of this result, a row the orders server returned for harbor-goods, meets a different strategy:
{
"owner": "priya@harbor-goods.example",
"host": "10.0.4.7",
"key": "AKIAIOSFODNN7EXAMPLE"
}{
"owner": "pr***le",
"host": "[REDACTED:internal]",
"key": "****"
}The email is personal data, so Partial keeps two characters at each end. The private address is internal and becomes a type label, and the AWS access key ID is a secret and is masked. SanitizerHook returns the rewritten value and records guard evidence that it redacted the result; the call itself stays allowed.
A detector that recommends Drop, Fingerprint, or Tokenize overrides its category default. Where findings overlap, the sanitizer merges their extent and applies the stronger strategy, in the order Drop, Mask, Tokenize, Fingerprint, TypeLabel, Partial, Keep. Detector confidence values are configured scores, not measured probabilities that a match is sensitive.
Size limit and the truncation marker
The scan cap, max_input_bytes, applies to each scanned string and defaults to 1,000,000. Text past it is cut at a character boundary, and the returned text carries [TRUNCATED_UNSCANNED_OUTPUT]. The marker describes omitted data and carries no finding about the omitted suffix, so set the host's whole-response size limit separately.
ResponseSanitizationGuard refuses sensitive arguments and fails closed on its own patterns
ResponseSanitizationGuard, guard name response-sanitization, implements chio_kernel::Guard and also exposes scan_response, which a post-invocation hook calls to redact or block the response itself. Seven built-in patterns, each with a sensitivity level, cover both directions.
- Before dispatch.
evaluatescans the request arguments as a JSON string withscan, which skips patterns belowmin_level. Any remaining finding returnsGuardDecision::deny; otherwiseGuardDecision::allow. SanitizationAction::Block. A match at or abovemin_levelmakes the call path treat the response as denied, andscan_responsereturnsScanResult::Blockedwith the findings.SanitizationAction::Redact. Each match is replaced with its pattern'sredactionstring, andScanResult::Redactedcarries the count and the original findings as evidence.
If any built-in pattern fails to compile, default_patterns() discards the partial set and installs one catch-all, redaction_unavailable_fail_closed, which matches every response at level High. The guard then redacts or blocks everything, because a reduced set would let the dropped category through. A custom pattern built with build_pattern returns None for an invalid regex; reject that configuration before installing the guard.
Content review refuses outbound PII and banned words before a SaaS call
ContentReviewGuard, guard name content-review, reviews outbound content for messaging, email, and payment tools such as Slack, SendGrid, Twilio, and Stripe. It applies to calls classified as ToolAction::ExternalApiCall, and the host registers it explicitly.
- PII. Personal data in a message body or email text denies the call, and the detected categories are recorded as tracing evidence.
- Tone. A hit on profanity or the configured
banned_wordslist denies the call. A message with both PII and profanity yields one deny and logs both categories. - Money. A payment whose amount meets the matched grant's
Constraint::RequireApprovalAbovethreshold yieldsVerdict::PendingApprovalwhen the amount can be evaluated. The kernel rejects a pending result from a registered guard, so collect sign-off through the hosted approval integration.
Detected PII and profanity always deny; the guard has no advisory mode. To log a match without refusing, run a custom guard or a post-invocation hook. Rules come from per_service when the service has an entry and from default_rules otherwise.
Content review joins the pipeline and the sanitizer joins the hooks
Add content review to the pre-invocation pipeline and append the output engine as a post-invocation hook, leaving existing hooks in place:
use chio_guards::{ContentReviewGuard, GuardPipeline, SanitizerHook};
// Pre-invocation pipeline: review outbound SaaS calls.
let mut pipeline = GuardPipeline::default_pipeline();
pipeline.add(Box::new(ContentReviewGuard::new()));
kernel.add_guard(Box::new(pipeline));
// Append the full output engine without replacing existing hooks.
kernel.add_post_invocation_hook(Box::new(SanitizerHook::new()));SecretLeakGuard blocks secrets in writes to the filesystem, and OutputSanitizer redacts them in returned tool data. Their detector catalogs overlap on AWS keys, GitHub tokens, GCP service-account JSON, and PEM keys, so running both covers filesystem writes and tool responses.
Read more in Filesystem Guards, with the write-side detector list.
Sanitizer configuration, detectors, and limits
Try pii_email, Partial, or max_scan_bytes.
ResponseSanitizationGuard types
The guard is defined in crates/guards/chio-guards/src/response_sanitization/simple.rs. The module splits into submodules under response_sanitization/ (simple.rs, sanitizer.rs, types.rs, overlap.rs, and others), and the top-level response_sanitization.rs re-exports them.
/// Classification level for a detected pattern.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SensitivityLevel {
// ... one doc comment per variant
Low,
Medium,
High,
}
/// A named pattern that matches sensitive data.
#[derive(Debug, Clone)]
pub struct SensitivePattern {
// ... one doc comment per field
pub name: String,
regex: Regex,
pub level: SensitivityLevel,
pub redaction: String,
}
/// Action to take when sensitive data is detected by the simple guard.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SanitizationAction {
Block,
Redact,
}
// ... default_patterns()
/// Guard that scans responses for PII/PHI patterns and redacts or blocks them.
pub struct ResponseSanitizationGuard {
patterns: Vec<SensitivePattern>,
min_level: SensitivityLevel,
action: SanitizationAction,
}Default patterns
Quoted from default_patterns() at response_sanitization/simple.rs:44. The seven specs live in a [(&str, &str, SensitivityLevel, &str); 7] array and compile in a loop through compile_required_pattern. On any compile failure the function returns the single catch-all pattern: name redaction_unavailable_fail_closed, regex [\s\S]+, level High, redaction [REDACTED].
let specs: [(&str, &str, SensitivityLevel, &str); 7] = [
(
r"\b\d{3}-\d{2}-\d{4}\b",
"SSN",
SensitivityLevel::High,
"[SSN REDACTED]",
),
(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
"email",
SensitivityLevel::Medium,
"[EMAIL REDACTED]",
),
(
r"\b(?:\(\d{3}\)\s*|\d{3}[-.])\d{3}[-.]?\d{4}\b",
"phone",
SensitivityLevel::Low,
"[PHONE REDACTED]",
),
(
r"\b(?:\d{4}[-\s]?){3}\d{4}\b",
"credit-card",
SensitivityLevel::High,
"[CARD REDACTED]",
),
(
r"\b(?:\d{2}/\d{2}/\d{4}|\d{4}-\d{2}-\d{2})\b",
"date-of-birth",
SensitivityLevel::Low,
"[DATE REDACTED]",
),
(
r"\bMRN[:\s#]*\d{6,12}\b",
"MRN",
SensitivityLevel::High,
"[MRN REDACTED]",
),
(
r"\b[A-Z]\d{2}(?:\.\d{1,4})?\b",
"ICD-10",
SensitivityLevel::Medium,
"[ICD REDACTED]",
),
];The redact path, ResponseSanitizationGuard::redact at response_sanitization/simple.rs:177, runs the patterns in declaration order with Regex::replace_all per pattern. Patterns below min_level are skipped through level_ord (Low=0, Medium=1, High=2). The Guard impl at response_sanitization/simple.rs:235-249 returns Ok(GuardDecision::deny(Vec::new())) on a finding and Ok(GuardDecision::allow()) otherwise. The MRN, DOB, ICD-10, and US-phone detectors of this simple API are outside the full sanitizer's default catalog.
OutputSanitizer configuration
The sanitizer lives in response_sanitization/sanitizer.rs, with configuration types in types.rs and detectors in detectors.rs. It is a stateless engine configured by OutputSanitizerConfig, plus a token vault for the Tokenize strategy. Hosts can call it directly; it has no Guard impl, and its kernel integration is the post-invocation SanitizerHook.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct OutputSanitizerConfig {
pub categories: CategoryConfig,
pub redaction_strategies: HashMap<SensitiveCategory, RedactionStrategy>,
pub entropy: EntropyConfig,
pub allowlist: AllowlistConfig,
pub denylist: DenylistConfig,
pub max_input_bytes: usize,
pub include_findings: bool,
}Defaults:
categories.secrets,categories.pii,categories.internal:trueredaction_strategies[Secret]:Mask;redaction_strategies[Pii]:Partial;redaction_strategies[Internal]:TypeLabelentropy.enabled:true;entropy.threshold:4.5bits per character;entropy.min_token_len:16max_input_bytes:1_000_000;include_findings:true
Detector catalog
From compiled_patterns(). Each detector carries a stable ID, a category, a confidence, and a recommended redaction strategy. Detectors with validators run the regex first and then the validator on the matched text: the Luhn check rejects all-same-digit sequences and any number outside 13 to 19 digits, and the SSN validators reject area 0, 666, and 900 to 999, and a zero group or zero serial.
| Detector ID | Category | Confidence | Recommended | Validator |
|---|---|---|---|---|
secret_ | Secret | 0.99 | Mask | none |
secret_ | Secret | 0.9 | Mask | none |
secret_ | Secret | 0.99 | Mask | none |
secret_ | Secret | 0.99 | Mask | none |
secret_ | Secret | 0.95 | Mask | none |
secret_ | Secret | 0.97 | Drop | none |
secret_ | Secret | 0.99 | Mask | none |
secret_jwt | Secret | 0.85 | Mask | none |
secret_ | Secret | 0.85 | Mask | none |
secret_ | Secret | 0.7 | Mask | none |
pii_ssn | PII | 0.9 | Mask | is_ |
pii_ssn_compact | PII | 0.7 | Mask | is_ |
pii_credit_card | PII | 0.9 | Mask | Luhn check |
pii_email | PII | 0.95 | Partial | none |
internal_ | Internal | 0.8 | TypeLabel | none |
Redaction strategies
Mask: replaces the match with the constant****.Fingerprint: replaces it with[FP:…]holding the first 8 bytes of its SHA-256 as 16 hex characters, so the same value correlates across calls without exposure.Drop: replaces it with empty text. A JSON string field that consists entirely of the match becomesnull.Tokenize: replaces it with[TOKEN:…], an opaque ID whose mapping is recorded in a sharedTokenVaultfor reversible workflows.Partial: keeps the first 2 and last 2 characters with***between them; strings of 4 characters or fewer become all asterisks.TypeLabel: replaces it with a typed label,[REDACTED:email]for a secret or PII data type and[REDACTED:internal]for internal data.Keep: leaves the output unchanged and records the finding.
Entropy detector
EntropyConfig at response_sanitization/types.rs:104, with its defaults:
pub struct EntropyConfig {
pub enabled: bool,
pub threshold: f64,
pub min_token_len: usize,
}
impl Default for EntropyConfig {
fn default() -> Self {
Self {
enabled: true,
threshold: 4.5,
min_token_len: 16,
}
}
}The entropy loop in response_sanitization/sanitizer.rs (lines 286-312) skips tokens shorter than min_token_len with if token.len() < self.config.entropy.min_token_len and tokens whose Shannon entropy is below self.config.entropy.threshold. Each remaining token becomes a finding with category Secret and confidence 0.6. Candidate tokens are base64-shaped:
fn is_candidate_secret_token(token: &str) -> bool {
token.bytes().all(|b|
b.is_ascii_alphanumeric()
|| matches!(b, b'+' | b'/' | b'=' | b'-' | b'_'))
}Allowlist and denylist
AllowlistConfig { exact, patterns }: known-safe values that stay unredacted, such as test fixtures and sample tokens. Both lists are checked.DenylistConfig { exact, patterns }: values that are always redacted, whatever the category or detector.- A pattern that fails to compile raises
OutputSanitizerConfigError::InvalidPatternwith the list name,"allowlist"or"denylist".
Overlap resolution
resolve_overlaps at response_sanitization/overlap.rs:58 merges findings whose byte ranges overlap and keeps the highest-ranked strategy among them; strategy_rank is at line 10. A higher number wins within an overlapping group:
fn strategy_rank(s: &RedactionStrategy) -> u8 {
match s {
RedactionStrategy::Keep => 0,
RedactionStrategy::Partial => 1,
RedactionStrategy::TypeLabel => 2,
RedactionStrategy::Fingerprint => 3,
RedactionStrategy::Tokenize => 4,
RedactionStrategy::Mask => 5,
RedactionStrategy::Drop => 6,
}
}The merge sorts spans by start ascending, then by end descending, so the longest match at a start position comes first. It folds each span into the previous one when they overlap: the merged end becomes last.0.end.max(current.0.end), and the strategy changes only when strategy_rank(current) > strategy_rank(last). Rank applies even when the findings have different extents.
spans.sort_by(|a, b| {
a.0.start
.cmp(&b.0.start)
.then_with(|| b.0.end.cmp(&a.0.end))
});
let mut merged: Vec<ResolvedSpan> = Vec::new();
for current in spans {
if let Some(last) = merged.last_mut() {
if current.0.start < last.0.end {
let new_end = last.0.end.max(current.0.end);
last.0.end = new_end;
if strategy_rank(¤t.1) > strategy_rank(&last.1) {
last.1 = current.1;
last.2 = current.2;
last.3 = current.3;
last.4 = current.4;
}
continue;
}
}
merged.push(current);
}Detector recommendations of Drop, Fingerprint, or Tokenize replace the category default before the merge runs (redaction_strategy_for_finding, overlap.rs:24-42), so a finding a detector flags as Drop is dropped even where its category default is Mask.
ContentReviewGuard configuration
Defined in crates/guards/chio-guards/src/content_review.rs.
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ContentReviewRules {
pub detect_pii: bool,
pub detect_profanity: bool,
pub banned_words: Vec<String>,
pub extra_patterns: Vec<String>,
pub max_scan_bytes: usize,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ContentReviewConfig {
pub enabled: bool,
pub default_rules: ContentReviewRules,
pub per_service: HashMap<String, ContentReviewRules>,
}ContentReviewGuard defaults
enabled,default_rules.detect_pii,default_rules.detect_profanity:truedefault_rules.banned_words,default_rules.extra_patterns,per_service: emptydefault_rules.max_scan_bytes:65536(64 KiB)
Pattern safety limits
From CompiledRules::compile. A pattern that fails a check raises ContentReviewError::UnsafePattern or ContentReviewError::InvalidPattern at policy load.
- At most
64extra_patternsper rule set. - At most
512characters per pattern. - A complexity score of at most
96, a heuristic over alternation, quantifier, and group counts. - Regex builder limits
EXTRA_PATTERN_REGEX_SIZE_LIMIT = 1 << 20andEXTRA_PATTERN_DFA_SIZE_LIMIT = 1 << 20.
ContentReviewGuard failure behavior
- An invalid extra-pattern regex raises
ContentReviewError::InvalidPattern. - A pattern over the safety limits raises
ContentReviewError::UnsafePattern. - A service with no
per_serviceentry usesdefault_rules. - Actions other than
ExternalApiCallpass withVerdict::Allow.