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";
}read 和 grep 直接放行。读文件搞不出什么破坏。write 和 edit 要确认——改对了没事,改错了就可能丢数据。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 的输出直接落地,中间过滤,层层收紧。
八篇串联
回顾一下整个过程:
- Agent Loop — 心脏,让 LLM 能循环调用工具
- Memory — 记性,让 Agent 记得发生过什么
- Plan — 规划,复杂任务先拆再执行
- Rules / Skills / MCP — 边界和武器,定义能做什么、怎么做
- SubAgent — 分身,把子任务委派出去
- Multi-Agent — 团队,从函数变成对象,从单打变协作
- 上下文压缩 — 续航,不让 messages 把自己压死
- 安全与权限 — 刹车,不让能力变成破坏力
前六篇在铺路——让 Agent 能干更多。后两篇在修护栏——让它不会跑着跑着翻车。
给 Agent 加一个工具只需要几行代码。给它划一条线,不让它踩过去,需要想清楚哪些东西绝对不能碰、哪些要问过你才行、哪些可以放心让它自己来。这套分层的判断,比加工具难。
下一篇:9. RAG 知识库