上一篇把 MCP server 跑通,工具是真能调了。紧接着我就开始不安——我现在给它的权限是"能读能写能跑 shell",而它是一个会被上下文里一句话带偏的东西。

工具接上之后我做的第一件事不是加更多工具,是加护栏。这篇记的是我给工具调用层装的六道闸,零依赖,Node 18+ 直接跑,下面所有输出都是本机实跑的。

我为什么觉得护栏不是可选项

我去翻了 OWASP 2026 版的《Top 10 for Agentic Applications》,ASI01–ASI10 十个类别里,跟工具调用直接相关的有三条(核对日期 2026-09-29):

编号风险白话
ASI02Tool Misuse and Exploitation用合法的工具干不合法的事——权限给太宽,出了事算我的
ASI05Unexpected Code Execution提示词里塞一句 rm -rf /,它当任务执行了
ASI08Cascading 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,钳制等于白做。

六道闸门

按顺序过,任何一道不过就短路返回:

  1. 预算闸 —— 最大步数、最大工具调用总数(防 ASI08 的账单爆炸)
  2. 白名单闸 —— 工具名不在声明列表里,直接拒
  3. 参数 Schema 闸 —— 必填、类型、未知参数;数值超限钳制而非拒绝
  4. 路径边界闸 —— 解析后必须落在工作区内,挡住 ../../
  5. 命令黑名单闸 —— 正则命中危险模式即拒,不做智能判断
  6. 循环熔断闸 —— 相同调用指纹在滑动窗口内重复到阈值就断

第 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" 越出工作区

出事之后能回答"它当时到底干了什么",这个比拦截本身更重要。

这套护栏拦不住什么

这部分比前面更值得看,别看完代码就觉得万事大吉:

  1. 语义层面的滥用。用完全合法的参数把客户名单读出来、再用合法的邮件工具发出去——单看每一步都是合规调用。这是 ASI02 最难的部分,规则拦不住,只能靠工具权限本身的设计:读工具就不该有写权限,内部工具就不该能对外发。
  2. 间接提示注入。护栏在工具调用层,注入发生在模型输入层。它读到一份含隐藏指令的文档后改了目标,后续所有调用看起来都"合法",护栏对此无感。
  3. 慢速循环。我的循环检测用 12 次滑动窗口,能抓"卡住了反复重试",抓不到"每隔 50 步来一次"。真要防得按指纹累计计数,不是窗口计数。
  4. 多 Agent 的混淆代理人。A 让 B 去做 B 本来不该做的事,B 的护栏看 A 是"可信调用方"。这是 ASI03,得在 Agent 间通信层做意图签名,单层护栏解决不了。

所以护栏是下限,不是上限。它保证"最蠢的错误不会造成最大的损失",不保证系统安全。

最后说两句

如果只从这篇带走一件事,我希望是这句:护栏必须是代码,不能是提示词。

提示词约束是给模型一个建议,遵守与否取决于它当天的心情和上下文里塞了什么;代码约束是给系统一个不变量,无论它怎么发疯都不会变。这两者的可靠性差着几个数量级,而 Agent 这个东西的特色恰恰是——它会以你没想到的方式发疯。

"拒绝"和"钳制"要分开,这条是我写完第一版才想明白的。一个只会说"不"的护栏,Agent 会跟它反复拉扯,最后任务失败、用户骂娘。会削参数的护栏才活得下去。

还有一句不太中听的:就算六道闸全装上,让它跑删数据、转账、改权限这类操作,还是应该有人按确认键。护栏的作用是把"需要人确认"的次数从 100 次降到 3 次,不是降到 0 次。降到 0 次那天,离事故就不远了。


参考与核对

Last modification:September 29, 2026
如果觉得我的文章对你有用,请随意赞赏