Guardrails
A guardrail rule set is a named set of detectors that run inside the request frame: on what you send before it leaves the gateway, and on what comes back before it is handed to you (streamed output is the exception, covered below). On the inference path that means your prompts and your completions, at the same point the outbound scrub runs. The same rule sets also run on three other surfaces: tool calls through the MCP Gateway, the requests and responses of a Sable Service, and, for the blocklist only, code submitted to a sandbox. 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.
A rule set does not mean the same thing on every surface. Which detectors run where, and what is deliberately left unscreened, is fixed and listed under What is screened on each surface. Read that table before you assume a key with a rule set is filtered everywhere.
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. This holds on every surface and is pinned by tests: the detector's own test, one on the MCP Gateway and one on Services each send a known injection reworded and assert that it is not caught.
- 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. The same is true of tool arguments, tool results, service bodies and submitted code. 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 | Categories it reportsemail, phone, national_id, credit_card, evm_address, btc_address | What it looks forEmail 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 | Categories it reportsapi_key, jwt, private_key, high_entropy_hex, high_entropy_blob | What it looks forKnown 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 | Categories it reportsinstruction_override, role_override, exfiltration, tool_abuse | What it looks forA 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 | Categories it reportsterm | What it looks forYour 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, output, or both — and an
action. input is the request leg, what the caller sends through Sable, and
output is the response leg, what comes back. On the inference path those are
the prompt and the completion; what they are elsewhere is in the
per-surface table. The actions:
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. Submitted code is the exception: it is never rewritten, so on sandboxes aredactblocklist is recorded asallow.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. What a block costs on the output side differs by surface and is stated for each one below.
What is screened on each surface
The rule set is the one already attached through the key's
policy. Nothing new is attached for the other surfaces, and
enforcement on all of them is the same SABLE_GUARDRAILS_ENFORCED switch
(see Observe before you refuse).
| Surface | input leg | output leg | Detectors that run |
|---|---|---|---|
| Inference | input legThe prompt | output legThe completion | Detectors that runAll four, each in its configured direction. |
MCP Gateway tools/call | input legString values in the call's arguments | output legString values in the tool result, or in the JSON-RPC error object | Detectors that runAll four in their configured direction, except that a configured prompt_injection detector runs on both legs whatever its direction says. |
| Sable Services | input legThe buyer's request body | output legThe seller's response: an endpoint's body, or a hosted agent's stdout and stderr | Detectors that runAll four, each in its configured direction. The rule set is the seller's. |
| Sandboxes | input legThe submitted code; for a session exec, the code, or the command line and its stdin | output legNot screened | Detectors that runThe blocklist only, and only if its direction is input or both. |
Two rules here differ from the inference path, and both are deliberate:
- On the MCP Gateway, prompt-injection runs on both legs. The tool result
is third-party text on its way into an agent's context, which is exactly
where an injected instruction arrives. A detector configured
inputfor chat (screen what reaches the model) would otherwise never look at it. The widening only ever adds a scan; the detector's action is unchanged. - Off the inference path there is a scan budget, and the receipt says when it ran out. See The scan budget.
MCP Gateway
Arguments are screened before the call leaves for the third-party server, and the result before it is returned to your agent or fingerprinted.
- A
redacton the arguments rewrites what the upstream receives. The receipt'sactionsfingerprint is still over the arguments as your agent sent them, so you can recompute it. Aredacton the result rewrites what your agent receives, andresult_fpis over the result as returned. - An enforced
blockon the arguments means the upstream is not dialed, no credit is reserved, and nothing is billed. - An enforced
blockon the result means the result is withheld. The upstream did run, so the call is still metered at the server's per-call price. - Either refusal is a JSON-RPC error, not the plain JSON body the inference
path returns: HTTP
422,error.code-32004, witherror.data.type"guardrail_blocked", the stamp aterror.data.guardrailsand the signed receipt aterror.data.sable_receipt(and in thex-sable-receiptheaders). The receipt'sstatusisblockedand it carries noresult_fp. - Only
tools/callis screened.resources/read,prompts/getand the other methods the proxy forwards are not, even though they also return third-party text.
Sable Services
Invocation is keyless, so there is no caller key to read a policy from. The
rule set is the seller's, and it comes from either or both of two places: the
policy on the hosted agent's own key (for kind: "agent"), and a policy the
listing itself names (policy_id on POST /v1/services or
PATCH /v1/services/manage/:slug; it must be one of the seller's own policies,
and only its guardrail_ruleset_id applies). When both exist, both run and the
stricter setting of each detector wins.
- An enforced
blockon the request refuses it before the payment is claimed and before anything is dialed. The buyer's transaction is not consumed and still buys a call. - An enforced
blockon the response withholds it, and the payment stays consumed. The seller's side ran and was paid on-chain; releasing the claim would let a buyer who is deliberately tripping the seller's filter re-run the seller's compute for free. The buyer gets a signed receipt naming the rule and the category. - A service that is screened says so on its public terms
(
GET /v1/services/:slug→guardrails), including this payment rule, so a buyer can read it before paying. - Findings for a service are recorded under the seller's account, because the rule set is theirs.
Sandboxes
Only your own blocklist terms run, on what was submitted for execution: the
code of a one-shot run, and for a session exec
either the code, or the argv command line together with stdin. Everything
else is left alone, on purpose:
pii,secretsandprompt_injectiondo not run on code. Source legitimately contains key-shaped constants, hex blobs, e-mail addresses and strings likedrop table. Those detectors would fire on ordinary programs.envis never read. It exists to carry secrets; a detector there would fire on every correct use.- Output is not screened. stdout, stderr and workspace files are produced by your own program and returned to you, and can be streamed, where nothing can be recalled. Files written through the session file endpoints are not screened either. If that output later goes into a prompt, the inference-path guardrail sees it there.
- Code is never rewritten. A program with a marker spliced into it is a
different program, so
redactis recorded asallow. A rule set with no blocklist, or one whose blocklist direction isoutputonly, runs nothing here, and the run's receipt then carries noguardrailsblock at all rather than claiming a screen that did not happen.
An enforced block refuses the run with 422 before any credit is reserved
and before anything executes. Nothing is billed, and the refusal has its own
signed receipt (status: "blocked", zero cost). One difference with
"stream": true: the response has already begun as an event stream by the
time the run is screened, so the refusal arrives as a sable.error event
carrying the guardrail_blocked message, not as a 422 with the stamp. The
receipt is still minted; fetch it with GET /v1/receipts/:id, using the id in
the x-sable-request-id response header.
What is not screened anywhere
- Image and video prompts, and embeddings input.
- Sandbox stdout, stderr, workspace files and
env. - JSON key names and numbers. JSON is screened by its string values, so a card number sent as a JSON number is not seen.
- A body that is not UTF-8 text.
- Anything past the scan budget below.
GET /v1/guardrails/findings returns this scope as a coverage string, so the
API states its own limits. A key carrying a rule set is still not a key whose
every byte is filtered. Where you need a hard bound, the mechanisms that bind
whatever the content looks like are the key policy (allowed_tools,
deny_sandbox_network), the node-level egress policy, and spend caps.
The scan budget
On the MCP Gateway, Services and sandboxes, one evaluation scans at most
256 KiB of any one string and 1 MiB across one payload (one leg of one
call), and does not descend JSON nested more than 64 levels deep. Past any of
those, or when a body is not text at all, the stamp carries
"scan_partial": true and the counts describe only the window that was
scanned. A redact likewise applies only inside that window.
Padding a tool result past the budget is therefore a way to get text through
unscanned. The receipt tells you it happened; it does not stop it. If that
matters for a server you proxy, treat scan_partial: true as a finding in its
own right.
On the inference path the bound is the first 256 KiB of each message, as it
always was, and scan_partial is never set there.
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, and it is one switch for every surface above. 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. The same holds on the other
surfaces: an mcp_call, service or sandbox receipt carries a guardrails
block only when a rule set governed that call, and scan_partial appears on
the stamp only when it is true.
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.
That is the inference path's shape. A Service or a buffered sandbox run answers
with the same 422 body (a Service adds call_id, service and receipt),
and a message that says what happened to the call or the payment. The MCP
Gateway answers with a JSON-RPC error instead, described
above.
The findings feed
GET /v1/guardrails/findings (session-authed) reads findings back out of your
own stored receipts, which is the only place they exist. Each event carries a
surface (inference, mcp_call, service or sandbox), because
direction names a different leg on each. The envelope also carries
by_surface, a count per surface of the scanned receipts that carried a
guardrails block, and coverage, the scope statement quoted above. Where the
receipt names one, an event also includes the tool, server_id, slug or
resource the call was made against: identifiers, never content.
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. Events from the MCP Gateway,
Services and sandboxes also carry surface; an event with no surface is from
the inference path. See Webhooks.
Limits
- 100 rule sets per account; 256 blocklist terms per set; each term ≤ 200 characters.
- On the inference path, detection scans the first 256 KiB of each message.
Longer text is not scanned past that point, and the counts describe only what
was scanned. On the other surfaces the bounds are 256 KiB per string and
1 MiB per payload, flagged with
scan_partial; see The scan budget. - 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.