8. 安全和权限控制

前面七篇文章做的事情很一致:给 Agent 塞能力。工具、记忆、规划、团队、压缩——每一步都在让它能碰更多东西。

这篇反过来。我们要给它踩刹车。

当你把一个能执行 bash 命令的 Agent 放到真实系统上时,它的破坏力不比一个初级运维差。区别在于——初级运维搞炸了,大概率是自己的锅。Agent 搞炸了,是你让它干的。

它不是故意的

LLM 不会主动作恶。它没有恶意,只有误解。

你说”清理一下临时文件”,它觉得 /tmp 不够干净,顺手扫到了 /。你说”帮我跑一下那个脚本”,它从网上搜到一段 curl | bash,原样执行。你让它”检查这个大文件的最后几行”,它直接把整个文件塞进 messages,上下文当场爆掉。

这些都是无心之过。LLM 只是在尽力完成你交代的事——它不知道 rm 和 rm -rf 的区别,不知道哪个脚本是安全的,不知道你的系统边界在哪。

所以安全设计的前提不是”防住恶意模型”,是别让它误伤自己

三层过滤

思路很简单:每条命令在落地执行之前,过三道检查。像一个漏斗——越靠近执行层,拦得越紧。

LLM 输出命令


┌──────────────┐
│  黑名单拦截   │ ← 防线 1:明显找死,直接毙掉
└──────┬───────┘
       │ 通过

┌──────────────┐
│  用户确认     │ ← 防线 2:高危操作,让人看一眼
└──────┬───────┘
       │ 通过

┌──────────────┐
│  执行 & 截断  │ ← 防线 3:输出太大,直接掐
└──────────────┘

防线 1:黑名单——找死的不给机会

有些命令不需要问。直接拦。

const BLOCKLIST = [
  /rm\s+(-[rRf]+\s+)*\//,        // rm 作用于根目录
  /mkfs\./,                        // 格式化
  />\s*\/dev\/sd/,                 // 重定向覆盖磁盘设备
  /chmod\s+(-[Rr]+\s+)*777/,      // 危险的权限变更
  /dd\s+if=.*of=\/dev\/sd/,        // 直接写磁盘
  /:\(\)\s*\{\s*:\s*\|:&\s*\};:/, // fork bomb
];
 
function checkBlocklist(command: string): boolean {
  for (const pattern of BLOCKLIST) {
    if (pattern.test(command)) {
      return false; // 拦截
    }
  }
  return true;
}

被黑名单拦下的命令,不能只报错然后沉默。要把拦截原因传回给 LLM——它看到”你的命令被安全策略拦截:禁止对根目录执行递归删除”,通常会自己换个安全的方案。LLM 会调整,只要你告诉它哪条路走不通。

防线 2:用户确认——过不去的就叫人

黑名单没拦住的,不代表就安全。rm -rf ./node_modules 不触及黑名单,但如果你在项目根目录跑,它就等于清空依赖。

确认策略按工具分级,不给所有工具上同一把锁:

type ConfirmationLevel = "allow" | "confirm" | "block";
 
const TOOL_POLICIES: Record<string, ConfirmationLevel> = {
  read: "allow",           // 只读,直接放行
  grep: "allow",            // 只读
  write: "confirm",         // 写入,确认
  edit: "confirm",          // 修改,确认
  bash: "confirm",          // 终端,一律确认
};
 
async function confirmIfNeeded(
  tool: string,
  args: Record<string, unknown>
): Promise<boolean> {
  const level = TOOL_POLICIES[tool] ?? "confirm";
 
  if (level === "allow") return true;
  if (level === "block") return false;
 
  // 进入确认流程
  const message = formatConfirmation(tool, args);
  const response = await askUser(message); // Allow / Deny / Quit
  return response === "allow";
}

readgrep 直接放行。读文件搞不出什么破坏。writeedit 要确认——改对了没事,改错了就可能丢数据。bash 一律确认,因为你不确定 LLM 会在里面写什么。

确认不是永久的。用户每次看到弹窗,三条路:Allow(这次放行)、Deny(这次拒绝)、Quit(终止整个任务)。不设”记住我的选择”——安全决策的上下文会变,这次放行的 rm ./dist,下次可能就不该放。

防线 3:输出截断——别让结果撑爆上下文

前两道防线管的是”能不能执行”。第三道管的是”执行完了之后”。

