自定义脚本安全护栏
自定义脚本安全护栏会在网关内运行你编写的检查逻辑。它适用于自研分类器、AISIX 尚未集成的商业产品,或组合多项检查的策略。
远程安全护栏集成使用特定服务提供方的协议。内容筛查 API 没有行业标准,因此连接其他服务通常需要一个适配器,在 AISIX 与该服务之间转换协议。自定义脚本则把这层转换放在网关内部。
你可以通过 AISIX Cloud 或开源 AISIX 网关的 resources.yaml 文件配置自定义脚本安全护栏。本指南将编写一个脚本,限制其初始作用域,并验证 AISIX 会在调用上游模型前阻断命中的请求。
准备工 作
开始前,请准备以下内容:
- 阅读安全护栏行为,了解钩子点、执行模式和失败策略。
- 一个网关可以访问的检查服务。脚本在你自己的网关内运行,因此它可以是你私有网络中的服务。
- 以下配置路径之一:
- AISIX Cloud,其中包含环境、已接入的网关和具有写权限范围的 Admin Token。对于 On-Premises,请按照 AISIX Cloud 快速入门操作。如需申请混合云访问权限,请联系 API7。
- 加载声明式
resources.yaml文件的开源 AISIX 网关。
- 可用的模型别名和能发送 Chat Completions 请求的调用方 API Key。
curl。AISIX Cloud 路径还会用到jq。
导出验证请求使用的网关参数:
# AISIX_PROXY 不含尾部斜杠或端点路径
# 本地快速入门使用 http://127.0.0.1:3000
export AISIX_PROXY="YOUR_AISIX_GATEWAY_URL"
export AISIX_API_KEY="YOUR_CALLER_API_KEY"
export AISIX_MODEL="YOUR_MODEL_ALIAS"
# 用于 Cloud 创建请求,或传给开源网关进程。
export SCREENING_SERVICE_KEY="YOUR_SCREENING_SERVICE_KEY"
编写脚本
脚本是一个 ES 模块,导出 checkInput、checkOutput 或两者。未导出的钩子会被跳过,因此一个脚本可以只覆盖一个方向。
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" };
}
如该脚本在收到非 2xx 响应时所做的那样,抛出异常会把决定交给安全护栏的失败策略,而不是自行猜测。默认策略会阻断脚本无法检查的请求。
钩子收到什么
| 字段 | 说明 |
|---|---|
ctx.hook | "input" 或 "output"。 |
ctx.text | 所有文本片段拼接后的结果,适用于把请求作为整体检查的脚本。 |
ctx.segments | 以数组形式给出的文本片段。做改写的脚本需为每个片段返回一个替换值。 |
ctx.messages | 带角色的片段,形如 {role, text}。 |
ctx.model | 请求指向的模型。仅输入钩子有。 |
ctx.secrets | 安全护栏上配置的凭证值,按名称索引。 |
钩子返回什么
| 返回值 | 结果 |
|---|---|
{action: "none"} | 放行。 |
{action: "block", reason_code, reason} | 拒绝。两个字段都只进入网关日志,都不会到达调用方。 |
{action: "mask", segments} | 放行,但内容已被替换。segments 的条目数必须与 ctx.segments 一致。在包含下文所述安全遥测行为的网关上,可选的旧版 counts 对象仍会被接受,但其内容会被忽略。 |
返回其他任何内容——包括什么都不返回——都会被当作失败,而不是放行。一个什么都没判定的脚本,不应被读成一个判定为允许的脚本。
脚本可以调用什么
| API | 用途 |
|---|---|
fetch(url, init) | 调用你的服务。返回 {status, ok, headers, text(), json()}。 |
console.log/info/warn/error/debug | 写入网关日志。 |
crypto.hmac(alg, key, data, outEncoding, keyEncoding) | HMAC-SHA1 或 HMAC-SHA256,用于需要签名请求的服务。 |
crypto.hash(alg, data, outEncoding) | SHA-1 或 SHA-256。 |
crypto.base64Encode/base64Decode(data) | Base64。 |
crypto.randomUUID() | 随机 UUID ,可用作 nonce。 |
aisix.embed(model, texts) | 使用同一 AISIX Cloud 环境或资源文件配置中可用的 embedding 模型对文本进行向量化。 |
crypto.hmac 接受并返回 "hex" 或 "base64",读取密钥时支持 "utf8"、"hex" 或 "base64"。之所以支持以 hex 读取密钥,是为了能表达派生链——每一步的原始输出作为下一步的密钥:
let k = crypto.hmac("sha256", "AWS4" + ctx.secrets.SECRET, date, "hex");
k = crypto.hmac("sha256", k, region, "hex", "hex");
脚本无法访问文件系统和环境变量,也没有定时器。fetch 失败或算法不受支持都会抛出异常,因此你的脚本可以自行决定如何处理。
创建自定义脚本安全护栏
选择一种配置路径。无论使用哪种路径,都要先将安全护栏挂载到一个测试模型,再发送流量。
AISIX Cloud
导出控制面连接参数和测试模型 ID:
# AISIX_CP 包含 /api,且不含尾部斜杠
# 本地私有化部署快速入门使用 http://localhost:8080/api
export AISIX_CP="YOUR_AISIX_CLOUD_ADMIN_API_URL"
export AISIX_TOKEN="YOUR_ADMIN_TOKEN"
export ENV_ID="YOUR_ENVIRONMENT_ID"
export MODEL_ID="YOUR_TEST_MODEL_ID"
以禁用状态创建安全护栏,并获取其 ID。使用 jq 构建请求可以保留多行脚本,并安全编码检查服务的凭证:
CUSTOM_SCRIPT=$(cat <<'EOF'
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" };
}
EOF
)
CUSTOM_PAYLOAD=$(
jq -n \
--arg script "$CUSTOM_SCRIPT" \
--arg scanKey "$SCREENING_SERVICE_KEY" \
'{
name: "in-house-screening",
enabled: false,
kind: "custom",
hook_point: "input",
fail_open: false,
config: {
script: $script,
secrets: {SCAN_KEY: $scanKey},
timeout_ms: 5000
}
}'
)
GUARDRAIL_RESPONSE=$(printf '%s\n' "$CUSTOM_PAYLOAD" | \
curl --fail-with-body -sS -X POST "$AISIX_CP/environments/$ENV_ID/guardrails" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @-)
export GUARDRAIL_ID=$(printf '%s\n' "$GUARDRAIL_RESPONSE" | jq -er '.guardrail.id')
AISIX Cloud 会在创建或更新安全护栏时验证脚本。凭证值会静态加密且永不返回;读取操作只会显示凭证名称。
先把安全护栏挂载到测试模型,再启用它。&& 可以防止挂载失败后启用一个没有作用域的安全护栏:
curl --fail-with-body -sS -X POST "$AISIX_CP/environments/$ENV_ID/guardrails/$GUARDRAIL_ID/attachments" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"scope_type\": \"model\",
\"scope_id\": \"${MODEL_ID}\"
}" && \
curl --fail-with-body -sS -X PATCH "$AISIX_CP/environments/$ENV_ID/guardrails/$GUARDRAIL_ID" \
-H "Authorization: Bearer $AISIX_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"enabled": true
}'
AISIX Cloud 控制台提供脚本编辑器、凭证名称/值编辑器和作用域选择。在安全护栏必须保持禁用直至挂载成功时,请使用上述 Admin API 顺序。
开源 AISIX 网关
将以下安全护栏和模型挂载添加到网关的资源文件中。把 scope_id 设为你在 AISIX_MODEL 中导出的同一个模型别名。
如果 guardrails 或 guardrail_attachments 已经存在,请把条目添加到现有集合中,不 要创建重复的顶层键。块标量可以让脚本保持可读,而环境变量插值可避免把检查服务凭证写入文件。
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: ${SCREENING_SERVICE_KEY}
timeout_ms: 5000
guardrail_attachments:
- guardrail_id: in-house-screening
scope_type: model
scope_id: YOUR_MODEL_ALIAS
priority: 100
模型挂载会把安全护栏限制在该别名上。没有任何挂载时,安全护栏会加载,但不会检查流量。
应用资源文件前,先在一个短生命周期容器中验证完整文件。
SCREENING_SERVICE_KEY 是网关进程新增的环境变量,因此正在运行的容器无法继承主机中的导出值。请在 resources.yaml 所在目录运行以下命令。该命令假定快速入门中的 OPENAI_API_KEY 和 CALLER_API_KEY 变量已存在;请替换或添加 -e 条目,确保容器能收到文件引用的每个环境变量。
docker run --rm \
-v "$(pwd):/etc/aisix:ro" \
-e OPENAI_API_KEY \
-e CALLER_API_KEY \
-e SCREENING_SERVICE_KEY \
--entrypoint /usr/local/bin/aisix \
ghcr.io/api7/aisix:1.2.0 \
validate --resources /etc/aisix/resources.yaml
验证通过后,按照添加新环境变量中的步骤重新创建网关,并在该流程中同样将镜像替换为 ghcr.io/api7/aisix:1.2.0。
aisix validate 会检查资源结构,并构建每个已启用的安全护栏。如果自定义脚本无法编译,它会以非零状态退出,因此只能在验证成功后应用该文件。
验证安全护栏
发送一个你的检查服务会拒绝的请求:
curl -i "$AISIX_PROXY/v1/chat/completions" \
-H "Authorization: Bearer $AISIX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "'"$AISIX_MODEL"'",
"messages": [
{
"role": "user",
"content": "something your service denies"
}
]
}'
AISIX 返回 422 和 content_filter 错误,并且不会调用上游模型。无关的请求照常返回模型的回答。
调试脚本
没有单独的控制台可以试运行脚本。在允许、阻断和失败路径都符合预期前,请让安全护栏保持只挂载到一个测试模型。
- 准备一个测试模型别名。
- 无论使用 AISIX Cloud 还是
resources.yaml,都只把安全护栏挂载到该模型。 - 向测试别名发送请求并查看网关日志。脚本中
console函数的输出会显示在日志中,因此你可以检查服务返回了什么,以及脚本做出了什么决定。 - 只有在允许、阻断和失败路径都符合预期后,才扩大现有挂载的范围或添加其他挂载。
脚本行为不符合预期时,先检查两件事:
- 在部署前发现语法错误。 AISIX Cloud 会在保存安全护栏时拒绝语法错误。对于资源文件,
aisix validate会构建已启用的安全护栏;如果脚本无法编译,它会以非零状态退出。 - 运行期失败的脚本会出现在网关日志和该请求的用量事件中,原因字段会区分失败种类:
custom_timeout— 脚本超过了执行预算。custom_script_error— 脚本抛出了异常。custom_unknown_action— 返回的action不受支持。custom_no_verdict— 钩子没有返回决定或省略了action。custom_bad_verdict— 裁决的结构或类型错误,或脱敏结果缺少片段、片段数量不匹配。custom_engine_error— AISIX 无法启动脚本引擎或安装宿主函数。
检查模型响应
导出 checkOutput,并把 hook_point 设为 output 或 both。响应钩子以相同的结构接收模型的回复。
对于流式响应,AISIX 按窗口释放内容:每个窗口先被检查,通过后再释放。这样流式客户端仍能保持响应,而不必等待整段回答。如果你更希望缓冲整段响 应后一次性检查,可将 stream_processing_mode 设为 buffer_full。
改写内容
返回 {action: "mask", segments},为 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 };
}
包含此行为的网关会根据返回的片段生成用量事件计数:名称固定为 custom,值为替换结果与原文不同的片段数。脚本返回的 counts 对象仍会被接受,但其内容会被忽略。脚本不能自行选择遥测名称或数值,因为两者都可能暴露请求内容 或 ctx.secrets。AISIX Cloud 会原样存储用量详情,因此由旧版网关创建的记录可能保留脚本提供的旧版计数;升级不会改写这些记录。
在 AISIX 无法把文本替换回去的调用位置上,改写请求会阻断而不是放行原文,因此策略不会只被执行一半。
限制与失败行为
| 配置项 | 默认值 | 说明 |
|---|---|---|
timeout_ms | 5000 | 单次钩子调用的墙钟时间预算,涵盖脚本本身及其发起的每次调用。脚本 API 没有单独的计时器,也不能为单个 fetch 设置超时。 |
max_memory_bytes | 16777216 | 脚本的内存上限。单个响应体最多可占用其四分之一,因为响应体需要在同一块内存中解析。 |
fail_open | false | 是否放行脚本无法检查的请求。默认行为是阻断请求。 |
output_fail_open | false | 是否放行脚本无法检查的响应。默认行为是阻断响应。 |
该预算既涵盖陷入循环的脚本,也涵盖等待一个永不返回的调用的脚本。预算耗尽时,由失败策略决定最终裁决。
每次调用都在全新的沙箱中运行,因此脚本存下的任何内容都不会留到下一个请求。需要持久保存的检查状态应放在你自己的服务里。