返回课程首页

9

给危险操作增加用户确认

建立工具权限、审批、信任与沙箱边界。

源码基线:Pi v0.82.0 · commit 518855d

桌面 Agent 已经能读取文件、并行执行工具,也能被用户停止。现在用户提出一个看似简单的要求:

读取文件可以直接执行;删除文件、修改敏感配置或运行高风险命令前,必须先问我。

这不是给按钮加一个确认框那么简单。我们需要回答五个不同的问题:

  1. 模型能调用哪些能力?
  2. 某一次工具调用是否允许执行?
  3. 哪些路径必须受到特殊保护?
  4. 项目里的扩展、技能和设置能否被加载?
  5. 即使应用策略出错,操作系统层面还能否限制损害范围?

Pi 对这些问题给出了不同层次的机制。最重要的结论先放在前面:

Pi 默认没有“每次危险操作都弹窗”的权限系统。已注册的工具在参数验证通过后会执行,除非 beforeToolCall Hook 或 coding-agent 的 tool_call 扩展事件明确阻止它。

1. 先画出五层安全边界

flowchart TB
    M["模型产生 Tool Call"] --> C["能力边界:是否注册了这个工具"]
    C --> V["参数边界:Schema 验证"]
    V --> P["策略边界:beforeToolCall / tool_call"]
    P --> T["工具 execute"]
    T --> O["系统边界:容器、VM、OS 沙箱"]

    R["Project Trust"] -.控制加载.-> X["项目设置、扩展、技能、提示词等"]
    X -.可能改变.-> C
    X -.可能安装 Hook.-> P

这五层不能互相替代:

层次 回答的问题 Pi 默认行为
工具注册 Agent 有没有这项能力 只有注册到 tools 的工具可调用
Schema 参数结构是否合法 执行前验证
Permission Gate 这一次调用是否允许 默认允许;Hook 可阻止
Project Trust 项目本地资源是否可加载 按信任决策加载或跳过
沙箱/容器 进程实际能访问什么 Pi 没有内置沙箱,继承当前用户权限

Project Trust 不是文件权限,确认框也不是沙箱。后面会逐层建立它们。

2. Pi 默认怎样执行一个工具

回到 packages/agent/src/agent-loop.ts。一条 Tool Call 的执行前流程可以简化为:

flowchart TD
    A["Assistant 输出 Tool Call"] --> B{"按名称找到工具?"}
    B -->|否| E1["生成错误 Tool Result"]
    B -->|是| C["prepareArguments 可选预处理"]
    C --> D{"参数通过 Schema?"}
    D -->|否| E2["生成错误 Tool Result"]
    D -->|是| H["beforeToolCall"]
    H --> I{"block === true?"}
    I -->|是| E3["生成被阻止的错误 Tool Result"]
    I -->|否| X["tool.execute(...)"]

因此,beforeToolCall 正好处于:

  • 工具已经找到之后;
  • 参数已经验证之后;
  • 副作用真正发生之前。

它就是基础 Agent 层最合适的 Permission Gate。

packages/agent/src/types.ts 中的结果接口是:

interface BeforeToolCallResult {
  block?: boolean;
  reason?: string;
}

Hook 的签名允许没有返回值:

beforeToolCall?: (...) =>
  Promise<
    BeforeToolCallResult | undefined
  >;

阻止调用:

agent.beforeToolCall = async ({ toolCall }) => {
  if (toolCall.name === "delete_file") {
    return {
      block: true,
      reason: "删除操作尚未获得用户批准",
    };
  }
};

Loop 不会调用工具,而是把原因包装成错误 Tool Result。模型也能看到这次操作没有发生,从而改用其他方案或向用户解释。

3. 为桌面 Agent 建立风险分类

先不要急着弹窗。第一步是把工具调用分成稳定、可测试的风险等级:

type RiskLevel =
  | "allow"
  | "confirm"
  | "deny";

type ToolDecision = {
  level: RiskLevel;
  reason: string;
  resource?: string;
};

一个简单策略可以是:

function classifyToolCall(
  name: string,
  args: Record<string, unknown>,
): ToolDecision {
  if (name === "read_file") {
    return {
      level: "allow",
      reason: "只读操作",
    };
  }

  if (name === "write_file") {
    return {
      level: "confirm",
      reason: "将修改本地文件",
      resource: String(args.path),
    };
  }

  if (name === "delete_file") {
    return {
      level: "confirm",
      reason: "删除操作可能不可恢复",
      resource: String(args.path),
    };
  }

  return {
    level: "deny",
    reason: "策略尚未识别这个工具",
  };
}

