上一篇把 MCP server 跑通,工具是真能调了。紧接着我就开始不安——我现在给它的权限是"能读能写能跑 shell",而它是一个会被上下文里一句话带偏的东西。
工具接上之后我做的第一件事不是加更多工具,是加护栏。这篇记的是我给工具调用层装的六道闸,零依赖,Node 18+ 直接跑,下面所有输出都是本机实跑的。
我为什么觉得护栏不是可选项
我去翻了 OWASP 2026 版的《Top 10 for Agentic Applications》,ASI01–ASI10 十个类别里,跟工具调用直接相关的有三条(核对日期 2026-09-29):
| 编号 | 风险 | 白话 |
|---|---|---|
| ASI02 | Tool Misuse and Exploitation | 用合法的工具干不合法的事——权限给太宽,出了事算我的 |
| ASI05 | Unexpected Code Execution | 提示词里塞一句 rm -rf /,它当任务执行了 |
| ASI08 | Cascading Failures | 两个 Agent 互相喂输出形成死循环,资源耗尽、账单爆炸 |
这份文档提的核心原则叫 Least-Agency(最小自主权),是最小权限原则在 Agent 场景的扩展:不必要的自主性就是攻击面。
落到工程上就一句话——它能做什么,要在代码里被显式地、可审计地限制住,而不是在提示词里写一句"请不要做坏事"。提示词是建议,代码才是约束。
护栏该装在哪一层
调用链一般是:
模型输出 → 解析成 tool_call → 【护栏】 → 真正执行 → 结果回灌唯一可靠的拦截点是"解析之后、执行之前"。这点我试过别的方案,都不行:
- 放在模型之前(提示词约束):它可能不听,也可能被注入绕开
- 放在执行之后(事后审计):
rm -rf已经跑完了,审计的价值只剩写事故报告 - 放在解析之后:参数已经是结构化 JSON,可以做确定性的、可测试的规则判断——这才是工程能兜住的部分
代码层面一句话:
const decision = guard.check(toolCall);
if (!decision.ok) return { error: decision.code, reason: decision.reason };
// 注意:用护栏返回的 params,它可能已被钳制过
const result = await realTool(decision.params);关键点:要用护栏返回的 params,不是原始的。因为它可能把 max_bytes: 99999999 削到安全范围,也可能做了路径规范化。这行我一开始就写错了,用了原始 params,钳制等于白做。
六道闸门
按顺序过,任何一道不过就短路返回:
- 预算闸 —— 最大步数、最大工具调用总数(防 ASI08 的账单爆炸)
- 白名单闸 —— 工具名不在声明列表里,直接拒
- 参数 Schema 闸 —— 必填、类型、未知参数;数值超限钳制而非拒绝
- 路径边界闸 —— 解析后必须落在工作区内,挡住
../../ - 命令黑名单闸 —— 正则命中危险模式即拒,不做智能判断
- 循环熔断闸 —— 相同调用指纹在滑动窗口内重复到阈值就断
第 3 道我想多说两句:超限用钳制,不要拒绝。
我一开始写的是"超上限就拒",跑测试的时候发现问题:Agent 说要读一个文件、参数填 100MB,这多半不是攻击,是它没数。直接拒绝,任务当场失败;钳到 1MB,既安全又能继续跑。区分"恶意"和"蠢",护栏才活得下去。
完整实现
先是工具声明和危险命令表——这是唯一需要按自己场景改的地方:
const P = require('path').posix;
const ROOT = '/srv/agent/workspace'; // 文件类工具的活动边界
const TOOLS = {
read_file: {
params: {
path: { type: 'string', required: true },
max_bytes: { type: 'number', required: false, default: 65536, max: 1048576 },
},
touchesFs: true,
},
write_file: {
params: {
path: { type: 'string', required: true },
content: { type: 'string', required: false, default: '', maxLength: 200000 },
},
touchesFs: true,
},
list_dir: {
params: { path: { type: 'string', required: false, default: ROOT } },
touchesFs: true,
},
run_shell: { params: { cmd: { type: 'string', required: true } }, touchesShell: true },
};
// 危险命令模式:命中即拒,不做任何"智能"判断
const DANGEROUS = [
{ re: /\brm\s+-[rRf]{1,2}\b/, why: '递归删除' },
{ re: /\bdd\s+if=/, why: '裸盘写入' },
{ re: /\b(shutdown|reboot|init\s+0)\b/, why: '关机/重启' },
{ re: /\bsudo\b/, why: '提权' },
{ re: /\b(curl|wget)\b[^\n|]*\|\s*(ba)?sh\b/, why: '下载即执行' },
{ re: />\s*\/dev\/sd[a-z]/, why: '写入块设备' },
{ re: /:\(\)\s*\{/, why: 'fork 炸弹' },
];路径边界检查,用 path.posix 保证跨平台行为一致:
function withinRoot(p) {
const abs = P.resolve(ROOT, String(p));
if (abs !== ROOT && !abs.startsWith(ROOT + '/')) return null;
return abs;
}核心 check():
check(call) {
const tool = call.tool;
const params = { ...(call.params || {}) };
const deny = (code, reason) => {
this.log(false, tool, params, code, reason);
return { ok: false, code, reason };
};
// 闸门 1:预算
this.steps++; this.calls++;
if (this.steps > this.maxSteps) return deny('BUDGET_STEPS', `步数超过上限 ${this.maxSteps}`);
if (this.calls > this.maxCalls) return deny('BUDGET_CALLS', `工具调用总数超过上限 ${this.maxCalls}`);
// 闸门 2:白名单
const def = TOOLS[tool];
if (!def) return deny('TOOL_NOT_ALLOWED', `工具 "${tool}" 未在白名单内`);
// 闸门 3:参数 schema(含默认值填充与超限钳制)
const clamps = [];
for (const [name, rule] of Object.entries(def.params)) {
let v = params[name];
if (v === undefined) {
if (rule.required) return deny('PARAM_MISSING', `必填参数 "${name}" 缺失`);
if (rule.default !== undefined) { params[name] = rule.default; v = rule.default; }
else continue;
}
if (typeof v !== rule.type)
return deny('PARAM_TYPE', `参数 "${name}" 应为 ${rule.type},实际 ${typeof v}`);
if (rule.type === 'number' && rule.max !== undefined && v > rule.max) {
clamps.push(`${name}: ${v} → ${rule.max}`);
params[name] = rule.max;
}
}
for (const name of Object.keys(params))
if (!def.params[name]) return deny('PARAM_UNKNOWN', `参数 "${name}" 不属于该工具`);
// 闸门 4:路径边界
if (def.touchesFs && params.path !== undefined) {
const safe = withinRoot(params.path);
if (!safe) return deny('PATH_ESCAPE', `路径 "${params.path}" 越出工作区 ${ROOT}`);
params.path = safe;
}
// 闸门 5:命令黑名单
if (def.touchesShell && params.cmd !== undefined) {
for (const d of DANGEROUS)
if (d.re.test(params.cmd)) return deny('CMD_DANGEROUS', `命令命中危险模式(${d.why})`);
}
// 闸门 6:循环熔断
const fp = tool + ':' + JSON.stringify(params);
this.recent.push(fp);
if (this.recent.length > 12) this.recent.shift();
const dup = this.recent.filter(x => x === fp).length;
if (dup >= this.loopThreshold)
return deny('LOOP_DETECTED', `相同调用出现 ${dup} 次(阈值 ${this.loopThreshold})`);
this.log(true, tool, params, 'ALLOW', clamps.join(', '));
return { ok: true, code: 'ALLOW', reason: 'ok', params, note: clamps.join(', ') };
}跑出来的结果
六个场景,24 次调用。本机实际输出:
### 场景 B:越权与非法输入
工具 结果 判定码 说明
------------------------------------------------------------------------------
exec 拦截 TOOL_NOT_ALLOWED 工具 "exec" 未在白名单内
read_file 拦截 PARAM_MISSING 必填参数 "path" 缺失
read_file 拦截 PARAM_TYPE 参数 "path" 应为 string,实际 number
read_file 拦截 PATH_ESCAPE 路径 "../../etc/passwd" 越出工作区
read_file 拦截 PATH_ESCAPE 路径 "/etc/shadow" 越出工作区
write_file 拦截 PARAM_UNKNOWN 参数 "mode" 不属于该工具
### 场景 C:危险命令
run_shell 放行 ALLOW 正常执行 ← ls -la 正常放行
run_shell 拦截 CMD_DANGEROUS 命令命中危险模式(递归删除)
run_shell 拦截 CMD_DANGEROUS 命令命中危险模式(下载即执行)
run_shell 拦截 CMD_DANGEROUS 命令命中危险模式(提权)
### 场景 D:参数钳制
read_file 放行 ALLOW 参数已钳制: max_bytes: 99999999 → 1048576
### 场景 E:循环熔断
read_file 放行 ALLOW
read_file 放行 ALLOW
read_file 放行 ALLOW
read_file 放行 ALLOW
read_file 拦截 LOOP_DETECTED 相同调用出现 3 次(阈值 3)
read_file 拦截 LOOP_DETECTED 相同调用出现 3 次(阈值 3)
### 场景 F:预算耗尽(maxSteps=3)
list_dir 放行 ALLOW
read_file 放行 ALLOW
read_file 放行 ALLOW
read_file 拦截 BUDGET_STEPS 步数超过上限 3
总调用 24 次 | 放行 12 | 拦截 12场景 C 里 ls -la 被正常放行,这是我特意要看的:黑名单不是一刀切禁 shell。能放行的要放行,护栏的价值在精确,不在粗暴。一个把所有 shell 调用都拒掉的护栏,会让 Agent 变成废物。
每次判定都写进 guard.audit,长这样,可以落盘、可以回放:
step=1 DENY tool=exec code=TOOL_NOT_ALLOWED 工具 "exec" 未在白名单内
step=2 DENY tool=read_file code=PARAM_MISSING 必填参数 "path" 缺失
step=4 DENY tool=read_file code=PATH_ESCAPE 路径 "../../etc/passwd" 越出工作区出事之后能回答"它当时到底干了什么",这个比拦截本身更重要。
这套护栏拦不住什么
这部分比前面更值得看,别看完代码就觉得万事大吉:
- 语义层面的滥用。用完全合法的参数把客户名单读出来、再用合法的邮件工具发出去——单看每一步都是合规调用。这是 ASI02 最难的部分,规则拦不住,只能靠工具权限本身的设计:读工具就不该有写权限,内部工具就不该能对外发。
- 间接提示注入。护栏在工具调用层,注入发生在模型输入层。它读到一份含隐藏指令的文档后改了目标,后续所有调用看起来都"合法",护栏对此无感。
- 慢速循环。我的循环检测用 12 次滑动窗口,能抓"卡住了反复重试",抓不到"每隔 50 步来一次"。真要防得按指纹累计计数,不是窗口计数。
- 多 Agent 的混淆代理人。A 让 B 去做 B 本来不该做的事,B 的护栏看 A 是"可信调用方"。这是 ASI03,得在 Agent 间通信层做意图签名,单层护栏解决不了。
所以护栏是下限,不是上限。它保证"最蠢的错误不会造成最大的损失",不保证系统安全。
最后说两句
如果只从这篇带走一件事,我希望是这句:护栏必须是代码,不能是提示词。
提示词约束是给模型一个建议,遵守与否取决于它当天的心情和上下文里塞了什么;代码约束是给系统一个不变量,无论它怎么发疯都不会变。这两者的可靠性差着几个数量级,而 Agent 这个东西的特色恰恰是——它会以你没想到的方式发疯。
"拒绝"和"钳制"要分开,这条是我写完第一版才想明白的。一个只会说"不"的护栏,Agent 会跟它反复拉扯,最后任务失败、用户骂娘。会削参数的护栏才活得下去。
还有一句不太中听的:就算六道闸全装上,让它跑删数据、转账、改权限这类操作,还是应该有人按确认键。护栏的作用是把"需要人确认"的次数从 100 次降到 3 次,不是降到 0 次。降到 0 次那天,离事故就不远了。
参考与核对
- OWASP Top 10 for Agentic Applications for 2026(ASI01–ASI10):https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026/ —— 核对日期 2026-09-29
- 本文代码与测试脚本:
agent-guard.js/guard-test.js,零依赖,Node 18+ 可直接运行
