权限先于副作用
让安全策略成为确定性管道,而不是寄托在 Prompt 上。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
权限先于副作用
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 模型是概率系统,权限必须是确定性边界。
- 拒绝应发生在 handler 之前,并保留可审计原因。
- 工作区路径、命令分类和审批状态要统一建模。
顺着原文把边界看清
01导读:问题背景与本章目标前两章的 Agent 已经能执行 PowerShell,也能读、写、编辑和查找文件。⌄
前两章的 Agent 已经能执行 PowerShell,也能读、写、编辑和查找文件。
能力够用了,风险也随之出现。
假设用户只说“清理项目里的临时文件”,模型却生成了:
Remove-Item -LiteralPath 'C:\temp' -Recurse -Force如果运行时没有权限边界,Agent Loop 会照常把命令交给 PowerShell。模型写错一个路径,影响范围就可能完全不同。
问题不在于模型“坏”。模型只是在预测下一步。真正的问题是:我们把安全完全委托给了概率模型,却没有在副作用发生前设置确定性边界。
这一章把结构化权限策略插进工具准备和 handler 执行之间。
02先看一眼真实的决策过程先不看代码。假设模型在同一次会话里先后提出三个调用,看看它们各走到哪一步。⌄
先不看代码。假设模型在同一次会话里先后提出三个调用,看看它们分别走到哪一步。
调用 A:glob(read) → 默认允许,仍留记录
① 工作区硬边界 effect 不是 write,不表态 → 无候选
② Shell 默认 effect 不是 execute,不表态 → 无候选
③ 规则 confirm-file-write 不匹配 → 无候选
④ 合并 一个候选都没有 → passthrough
⑤ 收敛 passthrough → allow(default)
⑥ 审批 不需要
⑦ 审计 [Permission] glob: allow (default) - No ...
⑧ 执行 handler 跑起来调用 B:write_file(工作区内) → 弹审批,且只授权这一次
③ 规则 confirm-file-write 匹配 → ask
④ 合并 最强的是 ask
⑤ 收敛 调用 ApprovalProvider → 工具调用需要批准: write_file
参数: {"path":"chapter3-note.txt",...} 允许本次调用? [y/N]
→ allow(terminal-approval)
⑥ 审计 [Permission] write_file: allow (terminal-approval)
⑦ 执行 handler 跑起来调用 C:write_file(工作区外) → 硬拒绝,本章最重要的例子
① 工作区硬边界 safePath 拒绝 → deny
③ 规则 confirm-file-write 匹配 → ask
④ 合并 deny 和 ask 都在 → deny 优先
⑥ 审批 ❌ 根本没调用,用户看不到任何提示框
⑦ 审计 [Permission] write_file: deny (workspace-boundary)
⑧ 执行 ❌ handler 一次都没跑
⑨ 回填模型 Error [permission_denied]: Writing outside ...调用 C 是本章最重要的例子:规则明明说了 ask(问一下就能过),但硬边界的 deny 直接压过它——不给用户「批准一个不该被批准的操作」的机会。
| 调用 A glob | 调用 B 区内 write | 调用 C 区外 write | |
|---|---|---|---|
| 合并结果 | passthrough | ask | deny |
| 弹审批框? | 否 | 是 | 否 |
| handler 执行? | 是 | 看用户输入 | 否 |
| 进审计? | 是 | 是 | 是 |
最后一行值得单独看:三种情况都进审计。审计记的不是「危险操作」,是「每一个最终决定」。
03先定义验收结果1. deny 的优先级高于 ask、allow 和 passthrough;⌄
P03 必须满足以下可观察行为:
deny的优先级高于ask、allow和passthrough;- 命中
deny时,不询问用户,也不执行 handler; ask只有得到明确的allow决定后才能执行;- 没有审批器、审批异常、非法审批结果、直接回车和无交互输入都默认拒绝;
- 所有
shell调用默认进入审批,不按命令文本猜测风险; - 普通读取在没有规则反对时直接放行;
- P02 的文件写入不审批,P03 的
write_file和edit_file才新增审批; - 工作区外写入属于系统硬拒绝,规则、未来 Hook 建议和人工批准都不能放宽;
- 权限拒绝和权限评估异常仍要生成与原调用 ID 配对的 tool result;
- P03 的每个最终权限决定都进入审计。
直接测试位于 code/chapters/ch03/tests/permissions.test.ts 和 code/chapters/ch03/tests/ch03-permissions.test.ts。
04为什么 Prompt 不是安全边界可以在 system prompt 里写“不要删除系统文件”,但这只是软约束:⌄
可以在 system prompt 里写“不要删除系统文件”,但这只是软约束:
- 模型可能理解错误;
- 用户输入可能与系统指令发生冲突;
- 多轮上下文可能弱化早期提醒;
- 不可能枚举所有危险命令和路径组合;
- Prompt 无法证明副作用发生前一定执行了检查。
权限策略必须在代码里,并且位置固定:
模型返回 tool_calls
↓
ToolRegistry.prepare:查找 + JSON 解析 + Zod 校验
↓
PermissionPolicy.decide:硬边界 + 规则 + 审批 + 审计
↓
ToolRegistry.invoke:执行 handler
↓
回填相同 tool_call_id模型只能提出调用请求。是否执行由 Harness 决定。
05权限不是 boolean,而是四态决定如果权限函数只返回 true 或 false,运行时不知道“需要询问用户”和“这条规则不发表意见”的区别,也无法解释决定来源。⌄
如果权限函数只返回 true 或 false,运行时不知道“需要询问用户”和“这条规则不发表意见”的区别,也无法解释决定来源。
本章定义四种行为:
| behavior | 含义 |
|---|---|
allow | 明确允许 |
deny | 明确拒绝 |
ask | 必须取得一次显式审批 |
passthrough | 当前参与方不表态,交给其他规则或默认值 |
code/chapters/ch03/src/core/permissions.ts 用固定元组同时提供运行时集合和 TypeScript 类型:
export const PERMISSION_BEHAVIORS = Object.freeze([
"allow",
"deny",
"ask",
"passthrough",
] as const);
export type PermissionBehavior =
(typeof PERMISSION_BEHAVIORS)[number];每个决定还必须携带原因和来源:
const decision = new PermissionDecision(
"ask",
"File writes require explicit approval from chapter 3 onward",
"confirm-file-write",
);PermissionDecision 在构造时拒绝非法 behavior、空原因和空来源,并冻结实例,构造完成后不能被外部修改。最终拒绝可以直接转换成稳定工具结果:
toToolResult(): ToolResult {
if (this.behavior !== "deny") {
throw new PermissionContractError(
"only a final deny decision can become a tool result",
);
}
return toolError("permission_denied", this.reason);
}因此模型看到的不是含混的 Permission denied.,而是:
Error [permission_denied]: Writing outside the workspace is forbidden06ToolResult 与工具错误码体系每一条工具调用的返回值不是普通字符串,而是带语义的结构化契约:⌄
每一条工具调用的返回值不是普通字符串,而是带语义的结构化契约:
export interface ToolResult {
readonly content: string;
readonly isError: boolean;
readonly errorCode?: string;
}
export function toolSuccess(content: string): ToolResult {
return Object.freeze({ content, isError: false });
}
export function toolError(errorCode: string, message: string): ToolResult {
return Object.freeze({
content: `Error [${errorCode}]: ${message}`,
isError: true,
errorCode,
});
}isError 让 Agent Loop 不需要解析文本就知道这次调用是否失败。errorCode 是稳定错误键,例如 permission_denied、unknown_tool、invalid_json、invalid_arguments、shell_timeout、path_escape。
模型看到的是可读错误文本,测试和上游逻辑看到的是结构化字段。
Tool handler 的返回值也被视为不可信边界。ToolRegistry.invoke() 会用 isToolResult() 校验返回对象,防止自定义 handler 返回畸形结构污染会话历史:
if (!isToolResult(result)) {
return toolError("invalid_tool_result", "Tool handler returned an invalid result");
}因此 PermissionDecision.toToolResult() 能稳定回填给模型:它通过 toolError("permission_denied", this.reason) 生成一个满足 ToolResult 契约的对象,再让 Agent Loop 继续完成与原始调用 ID 配对的回填消息。
07PermissionRequest 只接收准备完成的调用权限规则不应该重新解析 OpenAI 参数,也不应该处理未知工具。⌄
权限规则不应该重新解析 OpenAI 参数,也不应该处理未知工具。
PermissionRequest 只接受已经通过 ToolRegistry.prepare() 的调用:
if (
options.prepared.error !== undefined ||
options.prepared.definition === undefined ||
options.prepared.arguments === undefined
) {
throw new PermissionContractError(
"permission request requires a valid prepared tool call",
);
}此时运行时已经知道:
- 工具定义确实存在;
- arguments 是合法 JSON object;
- Zod schema 已通过;
- 工具的
effect和 handler 来自受信任的本地ToolDefinition; ToolContext已带入 workspace 和 identity。
非法 JSON、额外字段或错误类型在权限系统之前就失败。这一点很重要:权限层只回答“一个合法调用能否执行”,不重复工具注册表的职责。
recommendations 是结构化建议列表。第 3 章暂时不产生它;第 4 章的 PreToolUse Hook 会使用这条通道。建议仍然参与统一优先级,不能绕过系统拒绝。
08ToolRegistry 内部流程权限层看到的 prepared 来自 ToolRegistry.prepare()。注册表在运行前已经完成三件事:⌄
权限层看到的 prepared 来自 ToolRegistry.prepare()。注册表在运行前已经完成三件事:
const definition = this.#definitions.get(call.name);
if (definition === undefined) {
return { call, error: toolError("unknown_tool", `Unknown tool: ${call.name}`) };
}工具名必须唯一且只含 A-Za-z0-9_;注册时还会把 ToolDefinition 转换成 StoredToolDefinition。转换时包一层重新 parse(),即使调用方绕过 prepare() 直接 invoke(),handler 也拿不到未校验输入。
每轮 Agent Loop 使用 snapshot() 拿不可变注册表快照,防止模型请求与执行之间注册表被篡改。openAITools() 只把 name、description、JSON Schema 暴露给模型;handler、effect 和内部存储不会进入模型可见的工具定义。
权限拒绝不会漏掉消息配对:ToolRegistry.invoke() 还会用 isToolResult() 校验 handler 返回值,畸形结果被转换成 invalid_tool_result。
09PermissionRule:条件、行为、原因放在一起const confirmFileWrite = new PermissionRule({⌄
一条规则包含四项:
const confirmFileWrite = new PermissionRule({
name: "confirm-file-write",
behavior: "ask",
reason: "File writes require explicit approval from chapter 3 onward",
matches: (request) => {
const name = request.prepared.definition?.name;
return name === "write_file" || name === "edit_file";
},
});规则匹配时,自动生成以规则名为 source 的 PermissionDecision;不匹配时返回 undefined,也就是不参与本次合并。
规则 matcher 如果抛出异常,策略不会继续假设它允许:
try {
return rule.evaluate(request);
} catch {
return new PermissionDecision(
"deny",
`Permission rule failed: ${rule.name}`,
rule.name,
);
}这是 fail closed:权限依赖失败时拒绝,不把“无法判断”解释成“可以执行”。
OWASP AI Agent Security Cheat Sheet 同样把“权限评估失败时默认拒绝”列为工具安全基础原则(https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html)。P03 的规则异常、审批异常和审计异常都按同一原则处理,而不是让策略故障降级成放行。
10PermissionContractError:权限契约错误本章多处抛出 PermissionContractError:构造 PermissionDecision 时传入了非法 behavior、创建 PermissionRequest 时传入了未准备好的调用、toToolResult() 在非 deny 决定上调用,等等。它的设计含义是:权限系统的调用方违反了使用契约,而非运行时的外部故障。⌄
本章多处抛出 PermissionContractError:构造 PermissionDecision 时传入了非法 behavior、创建 PermissionRequest 时传入了未准备好的调用、toToolResult() 在非 deny 决定上调用,等等。它的设计含义是:权限系统的调用方违反了使用契约,而非运行时的外部故障。
export class PermissionContractError extends Error {}PermissionContractError 与普通运行时错误在执行路径上的处理一致:Agent Loop 的边界 try/catch 会把它捕获,并统一转换成 permission_evaluation_error 回填给模型,不会向上传播到 bootstrap 层。
它的价值在语义层:它专门表示权限系统的调用方违反了使用契约。单元测试可以据此把“调用方用错 API”和“权限评估故障”区分开。
11三道闸门如何合并第二层是结构化参与方:Shell 默认策略、规则,以及后续章节的 Hook 建议。⌄
一次权限判断有三层输入。
第一层是系统硬边界:工作区外写入直接 deny。
第二层是结构化参与方:Shell 默认策略、规则,以及后续章节的 Hook 建议。
第三层是显式审批:只有合并结果为 ask 时才调用。
合并优先级固定为:
deny > ask > allow > passthrough核心代码会按这个顺序寻找第一个决定:
function strongestDecision(
decisions: readonly PermissionDecision[],
): PermissionDecision {
for (const behavior of ["deny", "ask", "allow"] as const) {
const decision = decisions.find(
(candidate) => candidate.behavior === behavior,
);
if (decision !== undefined) {
return decision;
}
}
return new PermissionDecision(
"passthrough",
"No permission participant made a decision",
"default",
);
}只有 passthrough 或完全没有参与方时,最终才使用默认允许:
final = new PermissionDecision(
"allow",
"No permission rule blocked the request",
"default",
);这不是“所有操作默认安全”。shell 会自动产生 ask;P03 中带路径的 write 会先经过工作区硬边界,两个文件写工具还有显式 ask 规则。默认 allow 只落在没有规则反对、也没有更高优先级决定的读取操作上。
Claude Code 官方权限文档使用同样的评估顺序:deny、ask、allow 依次判断,规则的具体程度不会改变顺序;任何层级上的 deny 都不能被其他层级的 allow 覆盖(https://code.claude.com/docs/en/permissions)。
12工作区边界为什么必须在规则之前{"path":"../outside.txt","content":"x"}⌄
考虑下面的调用:
{"path":"../outside.txt","content":"x"}项目规则可能说允许写入,未来 Hook 也可能建议允许,用户甚至可能输入 y。但工作区外写入仍必须拒绝,而且不能先弹审批框。
PermissionPolicy 因此先询问受信任的 WorkspaceWriteBoundary:
const allowed = await this.#writeBoundary.isPathWithinWorkspace(
request.context.workspace,
rawPath,
);
return allowed
? undefined
: new PermissionDecision(
"deny",
"Writing outside the workspace is forbidden",
"workspace-boundary",
);core 只依赖接口,不导入 Node adapter。组合根注入的 NodeWorkspaceFileSystem 实现真实路径判断:
async isPathWithinWorkspace(
workspace: string,
relativePath: string,
): Promise<boolean> {
try {
await safePath(workspace, relativePath);
return true;
} catch (error) {
if (error instanceof WorkspacePathError) {
return false;
}
throw error;
}
}所以这条边界继承第 2 章的全部路径保证:拒绝绝对路径、..、Windows 保留名、非法字符,以及 junction 或符号链接逃逸。
P03 的组合根总会注入 write boundary。如果真实路径解析抛出异常,带 path 的 write 会得到:
Write path could not be resolved safely不会降级成允许。P01、P02 的 Shell-only 策略不安装这项参与方,文件路径继续由第 2 章的 handler 边界校验;这样既保留旧错误语义,也不会削弱 P03。
13Shell 为什么一律 askGet-Content 看起来只读,但参数可以指向工作区外;一个普通命令还可以调用脚本、启动子进程或访问网络。只匹配 Remove-Item、Format-Volume 等词既不完整,也容易绕过。⌄
不能用关键词黑名单判断 PowerShell 是否安全。
Get-Content 看起来只读,但参数可以指向工作区外;一个普通命令还可以调用脚本、启动子进程或访问网络。只匹配 Remove-Item、Format-Volume 等词既不完整,也容易绕过。
因此,只要工具 effect 是 execute,系统就产生默认 ask:
if (definition.effect !== "execute") {
return undefined;
}
return new PermissionDecision(
"ask",
"Shell execution requires approval",
"shell-default",
);P01、P02 和 P03 的真实 CLI 都保留这条行为。离线测试可以显式注入审批、审计和文件系统替身,不依赖真实终端。
14ask 必须得到显式 allowexport interface ApprovalProvider {⌄
ApprovalProvider 是异步边界:
export interface ApprovalProvider {
decide(request: PermissionRequest): Promise<PermissionDecision>;
}只有一个真正的 PermissionDecision("allow", ...) 能通过。
以下情况都变成 deny:
- 没有审批器;
- 审批器抛出异常;
- 审批器返回普通 object 或 boolean;
- 审批器再次返回
ask; - 审批器返回
passthrough; - 终端没有交互 stdin;
- 用户直接回车或输入其他内容。
真实终端实现只接受 y 和 yes:
const answer = await terminal.question("允许本次调用? [y/N] ");
const normalized = answer.trim().toLowerCase();
const allowed = normalized === "y" || normalized === "yes";
return new PermissionDecision(
allowed ? "allow" : "deny",
allowed
? "User approved this tool call"
: "User denied this tool call",
"terminal-approval",
);审批只授权当前一次调用。策略不保存“永远允许”,也不偷偷修改后续规则。
15审计记录最终决定if (this.#audit !== undefined) {⌄
审计发生在规则合并和审批完成之后,所以记录的是最终结果:
if (this.#audit !== undefined) {
await this.#audit.record(request, final);
}P03 的终端审计格式为:
[Permission] write_file: allow (terminal-approval) - User approved this tool call它包含工具名、最终行为、决定来源和原因。
如果审计器本身失败,运行时不能执行一个未留下要求记录的副作用。异常会被 Agent Loop 转换成:
Error [permission_evaluation_error]: Permission evaluation failedhandler 调用次数保持为零,消息仍然配对。
16消息契约:每个工具调用必须配对权限决定和 handler 执行完成后,Agent Loop 使用 toolMessage(result.content, call.id) 回填。回填不是可选的:⌄
权限决定和 handler 执行完成后,Agent Loop 使用 toolMessage(result.content, call.id) 回填。回填不是可选的:
validateToolPairing(this.#history);validateToolPairing() 在每一轮开始时检查消息历史:assistant 工具调用之后必须立即跟随对应数量的 tool 消息,ID 必须严格匹配,多余的 tool 消息和孤儿结果都会被拒绝。这保证发送给模型的会话始终满足 Chat Completions 协议,也保证被权限拒绝的调用仍然以 permission_denied 结果回到模型视野,模型可以看到它提出的调用确实被拒绝了。
17插入 Agent Loop第 2 章已经把工具准备和执行分开。第 3 章只在中间增加权限步骤:⌄
第 2 章已经把工具准备和执行分开。第 3 章只在中间增加权限步骤:
if (prepared.error !== undefined) {
result = prepared.error;
} else if (this.#permissionPolicy !== undefined) {
try {
const decision = await this.#permissionPolicy.decide(
new PermissionRequest({ prepared, context }),
);
result = decision.isAllowed
? await tools.invoke(prepared, context)
: decision.toToolResult();
} catch {
result = toolError(
"permission_evaluation_error",
"Permission evaluation failed",
);
}
} else {
result = await tools.invoke(prepared, context);
}
this.#history.push(toolMessage(result.content, call.id));顺序不能反过来:
prepare → permission → handler如果先执行 handler 再判断,审批就只剩日志价值,无法阻止副作用。
P03 的组合根要求显式提供 approvalProvider 和 auditSink,并由固定 profile 自己组装规则与工作区边界。调用方不能换成一个宽松的 PermissionPolicy 来跳过“写入必审批、决定必审计”。缺少任一边界时,Agent 在启动构建阶段失败。P01、P02 的离线单元测试仍可不注入审批器;真实 CLI 会注入审批器,让 Shell 保持 ask。
18Agent Loop 的三种终止状态Agent Runner 不是无限循环。它有三种可交付终止状态:⌄
Agent Runner 不是无限循环。它有三种可交付终止状态:
RunResult:模型在maxTurns内返回非空finalText,turns记录实际轮数;AgentLimitError:达到maxTurns仍未结束,调用方不能把中间历史当作最终答案;AgentRunError/IncompleteModelReplyError:模型输出被截断、被内容过滤,或者停止时既没有文本也没有工具调用。
export class AgentRunError extends Error {
override readonly name: string = "AgentRunError";
}
export class AgentLimitError extends AgentRunError {
override readonly name: string = "AgentLimitError";
}
export class IncompleteModelReplyError extends AgentRunError {
override readonly name: string = "IncompleteModelReplyError";
}权限错误不是这三类运行错误之一:权限拒绝会作为 tool result 回到模型,权限评估异常会作为 permission_evaluation_error 回到模型,Loop 仍可继续。
19P02 与 P03 的准确差异export const P03: ChapterProfile = Object.freeze({⌄
P03 只增加一个 capability:
export const P03: ChapterProfile = Object.freeze({
chapter: 3,
capabilities: new CapabilitySet([
"loop",
"powershell",
"tool_registry",
"files",
"policy",
]),
});两个 profile 使用相同的五工具注册表和同一个 Agent Loop。组合根根据固定 capability 构建策略,而不是接收调用方拼好的策略:
if (!profile.capabilities.has("policy")) {
return dependencies.approvalProvider === undefined
? undefined
: new PermissionPolicy({ approval: dependencies.approvalProvider });
}
if (dependencies.approvalProvider === undefined) {
throw new Error("approvalProvider is required for chapter 3 or later");
}
if (dependencies.auditSink === undefined) {
throw new Error("auditSink is required for chapter 3 or later");
}
return new PermissionPolicy({
rules: [
new PermissionRule({
name: "confirm-file-write",
behavior: "ask",
reason: "File writes require explicit approval from chapter 3 onward",
matches: (request) => {
const name = request.prepared.definition?.name;
return name === "write_file" || name === "edit_file";
},
}),
],
approval: dependencies.approvalProvider,
audit: dependencies.auditSink,
writeBoundary: fileSystem,
});因此:
| 操作 | P02 | P03 |
|---|---|---|
read_file / glob | 直接允许 | 直接允许并审计 |
工作区内 write_file / edit_file | 直接允许 | ask,最终决定审计 |
| 工作区外 write | 文件 handler 返回 path_escape | 权限硬边界拒绝并审计 |
shell | ask | ask 并审计 |
"直接允许"仍然要先经过文件工具自己的 Zod 和 safePath 边界。P02 因此仍会拒绝工作区外路径,只是保留第 2 章可观察到的 path_escape;P03 才把这类写入提前到 handler 之前,以 permission_denied 拒绝。
20CapabilitySet:为什么能力集不能是裸 SetP03.capabilities 的类型是 ReadonlySet<Capability>,但组合根还需要保证它真的是不可变的。源码里用 CapabilitySet 包装私有 Set:⌄
P03.capabilities 的类型是 ReadonlySet<Capability>,但组合根还需要保证它真的是不可变的。源码里用 CapabilitySet 包装私有 Set:
class CapabilitySet implements ReadonlySet<Capability> {
readonly #values: Set<Capability>;
// 只暴露 has / entries / keys / values / forEach / size
}TypeScript 的 ReadonlySet 只是类型层面的只读,运行时仍然可以拿到 Set 并调用 add。CapabilitySet 隐藏底层集合,使 profile 能力一旦构造完成就无法被外部修改,组合根才能信任 capabilities.has("policy") 的判断。
21四个具体场景本节包含实现边界、验证方式和配套示例。⌄
221. 列出文件{"pattern":"chapters/ch03/src//.ts"}⌄
模型调用:
{"pattern":"chapters/ch03/src/**/*.ts"}glob 是 read,没有规则反对时默认允许。
232. 执行 PowerShell{"command":"Get-ChildItem -Force"}⌄
模型调用:
{"command":"Get-ChildItem -Force"}即使命令看起来只读,shell 仍进入审批:
工具调用需要批准: shell
原因: Shell execution requires approval
参数: {"command":"Get-ChildItem -Force"}
允许本次调用? [y/N]243. 修改工作区文件P03 中的 writefile 和 editfile 命中 confirm-file-write。输入 y 后只执行这一次;拒绝或回车时,文件不变化。⌄
P03 中的 write_file 和 edit_file 命中 confirm-file-write。输入 y 后只执行这一次;拒绝或回车时,文件不变化。
254. 写入工作区外{"path":"../outside.txt","content":"x"}⌄
{"path":"../outside.txt","content":"x"}系统返回工作区硬拒绝,不调用审批器,也不创建外部文件。
26工具实现:effect 如何驱动权限边界features/builtin-tools.ts 是 P02、P03 共用的五工具实现;P01 只注册其中的 shell。五个工具都通过 z.strictObject() 定义参数,并在注册时携带 effect 标签。⌄
features/builtin-tools.ts 是 P02、P03 共用的五工具实现;P01 只注册其中的 shell。五个工具都通过 z.strictObject() 定义参数,并在注册时携带 effect 标签。
这个标签不是给模型看的:openAITools() 只序列化 name、description 和 JSON Schema。它是权限策略判断默认行为的语义依据。
| 工具 | effect | 权限路径 | 主要稳定错误码 |
|---|---|---|---|
shell | execute | Shell 默认 ask | shell_start_failed、shell_timeout、shell_failed |
read_file | read | 默认 allow | path_escape、invalid_utf8、file_not_found、invalid_path、filesystem_error |
write_file | write | 工作区硬边界,再走 confirm-file-write | path_escape、invalid_path、filesystem_error |
edit_file | write | 工作区硬边界,再走 confirm-file-write | text_not_found、invalid_utf8、file_not_found、invalid_path、filesystem_error |
glob | read | 默认 allow | path_escape、filesystem_error |
以 shell 为例,工具只负责把 CommandResult 归一成 ToolResult:
effect: "execute",
handler: async ({ command }, context) => {
let result: CommandResult;
try {
result = await commandRunner.run(command, context.workspace);
} catch {
return toolError("shell_start_failed", "PowerShell process could not be started");
}
let output = result.output.length === 0 ? "(no output)" : result.output;
if (result.truncated) {
output = `${output}\n[output truncated]`;
}
if (result.timedOut) {
return toolError("shell_timeout", output);
}
if (result.exitCode !== 0) {
return toolError(
"shell_failed",
`PowerShell exited with code ${result.exitCode}\n${output}`,
);
}
return toolSuccess(output);
},write_file 和 edit_file 标记为 write 后,PermissionPolicy 会在规则之前先读取 path 并调用 WorkspaceWriteBoundary。只有工作区内路径才会继续进入 confirm-file-write 的人工审批。
read_file 和 glob 是 read,没有规则反对时由默认策略放行,但仍会经过 safePath 与错误归一。
工具 handler 对已知领域错误返回稳定错误码,对未知错误继续抛出。ToolRegistry.invoke() 的边界 try/catch 会把未知故障统一转换为 tool_execution_error。这样模型收到的是可继续规划的失败原因,而不是运行时堆栈。
27当前边界与刻意不做的事本章没有实现配置文件分层、组织角色、规则热更新或“永久允许”。这些能力没有出现在 P03 验收契约中。⌄
本章没有实现配置文件分层、组织角色、规则热更新或“永久允许”。这些能力没有出现在 P03 验收契约中。
Claude Code 的真实产品会用 Managed、Project、User 多级设置承载权限规则,并且任何层级的 deny 都不能被其他层级的 allow 覆盖。P03 不实现这套分层,但保留了最关键的 deny 优先语义。
本章也不调用另一个模型判断命令风险。概率分类器可以辅助提示,不能替代硬权限语义。
Shell 仍然不是 sandbox。审批降低误执行风险,不提供操作系统隔离。真正的强隔离需要受限账户、容器、虚拟机或操作系统沙箱,这不属于本教程当前范围。
Claude Code 文档还区分裸工具名 deny 和带范围 deny:Bash 会从模型可见的工具列表中移除,Bash(rm *) 则保留工具但阻止匹配调用。P03 的 deny 都在执行前生效,没有实现“从模型可见工具列表中移除”的优化;对本章验收目标而言,阻止副作用已经成立。
第 4 章才加入 Hook。recommendations 已预留结构化通道,但 P03 不假装已经有 Hook 生命周期。Claude Code 的 PreToolUse Hook 也不会绕过 deny 和 ask 规则:即使 Hook 返回 allow,匹配的 deny 仍然阻止调用。
28组合根:权限策略在哪里被拼装bootstrap.ts 是组合根。它把模型、文件系统、Shell、工具、权限策略组装成一个 AgentRunner:⌄
bootstrap.ts 是组合根。它把模型、文件系统、Shell、工具、权限策略组装成一个 AgentRunner:
export interface BuildDependencies {
readonly model: ModelClient;
readonly workspace: string;
readonly commandRunner?: CommandRunner;
readonly fileSystem?: WorkspaceFileSystem;
readonly approvalProvider?: ApprovalProvider;
readonly auditSink?: AuditSink;
readonly maxTurns?: number;
}关键约束是:组合根不接收调用方拼好的 PermissionPolicy,而是根据固定 ChapterProfile 的 capability 自己构造策略。
profileForChapter(profile.chapter) !== profile 的引用相等检查,会拒绝调用方伪造的同编号 profile。P03 缺少 approvalProvider 或 auditSink 时,buildAgent() 直接抛错,而不是带着宽松策略启动。
CLI 侧也在 cli.ts 实现 TerminalApprovalProvider 和 TerminalAuditSink。审批器先检查 stdin.isTTY:没有交互输入时直接拒绝,避免自动化环境默认放行。
审计器输出 [Permission] 工具名: behavior (source) - reason。配置错误返回退出码 2,运行错误返回退出码 1,两者都在进程边界收敛成可预期的 CLI 行为。
29从 ai-agent-book 学到什么ai-agent-book 的《工具》一章用“执行工具”专节讨论生产级安全,核心是层次化防护:⌄
ai-agent-book 的《工具》一章用“执行工具”专节讨论生产级安全,核心是层次化防护:
输入验证
↓
权限控制
↓
提议者-审核者(事前审批 + 事后验证)
↓
Sidecar 并行校验
↓
沙盒隔离它还在“实验 4-2:执行工具 MCP 服务器”中要求把安全机制做成可运行实验:文件写入后自动 lint、危险命令检测、沙盒 Python 执行、长输出截断与持久化。
这个设计方式值得借鉴:每一项机制都要有可观察的输入、行为和输出,而不是只停留在架构描述。
本教程 P03 已经覆盖了最前面两层(输入验证 + 权限控制),并实现了其中的拒绝优先和 fail closed。ai-agent-book 补足了更完整的生产视角:
- 风险分级而不是一刀切:低风险、可逆操作可以自动放行;高风险、不可逆操作才要求人工审批。P03 保持 Shell 一律
ask,是教学上更严格的默认值,真实产品需要按风险等级分层。 - 提议者-审核者:独立模型或人工在操作前审批“计划”,在操作后验证“结果”。ai-agent-book 还强调模型族与能力级别选择:审查模型应来自不同家族、能力相近,避免同源偏好产生相同盲区。P03 只做人工单次调用审批,没有独立模型审查。
- 执行-验证-反馈:工具执行后自动运行验证(例如写代码后自动跑 lint),把结构化错误返回给 Agent,形成下一轮修正的闭环。P03 没有自动验证器,权限系统只管“执行前”。
- Sidecar:轻量安全检查与主模型流式输出并行,只读取结构化工具字段,避免提示注入通过自由文本影响判断;连续拒绝时还应启用熔断器,避免 Agent 无限重试。P03 是同步决策,没有并行 Sidecar。
- 长输出截断与持久化:超长输出只保留头尾,完整结果写入临时文件并引导
read_file继续读取。P03 的 PowerShell adapter 已经限制输出上限,但还没有“完整结果落盘 + 文件引导”。 - 可观测性:每次调用的时间、参数、结果、耗时、性能指标和告警都要可追踪。P03 的审计覆盖了最终权限决定,但还没有性能指标和告警。
- 幂等性与取消语义:超时或取消后必须知道副作用是否发生,重试不能重复外部操作。P03 的
ToolContext预留了idempotencyKey,但还没有真正实现幂等去重。 - 沙盒隔离:审批不是隔离。ai-agent-book 明确把沙盒作为独立防线,P03 也明确声明 Shell 不是 sandbox。
30配套代码的文件结构code/chapters/ch03/src/ 是第 3 章可运行快照,沿用“core 契约 + adapters 边界 + features 工具集 + chapters 入口”的分层。逐文件责任如下:⌄
code/chapters/ch03/src/ 是第 3 章可运行快照,沿用“core 契约 + adapters 边界 + features 工具集 + chapters 入口”的分层。逐文件责任如下:
| 路径 | 责任 |
|---|---|
chapters/ch01.ts | P01 固定入口,保持旧章节可独立运行 |
chapters/ch02.ts | P02 固定入口,暴露文件工具但不接入权限策略 |
chapters/ch03.ts | P03 固定入口,选择包含 policy 的 profile |
bootstrap.ts | 组合根:按 profile 能力拼装模型、文件系统、工具和权限策略 |
cli.ts | CLI 适配器:解析参数、提供终端审批与审计、把错误映射为退出码 |
config.ts | 配置适配器:读取 .env 或 mapping,校验 OPENAI_* 设置 |
core/commands.ts | 命令契约:定义 CommandRunner 与 CommandResult |
core/filesystem.ts | 文件契约:定义工作区文件接口和稳定领域错误 |
core/loop.ts | Agent Loop:准备、权限、执行、回填的主循环 |
core/messages.ts | 消息契约:保证 assistant 工具调用与 tool 结果严格配对 |
core/model.ts | 模型契约:定义 ModelClient、FinishReason、ModelReply 与 token usage |
core/permissions.ts | 权限系统:四态决定、规则、审批、审计和工作区硬边界 |
core/profiles.ts | 章节能力档案:固定 P01/P02/P03 与不可变 CapabilitySet |
core/tools.ts | 工具注册表:注册、解析、校验、快照和调用 |
features/builtin-tools.ts | 工具实现:shell、read_file、write_file、edit_file、glob |
adapters/filesystem.ts | Node 文件系统实现:safePath、真实路径解析和 glob |
adapters/openai-chat.ts | OpenAI adapter:把供应商响应归一成 core 契约 |
adapters/powershell.ts | PowerShell 进程执行器:超时、输出上限和稳定结果 |
其中 core/filesystem.ts 把 ENOENT、EISDIR 等平台错误归一为 FileNotFoundError、InvalidFilePathError 等稳定领域错误,工具层再映射成 file_not_found、invalid_path 等错误码。
features/builtin-tools.ts 的所有输入 schema 都使用 z.strictObject(),多余字段在权限层之前就会被 ToolRegistry.prepare() 拒绝。
adapters/openai-chat.ts 会在发送请求前再次 validateToolPairing(),并把 unknown 供应商响应逐层缩窄为 ModelReply。adapters/powershell.ts 使用 -NoLogo -NoProfile -NonInteractive 启动 PowerShell,默认限制 120 秒超时和 50 KB 输出,超时后杀掉子进程并返回 shell_timeout。
adapters/filesystem.ts 的 glob 用受限正则解析 *、**、? 和字符类,不把未验证模式交给 Shell;遍历目录时只跟随真实目录,不跟随符号链接目录,避免递归越界。这些边界都是权限系统之外的独立防线。
31运行第 3 章在 PowerShell 中进入 code/ 并安装锁定依赖:⌄
在 PowerShell 中进入 code/ 并安装锁定依赖:
Set-Location 'F:\笔记\Agent实操\code'
npm ci固定入口:
npm run ch03 -- --prompt '读取 README.md,然后把一句摘要写入 chapter3-note.txt'统一入口:
npm run agent-tutorial -- run --chapter 3 --prompt '读取 README.md,然后把一句摘要写入 chapter3-note.txt'模型请求 write_file 时,终端会显示工具名、规则原因和校验后的参数。只有输入 y 或 yes 才会写入。
运行第 3 章专项测试:
npm run test:ch03运行完整离线门禁:
npm run typecheck
npm test
npm run lint
npm run format:check
npm run build测试不读取真实 API Key,也不访问网络。真实 OpenAI 运行只能作为额外冒烟证据。
这一章建立了一个不会被弱允许覆盖的副作用边界:
ToolRegistry.prepare
↓
workspace hard boundary
↓
rules / recommendations
↓
deny > ask > allow > passthrough
↓
explicit approval when needed
↓
final audit
↓
ToolRegistry.invoke下一章会加入 Hooks,把工具执行前后、用户输入和停止阶段的扩展逻辑从循环中拆出去。无论 Hook 给出什么建议,系统 deny 仍然拥有最终优先级。
32验证与实验10 个测试文件、61 个测试。本章新增两个,其余 8 个是前两章累计测试。⌄
npm run test:ch03 会执行 10 个测试文件、61 个测试。本章新增的两个是:
| 测试文件 | 验证内容 |
|---|---|
permissions.test.ts | PermissionDecision 构造校验、规则合并优先级、审批 fail closed 全部分支、工作区硬边界、审计调用时机 |
ch03-permissions.test.ts | 端到端:审批器有没有被调用、副作用有没有发生、消息是否配对、回填内容是否精确 |
| 其余 8 个 | 前两章累计测试(loop/files/shell/messages/profiles/config/openai-chat) |
这里最值得学的是断言的写法。只断言「返回了错误」是不够的——一个实现可能先执行副作用再返回错误,测试照样通过。ch03-permissions.test.ts 用了三类更硬的断言:
expect(approval.requests).toEqual([]); // 审批器一次都没被调用
await expect(readFile(outside, "utf8")).rejects.toThrow(); // 文件确实没被创建
validateToolPairing(result.history); // 消息结构仍然完整测试断言的回填内容是逐字精确的完整字符串(如 "Error [permission_denied]: Writing outside the workspace is forbidden")。改动任何一条 reason 文案都会让测试变红——这是有意的,reason 会进入模型上下文。
四个建议动手做的小实验:
| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一·让规则 matcher 抛异常 | 得到 deny,source 是 confirm-file-write | 规则故障导向拒绝,而不是「这条规则不算」 |
| 二·加一条 allow 规则 | 写 ../outside.txt 仍是 deny (workspace-boundary) | allow 加多少条都赢不了 deny——最重要的不变量 |
| 三·审批器返回 passthrough | 最终 deny,source 是 approval | ask 只能被 allow/deny 收敛,弃权算拒绝 |
| 四·让审计器抛异常 | Error [permission_evaluation_error],且文件没被读 | 审计是执行前置条件,不是「顺便记一下」 |
33本章小结三句话版本、一定要记住的七条、以及本章「还没做什么」。⌄
三句话版本:
- 权限判断必须在代码的固定位置——prepare 之后、invoke 之前,Prompt 做不到这个保证。
- 决定不是 boolean 而是四态,因为「需要问一下」和「我不表态」都必须能表达出来。
- deny > ask > allow > passthrough:任何一个参与方的拒绝,都不能被其他人的允许推翻。
一定要记住的七条:
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 顺序是 prepare → permission → handler,反了就只剩日志价值 | 插入 Agent Loop |
| 2 | 硬边界的 deny 出现时,审批框根本不弹 | 工作区边界先于规则 |
| 3 | 「没有得到明确的 yes」和「得到明确的 no」等价——七种情况都是 deny | ask 必须得到显式 allow |
| 4 | 审批器返回值按 unknown 处理,用 instanceof 验证 | ask 必须得到显式 allow |
| 5 | 审批只授权这一次,不存在「永久允许」 | ask 必须得到显式 allow |
| 6 | 审计记录最终决定且发生在执行之前;审计失败则不执行 | 审计记录最终决定 |
| 7 | execute 一律 ask,不按命令文本猜风险 | Shell 为什么一律 ask |
本章代码边界(明确「还没做什么」):Hook 建议(第 4 章)、配置分层、永久允许、规则热更新、独立模型审查、按风险分级默认值、性能指标/告警/幂等去重、操作系统级沙箱——均刻意不做。关键词一句:审批不等于隔离。
检查你是否真的读懂了:
- 一次 glob 调用,四个参与方分别产出什么候选?最终 source 是什么?
- 为什么 boolean 表达不了「需要问一下」?
- 候选集合是 [deny, ask] 时,用户会看到审批框吗?
- 审批器抛异常,最终 behavior 是什么?
- 审计器抛异常,handler 执行了吗?模型收到什么?
- 第 3 章的 Agent Loop 比第 2 章多了几处改动?
- toToolResult() 为什么只允许 deny 调用?
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始