默认拒绝未知工具比默认允许更适合有权限要求的桌面应用。风险分类属于应用策略,并不是 Pi 内置规则。

4. 把桌面确认框接入 beforeToolCall

假设桌面宿主提供:

type ApprovalDialog = {
  confirm(input: {
    title: string;
    description: string;
    toolName: string;
    resource?: string;
    signal?: AbortSignal;
  }): Promise<"allow-once" | "deny">;
};

可以把它接入 Agent:

agent.beforeToolCall = async (
  { toolCall, args },
  signal,
) => {
  const decision = classifyToolCall(
    toolCall.name,
    args as Record<string, unknown>,
  );

  if (decision.level === "allow") {
    return;
  }

  if (decision.level === "deny") {
    return {
      block: true,
      reason: decision.reason,
    };
  }

  try {
    const answer = await approvalDialog.confirm({
      title: "允许 Agent 执行此操作吗?",
      description: decision.reason,
      toolName: toolCall.name,
      resource: decision.resource,
      signal,
    });

    if (answer === "allow-once") {
      return;
    }

    return {
      block: true,
      reason: "用户拒绝了此次操作",
    };
  } catch {
    return {
      block: true,
      reason: "审批界面不可用,操作已阻止",
    };
  }
};

这里采用 fail closed:

  • 用户拒绝:阻止;
  • 用户关闭窗口:阻止;
  • 审批超时:阻止;
  • UI 通道断开:阻止;
  • Agent 被取消:阻止。

不要在确认框出现前启动工具,也不要先做一半再询问。

5. 为什么 Hook 中不要随意修改参数

Agent 的 beforeToolCall 同时接收两个容易混淆的值:

  • toolCall:Assistant Message 中的原始 Tool Call;
  • args:经过可选参数准备后,被复制、转换并通过 Schema 验证,最终真正交给工具执行的参数。

权限判断应使用 args。修改 toolCall.arguments 通常不会改变实际执行参数;修改 args,或在 coding-agent 扩展中修改 event.input,才会改变工具收到的值。源码不会在 Hook 后自动再做一次 Schema 验证。

因此下面的写法有风险:

agent.beforeToolCall = async ({ args }) => {
  const input = args as {
    path: string;
  };

  input.path = calculateAnotherPath();
};

新的值可能:

  • 不再符合 Schema;
  • 绕过前面基于旧参数做出的风险判断;
  • 让确认框显示的目标与实际执行目标不一致。

权限 Hook 最好只做三件事:

  1. 读取经过验证的参数;
  2. 计算允许、确认或拒绝;
  3. 返回是否阻止。

如果确实要重写参数,应在修改后自行重新验证,并确保审批页面展示最终参数。

6. 用 coding-agent 扩展实现 Permission Gate

如果桌面应用使用完整的 coding-agent 层,可以通过扩展监听 tool_call

import type {
  ExtensionAPI,
} from "@earendil-works/pi-coding-agent";

export default function permissionGate(
  pi: ExtensionAPI,
) {
  pi.on("tool_call", async (event, ctx) => {
    if (event.toolName !== "bash") {
      return;
    }

    const command = String(event.input.command);
    const isDangerous =
      /\brm\s+(-rf?|--recursive)/i.test(command) ||
      /\bsudo\b/i.test(command);

    if (!isDangerous) {
      return;
    }

    if (!ctx.hasUI) {
      return {
        block: true,
        reason: "没有可用的审批界面",
      };
    }

    const allowed = await ctx.ui.confirm(
      "危险命令确认",
      command,
    );

    if (!allowed) {
      return {
        block: true,
        reason: "用户拒绝了危险命令",
      };
    }
  });
}

仓库中的 packages/coding-agent/examples/extensions/permission-gate.ts 采用同一思路:

  • 只检查 bash
  • 用正则识别少量危险命令;
  • 没有 UI 时默认阻止;
  • 用户没有明确同意时阻止。

示例的正则只是演示,不是完整 Shell 安全解析器。命令可能通过别名、脚本、编码、变量展开或另一个解释器产生相同副作用。生产策略更适合按能力拆分工具,并把高风险执行放进沙箱。

7. 扩展事件怎样接到 Agent Hook

扩展层并没有绕过 Agent Loop。调用链是:

