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.
@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.
| Scrubber | Pattern | Notes |
|---|---|---|
SsnUsScrubber | US Social Security numbers | Redacts the matched span; the redaction key identifies the pattern family for audit |
CreditCardScrubber | Credit card numbers validated via the Luhn check digit | Validation cuts the false-positive rate of naive 16-digit regex matches against arbitrary numeric content |
UsDriverLicenseScrubber | US driver's license numbers | Per-state heuristics — the format varies; the scrubber composes a state-keyed pattern table |
KeyedRegexScrubber | Generic pattern + key adapter | The 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 inSCRUBBABLE_FIELDS, empty entries included. A plugin that declares a new type throughcontributeEventTypesships 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
Safety policies
Prompt-input scrubbing and refusal contracts — the synthesis-time counterpart to write-time scrubbers.
Compliance
Where the four bundled scrubbers (SSN-US, Luhn, US-DL, KeyedRegex) ship from.
Plugin contract
The HarnessPlugin surface that exposes the contributeScrubbers hook.
Audit ledger
The audit row every redacted event is joinable against by turnId.