防线 3 解决第 7 篇压缩搞不定的场景:LLM 调了一个命令,返回了 10 万行日志。压缩是在多轮对话后触发,但这一条 tool 返回本身就足以把上下文撑满。

function truncateOutput(output: string, maxLength = 8000): string {
  if (output.length <= maxLength) return output;
 
  const head = output.slice(0, 2000);           // 保留头
  const tail = output.slice(output.length - 4000); // 保留尾
 
  return `${head}\n\n... [省略 ${output.length - 6000} 字符] ...\n\n${tail}`;
}

留头不留尾。头部通常是初始信息、命令回显;尾部是最终状态和报错堆栈。中间的大段重复日志,扔了不可惜。

被截断后,追加一行提示给 LLM:“输出过长已截断。如需完整内容,请用更精确的命令重新获取。” LLM 知道发生了什么,下次调用可以缩小范围。

从硬编码到 Hook 管道

上面的实现把安全检查塞进了每个工具函数里。初期还行,但随着策略变多——黑名单、确认、截断、审计日志、速率限制——代码会变成一锅粥。

解法是 Hook。把安全检查从工具函数里抽出来,变成可插拔的管道:

type BeforeHook = (tool: string, args: Record<string, unknown>) => Promise<boolean>;
type AfterHook = (tool: string, result: string) => Promise<string>;
 
const beforeHooks: BeforeHook[] = [
  blacklistHook,      // 防线 1
  confirmHook,         // 防线 2
  auditLogHook,        // 审计:记录所有工具调用
];
 
const afterHooks: AfterHook[] = [
  truncateHook,        // 防线 3
];
 
async function executeTool(tool: string, args: Record<string, unknown>) {
  // 执行前管道
  for (const hook of beforeHooks) {
    const ok = await hook(tool, args);
    if (!ok) return "命令已被安全策略拦截。";
  }
 
  const result = await runTool(tool, args);
 
  // 执行后管道
  let output = result;
  for (const hook of afterHooks) {
    output = await hook(tool, output);
  }
 
  return output;
}

新增安全策略变成了一行 beforeHooks.push(...)。不碰核心逻辑,不影响已有 Hook。每个 Hook 只做一件事——黑名单只管拦截、确认只管问用户、截断只管裁长度。管道本身不关心 Hook 内部干了什么,它只负责按顺序调。

Agent 核心循环里也感受不到这套东西的存在。它发 tool_call,你的代码执行,结果回来。中间的拦截、确认、截断,对 LLM 完全透明。

和生产方案的差距

这套实现够防住无心之过。生产级 Agent 的安全底座要厚实得多:

静态匹配 → 意图审计。 我们的黑名单是正则——看命令长什么样。生产上会跑一个小模型做语义审计:不是看命令文本,而是理解”这条命令想干什么”。同样的 rm,清 build 缓存和删用户目录,意图完全不同。正则分不出这两个场景。

宿主机执行 → 沙箱。 我们直接在本地跑命令。Claude Code 可以用 worktree 隔离文件操作,一些平台把 Agent 整个丢进 Docker 容器或轻量 VM。即使 Agent 真的执行了 rm -rf /,毁掉的也是一个可重建的容器,不是你宿主机上的数据。

条数限制 → 精确 token 预算。 截断阈值用的字符数,图省事。生产上用 token 计数器,精确到每个模型调用还剩多少额度,动态调配。

核心思路倒是没变:不让 LLM 的输出直接落地,中间过滤,层层收紧。

八篇串联

回顾一下整个过程:

  1. Agent Loop — 心脏,让 LLM 能循环调用工具
  2. Memory — 记性,让 Agent 记得发生过什么
  3. Plan — 规划,复杂任务先拆再执行
  4. Rules / Skills / MCP — 边界和武器,定义能做什么、怎么做
  5. SubAgent — 分身,把子任务委派出去
  6. Multi-Agent — 团队,从函数变成对象,从单打变协作
  7. 上下文压缩 — 续航,不让 messages 把自己压死
  8. 安全与权限 — 刹车,不让能力变成破坏力

前六篇在铺路——让 Agent 能干更多。后两篇在修护栏——让它不会跑着跑着翻车。

给 Agent 加一个工具只需要几行代码。给它划一条线,不让它踩过去,需要想清楚哪些东西绝对不能碰、哪些要问过你才行、哪些可以放心让它自己来。这套分层的判断,比加工具难。


下一篇9. RAG 知识库