8. 安全和权限控制
前面七篇文章给 Agent 持续增加能力:工具调用、持久记忆、任务规划、能力扩展、团队协作、上下文压缩。每一步都在扩展它可操作的范围。
这一篇的方向相反:引入约束。当一个能执行 bash 命令的 Agent 运行在真实系统上时,它的破坏力不低于一个初级运维。区别在于——运维误操作的责任在他自己,Agent 误操作的指令是你发给它的。
威胁模型
LLM 不具有主动恶意。它的问题不是攻击意图,而是指令理解的不完备。
你要求”清理临时文件”,它可能判定 /tmp 不够彻底,将递归范围扩展到 /。你要求”执行那个脚本”,它可能从网络检索到一段 curl | bash 并原样运行。你要求”检查大文件的末尾几行”,它可能将整个文件加载进 messages,直接耗尽上下文窗口。
这些行为都属于非预期执行,而非恶意破坏。LLM 在尽力完成你交代的任务——它不理解 rm 和 rm -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";
}read 和 grep 只读文件系统,不产生副作用,直接放行。write 和 edit 修改文件内容,需要确认——修改正确则无事,修改错误则数据丢失。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 的输出直接作用于系统,中间过滤,层层收紧。
八篇串联
- Agent Loop — 循环调用工具的核心调度逻辑
- Memory — 持久化 Agent 的运行轨迹
- Plan — 复杂任务的分解与执行策略
- MCP — 定义行为边界与能力扩展协议
- SubAgent — 子任务委派与并行执行
- Multi-Agent — 从单一函数到持久对象,从独立执行到团队协作
- 上下文压缩 — 抑制 messages 无限增长,保障长任务续航
- 安全与权限 — 在能力与可控性之间建立边界
前六篇扩展 Agent 的能力范围。后两篇建立机制,防止能力超出可控边界。
为 Agent 增加一个工具只需要几行代码。划定一条它不可跨越的边界,需要系统性地判断:哪些操作绝对禁止,哪些需要人工批准,哪些可以自动执行。这种分层的安全决策,比增加工具更复杂。
下一篇:9. RAG 知识库