上一篇把内部事件映射成公开 SSE 协议:工具状态可见,工具内容不可见。这一篇接着谈安全边界:Agent 能调哪些工具,自定义工具在「描述 / schema / 执行」上各自守哪一段。
CLI 那套默认工具,原样搬到长时间跑的 Web 服务上会过宽。bash 一开,模型就能在服务端进程上下文里跑 shell;edit / write 默认开着,任何能发 prompt 的人都能改磁盘。工具面本身就是信任边界的一部分。
内置工具与三种控制方式
Pi 内置工具名是固定的一组:read、bash、edit、write、grep、find、ls。默认内置是 read、bash、edit、write——本地 CLI 用着顺手,Web Agent 就偏宽了。
createAgentSession 给了三种控法:
// 1. allowlist:只启用名单里的工具(自定义工具名也要写进去)
const { session } = await createAgentSession({
tools: ["read", "grep", "find", "ls"],
});
// 2. denylist:在当前可用集合上再剔除
const { session: s2 } = await createAgentSession({
excludeTools: ["ask_question"],
});
// 3. 关掉整类
// noTools: "all" —— 全部关掉
// noTools: "builtin" —— 只关默认内置,保留扩展/自定义工具
课程最终应用选的是明确 allowlist,而不是「默认全集再 exclude」。上游如果新增内置工具,allowlist 不会自动放行;exclude 则可能漏掉新名字。这和上一篇事件映射的 default → undefined 是同一条原则:没点名的,默认不给。
最终应用默认清单:
read, grep, find, ls, project_status只有显式环境开关才追加 edit / write,始终不开放 bash。Web 进程一旦把 shell 交给模型,后面的只读、路径约束都谈不上了。
工具面:信任决策放在服务端
我把「启用哪些工具」「workspace 是什么」收敛到一个只读的服务端配置函数。HTTP 层只能拿到解析后的结果,改不了 cwd,也改不了工具清单。
// lessons/03-tools-and-safety/tool-surface.ts(节选)
const READ_ONLY_BUILTINS = ["read", "grep", "find", "ls"] as const;
const WRITE_BUILTINS = ["edit", "write"] as const;
// bash 被故意排除:长时间运行的 Web 服务不能把任意 shell 交给模型
const FORBIDDEN_BUILTINS = ["bash"] as const;
export function buildToolSurface(config: ToolSurfaceConfig): ToolSurface {
const cwd = path.resolve(config.workspace); // 服务端解析为绝对路径
const customTools = [
createProjectStatusTool(cwd),
createReadTicketTool(config.authorizedTicketIds ?? ["TICKET-1", "TICKET-2", "TICKET-42"]),
createSearchDocsTool(config.docsDir),
];
const tools: string[] = [
...READ_ONLY_BUILTINS,
"project_status",
"read_ticket",
"search_docs",
];
if (config.enableWriteTools) tools.push(...WRITE_BUILTINS);
// belt-and-suspenders:禁用工具永远不能混进 allowlist
for (const name of tools) {
if ((FORBIDDEN_BUILTINS as readonly string[]).includes(name)) {
throw new Error(`Internal error: forbidden tool in surface: ${name}`);
}
}
return { cwd, tools, customTools };
}有两个容易踩的点。
一是自定义工具名也要写进 tools allowlist。SDK 文档写得很清楚:传了 tools 之后,自定义 / 扩展工具不会自动启用,必须把名字(如 project_status)一并列入。漏写的表现是「工具定义在,模型却调不到」。调试时很容易误判成模型不会用工具。
二是 cwd 在服务端启动时解析并冻结。path.resolve(config.workspace) 得到绝对路径,再塞进闭包。createAgentSession({ cwd, tools, customTools }) 时,内置 read / ls 也按这个 cwd 构建。客户端再传 cwd,HTTP 层直接拒绝:
export function createToolSurfaceResolver(config: ToolSurfaceConfig) {
const surface = buildToolSurface(config);
return function resolveToolSurface(clientCwd?: unknown): ToolSurface {
if (clientCwd !== undefined) {
throw new Error(
"Client-supplied cwd is not permitted; the workspace is server-resolved.",
);
}
return surface;
};
}客户端 cwd 为什么必须拒?课程里给了几层理由,我按自己的理解排一下:
- HTTP 输入不可信。它要是能定 cwd,
read/grep/find就能指向/etc、~/.ssh,只读边界立刻失效。 - 自定义工具闭包里拿的是服务端
workspace/docsDir。cwd 一旦被改,内置ls和project_status会报两棵不同的树,模型和客户端看到互相矛盾的状态。 - 后面会话持久化、资源发现都以 cwd 为键,可变 cwd 会把恢复和隔离搞乱。
更稳的做法是:HTTP 层根本不接收 cwd 字段,而不是「接收后再校验」。reject 只是把这个不变量显式写出来。
自定义工具:描述、schema、execute 各管一段
官方用法很短:
import { Type } from "typebox";
import { defineTool } from "@earendil-works/pi-coding-agent";
const myTool = defineTool({
name: "my_tool",
label: "My Tool",
description: "Does something useful",
parameters: Type.Object({
input: Type.String({ description: "Input value" }),
}),
execute: async (_toolCallId, params) => ({
content: [{ type: "text", text: `Result: ${params.input}` }],
details: {},
}),
});落地时要把职责拆开。课程反复强调:三层不能互相替代。
| 层 | 管什么 | 不管什么 |
|---|---|---|
description | 模型何时该调用 | 参数校验、业务授权 |
parameters(TypeBox schema) | 什么形状的参数能进 execute | 业务上是否允许 |
execute | 业务授权与路径收容 | 不能默认参数一定合法(仍可再检) |
read_ticket 把这三层拆得最清楚。
description 把格式和「有 allowlist」说清楚,模型才知道该不该调、传什么:
description:
"Read a support ticket by its id. The id must look like TICKET-NNNN (1-6 digits). " +
"Only tickets on the server-configured allowlist can be read; other ids are rejected " +
"even if they match the id format.",但这只是自然语言提示,不能当安全控制。模型完全可能传一个格式对、但不在名单里的 id。
schema 管形状:
export const READ_TICKET_ID_PATTERN = "^TICKET-[0-9]{1,6}$";
export const readTicketParameters = Type.Object({
id: Type.String({ pattern: READ_TICKET_ID_PATTERN }),
});ticket-1(大小写)、TICKET-abc、TICKET-1234567(7 位)、缺字段——这些在进 execute 之前就被挡掉。TICKET-999 格式合法,能通过 schema。
真正的授权在 execute:
export function assertAuthorizedTicket(
id: string,
authorizedIds: ReadonlySet<string>,
): void {
if (!authorizedIds.has(id)) {
// schema 放行的 id 仍可能不在 allowlist 里
throw new Error(`Unauthorized ticket: ${id}`);
}
}
export function runReadTicket(
id: string,
authorizedIds: ReadonlySet<string>,
): AgentToolResult<{ id: string }> {
assertAuthorizedTicket(id, authorizedIds);
return {
content: [textBlock(`Ticket ${id}: (synthetic body for lesson 3 demo)`)],
details: { id },
};
}TICKET-999 过了 schema,在 execute 里被拒。格式合法 ≠ 业务允许。测试也把两层分开断言:
// schema 放行
expect(Check(readTicketParameters, { id: "TICKET-999" })).toBe(true);
// 授权层拒绝
expect(() => runReadTicket("TICKET-999", authorized)).toThrow(/Unauthorized ticket/);project_status:无参,路径来自闭包
实验 2 做一个无参数的 project_status:返回 workspace 是否存在、是否为目录。参数对象是空的 Type.Object({}),路径完全来自工厂闭包:
export function createProjectStatusTool(workspace: string): ToolDefinition {
return defineTool({
name: "project_status",
label: "Project status",
description:
"Report whether the configured project workspace exists and is a directory. " +
"Takes no arguments; the workspace path is server-configured and never read from input.",
parameters: projectStatusParameters,
execute: async () => runProjectStatus(workspace),
});
}不存在时返回 exists: false,不抛错,让模型直接看到「workspace 缺失」这个事实。路径从不从工具参数读。如果 project_status 接受一个 path 参数,模型(或 prompt 注入)就能拿它去探任意路径。无参 + 闭包,等于把可观测范围钉死在服务端配置上。
写工具开关:默认关,开也只在沙盒
实验 4 用环境变量 ENABLE_WRITE_TOOLS=true 临时打开 edit / write。smoke 会并列打印两种工具面,再按当前开关建 session:
[surface] read-only tools : read, grep, find, ls, project_status, read_ticket, search_docs
[surface] write-enabled : read, grep, find, ls, project_status, read_ticket, search_docs, edit, write
[surface] bash in either? : false打开时,在 workspace/sandbox 里让模型写一个 hello.txt 再 read 回来;关掉开关后,session.getActiveToolNames() 里就没有 edit。要验证的是:开关关掉后工具从 allowlist 消失,模型侧根本看不见它。不是「模型会不会写文件」。
课程 README 也写了:默认 Agent 只能读 ./workspace,只有第 3 课的受控实验才应设 ENABLE_WRITE_TOOLS=true。生产上这个开关应该是部署配置,不是 HTTP 请求参数。
作业:search_docs 的路径收容
作业是新增 search_docs:query 长度 2–100,只搜教师提供的文档目录,最多返回 10 条相对路径。还要补路径逃逸与超长 query 测试。
schema 先卡长度:
export const searchDocsParameters = Type.Object({
query: Type.String({ minLength: 2, maxLength: 100 }),
});execute 里再做一层 trim 后校验。两个空格能过 schema(长度 2),trim 之后长度为 0,必须拒绝:
export function validateSearchQuery(raw: string): string {
const query = raw.trim();
if (query.length < 2 || query.length > 100) {
throw new Error(`query length must be 2..100 chars after trim (got ${query.length})`);
}
return query;
}路径逃逸是作业真正要练的东西。几条约束叠在一起:
// 1. query 只当文本关键词,绝不拼进路径
const needle = query.toLowerCase();
// 2. 枚举时跳过符号链接,防 symlink 指到目录外
if (entry.isSymbolicLink()) continue;
// 3. 每个结果再用 isPathInside 收口
if (!isPathInside(docsDir, abs)) continue;
// 4. 对外只返回相对路径
const rel = path.relative(docsDir, abs);isPathInside 的实现是常见写法:
export function isPathInside(parent: string, child: string): boolean {
const rel = path.relative(parent, child);
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}测试里有一组专门盯逃逸:
// 目录外有匹配文件;query 长得像路径穿越,也只是文本搜索
await writeFile(path.join(outsideDir, "secret.md"), "outside secret");
expect(await searchDocs(docsDir, "../outside/secret")).toEqual([]);
expect(await searchDocs(docsDir, "outside secret")).toEqual([]);
// docsDir 里的 symlink 指向目录外 → 跳过,不跟随
await symlink(outsideDir, path.join(docsDir, "link"), "dir");
expect(await searchDocs(docsDir, "matchme")).toEqual([]);我后来才想通一件事:query 里的 .. / / 本身不会造成路径逃逸,因为它们从不进入 path.join。真正的逃逸面在枚举范围和 symlink,不在 query 字符串。把 query 当路径片段拼接,才是常见的实现错误。
公开事件仍然不带 params / result
上一篇的映射约束这一课继续生效。smoke 订阅工具事件时只打名字:
case "tool_execution_start":
console.log(`\n[tool] ${event.toolName} start`);
break;
case "tool_execution_end":
console.log(`\n[tool] ${event.toolName} end (error=${event.isError})`);
break;验收标准写得很明确:工具公开事件不含 params 和 result 正文。search_docs 的 query、read_ticket 的 id、文件内容,这些可以进模型上下文(工具结果本来就是给模型看的),但不能原样跨 HTTP 边界。Web 层继续用第 2 课的 toPublicEvent,只放行 toolCallId + toolName + isError。
最终参考实现 apps/web-agent/src/pi-runtime.ts 也是同一条默认:
const tools = ["read", "grep", "find", "ls", "project_status"];
if (config.enableWriteTools) tools.push("edit", "write");
// 没有 bash
审计扩展只记工具名,不记参数:
pi.on("tool_call", (event) => {
// Do not log arguments: they may contain source code or secrets.
console.info(JSON.stringify({ kind: "tool_call", toolName: event.toolName }));
return undefined;
});跑起来
离线测试不消耗模型额度,直接测纯函数和 schema:
npx vitest run lessons/03-tools-and-safety/tools.test.ts真模型 smoke / 作业:
npm run lesson:03:smoke
# 演示写工具
ENABLE_WRITE_TOOLS=true npm run lesson:03:smoke
npm run lesson:03:homeworksmoke 会让模型先 project_status 再 ls,看工具名是否在只读集合里;homework 种子几个含 pi 关键词的文档,让模型用 search_docs 返回相对路径。
小结
这一课下来,我觉得真正要记住的是三块:
工具面用白名单。默认只读加自定义状态工具;edit / write 靠显式开关;bash 不进 Web Agent。
自定义工具三层各管一段:description 管何时,schema 管形状,execute 管业务授权和路径收容。少一层都会出洞。
cwd / 文档目录是服务端配置,不是 HTTP 输入。无参工具用闭包钉死范围;search_docs 把 query 当关键词,跳过 symlink,只回相对路径。
和第 2 课拼起来:那边管工具执行结果不跨边界,这边管工具能力本身就被收窄。两边一起,才构成能挂在 HTTP 上的工具信任边界。
代码在 lessons/03-tools-and-safety/:tool-surface.ts、tools.ts、tools.test.ts、smoke.ts / homework.ts。最终应用对应 apps/web-agent/src/tools.ts 与 pi-runtime.ts 的 allowlist。下一篇记资源加载器。
参考
代码固定使用 @earendil-works/[email protected],避免上游快速变化破坏可复现性。