Skip to main content

Custom Script Guardrails

A custom script guardrail runs screening logic you write inside the gateway. Use it when the service you want to screen against is not one of the built-in providers — an in-house classifier, a commercial product AISIX has no integration for, or a policy that combines several checks.

Every other guardrail kind speaks one provider's protocol. There is no industry standard for content-screening APIs, so reaching a service that speaks its own would otherwise mean building and operating a translation service in front of it. A custom script puts that translation inside the gateway instead, where it is a dozen lines of configuration rather than a deployment.

In this guide, you will write a script that screens prompts against your own service, attach it to a single model so you can try it safely, and verify that AISIX blocks a matching request before calling the upstream model.

Prerequisites

Before starting, prepare the following:

  • Review Guardrail Behavior for hook points, enforcement modes, and failure policies.
  • A screening service the gateway can reach. The script runs inside your gateway, so this can be a service on your private network.
  • One of these configuration paths:
    • AISIX Cloud with an environment, an attached gateway, and a write-scoped admin token. For On-Premises, follow the AISIX Cloud Quickstart. To request Hybrid Cloud access, contact API7.
    • An open-source AISIX gateway that loads a declarative resources.yaml file.
  • A working model alias and caller API key that can send Chat Completions requests.
  • curl. The AISIX Cloud path also uses jq.

Write a Script

A script is an ES module exporting checkInput, checkOutput, or both. A hook you do not export is skipped, so a script may cover one direction only.

export async function checkInput(ctx) {
const resp = await fetch("https://screening.internal/scan", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": ctx.secrets.SCAN_KEY,
},
body: JSON.stringify({ text: ctx.text }),
});
if (!resp.ok) {
throw new Error("screening service returned " + resp.status);
}
const body = await resp.json();
return body.outcome.deny
? { action: "block", reason_code: body.outcome.rule }
: { action: "none" };
}

Throwing, as this script does on a non-2xx response, hands the decision to the guardrail's failure policy rather than guessing. With the default policy that blocks the request, which is what a screening control should do when it cannot screen.

What the Hook Receives

FieldDescription
ctx.hook"input" or "output".
ctx.textEvery text slot joined, for a script that screens the request as a whole.
ctx.segmentsThe text slots as an array. A rewriting script returns one replacement per slot.
ctx.messagesThe slots with their roles, as {role, text}.
ctx.modelThe model the request addressed. Input hook only.
ctx.secretsThe values configured on the guardrail, by name.

What the Hook Returns

Return valueResult
{action: "none"}Allowed.
{action: "block", reason_code, reason}Refused. Both fields go to your gateway logs; neither reaches the caller.
{action: "mask", segments, counts}Allowed with the text replaced. segments must have one entry per ctx.segments entry.

Returning anything else — including nothing — is treated as a failure, not as an allow. A script that decided nothing must not read as a script that decided to permit.

What the Script Can Call

APIPurpose
fetch(url, init)Call your service. Returns {status, ok, headers, text(), json()}.
console.log/info/warn/error/debugWrite to your gateway's log.
crypto.hmac(alg, key, data, outEncoding, keyEncoding)HMAC-SHA1 or HMAC-SHA256, for a service that requires a signed request.
crypto.hash(alg, data, outEncoding)SHA-1 or SHA-256.
crypto.base64Encode/base64Decode(data)Base64.
crypto.randomUUID()A random UUID, for a nonce.
aisix.embed(model, texts)Embed text with an embedding model in the same environment, for screening by meaning.

crypto.hmac accepts and returns "hex" or "base64", and reads its key as "utf8", "hex", or "base64". Reading a key as hex is what lets you express a derivation chain, where each step's raw output keys the next:

let k = crypto.hmac("sha256", "AWS4" + ctx.secrets.SECRET, date, "hex");
k = crypto.hmac("sha256", k, region, "hex", "hex");

There is no file system, no environment access, and no timer. A failed fetch or an unsupported algorithm throws, so your script can decide what to do about it.

Create a Custom Script Guardrail

Attach it to one model first, rather than the whole environment. A script is code you just wrote, and scoping it to a model you control means a mistake affects only the traffic you are testing with. See Debug a Script below.

AISIX Cloud

Create the guardrail with your script and the credentials it reads:

curl -X POST "$ADMIN_ENDPOINT/api/environments/$ENV_ID/guardrails" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "in-house-screening",
"enabled": true,
"kind": "custom",
"hook_point": "input",
"fail_open": false,
"config": {
"script": "export async function checkInput(ctx) { const r = await fetch(\"https://screening.internal/scan\", { method: \"POST\", headers: { \"content-type\": \"application/json\", \"x-api-key\": ctx.secrets.SCAN_KEY }, body: JSON.stringify({ text: ctx.text }) }); if (!r.ok) { throw new Error(\"screening returned \" + r.status); } const b = await r.json(); return b.outcome.deny ? { action: \"block\", reason_code: b.outcome.rule } : { action: \"none\" }; }",
"secrets": { "SCAN_KEY": "your-screening-service-key" },
"timeout_ms": 5000
}
}' | jq

