AGAgent 学习路线
第 3 / 20
CHAPTER 03 · GPT 生成学习页

权限先于副作用

让安全策略成为确定性管道,而不是寄托在 Prompt 上。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

权限先于副作用

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 模型是概率系统,权限必须是确定性边界。
  • 拒绝应发生在 handler 之前,并保留可审计原因。
  • 工作区路径、命令分类和审批状态要统一建模。
1解析工具调用
2结构化策略匹配
3批准 / 拒绝 / 询问
4handler 执行副作用
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

30 个小节46 组代码39 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

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
合并结果passthroughaskdeny
弹审批框?
handler 执行?看用户输入
进审计?

最后一行值得单独看:三种情况都进审计。审计记的不是「危险操作」,是「每一个最终决定」。


03先定义验收结果1. deny 的优先级高于 ask、allow 和 passthrough;

P03 必须满足以下可观察行为:

  1. deny 的优先级高于 askallowpassthrough
  2. 命中 deny 时,不询问用户,也不执行 handler;
  3. ask 只有得到明确的 allow 决定后才能执行;
  4. 没有审批器、审批异常、非法审批结果、直接回车和无交互输入都默认拒绝;
  5. 所有 shell 调用默认进入审批,不按命令文本猜测风险;
  6. 普通读取在没有规则反对时直接放行;
  7. P02 的文件写入不审批,P03 的 write_fileedit_file 才新增审批;
  8. 工作区外写入属于系统硬拒绝,规则、未来 Hook 建议和人工批准都不能放宽;
  9. 权限拒绝和权限评估异常仍要生成与原调用 ID 配对的 tool result;
  10. P03 的每个最终权限决定都进入审计。

直接测试位于 code/chapters/ch03/tests/permissions.test.tscode/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,运行时不知道“需要询问用户”和“这条规则不发表意见”的区别,也无法解释决定来源。

如果权限函数只返回 truefalse,运行时不知道“需要询问用户”和“这条规则不发表意见”的区别,也无法解释决定来源。

本章定义四种行为:

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 forbidden
06ToolResult 与工具错误码体系每一条工具调用的返回值不是普通字符串,而是带语义的结构化契约:

每一条工具调用的返回值不是普通字符串,而是带语义的结构化契约:

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_deniedunknown_toolinvalid_jsoninvalid_argumentsshell_timeoutpath_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-ItemFormat-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;
  • 用户直接回车或输入其他内容。

真实终端实现只接受 yyes

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 failed

handler 调用次数保持为零,消息仍然配对。

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 的组合根要求显式提供 approvalProviderauditSink,并由固定 profile 自己组装规则与工作区边界。调用方不能换成一个宽松的 PermissionPolicy 来跳过“写入必审批、决定必审计”。缺少任一边界时,Agent 在启动构建阶段失败。P01、P02 的离线单元测试仍可不注入审批器;真实 CLI 会注入审批器,让 Shell 保持 ask

18Agent Loop 的三种终止状态Agent Runner 不是无限循环。它有三种可交付终止状态:

Agent Runner 不是无限循环。它有三种可交付终止状态:

  • RunResult:模型在 maxTurns 内返回非空 finalTextturns 记录实际轮数;
  • 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,
});

因此:

操作P02P03
read_file / glob直接允许直接允许并审计
工作区内 write_file / edit_file直接允许ask,最终决定审计
工作区外 write文件 handler 返回 path_escape权限硬边界拒绝并审计
shellaskask 并审计

"直接允许"仍然要先经过文件工具自己的 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 并调用 addCapabilitySet 隐藏底层集合,使 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_fileedit_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权限路径主要稳定错误码
shellexecuteShell 默认 askshell_start_failedshell_timeoutshell_failed
read_fileread默认 allowpath_escapeinvalid_utf8file_not_foundinvalid_pathfilesystem_error
write_filewrite工作区硬边界,再走 confirm-file-writepath_escapeinvalid_pathfilesystem_error
edit_filewrite工作区硬边界,再走 confirm-file-writetext_not_foundinvalid_utf8file_not_foundinvalid_pathfilesystem_error
globread默认 allowpath_escapefilesystem_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_fileedit_file 标记为 write 后,PermissionPolicy 会在规则之前先读取 path 并调用 WorkspaceWriteBoundary。只有工作区内路径才会继续进入 confirm-file-write 的人工审批。

read_fileglobread,没有规则反对时由默认策略放行,但仍会经过 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 缺少 approvalProviderauditSink 时,buildAgent() 直接抛错,而不是带着宽松策略启动。

