Chio/Docs
LOGIN · JOIN

Response Sanitization

Refuse sensitive outbound content before dispatch and redact secrets and personal data from tool responses before the agent reads them.

Concept chio@5f63e6e

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.

Three calls on one ordinal time axis: where each stops and whether report.md existsinput checksoutput checkAllowede(t)report.mddelivered
e(t)=1[ t≥t2 ]delivered=1\begin{aligned} \htmlClass{role-seal}{e}(t) &= \mathbf{1}[\,t \ge t_2\,] \\ \htmlClass{role-call}{\text{delivered}} &= 1 \end{aligned}
dispatched and delivered
Denied at inpute(t)
e(t)=0delivered=0\begin{aligned} \htmlClass{role-seal}{e}(t) &= 0 \\ \htmlClass{role-call}{\text{delivered}} &= \htmlClass{role-deny}{0} \end{aligned}
not dispatched; no effect
Output blockede(t)report.md
e(t)=1[ t≥t2 ]delivered=0\begin{aligned} \htmlClass{role-seal}{e}(t) &= \mathbf{1}[\,t \ge t_2\,] \\ \htmlClass{role-call}{\text{delivered}} &= \htmlClass{role-deny}{0} \end{aligned}
content withheld;the write remains
t0requestt1dispatcht2writet3response
Three calls on one ordinal time axis: where each stops and whether report.md existsinput checksoutput checkAllowede(t)report.mddelivereddispatched and deliveredDenied at inpute(t)not dispatched; no effectOutput blockede(t)report.mdcontent withheld; the write remainst0requestt1dispatcht2writet3response

after the response: report.md exists in 2 of 3 calls; one response delivered.

e(t)={1[ t≥t2 ]if dispatched0if denied at input\htmlClass{role-seal}{e}(t) = \begin{cases} \mathbf{1}[\,t \ge t_2\,] & \text{if dispatched} \\ 0 & \text{if denied at input} \end{cases}
An ordinal sequence for one illustrative 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 call

A 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:

Tool result · before the output hookjson
{
  "owner": "priya@harbor-goods.example",
  "host": "10.0.4.7",
  "key": "AKIAIOSFODNN7EXAMPLE"
}
Delivered to the agent
{
  "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. evaluate scans the request arguments as a JSON string with scan, which skips patterns below min_level. Any remaining finding returns GuardDecision::deny; otherwise GuardDecision::allow.
  • SanitizationAction::Block. A match at or above min_level makes the call path treat the response as denied, and scan_response returns ScanResult::Blocked with the findings.
  • SanitizationAction::Redact. Each match is replaced with its pattern's redaction string, and ScanResult::Redacted carries 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_words list 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::RequireApprovalAbove threshold yields Verdict::PendingApproval when 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:

Kernel setup · embedding chio-guardsrust
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

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.

crates/guards/chio-guards/src/response_sanitization/simple.rsrust
/// 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].

crates/guards/chio-guards/src/response_sanitization/simple.rs46-89rust
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.

crates/guards/chio-guards/src/response_sanitization/types.rsrust
#[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: true
  • redaction_strategies[Secret]: Mask; redaction_strategies[Pii]: Partial; redaction_strategies[Internal]: TypeLabel
  • entropy.enabled: true; entropy.threshold: 4.5 bits per character; entropy.min_token_len: 16
  • max_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 IDCategoryConfidenceRecommendedValidator
secret_aws_access_key_id
Secret
0.99
Mask
none
secret_aws_secret_access_key
Secret
0.9
Mask
none
secret_github_token
Secret
0.99
Mask
none
secret_slack_token
Secret
0.99
Mask
none
secret_slack_webhook
Secret
0.95
Mask
none
secret_gcp_service_account
Secret
0.97
Drop
none
secret_pem_private_key
Secret
0.99
Mask
none
secret_jwt
Secret
0.85
Mask
none
secret_oauth_bearer
Secret
0.85
Mask
none
secret_password_assignment
Secret
0.7
Mask
none
pii_ssn
PII
0.9
Mask
is_valid_ssn_fragments
pii_ssn_compact
PII
0.7
Mask
is_valid_ssn_compact
pii_credit_card
PII
0.9
Mask
Luhn check
pii_email
PII
0.95
Partial
none
internal_private_ip
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 becomes null.
  • Tokenize: replaces it with [TOKEN:…], an opaque ID whose mapping is recorded in a shared TokenVault for 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:

crates/guards/chio-guards/src/response_sanitization/types.rsrust
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:

Candidate token filterrust
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::InvalidPattern with 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:

crates/guards/chio-guards/src/response_sanitization/overlap.rsrust
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.

crates/guards/chio-guards/src/response_sanitization/overlap.rsrust
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(&current.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.

content_review.rsrust
#[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: true
  • default_rules.banned_words, default_rules.extra_patterns, per_service: empty
  • default_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 64 extra_patterns per rule set.
  • At most 512 characters 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 << 20 and EXTRA_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_service entry uses default_rules.
  • Actions other than ExternalApiCall pass with Verdict::Allow.

Next: pair output redaction with input scanning

Response Sanitization · Chio Docs