Agent 安全护栏四重奏:路径沙箱、审批模式、敏感脱敏与重复拦截

Gabrielle Lv5

为什么 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
// tool-executor.ts — 统一的校验入口
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
// tool-executor.ts — 按工具名分别校验参数
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
// tool-executor.ts — 路径沙箱:拒绝越界访问
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
// tool-executor.ts — 连续重复调用检测
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
// tool-executor.ts — Promise 挂起等用户确认
async approve(runId: string, name: string, args: Record<string, unknown>): Promise<boolean> {
if (this.approvalPolicy === "auto") return true;
if (this.approvalPolicy === "never") return false;

// ask 模式:返回一个 Promise,既不 resolve 也不 reject
// — 挂起,等外部调 resolveApproval() 来唤醒
return new Promise((resolve, reject) => {
this.pendingApprovals.set(runId, { resolve, reject });
if (this.onApprovalRequired) {
this.onApprovalRequired({ runId, toolName: name, args });
}
});
}

流程:

  1. Agent 决定调用 write_fileexecute() 检测到 tool.schema.risky === true
  2. approve() 创建一个 Promise 并挂起 → 同时通过 onApprovalRequired 回调通知前端
  3. 前端通过 SSE 收到审批请求 → 展示确认按钮 → 用户点击”批准”或”拒绝”
  4. 前端调 /api/agent/approve → 后端 resolveApproval() 唤醒 Promise → execute() 继续或返回拒绝
1
2
3
4
5
6
7
8
// tool-executor.ts — 外部唤醒挂起的 Promise
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_KEYTOKENSECRETPASSWORD 或以这些结尾),取出它们的值。按值长度降序排列——防止短值覆盖长值的前缀。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// tool-executor.ts — 扫描环境变量敏感值
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
// tool-executor.ts — 文本脱敏
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
// tool-executor.ts — execute() 中的护栏协同
async execute(name: string, args: Record<string, unknown>, timeoutMs = 30000, runId?: string) {
// 1. 参数校验
try { this.validateTool(name, args); } catch (err) { return error; }

// 2. 重复调用检测
if (this.lastCall?.name === name && JSON.stringify(this.lastCall.args) === JSON.stringify(args)) {
return { success: false, content: '重复调用拦截...' };
}
this.lastCall = { name, args };

// 3. 审批检查
if (tool.schema.risky && runId) {
const approved = await this.approve(runId, name, args);
if (!approved) return { success: false, content: '审批拒绝...' };
}

// 4. 执行 + 脱敏
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 完整流水线:从文档加载到混合检索

  • Title: Agent 安全护栏四重奏:路径沙箱、审批模式、敏感脱敏与重复拦截
  • Author: Gabrielle
  • Created at : 2026-08-12 10:00:00
  • Updated at : 2026-08-10 23:12:05
  • Link: https://zoella-w.github.io/2026/08/12/102-agent-safety-guardrails/
  • License: This work is licensed under CC BY-NC-SA 4.0.