Secrets are stored encrypted and are never returned. A later read shows their names so you can see what your script may read, but never their values.

Attach it to a single model:

curl -X POST "$ADMIN_ENDPOINT/api/environments/$ENV_ID/guardrail_attachments" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"guardrail_id": "'"$GUARDRAIL_ID"'", "scope_type": "model", "scope_id": "'"$MODEL_ID"'", "enabled": true}' | jq

You can also create and edit the guardrail in the AISIX Cloud dashboard, which provides an editor for the script and a name/value editor for its secrets.

Open-Source AISIX Gateway

Add the guardrail to your resources.yaml file. A block scalar keeps the script readable:

guardrails:
- name: in-house-screening
enabled: true
kind: custom
hook_point: input
fail_open: false
script: |
export async function checkInput(ctx) {
const resp = await fetch("https://screening.internal/scan", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": ctx.secrets.SCAN_KEY,
},
body: JSON.stringify({ text: ctx.text }),
});
if (!resp.ok) {
throw new Error("screening service returned " + resp.status);
}
const body = await resp.json();
return body.outcome.deny
? { action: "block", reason_code: body.outcome.rule }
: { action: "none" };
}
secrets:
SCAN_KEY: your-screening-service-key
timeout_ms: 5000

guardrail_attachments:
- guardrail_name: in-house-screening
scope_type: model
scope_id: your-model-alias
enabled: true

Reload the gateway with SIGHUP.

Verify the Guardrail

Send a request your screening service refuses:

curl -i "$GATEWAY_ENDPOINT/v1/chat/completions" \
-H "Authorization: Bearer $CALLER_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "your-model-alias", "messages": [{"role": "user", "content": "something your service denies"}]}'

AISIX answers 422 with a content_filter error and never calls the upstream model. An unrelated request returns the model's answer as usual.

Debug a Script

There is no separate console for trying a script out. Debug it the way you would debug a route: against a model that only you are using.

  1. Create a model you can throw away. Point it at the same provider as your real model, with an alias like screening-test.
  2. Attach the guardrail to that model only, with scope_type: model as above. Nothing else in the environment is affected.
  3. Send requests to that alias and read your gateway's logs. console.log from your script appears there, so you can print what your service returned and what your script decided.
  4. Widen the scope once it behaves. Reattach the guardrail to the environment, or to the models you actually want screened, and delete the test model.

Two things to check first when a script does not behave:

  • A syntax error is refused at save time, with the line and column. If your guardrail saved successfully, the script parses.
  • A script that fails at runtime shows up in the gateway log and in the request's usage event, under a reason that says which kind of failure it was: custom_timeout for a script that ran out of its budget, custom_script_error for one that threw, custom_bad_verdict for one that returned something that is not a verdict.

Screen Model Responses

Export checkOutput and set hook_point to output or both. The response hook receives the model's reply in the same shape.

For a streamed response, AISIX releases content in windows: each window is screened, and released once it passes. This keeps a streaming client responsive rather than holding the whole answer back. Set stream_processing_mode to buffer_full if you would rather hold the entire response and screen it once.

Rewrite Content

Return {action: "mask", segments} with one replacement per entry in ctx.segments:

export function checkInput(ctx) {
const masked = ctx.segments.map((s) => s.replace(/\d{3}-\d{2}-\d{4}/g, "<SSN>"));
const changed = masked.filter((s, i) => s !== ctx.segments[i]).length;
if (changed === 0) return { action: "none" };
return { action: "mask", segments: masked, counts: { US_SSN: changed } };
}

counts is optional and records what you detected, by name, for the request's usage event. Record names only — recording matched content would put it in your telemetry.

Where AISIX cannot substitute the text back, a rewrite request blocks rather than releasing the original, so a policy is never half-applied.

Limits and Failure Behavior

SettingDefaultDescription
timeout_ms5000Budget for one hook invocation, covering the script and every call it makes. Set per-call timeouts in the script itself.
max_memory_bytes16777216Memory ceiling for the script. One response body may use a quarter of it, since the body is parsed inside the same memory.
fail_openfalseWhat happens to a request the script could not screen.
output_fail_openfalseThe same, for a response.

The budget covers two kinds of runaway independently: a script stuck in a loop is interrupted, and a script waiting on a call that never answers is abandoned. Either way the failure policy decides the verdict.

Each invocation runs in a fresh sandbox, so nothing a script stores survives to the next request. Screening state that has to persist belongs in your own service.

Next Steps