8. 安全和权限控制

前面七篇文章给 Agent 持续增加能力:工具调用、持久记忆、任务规划、能力扩展、团队协作、上下文压缩。每一步都在扩展它可操作的范围。

这一篇的方向相反:引入约束。当一个能执行 bash 命令的 Agent 运行在真实系统上时,它的破坏力不低于一个初级运维。区别在于——运维误操作的责任在他自己,Agent 误操作的指令是你发给它的。

威胁模型

LLM 不具有主动恶意。它的问题不是攻击意图,而是指令理解的不完备。

你要求”清理临时文件”,它可能判定 /tmp 不够彻底,将递归范围扩展到 /。你要求”执行那个脚本”,它可能从网络检索到一段 curl | bash 并原样运行。你要求”检查大文件的末尾几行”,它可能将整个文件加载进 messages,直接耗尽上下文窗口。

这些行为都属于非预期执行,而非恶意破坏。LLM 在尽力完成你交代的任务——它不理解 rmrm -rf 在破坏性上的差异,不具备判定脚本是否可信的能力,也不清楚你的系统边界在哪里。

安全设计的首要目标是防止 Agent 在正常执行过程中误伤系统。防御外部恶意注入是另一个问题域,优先级排在这之后。

三层过滤

安全策略的核心结构是:每条命令在落地执行之前,经过三道检查。过滤层次越靠近执行端,检查越严格。

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) {
  return !BLOCKLIST.some(p => p.test(command));
}

被黑名单拦截的命令,不能仅返回错误并静默。拦截原因需要传回给 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>) {
  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:输出截断

前两道防线控制命令的执行权限。第三道防线控制执行结果的传播范围。

这是第 7 篇的压缩机制覆盖不到的场景:LLM 执行一条命令后,工具返回了 10 万行日志。压缩在消息数量达到阈值时触发,但单条 tool 返回的数据量就足以在压缩触发前撑满上下文。

function truncateOutput(output: string, maxLength = 8000) {
  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) {
    if (!(await hook(tool, args))) return "命令已被安全策略拦截。";
  }
 
  let output = await runTool(tool, args);
 
  for (const hook of afterHooks) {
    output = await hook(tool, output);
  }
 
  return output;
}

新增安全策略变成一行 beforeHooks.push(...)。不修改核心逻辑,不影响已有 Hook。每个 Hook 的职责单一——黑名单只判断拦截与否、确认只处理用户交互、截断只控制输出长度。管道不关心各 Hook 的内部实现,仅负责按顺序调用。

Agent 核心循环对这一层完全无感知。它发送 tool_call,调用方执行并返回结果。中间的拦截、确认和截断对 LLM 透明——这正是分层设计的目标。

与生产方案的差距

上述实现能防御无心之过。生产级 Agent 的安全体系在此基础上做了几项重要扩展:

静态匹配 → 意图审计。 正则黑名单仅检查命令的文本形态。生产环境通常部署一个小模型进行语义审计——不匹配命令文本,而是推断”这条命令试图完成什么操作”。同样的 rm,清理构建缓存和删除用户目录的意图完全不同,正则无法区分。

宿主机执行 → 沙箱。 当前实现直接在宿主机上执行命令。Claude Code 使用 worktree 隔离文件操作,部分平台将 Agent 整体运行在 Docker 容器或轻量 VM 中。即使 Agent 执行了 rm -rf /,被销毁的是一个可重建的隔离环境,不是宿主机的文件系统。

字符截断 → 精确 token 预算。 截断阈值使用字符数,这是一种粗略的近似。生产环境中使用精确的 token 计数,动态跟踪每次模型调用剩余的额度并按需调配。

思路不变:不让 LLM 的输出直接作用于系统,中间过滤,层层收紧。

八篇串联

  1. Agent Loop — 循环调用工具的核心调度逻辑
  2. Memory — 持久化 Agent 的运行轨迹
  3. Plan — 复杂任务的分解与执行策略
  4. MCP — 定义行为边界与能力扩展协议
  5. SubAgent — 子任务委派与并行执行
  6. Multi-Agent — 从单一函数到持久对象,从独立执行到团队协作
  7. 上下文压缩 — 抑制 messages 无限增长,保障长任务续航
  8. 安全与权限 — 在能力与可控性之间建立边界

前六篇扩展 Agent 的能力范围。后两篇建立机制,防止能力超出可控边界。

为 Agent 增加一个工具只需要几行代码。划定一条它不可跨越的边界,需要系统性地判断:哪些操作绝对禁止,哪些需要人工批准,哪些可以自动执行。这种分层的安全决策,比增加工具更复杂。


下一篇9. RAG 知识库