桌面 Agent 已经能读取文件、并行执行工具,也能被用户停止。现在用户提出一个看似简单的要求:
读取文件可以直接执行;删除文件、修改敏感配置或运行高风险命令前,必须先问我。
这不是给按钮加一个确认框那么简单。我们需要回答五个不同的问题:
- 模型能调用哪些能力?
- 某一次工具调用是否允许执行?
- 哪些路径必须受到特殊保护?
- 项目里的扩展、技能和设置能否被加载?
- 即使应用策略出错,操作系统层面还能否限制损害范围?
Pi 对这些问题给出了不同层次的机制。最重要的结论先放在前面:
Pi 默认没有“每次危险操作都弹窗”的权限系统。已注册的工具在参数验证通过后会执行,除非
beforeToolCallHook 或 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 最好只做三件事:
- 读取经过验证的参数;
- 计算允许、确认或拒绝;
- 返回是否阻止。
如果确实要重写参数,应在修改后自行重新验证,并确保审批页面展示最终参数。
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.ts的emitToolCall();packages/coding-agent/src/core/extensions/types.ts的ToolCallEvent和ToolCallEventResult。
多个扩展都监听 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.ts 的 resolveToCwd() 会解析绝对路径、相对路径和 ~,但不会自动把路径限制在 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.md 和 CLAUDE.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 给出两类部署:
- 整个 Pi 进程放进容器、VM 或受策略控制的沙箱;
- 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-once 和 deny,通常更容易做对。
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 展示结果和审批记录
如果用户拒绝:
- Permission Gate 返回
block: true; delete_file.execute()不会运行;- Loop 生成错误 Tool Result;
- 模型获知操作被拒绝;
- UI 保留拒绝记录。
如果审批窗口崩溃,也走相同的阻止路径。安全操作不应依赖“没有返回值就当作同意”。
16. 最小可用安全方案
开发桌面 Agent 的第一版,可以按下面顺序落地:
- 只注册真正需要的工具;
- 用 Schema 缩小每个工具的参数空间;
- 在
beforeToolCall做allow / confirm / deny; - 无审批 UI、超时、取消和异常全部默认拒绝;
- 规范化路径后再应用 Protected Paths;
- 审批页展示最终工具名、操作和规范化目标;
- 审批后不要再次静默修改参数;
- 保存经过脱敏的审批记录;
- 对不受信任或无人看守的任务使用容器、VM 或系统沙箱;
- 只提供任务必需的目录、网络和短期凭据。
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. 自测
- 为什么
beforeToolCall比afterToolCall更适合做权限确认? - 当
beforeToolCall返回block: true时,工具是否会执行?模型会收到什么? - 为什么不能用
target.startsWith(root)判断路径位于工作区? - Project Trust 会不会限制
bash读取用户主目录? - coding-agent 扩展的
tool_call如何连接到基础 Agent Loop? - 为什么审批界面不可用时应默认拒绝?
- 容器以读写方式挂载工作区后,哪些宿主文件仍可能被修改?
- 为什么审批日志不应保存完整命令和环境变量?