pleach
Operate

Scrubbers — redaction at write time

The Scrubber contract that gates event log writes through allowlisted redactors — SSN-US, Luhn, US-DL, KeyedRegex out of the box, plus the contributeScrubbers plugin hook.

Scrubbers are one concept in the safety & determinism thematic island — siblings of safety, fabrication detection, determinism, and fingerprint.

A Scrubber gates writes into harness_event_log. When a row would persist, every registered scrubber runs against the payload first; matched spans get rewritten before the row reaches storage. The redacted form is what hits disk — the original payload never lands.

Not prompt-input scrubbing — that's /docs/safety. Not fabrication detection — that's /docs/fabrication-detection. Scrubbers sit downstream of synthesis, on the persistence boundary; safety and fabrication detectors sit upstream, on the model boundary.

Subpath@pleach/core/scrubbersSourcesrc/scrubbers/

The Scrubber contract

A scrubber is small: an id and a scrub(input, ctx) method. It takes a single string plus structural context and returns the redacted string plus attribution metadata. Scrubbers do not declare which event types they apply to — scoping is global (see below).

import type { Scrubber } from "@pleach/core/scrubbers";

Scoping is global, not per-scrubber. A single allowlist — SCRUBBABLE_FIELDS, keyed by event type — names the dotted field paths each event type exposes to redaction. The chain applies every registered scrubber to every allowlisted field; an individual scrubber never names an event type. The runtime still requires that every event type the substrate writes has an entry in that allowlist (see the coverage rule below).

scrub returns a ScrubResult: { redacted, matchCount, matchedScrubberIds }. redacted is the post-scrub string (equal to the input when nothing matched); matchCount is the number of redacted matches; matchedScrubberIds is the required list of scrubber ids that fired ([] when none did). These feed the event.redacted audit meta-event — downstream consumers (a security dashboard, a SOC2 evidence export) count redactions per type without re-running the regex against persisted rows. No original matched text, offsets, or spans are returned.

The four bundled scrubbers

These four ship in @pleach/compliance (see /docs/compliance for the package surface). Each independently implements the Scrubber contract.

ScrubberPatternNotes
SsnUsScrubberUS Social Security numbersRedacts the matched span; the redaction key identifies the pattern family for audit
CreditCardScrubberCredit card numbers validated via the Luhn check digitValidation cuts the false-positive rate of naive 16-digit regex matches against arbitrary numeric content
UsDriverLicenseScrubberUS driver's license numbersPer-state heuristics — the format varies; the scrubber composes a state-keyed pattern table
KeyedRegexScrubberGeneric pattern + key adapterThe shape hosts use for custom regulated identifiers (medical record numbers, tax IDs, internal employee ids)

The first three are independent implementations, not configurations of a shared base — SsnUsScrubber carries SSA invalid-range guards, CreditCardScrubber gates on a Luhn checksum plus an IIN range allowlist, and UsDriverLicenseScrubber anchors on per-state formats. KeyedRegexScrubber is the separate consumer-extension shape: hosts that need a new regulated identifier reach for it directly rather than waiting for an upstream addition.

The contributeScrubbers plugin hook

Hosts register additional scrubbers through the HarnessPlugin contract. The hook returns an array; the runtime composes contributions across every loaded plugin into the registry the event log writer consults.

// lib/plugins/tenantCompliancePlugin.ts
import type { HarnessPlugin } from "@pleach/core";
import type { Scrubber } from "@pleach/core/scrubbers";

const tenantIdentifierScrubber: Scrubber = {
  /* shape defined below in the custom example */
};

export const tenantCompliancePlugin: HarnessPlugin = {
  name: "tenant-compliance",
  contributeScrubbers: () => [tenantIdentifierScrubber],
};

Registration order matches the plugin registration order at runtime construction. Scrubbers compose by chaining — the redacted output of one scrubber is the input to the next. Two scrubbers that match overlapping spans should be sequenced explicitly at the registration site; the runtime won't reorder them.

Event-type allowlist coverage

Every event type the substrate writes must have an entry in SCRUBBABLE_FIELDS. An empty entry counts — the requirement is coverage, not redaction. The point is to force an allowlisting decision for every event type, so a new type can't sneak past the redaction surface.

The CI gate audit:c8-event-type-allowlist-coverage enforces this on the core repo: it scans the SCRUBBABLE_FIELDS keys against the event union, and a new event type that lands without an entry fails the audit. The same rule applies to consumer code: plugins that declare new event types through contributeEventTypes should ship a corresponding allowlist entry.

