青蛙小白
博客 / 2026/08

Pi SDK 学习笔记(三):工具面与安全边界

2026/08/11 · — 字 · 阅读约 — 分钟 ·
目录

上一篇把内部事件映射成公开 SSE 协议:工具状态可见,工具内容不可见。这一篇接着谈安全边界:Agent 能调哪些工具,自定义工具在「描述 / schema / 执行」上各自守哪一段。

CLI 那套默认工具,原样搬到长时间跑的 Web 服务上会过宽。bash 一开,模型就能在服务端进程上下文里跑 shell;edit / write 默认开着,任何能发 prompt 的人都能改磁盘。工具面本身就是信任边界的一部分。

内置工具与三种控制方式

Pi 内置工具名是固定的一组:readbasheditwritegrepfindls。默认内置是 readbasheditwrite——本地 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 一旦被改,内置 lsproject_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-abcTICKET-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.txtread 回来;关掉开关后,session.getActiveToolNames() 里就没有 edit。要验证的是:开关关掉后工具从 allowlist 消失,模型侧根本看不见它。不是「模型会不会写文件」。

课程 README 也写了:默认 Agent 只能读 ./workspace,只有第 3 课的受控实验才应设 ENABLE_WRITE_TOOLS=true。生产上这个开关应该是部署配置,不是 HTTP 请求参数。

作业:search_docs 的路径收容

作业是新增 search_docsquery 长度 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:homework

smoke 会让模型先 project_statusls,看工具名是否在只读集合里;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.tstools.tstools.test.tssmoke.ts / homework.ts。最终应用对应 apps/web-agent/src/tools.tspi-runtime.ts 的 allowlist。下一篇记资源加载器。

参考

代码固定使用 @earendil-works/[email protected],避免上游快速变化破坏可复现性。

评论