Guardrails
A guardrail rule set is a named set of detectors that run on your prompts and your completions inside the request frame — at the same point the outbound scrub runs, after the payload is unsealed and before anything crosses the network. Each detector is configured per direction with an action, and every evaluation is stamped on the signed receipt: which rule set, its hash, what it saw, and what it decided.
Read this part first
These are deterministic pattern detectors, not a safety classifier. There is no model here. The consequences are worth stating plainly, because a guardrail you misunderstand is worse than none:
- They produce false positives. A support ticket quoting a customer's email
address trips the PII detector. That is working as designed, and it is why the
default action is
allow(record only) and why enforcement is off by default. - They produce false negatives. A card number written in words, a secret split across two lines, an address in an image — none of those are caught.
- The prompt-injection heuristics catch known phrasings and are trivially evaded. They match strings like "ignore previous instructions". Anyone who rewords gets through. They are useful against untargeted, copy-pasted injection in retrieved content; they are not a defence against someone attacking you specifically.
- A guardrail is a control you can prove ran — not a guarantee. The receipt proves which rules governed a request and what they found. It does not, and cannot, prove that nothing bad got through.
- Sable cannot show you the text that matched. This is not a missing feature. Under the privacy contract, the prompt and the completion exist only inside the request frame; nothing is written to disk or to a log. So there is no snippet to retrieve afterwards, no "show me the example" button, and no support request that can produce one. A finding is a rule, a direction, a category, a count, a severity, an action, and a decision. That is the whole record, by construction.
The detectors
| Rule | Categories it reports | What it looks for |
|---|---|---|
pii | email, phone, national_id, credit_card, evm_address, btc_address | Email shapes; phone numbers that carry a leading + or grouping punctuation (a bare ten-digit number is not treated as a phone number); NNN-NN-NNNN national-id shapes; 13–19 digit runs that pass a Luhn check; 0x-prefixed 40-hex addresses; bech32 and base58 Bitcoin addresses. |
secrets | api_key, jwt, private_key, high_entropy_hex, high_entropy_blob | Known key prefixes (sk-, AKIA, ghp_, github_pat_, xoxb-, AIza, glpat-, and more), Bearer tokens, three-segment JWTs, PEM private key blocks (a certificate is not a secret), 32+ character hex runs, and 40+ character base64-ish blobs with mixed case and digits. |
prompt_injection | instruction_override, role_override, exfiltration, tool_abuse | A short, deliberately narrow list of known phrasings. Kept narrow on purpose: a broad list flags ordinary conversations about prompts, which is how a guardrail gets switched off. |
blocklist | term | Your own literal terms, matched case-insensitively. A term prefixed re: uses a tiny pattern language — literal text plus . (any character) and * (zero or more of the preceding). No alternation, classes, or anchors. |
The Luhn check is the reason pii can look for card shapes at all: 4111 1111 1111 1111 is reported, and 1234 5678 9012 3456 — same shape, same length — is
not, because it fails the checksum. Most order ids and invoice numbers fail it
too.
Directions and actions
Each detector takes a direction — input (the prompt), output (the
completion), or both — and an action:
allow— record the finding, change nothing. The observe mode, and the default.redact— replace each match in-frame with a typed marker such as[redacted:email], so the model can still reason about the structure of the text. On the input side this happens before the upstream call, so the vendor never sees the original.block— refuse. On the input side the request is refused with 422 before any upstream call, so a refused prompt never leaves the process, and you are not billed for inference that did not happen.
Streaming: what a guardrail can and cannot do
Output-direction rules behave differently on a stream, and the difference is real rather than cosmetic. Once a token has been flushed to your client, it is gone; nothing at the gateway can recall it.
- Detection always runs. At the end of a stream the completed text is evaluated and the findings land on the receipt exactly as they would for a buffered response.
- Blocking only works inside a buffered window. The operator setting
SABLE_GUARDRAIL_STREAM_WINDOW(bytes, 0 by default) holds back the start of the completion until that many bytes have accumulated, evaluates, and then either flushes what it held or discards it and emits a content-freeevent: sable.guardrailinstead. With the default of 0 nothing is held back, so an outputblockon a stream is recorded and not applied. Anything produced after the window has already left, whatever the window is set to. - Output redaction is never applied to a stream. Rewriting only the tail would be a worse lie than not rewriting at all.
If you need output-side enforcement to be reliable, do not stream that request.
Creating a rule set
curl https://api.buildsable.com/v1/guardrails \
-H "Authorization: Bearer $SABLE_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "support-agent",
"rules": {
"secrets": { "direction": "both", "action": "block" },
"pii": { "direction": "output", "action": "redact" },
"prompt_injection": { "direction": "input", "action": "allow" },
"blocklist": { "direction": "both", "action": "block",
"terms": ["Project Chimera", "re:acme-.*-secret"] }
}
}'
Attaching one
Guardrails ride the existing policy attachment rather
than adding a second one, so a key has exactly one place that says what governs
it. Put the rule set id in a policy, then mint a key with that policy_id:
# 1. a policy that carries the guardrail set
curl https://api.buildsable.com/v1/policies \
-H "Authorization: Bearer $SABLE_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"support","rules":{"guardrail_ruleset_id":"gr_…"}}'
# 2. a key bound to that policy
curl https://api.buildsable.com/v1/keys \
-H "Authorization: Bearer $SABLE_SESSION_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"support-agent","policy_id":"pol_…"}'
Observe before you refuse
SABLE_GUARDRAILS_ENFORCED defaults to false, the same way billing
enforcement did. With it off, everything runs and everything is recorded — a
block decision is reported honestly on the receipt as blocked: false, enforced: false — and nothing is refused. Watch the findings accumulate on real
traffic, tune the rule set, and only then ask your operator to switch
enforcement on. GET /v1/guardrails reports the deployment's current mode, and
the portal shows it at the top of the Guardrails page.
What lands on the receipt
{
"guardrails": {
"ruleset_id": "gr_9f1c…",
"sha256": "6d2a…",
"findings": [
{ "rule": "pii", "direction": "input", "category": "email",
"count": 2, "severity": "medium", "action": "redact" },
{ "rule": "secrets", "direction": "input", "category": "api_key",
"count": 1, "severity": "high", "action": "block" }
],
"blocked": true,
"enforced": true
}
}
The block is additive: a receipt for a request that ran under no guardrails is byte-identical to one minted before this feature existed, so nothing about your existing receipts or their signatures changed.
sha256 is the hash of the exact rules that were enforced, so editing a rule
set is visible in every receipt minted afterwards — you can prove not only that
a control ran, but which version of it.
A refused request
{
"error": {
"message": "blocked by a guardrail rule set: secrets (api_key) on the input side. Sable records the rule, the category, and a count — never the matched text, which is not stored anywhere and cannot be shown to you.",
"type": "guardrail_blocked",
"code": "guardrail_blocked"
},
"guardrails": { "…": "the same stamp as above" }
}
The response still carries x-sable-receipt headers: a refusal is a recorded
outcome with its own signed proof that the control ran, not a silent drop.
Webhooks
A block-action detector that matches fires guardrail_triggered — whether or
not enforcement acted on it, because an operator deciding whether to switch
enforcement on needs to see exactly those. The payload carries the rule set id,
the rule, the category, the direction, the findings, the decision
(blocked or observed), and nothing else. See Webhooks.
Limits
- 100 rule sets per account; 256 blocklist terms per set; each term ≤ 200 characters.
- Detection scans the first 256 KB of each message. Longer text is not scanned past that point, and the counts describe only what was scanned.
- At most 32 distinct findings ride on one receipt (they are aggregated by rule, direction, and category first, so this is generous); over that, the highest-severity ones are kept.