cerberust 0.1.1

Fast Rust guardrails for LLM input/output — composable scanners (PII, secrets, prompt-injection) and streaming middleware.
Documentation
# Core concepts

cerberust has a small, deliberately layered design. Once these five ideas click,
every scanner and every config knob falls into place.

## Scanner

A **scanner** inspects or transforms one piece of text and returns a verdict. It's
a pure function — same text in, same verdict out — with no I/O, no clock, no hidden
state:

```rust
fn scan<'a>(&self, text: &'a str, ctx: &mut ScanCtx) -> ScanResult<'a>;
// returns Verdict { text, valid, risk }
```

The verdict carries three things:

- **`text`** — the possibly-rewritten text. A pure detector returns the input
  unchanged (borrowed, zero-copy); a transformer returns the rewrite.
- **`valid`**`false` means "the stack should reject this." Honoured for
  scanners that are allowed to block.
- **`risk`**`0.0` (safe) to `1.0` (high). A pure transformer reports "not a risk
  judgment" so it doesn't pollute the aggregate.

Each scanner also declares two things about itself:

- **Direction**`Input` (runs on the prompt) or `Output` (runs on the reply).
- **Disposition**`Transform` (rewrites text, never blocks) or `Block` (may
  reject the request or response).

That's the whole contract. Every scanner in cerberust — PII, secrets, the ML
prompt-injection classifier, a sandboxed WASM guard — is the same shape.

## ScannerStack

A **stack** is an ordered set of scanners run as a unit. You hand it one flat list;
it partitions by direction and runs:

- **Input** scanners front-to-back, threading the rewritten text from one to the
  next. Redact PII, then mask secrets, then check the token budget — each sees the
  output of the last.
- **Output** scanners back-to-front (**LIFO**). Restore unwinds redaction in the
  exact inverse order it was applied.
- **Risk** aggregates as the **max** across scanners — one high-risk hit dominates.
  Pure transformers are skipped in the aggregate.

If a `Block` scanner returns `valid = false` and the stack is **fail-fast**, it
short-circuits: the request is rejected before the model is ever called. That's the
point of running blocking scanners first — a jailbreak or an over-budget prompt
never costs you a model call.

### Report metrics

After a run, the stack's **`ScanReport`** records one `ScanEntry` per scanner that
fired: its `valid`/`risk` verdict plus a content-free **`ScanMetrics`**:

- **`detections`** — matches/entities found (distinct redactions for a transform
  scanner; `1` on a hit for a block scanner).
- **`redacted`** — distinct values transformed/replaced.
- **`restored`** — sentinels put back (restore scanners).
- **`blocked`**`1` when the scanner returned a blocking `valid = false` verdict.
- **`by_entity_type`** — a map of entity **type** label (`EMAIL`, `AWS_ACCESS_KEY`…)
  to redaction count.
- **`latency_us`** — wall-clock time spent in that scanner's `scan`.

The stack derives these in its run loop, not the scanner: `scan` stays a pure
`text -> Verdict`. The loop times each `scan` and diffs the shared vault's
type-keyed tallies across the call, so a transform scanner's per-type counts come
from the intern delta and a restore scanner's `restored` from the restore-tally
delta. The timing is a single `Instant::now()` pair per scanner — cheap relative to
detection — and always on.

Every field is a count, a verdict, or a closed-vocabulary label (scanner id, entity
type). **No field ever carries matched content** — not a redacted value, not
plaintext — so the whole report is safe to log or emit as metrics. A test
(`report_carries_no_matched_content`) fails if any report field leaks a matched
value.

## Middleware

The stack works on text. **Middleware** wraps it around a real model call. The
shape is borrowed from the Vercel AI SDK: each middleware can transform the request
params on the way in (`transform_params`), wrap the generation (`wrap_generate`),
and wrap a streamed generation (`wrap_stream`).

```rust
MiddlewareChain::new(vec![&a, &b]) // nests a(b(model)) — first element outermost
```

The one middleware that bridges to the scanner tier is the **`GuardrailRunner`**:
it runs the input scanners before the model and the output scanners after. Other
middlewares compose in the same chain — for example `TierPolicy`, a fail-closed
guard that refuses to route a request marked confidential to a model whose provider
would see the prompt, unless the caller explicitly consents.

You don't have to use the middleware tier. If you just want to scan text, call
`stack.run_input(...)` and `stack.run_output(...)` directly. The middleware exists
to make "guard this model call" a one-liner and to compose with other request-level
concerns.

## The Vault

The **vault** is the keystone of redaction. It's a request-scoped map from each
placeholder back to the original it stands in for:

```
[REDACTED_EMAIL_1_a1b2c3d4]  ->  alice@example.com
```

A few properties make it safe:

- **Nonce-tagged.** Every request mints a fresh random nonce baked into every
  placeholder it emits (the `a1b2c3d4` above). This stops a caller from *guessing* a
  placeholder — embedding `[REDACTED_EMAIL_1_...]` in their own prompt and tricking
  the restore pass into splicing a different request's data into the reply. Without
  the nonce the placeholder is predictable; with it, they'd have to guess a random
  token.
- **Per-type counters.** Two different emails become
  `[REDACTED_EMAIL_1_...]` and `[REDACTED_EMAIL_2_...]`, so distinct values stay
  distinct and each restores to the right original. The same value seen twice in one
  request gets the same placeholder.
- **Zeroized on drop.** When the request ends, the originals are wiped from memory,
  not just freed.

The vault is shared across the whole stack and both directions: input scanners
intern into it, the restore scanner reads from it on output.