CLI 侧也在 cli.ts 实现 TerminalApprovalProviderTerminalAuditSink。审批器先检查 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 补足了更完整的生产视角:

  1. 风险分级而不是一刀切:低风险、可逆操作可以自动放行;高风险、不可逆操作才要求人工审批。P03 保持 Shell 一律 ask,是教学上更严格的默认值,真实产品需要按风险等级分层。
  2. 提议者-审核者:独立模型或人工在操作前审批“计划”,在操作后验证“结果”。ai-agent-book 还强调模型族与能力级别选择:审查模型应来自不同家族、能力相近,避免同源偏好产生相同盲区。P03 只做人工单次调用审批,没有独立模型审查。
  3. 执行-验证-反馈:工具执行后自动运行验证(例如写代码后自动跑 lint),把结构化错误返回给 Agent,形成下一轮修正的闭环。P03 没有自动验证器,权限系统只管“执行前”。
  4. Sidecar:轻量安全检查与主模型流式输出并行,只读取结构化工具字段,避免提示注入通过自由文本影响判断;连续拒绝时还应启用熔断器,避免 Agent 无限重试。P03 是同步决策,没有并行 Sidecar。
  5. 长输出截断与持久化:超长输出只保留头尾,完整结果写入临时文件并引导 read_file 继续读取。P03 的 PowerShell adapter 已经限制输出上限,但还没有“完整结果落盘 + 文件引导”。
  6. 可观测性:每次调用的时间、参数、结果、耗时、性能指标和告警都要可追踪。P03 的审计覆盖了最终权限决定,但还没有性能指标和告警。
  7. 幂等性与取消语义:超时或取消后必须知道副作用是否发生,重试不能重复外部操作。P03 的 ToolContext 预留了 idempotencyKey,但还没有真正实现幂等去重。
  8. 沙盒隔离:审批不是隔离。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.tsP01 固定入口,保持旧章节可独立运行
chapters/ch02.tsP02 固定入口,暴露文件工具但不接入权限策略
chapters/ch03.tsP03 固定入口,选择包含 policy 的 profile
bootstrap.ts组合根:按 profile 能力拼装模型、文件系统、工具和权限策略
cli.tsCLI 适配器:解析参数、提供终端审批与审计、把错误映射为退出码
config.ts配置适配器:读取 .env 或 mapping,校验 OPENAI_* 设置
core/commands.ts命令契约:定义 CommandRunnerCommandResult
core/filesystem.ts文件契约:定义工作区文件接口和稳定领域错误
core/loop.tsAgent Loop:准备、权限、执行、回填的主循环
core/messages.ts消息契约:保证 assistant 工具调用与 tool 结果严格配对
core/model.ts模型契约:定义 ModelClientFinishReasonModelReply 与 token usage
core/permissions.ts权限系统:四态决定、规则、审批、审计和工作区硬边界
core/profiles.ts章节能力档案:固定 P01/P02/P03 与不可变 CapabilitySet
core/tools.ts工具注册表:注册、解析、校验、快照和调用
features/builtin-tools.ts工具实现:shellread_filewrite_fileedit_fileglob
adapters/filesystem.tsNode 文件系统实现:safePath、真实路径解析和 glob
adapters/openai-chat.tsOpenAI adapter:把供应商响应归一成 core 契约
adapters/powershell.tsPowerShell 进程执行器:超时、输出上限和稳定结果

其中 core/filesystem.tsENOENTEISDIR 等平台错误归一为 FileNotFoundErrorInvalidFilePathError 等稳定领域错误,工具层再映射成 file_not_foundinvalid_path 等错误码。

features/builtin-tools.ts 的所有输入 schema 都使用 z.strictObject(),多余字段在权限层之前就会被 ToolRegistry.prepare() 拒绝。

adapters/openai-chat.ts 会在发送请求前再次 validateToolPairing(),并把 unknown 供应商响应逐层缩窄为 ModelReplyadapters/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 时,终端会显示工具名、规则原因和校验后的参数。只有输入 yyes 才会写入。

运行第 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.tsPermissionDecision 构造校验、规则合并优先级、审批 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 是 approvalask 只能被 allow/deny 收敛,弃权算拒绝
四·让审计器抛异常Error [permission_evaluation_error],且文件没被读审计是执行前置条件,不是「顺便记一下」

33本章小结三句话版本、一定要记住的七条、以及本章「还没做什么」。

三句话版本:

  1. 权限判断必须在代码的固定位置——prepare 之后、invoke 之前,Prompt 做不到这个保证。
  2. 决定不是 boolean 而是四态,因为「需要问一下」和「我不表态」都必须能表达出来。
  3. deny > ask > allow > passthrough:任何一个参与方的拒绝,都不能被其他人的允许推翻。

一定要记住的七条:

#结论出现在哪一节
1顺序是 prepare → permission → handler,反了就只剩日志价值插入 Agent Loop
2硬边界的 deny 出现时,审批框根本不弹工作区边界先于规则
3「没有得到明确的 yes」和「得到明确的 no」等价——七种情况都是 denyask 必须得到显式 allow
4审批器返回值按 unknown 处理,用 instanceof 验证ask 必须得到显式 allow
5审批只授权这一次,不存在「永久允许」ask 必须得到显式 allow
6审计记录最终决定且发生在执行之前;审计失败则不执行审计记录最终决定
7execute 一律 ask,不按命令文本猜风险Shell 为什么一律 ask

本章代码边界(明确「还没做什么」):Hook 建议(第 4 章)、配置分层、永久允许、规则热更新、独立模型审查、按风险分级默认值、性能指标/告警/幂等去重、操作系统级沙箱——均刻意不做。关键词一句:审批不等于隔离

检查你是否真的读懂了:

  1. 一次 glob 调用,四个参与方分别产出什么候选?最终 source 是什么?
  2. 为什么 boolean 表达不了「需要问一下」?
  3. 候选集合是 [deny, ask] 时,用户会看到审批框吗?
  4. 审批器抛异常,最终 behavior 是什么?
  5. 审计器抛异常,handler 执行了吗?模型收到什么?
  6. 第 3 章的 Agent Loop 比第 2 章多了几处改动?
  7. toToolResult() 为什么只允许 deny 调用?

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始