Agent 安全护栏四重奏:路径沙箱、审批模式、敏感脱敏与重复拦截
为什么 Agent 需要安全护栏?
一个能调用工具的 Agent 本质上拥有代码执行能力。read_file 能读文件系统,search_code 能执行 grep 命令,write_file 能覆盖本地文件。如果不加约束,一个错误的工具调用就能造成实际破坏。
这和传统 Web 应用的安全模型完全不同。传统应用中,用户能做什么由前端表单和 API 接口限定。但在 Agent 架构里,LLM 决定了调用什么工具、传什么参数——而 LLM 的输出不可靠。它可能因为 prompt 歧义、幻觉、甚至 prompt injection 而做出危险操作。
我的代码审查 Agent 实现了四道安全护栏,全部在 tool-executor.ts 中。从工具接收到执行,每道护栏是一层防线:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 工具调用请求 │ ▼ ┌─────────────┐ │ 1. 参数校验 │ ← 路径越界?参数类型错误?缺少必填?直接拒绝。 └──────┬──────┘ │ 通过 ▼ ┌─────────────┐ │ 2. 重复拦截 │ ← 连续两次完全相同的工具+参数?死循环信号,拒绝。 └──────┬──────┘ │ 通过 ▼ ┌─────────────┐ │ 3. 审批模式 │ ← writeFile 等高风险操作?挂起等人类确认。 └──────┬──────┘ │ 通过 ▼ ┌─────────────┐ │ 4. 敏感脱敏 │ ← 工具返回结果中扫描 API Key、Token,替换为 <redacted> └──────┬──────┘ │ ▼ 返回给 LLM
|
第一道:参数校验 + 路径沙箱
每个工具调用到达 execute() 前,必须先通过 validateToolArgs()。检验失败直接返回错误,不进入执行:
1 2 3 4 5 6 7 8 9 10 11
| try { this.validateTool(name, args); } catch (err) { return { success: false, content: `参数校验失败:${(err as Error).message}`, truncated: false, durationMs: 0, }; }
|
校验逻辑按工具名分别处理。以 read_file 为例:
1 2 3 4 5 6 7 8 9 10
| if (name === "read_file") { const filePath = validatePath(args.path as string, root); const stat = fs.statSync(filePath); if (!stat.isFile()) throw new Error("path is not a file"); const start = typeof args.startLine === "number" ? args.startLine : 1; const end = typeof args.endLine === "number" ? args.endLine : 200; if (start < 1 || end < start) throw new Error("invalid line range"); return; }
|
路径沙箱的核心逻辑在 validatePath():
1 2 3 4 5 6 7 8 9
| export function validatePath(rawPath: string, sandboxRoot?: string): string { const root = sandboxRoot ?? process.cwd(); const resolved = path.resolve(rawPath); if (!resolved.startsWith(root)) { throw new Error(`路径越界:${resolved} 不在沙箱 ${root} 内`); } return resolved; }
|
原理很简单:把用户传入的路径 path.resolve() 转成绝对路径 → 检查是否以项目根目录开头 → 不是就拒绝。这个设计防的是 LLM 被诱导尝试 read_file("../../etc/passwd") 或 read_file("/etc/passwd") 这类越界操作。
第二道:重复调用拦截
Agent 有时会陷入死循环——LLM 反复调用同一个工具、传相同的参数,等待”不同的结果”。这不是恶意行为,但浪费 token 和计算资源。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| if ( this.lastCall && this.lastCall.name === name && JSON.stringify(this.lastCall.args) === JSON.stringify(args) ) { return { success: false, content: `重复调用拦截:工具 ${name} 已用相同参数连续调用两次,请尝试其他操作`, truncated: false, durationMs: 0, }; } this.lastCall = { name, args };
|
lastCall 记录上一次调用的工具名 + 参数。如果当前调用和上一次完全相同,直接拒绝并返回提示。只检查连续两次的原因是:Agent 可能在多轮之后合理地再次调用同一个工具(比如两次 read_file 读不同文件),不被误拦。
第三道:审批模式
write_file 是高风险操作——它可以覆盖项目中的任何文件。默认情况下,Agent 在尝试写文件时会被挂起,等待人类确认。
审批模式有三种设置,参考 pico 的 --approval 参数:
1 2 3
| "auto" → 自动批准所有高风险操作(完全信任 Agent) "ask" → 高风险操作挂起,等待人类确认(默认) "never" → 永远拒绝高风险操作(只读模式)
|
核心实现是 Promise 挂起——这是整个审批系统最巧妙的部分:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| async approve(runId: string, name: string, args: Record<string, unknown>): Promise<boolean> { if (this.approvalPolicy === "auto") return true; if (this.approvalPolicy === "never") return false;
return new Promise((resolve, reject) => { this.pendingApprovals.set(runId, { resolve, reject }); if (this.onApprovalRequired) { this.onApprovalRequired({ runId, toolName: name, args }); } }); }
|
流程:
- Agent 决定调用
write_file → execute() 检测到 tool.schema.risky === true
approve() 创建一个 Promise 并挂起 → 同时通过 onApprovalRequired 回调通知前端
- 前端通过 SSE 收到审批请求 → 展示确认按钮 → 用户点击”批准”或”拒绝”
- 前端调
/api/agent/approve → 后端 resolveApproval() 唤醒 Promise → execute() 继续或返回拒绝
1 2 3 4 5 6 7 8
| resolveApproval(runId: string, approved: boolean): void { const pending = this.pendingApprovals.get(runId); if (pending) { this.pendingApprovals.delete(runId); pending.resolve(approved); } }
|
executor-store.ts 是一个全局 Map,按 runId 存储 ToolExecutor 实例,供审批 API 端点查找和决议。
第四道:敏感信息脱敏
工具返回结果(如 read_file 读出的代码内容)可能包含 API Key、Token、数据库连接字符串等敏感信息。如果这些直接返回给 LLM,它们会变成 LLM 的”知识”——下一次对话中,LLM 可能在其他上下文中吐出这些信息。
脱敏策略分两层:
第一层:扫描环境变量中的真实值。 detectSecretEnvItems() 在构造时扫描 process.env,找到所有看起来很敏感的变量(名字含 API_KEY、TOKEN、SECRET、PASSWORD 或以这些结尾),取出它们的值。按值长度降序排列——防止短值覆盖长值的前缀。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const DEFAULT_SECRET_ENV_NAMES = new Set([ "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY", "SILICONFLOW_API_KEY", "GITHUB_PAT", "SUPABASE_KEY", "DATABASE_URL", ]);
export function detectSecretEnvItems(): [string, string][] { const items: [string, string][] = []; for (const [name, value] of Object.entries(process.env)) { if (DEFAULT_SECRET_ENV_NAMES.has(name.toUpperCase()) || looksSensitiveEnvName(name)) { if (value) items.push([name, value]); } } items.sort((a, b) => b[1].length - a[1].length); return items; }
|
第二层:在工具返回结果上做文本替换。 execute() 中,每个工具返回的文本在返回给 LLM 之前,都会经过 redactText()——把扫描到的所有敏感值替换为 <redacted>。
1 2 3 4 5 6 7 8
| export function redactText(text: string, secrets: [string, string][]): string { let result = String(text); for (const [, value] of secrets) { if (value.length > 2) result = result.split(value).join("<redacted>"); } return result; }
|
四道护栏的协同
这四道护栏不是孤立的。它们按顺序串联在 execute() 方法中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| async execute(name: string, args: Record<string, unknown>, timeoutMs = 30000, runId?: string) { try { this.validateTool(name, args); } catch (err) { return error; }
if (this.lastCall?.name === name && JSON.stringify(this.lastCall.args) === JSON.stringify(args)) { return { success: false, content: '重复调用拦截...' }; } this.lastCall = { name, args };
if (tool.schema.risky && runId) { const approved = await this.approve(runId, name, args); if (!approved) return { success: false, content: '审批拒绝...' }; }
const rawResult = await Promise.race([tool.execute(args), timeout]); const cleanResult = redactText(rawResult, this.secrets); return { success: true, content: cleanResult }; }
|
每一道通过后,才进入下一道。任何一道失败,Agent 都会收到一个明确的错误消息(而非裸崩溃),可以据此调整下一步行为。
小结
安全护栏不是”加分项”——它是 Agent 从 demo 到可用系统的门槛。核心设计原则只有一条:永远不相信 LLM 的输出是安全的。 路径要校验,写入要审批,输出要脱敏,重复要拦截。每一道都是独立的防御层,一道失效不会导致全线崩溃。
下一篇:RAG 完整流水线:从文档加载到混合检索。