### Random vs deterministic suffix

That `a1b2c3d4` suffix is minted one of two ways, and the choice is the caller's:

- **Random (default).** A fresh nonce per request, shared by every placeholder it
  emits. Unguessable, but the same value redacts to a *different* placeholder each
  request.
- **Deterministic.** The suffix is `HMAC(key, value)` — a keyed function of the
  value — so the same value under the same key yields a byte-identical placeholder
  across requests. A replayed conversation prefix then redacts the same way turn
  after turn, which keeps a provider's prompt cache warm on the redacted text. It's
  a *keyed* HMAC, never a bare hash, so low-entropy PII isn't brute-forceable out of
  its placeholder — and it's opt-in (`Vault::deterministic(key)`), threaded in via
  `ScanCtx::new().with_vault(...)`. See
  [cache-stable redaction]scanners/restore.md#cache-stable-deterministic-redaction.

## RestorePolicy: round-trip vs one-way

Here's the design decision that makes cerberust composable: **whether a redaction is
reversible is a *policy*, not a property of the data.** The exact same redaction
machinery runs for PII and for secrets — they differ only in their restore policy.

| Policy | Behaviour | Default for |
|---|---|---|
| **`RoundTrip`** | redact on input → restore the original on output | PII |
| **`OneWay`** | redact on input → **never** restore | secrets |

- **`RoundTrip`** is for your user's own data. Their email is tokenized so the model
  never sees it, then put back in the reply so they read their real address. The
  model worked on a placeholder the whole time.
- **`OneWay`** is for things that should never come back: a leaked API key, a banned
  phrase. It's redacted on the way in and stays a placeholder forever — the provider
  never sees it and the user never gets it echoed back.

Restore is enforced **per placeholder**, not per type. So if the *model itself*
generates a new email in its reply, that email shares the `EMAIL` type but has no
vault entry from the input — it's masked one-way, never "restored" to something it
never was. Your user's input email round-trips; a model-hallucinated email gets
masked. Both happen in one output pass.

Every scanner that redacts lets you override its policy, so the same scanner can be
round-trip in one stack and one-way in another.

## Streaming hold-back

Redacting a streamed reply is the subtle case. The reply arrives in chunks, and a
secret can land *across* a chunk boundary:

```
chunk 1: "...here is the key AKIAIOS"
chunk 2: "FODNN7EXAMPLE, use it wisely"
```

If you emit chunk 1 as soon as it arrives, you've leaked `AKIAIOS` — half the key —
before you ever saw the rest. The fix is **hold-back**: don't flush a tail that
could still be completing a match.

Each scanner declares how much it needs held back:

- **`TokenBoundary`** — never flush a partial trailing token. Most PII and secret
  patterns are token-bounded; this is the default.
- **`MaxLen(n)`** — hold at most `n` bytes (the longest a pattern could still be
  completing).
- **`WholeStream`** — the scanner can't judge a chunk in isolation; it needs the
  whole reply (a blocking content gate, a future toxicity model).

The streaming runner combines these into one of two modes:

- **Incremental.** When every active output scanner is token-bounded, the runner
  flushes up to the *tightest* of three bounds: the last token boundary, a
  byte-fed DFA that knows when a multi-token pattern (a PEM block, a spaced card
  number) could still be forming, and the longest trailing run that could be an
  incomplete placeholder. It emits the safe prefix and carries the rest forward.
- **Buffer-and-gate.** If any output scanner needs the whole stream, the runner
  buffers the entire reply and scans once at the end. That trades first-token
  latency for a full-text verdict — the unavoidable cost of a gate that has to see
  everything, and documented so you choose it knowingly.

The guarantee either way: **streamed output is byte-identical to scanning the whole
response at once.** Streaming changes when bytes come out, never what comes out.

### Driving the runner

`StreamOutput` owns its hold-back state and borrows nothing across calls: you
create it once from the stack, then pass the stack to each `push` and to
`finish`. So one runner can be held across many independent async frame
callbacks — a per-frame SSE/JSON consumer keeps the `StreamOutput`, and hands it
the stack each time a frame arrives:

```rust
let mut runner = StreamOutput::new(&stack);
// …later, once per arriving frame, with no long-lived borrow on `stack`:
let safe = runner.push(&mut stack, frame)?;
// …at end of stream:
let tail = runner.finish(&mut stack)?;
```

The same stack must drive the whole stream — it holds the shared `Vault` the
restore pass reads.

### Encoding restored values

When you splice a restored original back into a structured stream (a value inside
a JSON string in an SSE frame), the raw original may carry characters the
surrounding format must escape. Install a `RestoreEncoder` on the stack
(`stack.set_restore_encoder(RestoreEncoder::new(json_escape))`) and the restore
pass runs it over **each restored original only** — never the surrounding model
bytes, which the upstream already encoded. The default is
`RestoreEncoder::identity`: originals are emitted verbatim, so restore is
unchanged unless you opt in.

## How they fit together

```
Params (prompt)
   │
   ▼  MiddlewareChain.transform_params
GuardrailRunner ──► ScannerStack.run_input ──► [PII redact][Secret mask][TokenLimit]
   │                                              │ interns into the Vault
   ▼                                              ▼
Model.generate  ◄───────────────────────── redacted prompt
   │
   ▼  reply
GuardrailRunner ──► ScannerStack.run_output ──► [Restore PII][Mask model PII]
   │                                              │ reads the Vault (round-trip only)
   ▼
guarded reply ──► your user
```

Ready for specifics? Each scanner has its own page in the
[scanner reference](scanners/), starting with what it protects you from.