sequenceDiagram
    participant Loop as agent-loop
    participant Session as AgentSession
    participant Runner as ExtensionRunner
    participant Ext as 扩展 tool_call
    participant Tool

    Loop->>Session: beforeToolCall
    Session->>Runner: emitToolCall(event)
    Runner->>Ext: 依次调用处理器
    Ext-->>Runner: block / undefined
    Runner-->>Session: 第一个 block 结果
    Session-->>Loop: block / undefined
    alt 被阻止
        Loop-->>Loop: 生成错误 Tool Result
    else 被允许
        Loop->>Tool: execute
    end

对应源码:

  • packages/coding-agent/src/core/agent-session.ts_installAgentToolHooks()
  • packages/coding-agent/src/core/extensions/runner.tsemitToolCall()
  • packages/coding-agent/src/core/extensions/types.tsToolCallEventToolCallEventResult

多个扩展都监听 tool_call 时,Runner 按注册顺序执行;遇到第一个 block 就返回。桌面应用应避免安装相互矛盾的审批扩展。

8. Protected Paths 不是字符串包含判断

Pi 仓库提供了 examples/extensions/protected-paths.ts。它用:

protectedPaths.some((path) =>
  inputPath.includes(path),
);

这适合说明扩展机制,但不适合直接作为严谨的安全边界。例如:

  • safe/.env.example 可能被过度拦截;
  • ../secret 需要先解析;
  • 符号链接可能把工作区内路径指向工作区外;
  • /work/app2 不能因为以 /work/app 开头就被当成其子目录。

桌面 Agent 至少应先把路径变成规范化绝对路径,再判断包含关系:

import {
  isAbsolute,
  relative,
  resolve,
  sep,
} from "node:path";

function isInside(
  root: string,
  target: string,
): boolean {
  const relation = relative(root, target);

  return relation === "" ||
    (
      relation !== ".." &&
      !relation.startsWith(`..${sep}`) &&
      !isAbsolute(relation)
    );
}

function resolveInputPath(
  cwd: string,
  inputPath: string,
): string {
  return resolve(cwd, inputPath);
}

不要使用:

target.startsWith(root);

因为 /workspace-copy 也以 /workspace 开头。

对已经存在的目标,还应使用 realpath 解析符号链接。对将要创建的文件,可以先规范化其最近的已存在父目录,再拼接剩余路径。删除符号链接时还要先决定策略针对“链接本身”还是“链接指向的目标”,这属于产品安全规则,不能靠一个字符串判断替代。

9. 设计路径策略

可以将路径规则分为三类:

type PathPolicy = {
  workspaceRoots: string[];
  denyReadRoots: string[];
  denyWriteRoots: string[];
  confirmWriteRoots: string[];
};

一个桌面 Agent 的初始策略可能是:

操作 工作区内普通文件 .env、密钥目录 工作区外
read 允许 拒绝或确认 拒绝
write/edit 确认或允许 拒绝 拒绝
delete 确认 拒绝 拒绝
shell 确认高风险命令 仍受沙箱限制 仍受沙箱限制

请注意,packages/coding-agent/src/core/tools/path-utils.tsresolveToCwd() 会解析绝对路径、相对路径和 ~,但不会自动把路径限制在 cwd。它是路径解析辅助函数,不是 Protected Paths 策略。

同样,Agent Harness 的路径工具把解析交给 ExecutionEnv.absolutePath();是否限制目录取决于宿主提供的 Execution Environment,而不是基础 Agent 自动保证。

10. Project Trust 到底信任什么

Project Trust 解决的是另一个问题:

进入一个仓库时,是否允许这个仓库自动改变 Pi 的设置、能力和提示词?

Pi 会检查两组受保护项目资源。

只检查当前工作目录下的:

  • .pi/settings.json
  • .pi/extensions
  • .pi/skills
  • .pi/prompts
  • .pi/themes
  • .pi/SYSTEM.md
  • .pi/APPEND_SYSTEM.md

另外,项目 .agents/skills 会从当前目录向祖先目录查找。

只有一个空的 .pi 目录并不触发信任要求。

信任解析流程可以概括为:

flowchart TD
    A["开始加载项目"] --> B{"本次运行有 trust override?"}
    B -->|有| Z["使用 override"]
    B -->|没有| C{"存在需信任的项目资源?"}
    C -->|没有| Y["视为 trusted"]
    C -->|有| F["预信任扩展处理 project_trust"]
    F --> G{"扩展给出 yes/no?"}
    G -->|有| H["使用扩展决定"]
    G -->|undecided| D{"当前或最近祖先有保存决定?"}
    D -->|有| E["使用最近的保存决定"]
    D -->|没有| I{"defaultProjectTrust"}
    I -->|always| Y
    I -->|never| N["untrusted"]
    I -->|ask 且有宿主 UI| Q["请求用户决定"]
    I -->|ask 但无 UI| N