If you genuinely want a type to write unredacted, give it an empty field list ([]) — the exported SCRUB_NONE_AUDITED sentinel is the grep-able way to spell it. The entry is the load-bearing artifact: [] means "audited, no scrubbable fields," distinguished from a missing entry (which fires [C8:unscrubbed-event-type]).

EventLogWriter integration

The writer applies scrubbers automatically when persisting. Consumer code that calls into the event log doesn't reach for the scrubber registry directly — the redaction step is structural, not opt-in.

import { EventLogWriter } from "@pleach/core/eventLog";

// Consumer code is unchanged by scrubber registration.
// The writer consults the registry at persist time.
await writer.append({
  type: "tool.completed",
  sessionId,
  turnId,
  payload: { /* ... */ },
});

The single write surface is what keeps the gate honest. There's no "raw" write path that skips scrubbers — bypassing redaction would mean bypassing the writer, which would also bypass the hash chain, the tenant scoping, and the append-only invariant. The scrubber pass is part of the same atomic step that lays down the row.

Production-path adoption

The scrubber hook runs at the EventLogWriter boundary, but a hook that nothing calls isn't a gate — it's a contract. Production adoption is what makes it load-bearing.

Earlier in the substrate, the canonical browser-event POST surface ran an inline row-builder and a direct insert into storage. The writer was in the codebase, but the route bypassed it. Any new event-type the writer's scrubber chain would have gated could land in the table unredacted simply because the route never asked the chain to run.

That bypass is now closed. The production route calls the writer's write and flush directly; the inline insert is retired. The scrubber chain runs against every row that lands through the canonical surface — there's no "stale code path" alternative for a host to accidentally pick.

The practical consequence: registering a scrubber against an event type the route writes is now sufficient. Previously, adoption took a second step (route refactor); now it doesn't. Hosts that wired scrubbers expecting production gating finally get it.

What scrubbers do NOT do

  • They don't redact prompt inputs sent to the model. That's a synthesis-time concern handled by safety policies — see /docs/safety. A scrubber runs after the model has already seen the data; redacting at write time protects the persisted surface, not the model surface.
  • They don't detect fabricated output. Fabrication is a content truthfulness signal, not a pattern match — see /docs/fabrication-detection.
  • They don't replace tamper-evidence. The hash chain (see /docs/hash-chain) proves a row hasn't changed since write; scrubbers shape what the row contains at write. Different jobs, different contracts.
  • They don't dedupe. A value redacted on one write site stays redacted on every other write site that matches the same pattern. Scrubbers are stateless across rows; the same input produces the same output every time.

Custom scrubber example

A tenant with internal employee identifiers shaped EMP- followed by six digits can register a KeyedRegexScrubber for them. The scrubber takes an id plus one or more entries — each an (name, pattern, replacement) tuple. The consumer supplies the replacement string; KeyedRegexScrubber does not auto-format one. Note there is no eventTypes field — scoping comes from SCRUBBABLE_FIELDS, not the scrubber:

// lib/plugins/employeeIdScrubber.ts
import { KeyedRegexScrubber } from "@pleach/compliance/scrubbers";
import type { HarnessPlugin } from "@pleach/core";

const employeeIdScrubber = new KeyedRegexScrubber({
  id: "internal-pii",
  entries: [
    {
      name:        "employee-id",
      pattern:     /\bEMP-\d{6}\b/g,
      replacement: "[REDACTED:EMPLOYEE_ID]",
    },
  ],
});

export const tenantCompliancePlugin: HarnessPlugin = {
  name: "tenant-compliance",
  contributeScrubbers: () => [employeeIdScrubber],
};

Register the plugin once at runtime construction. Every subsequent write through EventLogWriter runs the scrubber against the fields SCRUBBABLE_FIELDS allowlists, and rows persist with the supplied replacement — here [REDACTED:EMPLOYEE_ID] — in place of the matched text.

The entry name is what shows up in the matchedScrubberIds attribution (as <id>:<name>) and in any downstream audit consumer. Pick a name that reads in a compliance review — employee-id, not EMP_REGEX_V2.

What CI checks when you add a scrubber

A scrubber registered through contributeScrubbers adds redaction coverage; the gate that fails is the one on the event type it gates:

  • audit:c8-event-type-allowlist-coverage — every event type the substrate writes has an entry in SCRUBBABLE_FIELDS, empty entries included. A plugin that declares a new type through contributeEventTypes ships its allowlist entry in the same plugin.

Run npm run audit:c8-event-type-allowlist-coverage. The scrubber — the "clearer" — is one row in the extension map, which pairs every extension point with the gate that guards it.

Where to go next

On this page