预信任扩展中第一个返回 yes/no 的处理器优先;只有处理结果为 undecided,才继续读取保存决定。保存的决定按规范化目录写入 Agent 目录中的 trust.json;默认位置是 ~/.pi/agent/trust.json。当前目录没有记录时,最近的祖先记录可以生效。

用户可以只信任本次 Session,也可以把当前目录或其直接父目录的决定保存下来。扩展上下文的 isProjectTrusted() 返回当前项目的有效信任状态,其中也可能包括临时决定或启动参数覆盖,不应理解成“磁盘里一定有一条记录”。

11. 信任与不信任分别发生什么

项目被信任后,coding-agent 才会加载需要信任的项目资源,例如:

  • 项目 .pi/settings.json
  • 项目扩展、技能、提示模板和主题;
  • 项目 System Prompt 文件;
  • 项目设置要求安装的缺失包;
  • 项目本地或项目包管理的扩展。

拒绝信任时,这些受保护资源会被跳过。SettingsManager 也拒绝向未信任项目写入 project settings。

AGENTS.mdCLAUDE.md 这类 Context 文件默认仍会加载,除非关闭 Context 加载。这意味着:

Project Trust 防止仓库在批准前装载可执行扩展和受保护配置,但它不能消除来自仓库文字、代码、日志或构建输出的 Prompt Injection。

更关键的是,项目未被信任后,已注册工具仍然拥有进程本来的文件和系统权限。Project Trust 不会自动禁止模型读取仓库外的文件,也不会限制 Shell。

12. Pi 的默认权限模型

packages/coding-agent/docs/security.md 对默认边界写得很直接:

  • Pi 以启动它的用户账号权限运行;
  • 内置 read、write、edit、bash 等工具使用 Pi 进程的权限;
  • 扩展是同进程 TypeScript 模块,也拥有相同权限;
  • Pi 没有内置沙箱;
  • “没有权限弹窗”是 coding-agent 的默认使用方式。

因此默认链路可以写成:

模型请求
  → 工具已注册
  → 参数有效
  → 没有 Hook 阻止
  → 以 Pi 进程权限执行

“当前工作目录”不是系统安全边界,“项目未信任”也不是文件系统隔离。

13. Permission Gate 与沙箱的区别

机制 优点 局限
beforeToolCall / tool_call 能理解工具语义,适合审批与审计 错误策略、恶意扩展或未覆盖的工具可能绕过
Protected Paths 策略 能限制具体资源 必须正确处理路径、链接和所有写入通道
Project Trust 阻止仓库静默加载受保护资源 不限制执行期工具权限
容器 / VM / OS 沙箱 由系统强制文件、进程、网络边界 需要正确配置挂载、凭据和网络

权限 Hook 是“应用决定”,沙箱是“系统强制”。

仓库的 docs/containerization.md 给出两类部署:

  1. 整个 Pi 进程放进容器、VM 或受策略控制的沙箱;
  2. Pi 留在宿主机,把内置工具执行路由到隔离环境。

第二种方式需要格外注意:其他自定义扩展工具仍可能在宿主机执行,除非它们也把操作委托到隔离环境。

即使使用容器,把宿主工作区以读写方式挂载进去,容器内的写操作仍会改变宿主文件。需要更强保护时,应使用只读挂载,或通过复制输入、审核输出后再复制回来的方式工作。

14. 审批记录怎样设计

Pi 没有规定审批记录格式。桌面 Agent 可以建立自己的审计模型:

type ApprovalRecord = {
  toolCallId: string;
  toolName: string;
  risk: "confirm" | "deny";
  resource?: string;
  decision: "allow-once" | "deny";
  decidedAt: string;
  policyVersion: string;
  sessionId: string;
};

建议记录:

  • 哪一个 Tool Call;
  • 哪个工具和操作类型;
  • 经过规范化的资源标识;
  • 风险规则版本;
  • 用户决定;
  • 决定时间与 Session。

不建议无条件记录:

  • 完整密钥;
  • 文件正文;
  • 含 Token 的完整命令;
  • 未脱敏的环境变量。

需要审计命令时,可以保存脱敏摘要和内容摘要,而不是把秘密复制到另一份日志。

“始终允许”比“仅允许一次”危险得多。如果产品支持它,授权范围至少要包含:

工具 + 操作 + 规范化项目/资源范围 + 用户身份 + 策略版本

还要能查看、撤销和过期。第一版桌面 Agent 只支持 allow-oncedeny,通常更容易做对。

15. 把完整流程串起来

现在把删除文件的例子走一遍:

sequenceDiagram
    participant U as 用户
    participant M as 模型
    participant L as Agent Loop
    participant P as Permission Policy
    participant D as 桌面审批窗口
    participant T as delete_file
    participant S as 系统沙箱

    U->>M: 删除 build/cache
    M-->>L: delete_file(path)
    L->>L: 查找工具并验证参数
    L->>P: beforeToolCall
    P->>P: 规范化路径、风险分类
    P->>D: 请求 allow-once
    D-->>P: 用户允许
    P-->>L: 不阻止
    L->>T: execute
    T->>S: 请求删除规范化目标
    S-->>T: 仅允许工作区范围
    T-->>L: Tool Result
    L-->>M: 删除结果
    L-->>U: UI 展示结果和审批记录

如果用户拒绝:

  1. Permission Gate 返回 block: true
  2. delete_file.execute() 不会运行;
  3. Loop 生成错误 Tool Result;
  4. 模型获知操作被拒绝;
  5. UI 保留拒绝记录。

如果审批窗口崩溃,也走相同的阻止路径。安全操作不应依赖“没有返回值就当作同意”。

16. 最小可用安全方案

开发桌面 Agent 的第一版,可以按下面顺序落地:

  1. 只注册真正需要的工具;
  2. 用 Schema 缩小每个工具的参数空间;
  3. beforeToolCallallow / confirm / deny
  4. 无审批 UI、超时、取消和异常全部默认拒绝;
  5. 规范化路径后再应用 Protected Paths;
  6. 审批页展示最终工具名、操作和规范化目标;
  7. 审批后不要再次静默修改参数;
  8. 保存经过脱敏的审批记录;
  9. 对不受信任或无人看守的任务使用容器、VM 或系统沙箱;
  10. 只提供任务必需的目录、网络和短期凭据。

17. 常见误区

“我没有给工具绝对路径,所以它只能访问项目目录”

错误。coding-agent 的路径解析支持绝对路径和 ~,不会自动限制在 cwd。

“项目标记为 untrusted,工具就不能修改它”

错误。Project Trust 控制项目资源加载,不是工具执行权限。

“有确认框,就不需要沙箱”

错误。确认框依赖应用代码覆盖全部入口;沙箱才是更低层的系统边界。

“有容器,宿主文件就一定安全”

错误。读写挂载的宿主目录仍会被容器内操作修改。

“把所有 Shell 命令都交给正则检查就安全了”

错误。Shell 语义复杂,正则适合做风险提示,不适合充当完整隔离边界。

18. 本章小结

这一章从“删除前问一下”逐步建立了 Pi 的权限模型:

  • 工具注册定义能力边界;
  • Schema 定义参数边界;
  • beforeToolCall 是基础 Agent 的执行前 Permission Gate;
  • coding-agent 的 tool_call 扩展事件通过 AgentSession 接到同一个 Hook;
  • Protected Paths 是应用或扩展策略,不是 Pi 默认保证;
  • Project Trust 只保护项目本地设置、扩展、技能和提示等资源的加载;
  • Pi 默认以当前用户权限运行,没有内置沙箱和逐次权限弹窗;
  • 容器、VM 或 OS 沙箱提供真正的系统级隔离;
  • 审批失败时应默认拒绝,审批记录要脱敏并限定授权范围。

现在桌面 Agent 不仅“会使用工具”,也开始拥有可解释、可审计的行为边界。

19. 自测

  1. 为什么 beforeToolCallafterToolCall 更适合做权限确认?
  2. beforeToolCall 返回 block: true 时,工具是否会执行?模型会收到什么?
  3. 为什么不能用 target.startsWith(root) 判断路径位于工作区?
  4. Project Trust 会不会限制 bash 读取用户主目录?
  5. coding-agent 扩展的 tool_call 如何连接到基础 Agent Loop?
  6. 为什么审批界面不可用时应默认拒绝?
  7. 容器以读写方式挂载工作区后,哪些宿主文件仍可能被修改?
  8. 为什么审批日志不应保存完整命令和环境变量?