从零开发一个 Coding Agent(十三):实现安全的 read 文件读取工具

Coding Agent目前已经发布到npm,大家可以先行直接先体验一下最终会做成什么样。github地址:github.com 欢迎 Star 支持,npm地址@di-code/coding-agent - npm。直接npm install -g @di-code/coding-agent即可安装使用

本篇文章是《从零开发一个 Coding Agent》系列第十三篇。

上一篇中,我们已经完成了 CLI、print 模式和 JSONL 模式。现在用户可以从命令行向 Agent 提问,Agent 也可以调用 Faux Provider 得到确定性的回答。但是此时的 Agent 还只能"聊天",它并不能真正查看项目中的文件。

这一篇我们将实现第一个本地工具:read

完成后,调用方可以这样读取文件:

ts 复制代码
const readTool = createReadTool("D:\\pi\\di-code");

const result = await readTool.execute("call-1", {
	path: "packages/ai/src/index.ts",
	offset: 1,
	limit: 20,
});

工具会返回文件第 1 到 20 行。如果文件后面还有内容,结果末尾会告诉模型下一次应该使用哪个 offset 继续读取。

不过,read 绝不能只是简单调用一次 readFile()。一个 Coding Agent 读取的是用户电脑上的真实文件,因此必须同时解决这些问题:

  • Agent 可以读取哪些目录?
  • ../ 能不能越过项目根目录?
  • 根目录中的符号链接能不能指向外部文件?
  • 大文件是否会一次性塞满模型上下文?
  • 中文按字符数还是按 UTF-8 字节数计算?
  • 二进制文件能不能当作文本返回?
  • 操作已经取消时,工具应该怎样结束?

所以这一篇实现的不是一个"能读文件的函数",而是一个带有明确权限边界和输出边界的文件读取工具

read 工具在项目中的位置

当前项目的依赖方向是:

text 复制代码
ai <- agent <- coding-agent

三个包的职责不同:

职责
@di-code/ai 定义消息、工具结果和 Provider 无关的公共协议
@di-code/agent 负责 Agent Loop、参数校验、工具调用和错误回送
@di-code/coding-agent 负责文件系统、CLI 等真实产品能力

read 会访问 Node.js 文件系统,所以它必须放在 coding-agent,不能放进通用的 agent 包。

本篇只创建两个文件:

text 复制代码
di-code/
  packages/
    coding-agent/
      src/
        core/
          tools/
            read.ts
      test/
        read-tool.test.ts

read.ts 保存工具实现,read-tool.test.ts 使用临时目录验证正常读取和各种安全边界。

这一篇暂时不把工具接入 CLI,也不实现"模型请求读取 -> 工具执行 -> 结果回送 -> 模型回答"的完整流程。这里先把单独的 read 工具做正确,端到端接线放到后续文章。

一次读取经过哪些步骤

假设允许读取的根目录是:

text 复制代码
D:\pi\di-code

模型请求:

json 复制代码
{
  "path": "packages/ai/src/index.ts",
  "offset": 10,
  "limit": 20
}

工具不会立刻读取文件,而是依次执行下面的流程:

flowchart TD A[收到 path offset limit] --> B[检查取消和参数] B --> C[把输入路径解析为绝对路径] C --> D[检查词法路径没有越过根目录] D --> E[取得目标真实路径 realpath] E --> F[再次检查真实路径没有越过根目录] F --> G[读取为 Buffer] G --> H[检查是否像二进制文件] H --> I[按 UTF-8 文本分行] I --> J[应用 offset 和 limit] J --> K[应用最大行数和最大字节数] K --> L[返回文本和继续读取提示]

这里最容易忽略的是两次路径检查:

  1. 读取前检查用户输入解析出来的路径,防止 .. 或根外绝对路径。
  2. realpath() 后再检查一次,防止根目录内的符号链接指向外部文件。

后面会用具体例子解释为什么两次都不能少。

定义 read 工具的参数

创建文件:

text 复制代码
di-code/packages/coding-agent/src/core/tools/read.ts

先加入 import 和默认上限:

ts 复制代码
import { readFile, realpath } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import type { AgentTool } from "@di-code/agent";
import { type Static, type ToolResultContent, Type } from "@di-code/ai";

export const DEFAULT_READ_MAX_LINES = 2_000;
export const DEFAULT_READ_MAX_BYTES = 50 * 1024;

readFile() 负责读取文件,realpath() 负责取得文件真正指向的位置。node:path 中的函数用于解析并判断路径。

默认最多返回:

  • 2000 行;
  • 50 KiB,也就是 50 * 1024 字节。

两个上限同时存在。只要先碰到其中任何一个,工具就停止继续加入内容。

接着定义模型可以传入的参数:

ts 复制代码
export const readParameters = Type.Object({
	path: Type.String({ minLength: 1 }),
	offset: Type.Optional(Type.Integer({ minimum: 1 })),
	limit: Type.Optional(Type.Integer({ minimum: 1 })),
});

export type ReadParameters = Static<typeof readParameters>;

三个字段分别表示:

字段 含义 示例
path 要读取的文件路径 "src/index.ts"
offset 从第几行开始,行号从 1 开始 10
limit 最多读取多少行 20

例如一个文件有 100 行:

json 复制代码
{
  "path": "notes.txt",
  "offset": 10,
  "limit": 3
}

表示读取第 10、11、12 行。下一次继续读取时,应该使用 offset: 13

为什么 offset 从 1 开始,而不是从 0 开始?因为编辑器、终端错误信息和人类讨论代码时通常都说"第 1 行"。工具对外使用 1-based 行号,可以减少模型换算时的错误。只有在访问 JavaScript 数组时,才转换成从 0 开始的索引。

再定义创建工具时的配置和公开类型:

ts 复制代码
export interface ReadToolOptions {
	readonly maxLines?: number;
	readonly maxBytes?: number;
}

export type ReadTool = AgentTool<typeof readParameters>;

offsetlimit 是一次工具调用的参数;maxLinesmaxBytes 是应用创建工具时规定的安全上限。模型可以请求少读一些,但不能通过参数取消应用设置的总上限。

为什么 schema 校验后还要检查参数

Agent Loop 正常调用工具时,会根据 readParameters 校验参数。但测试代码、未来的 SDK 或其他内部代码也可能直接调用 execute()

所以工具内部仍然要做防御性检查:

ts 复制代码
function assertPositiveInteger(name: string, value: number | undefined): void {
	if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
		throw new Error(`${name} must be a positive integer`);
	}
}

这个函数接受字段名和数字:

  • 没有传值时允许通过,因为这些参数是可选的;
  • 传了值但不是整数时拒绝;
  • 小于 1 时拒绝。

例如:

ts 复制代码
assertPositiveInteger("offset", 1); // 通过
assertPositiveInteger("offset", 2.5); // 抛错
assertPositiveInteger("offset", 0); // 抛错

TypeScript 类型只能帮助我们检查开发阶段的代码,无法替代运行时校验。模型生成的 JSON、CLI 参数和磁盘数据都属于外部输入,程序在运行时必须重新确认它们是否有效。

把文本正确地分成行

接下来定义工具内部使用的文本窗口:

ts 复制代码
interface TextWindow {
	readonly content: string;
	readonly startLine: number;
	readonly endLine: number;
	readonly totalLines: number;
	readonly truncatedBy: "limit" | "lines" | "bytes" | null;
}

可以把 TextWindow 理解为"这次准备返回的文件切片"。它不仅保存文本,还要记住:

  • 从哪一行开始;
  • 到哪一行结束;
  • 文件总共有多少行;
  • 为什么发生截断。

这些信息会用于生成下一次读取提示。

然后实现分行函数:

ts 复制代码
function splitLines(text: string): string[] {
	if (text.length === 0) return [];
	const lines = text.split("\n");
	if (text.endsWith("\n")) lines.pop();
	return lines;
}

为什么不能只写 text.split("\n")?看这个文件:

text 复制代码
line 1\n
line 2\n

直接分割会得到:

ts 复制代码
["line 1", "line 2", ""]

最后的空字符串不是额外的第 3 行内容,而是末尾换行符产生的分隔结果,所以需要删除。

但文件内部真正的空行仍然应该保留。例如:

text 复制代码
line 1

line 3

应该得到:

ts 复制代码
["line 1", "", "line 3"]

另外,空文件应该是 0 行,所以 splitLines("") 直接返回空数组。

实现 offset 和 limit

现在实现用户主动请求的文本窗口:

ts 复制代码
function selectUserWindow(lines: readonly string[], offset: number, limit: number | undefined): TextWindow {
	if (lines.length === 0) {
		if (offset > 1) throw new Error(`Offset ${offset} is beyond end of file (0 lines total)`);
		return { content: "", startLine: 1, endLine: 0, totalLines: 0, truncatedBy: null };
	}

	const startIndex = offset - 1;
	if (startIndex >= lines.length) {
		throw new Error(`Offset ${offset} is beyond end of file (${lines.length} lines total)`);
	}

	const available = lines.slice(startIndex);
	const selected = limit === undefined ? available : available.slice(0, limit);
	const endLine = offset + selected.length - 1;

	return {
		content: selected.join("\n"),
		startLine: offset,
		endLine,
		totalLines: lines.length,
		truncatedBy: limit !== undefined && selected.length < available.length ? "limit" : null,
	};
}

用一个 4 行文件观察这段代码:

text 复制代码
line 1
line 2
line 3
line 4

调用参数是:

ts 复制代码
{ offset: 2, limit: 2 }

计算过程如下:

text 复制代码
offset = 2
startIndex = offset - 1 = 1
available = ["line 2", "line 3", "line 4"]
selected = ["line 2", "line 3"]
endLine = 2 + 2 - 1 = 3

最终返回第 2 到第 3 行,下一次应该从第 4 行继续。

如果 offset 已经超过文件末尾,工具不能返回一个看似成功的空字符串。明确抛出错误更容易让模型发现自己使用了错误的行号。

给模型加入继续读取提示

当用户的 limit 截断内容时,在结果末尾加入提示:

ts 复制代码
function appendContinuation(window: TextWindow, maxBytes: number): string {
	if (window.truncatedBy === null) return window.content;

	const nextOffset = window.endLine + 1;

	if (window.truncatedBy === "limit") {
		const remaining = window.totalLines - window.endLine;
		return `${window.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
	}

	const reason = window.truncatedBy === "bytes" ? ` (${formatByteLimit(maxBytes)} limit)` : "";
	return `${window.content}\n\n[Showing lines ${window.startLine}-${window.endLine} of ${window.totalLines}${reason}. Use offset=${nextOffset} to continue.]`;
}

例如读取第 2、3 行后,结果是:

text 复制代码
line 2
line 3

[1 more lines in file. Use offset=4 to continue.]

这段提示主要是给模型看的。模型不需要重新猜测文件还有多少内容,也不需要自己计算下一次的行号。

注意,工具结果只返回文本,不直接写入 stdout。最终显示为普通文本还是 JSONL,应该由 CLI 输出层决定。

限制文件只能在允许根目录内

文件读取工具最重要的安全规则是:目标文件必须位于 allowedRoot 内部

假设根目录是:

text 复制代码
D:\pi\di-code

下面的路径应该允许:

text 复制代码
packages\ai\src\index.ts
D:\pi\di-code\package.json

下面的路径应该拒绝:

text 复制代码
..\secret.txt
D:\other-project\config.json

为什么不能直接使用 startsWith

一种看起来很简单的写法是:

ts 复制代码
target.startsWith(root)

但它不能正确判断目录关系。例如:

text 复制代码
root   = C:\work
target = C:\work-secret\password.txt

target 的字符串确实以 C:\work 开头,但 work-secret 并不是 work 的子目录。

正确方式是使用 path.relative() 计算两个路径之间的目录关系:

ts 复制代码
function assertInsideRoot(root: string, target: string): void {
	const fromRoot = relative(root, target);

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

	throw new Error("Path is outside the allowed root");
}

relative(root, target) 的结果可以这样理解:

结果 含义
"" target 就是 root 本身
"src\\index.ts" target 在 root 内部
"..\\secret.txt" target 在 root 外部
绝对路径 两个路径不在可直接比较的同一根路径下

sep 是当前操作系统的路径分隔符。Windows 通常是 \,Unix 通常是 /。使用 Node 的路径 API,不要自己拼接某一种系统的分隔符。

第一次检查:阻止 .. 和根外绝对路径

实现路径解析:

ts 复制代码
async function resolveAllowedFile(inputPath: string, allowedRoot: string): Promise<string> {
	const rootReal = await realpath(allowedRoot);
	const candidate = resolve(rootReal, inputPath);
	assertInsideRoot(rootReal, candidate);

	const targetReal = await realpath(candidate);
	assertInsideRoot(rootReal, targetReal);

	return targetReal;
}

先看前半段:

ts 复制代码
const rootReal = await realpath(allowedRoot);
const candidate = resolve(rootReal, inputPath);
assertInsideRoot(rootReal, candidate);

resolve() 会把相对路径放到根目录下,也会规范化 ...

例如:

text 复制代码
root      = D:\pi\di-code
inputPath = packages\ai\src\index.ts
candidate = D:\pi\di-code\packages\ai\src\index.ts

如果输入是:

text 复制代码
..\secret.txt

解析后会变成:

text 复制代码
D:\pi\secret.txt

第一次 assertInsideRoot() 会在读取之前拒绝它。

检查必须放在目标文件的 realpath() 之前。否则一个根外且不存在的文件可能先抛出 ENOENT,从而暴露外部路径是否存在,还会让权限错误变成难以理解的"文件不存在"。

第二次检查:阻止符号链接逃逸

只有第一次检查仍然不够。考虑下面的目录:

text 复制代码
D:\pi\di-code\link.txt
    -> D:\private\secret.txt

从字符串路径看,link.txt 位于根目录内,所以第一次检查会通过。但它真正指向的文件位于根目录外。

因此还要执行:

ts 复制代码
const targetReal = await realpath(candidate);
assertInsideRoot(rootReal, targetReal);

realpath() 会解析符号链接,得到目标真正的位置。第二次检查会发现 D:\private\secret.txt 不在允许根目录内,并抛出:

text 复制代码
Path is outside the allowed root

两次检查负责的风险不同:

检查 阻止什么
candidate 的词法路径检查 ..、根外绝对路径,以及读取前的信息泄漏
targetReal 的真实路径检查 根内符号链接指向根外文件

只保留其中任何一次都会留下漏洞。

读取为 Buffer,而不是直接读取字符串

路径通过检查后,使用下面的代码读取文件:

ts 复制代码
const buffer = await readFile(absolutePath);

这里没有传入 "utf8",所以返回的是 Buffer,也就是原始字节。

这样做的原因是:在把文件解释为文本之前,我们要先判断它是否像二进制文件,还要按 UTF-8 字节数控制输出。

如果一开始就写:

ts 复制代码
await readFile(absolutePath, "utf8");

原始字节会立即被解码成 JavaScript 字符串,之后再做二进制判断会更困难。

拒绝明显的二进制文件

Coding Agent 的普通文本上下文不适合直接接收图片、压缩包或可执行文件。一个简单并且常见的判断方法是检查文件开头是否出现 NUL 字节,也就是数值 0

ts 复制代码
function containsNulByte(buffer: Buffer): boolean {
	const sampleLength = Math.min(buffer.length, 8 * 1024);

	for (let index = 0; index < sampleLength; index++) {
		if (buffer[index] === 0) return true;
	}

	return false;
}

这里只检查前 8 KiB,而不是扫描整个文件:

  • 大多数常见二进制格式很快就会出现 NUL;
  • 工具已经把文件读入内存,但没有必要再完整遍历一次大文件;
  • 这是"是否像二进制"的实用判断,不是完整的文件格式识别器。

使用时:

ts 复制代码
if (containsNulByte(buffer)) {
	throw new Error("Binary files are not supported by read");
}

本篇只支持 UTF-8 文本。图片读取、MIME 检测和专门的二进制工具不属于当前范围。

为什么要按 UTF-8 字节数截断

JavaScript 字符串的 .length 不是 UTF-8 字节数。

例如:

ts 复制代码
"a".length; // 1
Buffer.byteLength("a", "utf8"); // 1

"中".length; // 1
Buffer.byteLength("中", "utf8"); // 3

如果用 .length 计算输出大小,大量中文会被严重低估。这里统一使用:

ts 复制代码
Buffer.byteLength(text, "utf8")

只返回完整行

工具不能为了刚好卡在 50 KiB 而切断一行代码。例如下面的内容:

ts 复制代码
export function createSomethingImportant(

如果被截成:

ts 复制代码
export function createSome

模型可能误以为文件中真的存在一个不完整的标识符。因此我们的规则是:加入一整行之前先计算大小,装不下就停止,不返回半行。

实现输出上限:

ts 复制代码
function applyOutputLimits(window: TextWindow, maxLines: number, maxBytes: number): TextWindow {
	if (window.content === "") return window;

	const lines = window.content.split("\n");
	const selected: string[] = [];
	let bytes = 0;
	let truncatedBy: "lines" | "bytes" | null = null;

	for (const line of lines) {
		if (selected.length >= maxLines) {
			truncatedBy = "lines";
			break;
		}

		const separatorBytes = selected.length === 0 ? 0 : 1;
		const nextBytes = bytes + separatorBytes + Buffer.byteLength(line, "utf8");

		if (nextBytes > maxBytes) {
			if (selected.length === 0) {
				throw new Error("A single line exceeds the read byte limit");
			}
			truncatedBy = "bytes";
			break;
		}

		selected.push(line);
		bytes = nextBytes;
	}

	if (truncatedBy === null && selected.length < lines.length) {
		truncatedBy = "lines";
	}

	if (truncatedBy === null) return window;

	return {
		...window,
		content: selected.join("\n"),
		endLine: window.startLine + selected.length - 1,
		truncatedBy,
	};
}

这里有几个值得注意的细节。

第一,第二行开始之前要计算换行符的 1 个字节:

ts 复制代码
const separatorBytes = selected.length === 0 ? 0 : 1;

第一行前面没有换行符,后续每行与上一行之间都有一个 \n

第二,endLine 必须按照真正返回的行数重新计算。如果用户请求 100 行,但字节上限只容纳 2 行,那么下一次应该从第 3 行继续,不能从第 101 行继续。

第三,如果第一行本身就超过上限,工具选择抛错:

text 复制代码
A single line exceeds the read byte limit

如果返回空文本并提示仍从相同 offset 读取,模型下一次会再次得到同样的空结果,可能形成无限重试。

格式化字节上限提示

为了让提示更容易读,加入一个小函数:

ts 复制代码
function formatByteLimit(maxBytes: number): string {
	if (maxBytes % 1024 === 0) return `${maxBytes / 1024} KiB`;
	return `${maxBytes} bytes`;
}

例如:

text 复制代码
51200 -> 50 KiB
125   -> 125 bytes

当内容因字节上限截断时,结果可能是:

text 复制代码
line 1
line 2

[Showing lines 1-2 of 100 (50 KiB limit). Use offset=3 to continue.]

这样模型知道截断不是文件结束,而是工具的输出预算已经用完。

处理取消

工具的 execute() 可以收到一个 AbortSignal。当调用方已经取消当前请求时,read 工具不应该继续开始新的文件操作。

本篇固定检查两个时间点:

ts 复制代码
if (signal?.aborted) throw new Error("Operation aborted");

const absolutePath = await resolveAllowedFile(parameters.path, allowedRoot);

if (signal?.aborted) throw new Error("Operation aborted");

第一次检查发生在所有工作之前,第二次检查发生在异步路径解析之后、真正读取文件之前。

这能处理:

  • 调用工具之前就已经取消;
  • 等待 realpath() 期间发生取消。

当前版本没有承诺在任意时刻强制中断已经开始的磁盘读取。取消语义应该与整个工具系统统一设计,不能在一个工具里虚构"同步代码随时可停止"的能力。

组装 createReadTool

现在把前面的 helper 连接成真正的工具工厂:

ts 复制代码
export function createReadTool(allowedRoot: string, options: ReadToolOptions = {}): ReadTool {
	const maxLines = options.maxLines ?? DEFAULT_READ_MAX_LINES;
	const maxBytes = options.maxBytes ?? DEFAULT_READ_MAX_BYTES;

	assertPositiveInteger("maxLines", maxLines);
	assertPositiveInteger("maxBytes", maxBytes);

	return {
		name: "read",
		description: "Read a UTF-8 text file inside the allowed root. Use offset and limit for large files.",
		parameters: readParameters,

		async execute(_toolCallId, parameters, signal): Promise<ToolResultContent[]> {
			if (signal?.aborted) throw new Error("Operation aborted");
			if (parameters.path.length === 0) throw new Error("path must not be empty");

			assertPositiveInteger("offset", parameters.offset);
			assertPositiveInteger("limit", parameters.limit);

			const absolutePath = await resolveAllowedFile(parameters.path, allowedRoot);

			if (signal?.aborted) throw new Error("Operation aborted");

			const buffer = await readFile(absolutePath);
			if (containsNulByte(buffer)) {
				throw new Error("Binary files are not supported by read");
			}

			const userWindow = selectUserWindow(
				splitLines(buffer.toString("utf8")),
				parameters.offset ?? 1,
				parameters.limit,
			);

			const boundedWindow = applyOutputLimits(userWindow, maxLines, maxBytes);

			return [{ type: "text", text: appendContinuation(boundedWindow, maxBytes) }];
		},
	};
}

完整控制流程可以概括为:

text 复制代码
创建工具
  -> 确定 maxLines/maxBytes
  -> 校验工具配置

每次 execute
  -> 检查取消
  -> 校验 path/offset/limit
  -> 检查词法路径
  -> 检查真实路径
  -> 读取 Buffer
  -> 拒绝二进制
  -> 转成 UTF-8 并分行
  -> 应用 offset/limit
  -> 应用行数/字节上限
  -> 返回 ToolResultContent[]

execute() 返回的是 ToolResultContent[]

ts 复制代码
[{ type: "text", text: "文件内容" }]

不要再包一层 { content: [...] },因为当前项目中的 AgentTool.execute() 契约就是直接返回内容块数组。

工具遇到错误时也不需要自己创建 isError: true 的消息。它只要抛出异常,Agent Loop 会负责把异常转换成错误工具结果。这样文件工具不需要了解消息历史和循环控制。

为 read 工具编写测试

创建文件:

text 复制代码
di-code/packages/coding-agent/test/read-tool.test.ts

测试不要读取仓库中的真实文件,而要为每个测试创建临时目录:

ts 复制代码
import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createReadTool } from "../src/core/tools/read.ts";

describe("read tool text windows", () => {
	let root: string;
	let outside: string;

	beforeEach(async () => {
		root = await mkdtemp(join(tmpdir(), "di-code-read-root-"));
		outside = await mkdtemp(join(tmpdir(), "di-code-read-outside-"));
	});

	afterEach(async () => {
		await rm(root, { recursive: true, force: true });
		await rm(outside, { recursive: true, force: true });
	});
});

root 模拟允许读取的项目目录,outside 模拟项目外部目录。每个测试结束后都删除它们,测试之间不会互相污染。

测试普通文本

ts 复制代码
it("reads a UTF-8 text file", async () => {
	await writeFile(join(root, "notes.txt"), "第一行\nsecond line", "utf8");

	const blocks = await createReadTool(root).execute("call-1", {
		path: "notes.txt",
	});

	expect(blocks).toEqual([
		{ type: "text", text: "第一行\nsecond line" },
	]);
});

这个测试证明相对路径会基于 allowedRoot 解析,中文 UTF-8 文本能够正常返回。

测试 offset 和 limit

ts 复制代码
it("combines offset and limit", async () => {
	await writeFile(join(root, "window.txt"), "line 1\nline 2\nline 3\nline 4", "utf8");

	const blocks = await createReadTool(root).execute("call-2", {
		path: "window.txt",
		offset: 2,
		limit: 2,
	});

	expect(blocks).toEqual([
		{
			type: "text",
			text: "line 2\nline 3\n\n[1 more lines in file. Use offset=4 to continue.]",
		},
	]);
});

这里不仅检查返回了哪些行,还检查 continuation 提示中的剩余行数和下一个 offset。

测试根外路径

ts 复制代码
it("rejects absolute and relative paths outside the root", async () => {
	const outsideFile = join(outside, "secret.txt");

	await expect(
		createReadTool(root).execute("call-3a", { path: outsideFile }),
	).rejects.toThrow("Path is outside the allowed root");

	await expect(
		createReadTool(root).execute("call-3b", {
			path: relative(root, outsideFile),
		}),
	).rejects.toThrow("Path is outside the allowed root");
});

同一个测试覆盖两种逃逸方式:

  • 直接传入根外绝对路径;
  • 使用包含 .. 的相对路径。

目标文件故意不创建,用来证明权限检查发生在读取和目标 realpath() 之前,而不是先报 ENOENT

测试符号链接逃逸

ts 复制代码
it("rejects a symlink whose real target is outside the root", async () => {
	const outsideFile = join(outside, "secret.txt");
	await writeFile(outsideFile, "secret", "utf8");

	const link = join(root, "link.txt");
	await symlink(outsideFile, link, "file");

	await expect(
		createReadTool(root).execute("call-4", { path: "link.txt" }),
	).rejects.toThrow("Path is outside the allowed root");
});

Windows 创建符号链接可能需要开发者模式或额外权限。正式测试中可以只在收到 EPERMEACCES 时跳过 fixture,但最终仍要在一个确实能够创建符号链接的环境中执行这条安全分支。

测试中文的 UTF-8 字节限制

ts 复制代码
it("truncates by UTF-8 bytes without returning a partial line", async () => {
	const line = "中".repeat(20);
	await writeFile(join(root, "bytes.txt"), `${line}\n${line}\n${line}`, "utf8");

	const blocks = await createReadTool(root, {
		maxLines: 100,
		maxBytes: 125,
	}).execute("call-5", { path: "bytes.txt" });

	const output = blocks[0]?.type === "text" ? blocks[0].text : "";

	expect(output).toContain(`${line}\n${line}`);
	expect(output).not.toContain(`${line}\n${line}\n${line}`);
	expect(output).toContain(
		"[Showing lines 1-2 of 3 (125 bytes limit). Use offset=3 to continue.]",
	);
});

一个"中"占 3 个 UTF-8 字节,20 个就是 60 字节。两行加中间的换行符一共 121 字节,能够放进 125 字节;第三行加入后会超出限制,所以只返回前两行。

这个例子可以直接证明实现没有错误使用字符串字符数。

还需要覆盖哪些行为

完整测试还应包括:

  • 空文件返回空文本;
  • 文件末尾换行不多算一行;
  • offset 超过文件末尾时抛错;
  • offset: 0limit: 0 被拒绝;
  • 空路径被拒绝;
  • 根内缺失文件保留 Node 的 ENOENT
  • 含 NUL 字节的文件被拒绝;
  • 调用前已经取消时抛出 Operation aborted
  • maxLinesmaxBytes 必须是正整数;
  • 行数上限截断后给出正确的下一 offset;
  • 第一行就超过字节上限时抛出明确错误。

这些测试不只是为了提高覆盖率。每一条都对应一个调用者可能真正遇到的边界,或者一个文件系统安全风险。

运行验证

在 PowerShell 中进入项目目录:

powershell 复制代码
Set-Location D:\pi\di-code

先运行 read 工具的定向测试:

powershell 复制代码
npm test --workspace @di-code/coding-agent -- --run read-tool

当前完整测试文件应收集 19 个测试并全部通过:

text 复制代码
Test Files  1 passed
Tests       19 passed

然后检查这两个文件的格式:

powershell 复制代码
npx biome check packages/coding-agent/src/core/tools/read.ts packages/coding-agent/test/read-tool.test.ts

再运行 coding-agent 的完整测试和构建:

powershell 复制代码
npm test --workspace @di-code/coding-agent
npm run build --workspace @di-code/coding-agent
npx tsc --noEmit -p tsconfig.json

如果根 npm run check 因以前文件的换行格式失败,要区分"本篇新增文件的问题"和"之前已经存在的问题"。不要为了让检查变绿而顺手格式化无关文件。

常见错误

根外缺失文件返回 ENOENT

原因通常是只在 realpath(target) 后检查路径。根外文件不存在时,realpath() 已经先失败。

修正方向是先对 resolve(root, input) 得到的 candidate 做词法包含检查,再取得目标真实路径。

符号链接可以读到根外文件

原因通常是只检查了 candidate,没有检查 realpath(candidate) 的结果。

词法路径在根内不代表真实目标也在根内,两次检查不能合并。

使用 startsWith 判断子目录

C:\work-secret 也以 C:\work 开头。目录包含关系不是普通字符串前缀关系,应使用 relative()

offset 少读或多读一行

外部行号从 1 开始,数组索引从 0 开始:

ts 复制代码
const startIndex = offset - 1;

下一 offset 则是:

ts 复制代码
const nextOffset = endLine + 1;

文件末尾多出一个空行

"a\nb\n".split("\n") 会产生末尾空字符串。只有当原文以换行结尾时,删除这个由分隔符产生的最后元素。

中文文件超过预算

不要用 line.length 计算字节。使用:

ts 复制代码
Buffer.byteLength(line, "utf8")

同时记得从第二行开始加入换行符的 1 个字节。

截断后下一 offset 跳过内容

用户 limit 之后还要应用 maxLinesmaxBytesendLine 必须根据最终真正返回的行数重算,不能直接根据用户请求的 limit 计算。

返回了半行

先计算加入完整一行后的字节数,确认没有超出预算后再 push()。不要先拼接整个字符串再按字节切片。

工具自己构造 isError

read 工具只负责返回内容或抛出异常。错误消息的协议转换属于 Agent Loop,不属于文件系统工具。

安全边界回顾

到这里,read 工具建立了五层边界:

边界 作用
参数边界 path 非空,offset、limit 和配置上限必须有效
词法路径边界 拒绝 .. 和根外绝对路径
真实路径边界 拒绝根内符号链接指向根外文件
内容类型边界 拒绝明显的二进制内容
输出预算边界 限制行数和 UTF-8 字节数,不返回半行

另外,AbortSignal 给跨异步边界提供了明确的取消语义。

这些限制不是额外装饰。Coding Agent 的工具拥有真实权限,而模型输出属于不可信输入。工具层必须把模型的"请求"转换成受应用规则约束的"操作",不能把模型生成的路径直接交给操作系统。

总结

这一篇实现了一个可以独立使用的安全 read 工具:

  1. 使用 TypeBox 定义 path、1-based offsetlimit
  2. 使用工具内部的防御性检查,避免直接调用绕过 schema。
  3. 使用 splitLines() 正确处理空文件、内部空行和末尾换行。
  4. 先应用用户的 offset/limit,再应用应用级行数和字节上限。
  5. 使用 UTF-8 字节数计算输出预算,并且只返回完整行。
  6. 使用词法路径和真实路径两次检查,阻止 ..、根外绝对路径和符号链接逃逸。
  7. 在 Buffer 阶段拒绝明显的二进制文件。
  8. 截断时返回正确的下一 offset,让模型可以继续分页读取。
  9. read 工具只返回 ToolResultContent[] 或抛错,消息回送仍由 Agent Loop 负责。

现在我们拥有了第一个真正接触本地环境的工具,但它还没有接入 Agent 的完整运行流程。下一篇将把 read 注册到 AgentSession,并用 Faux Provider 验证下面这条端到端链路:

text 复制代码
用户提问
  -> 模型请求 read
  -> Agent 校验并执行 read
  -> 文件内容作为 tool_result 回送模型
  -> 模型根据文件内容给出最终回答
相关推荐
stuartevil1 小时前
怎么用 AI 创意生图做汽车改装外观效果图?
人工智能·汽车
东方小月1 小时前
从零开发一个 Coding Agent(十二):实现版本化 JSONL 与真实 CLI 入口
前端·设计模式·前端框架
嗯哼python2 小时前
从 0 到 1 构建 AI 模拟面试系统:RAG + LangGraph 的工程实践
人工智能·面试·职场和发展
空堂与归2 小时前
分类问题怎么建模?用逻辑回归实现概率预测
人工智能·机器学习·分类·逻辑回归
VisualComponents2 小时前
SEJFO依托Visual Components仿真工具,实现自动化业务销售全面升级
人工智能·机器人·工业自动化·工厂仿真·机器人离线编程
面包狗AI4S2 小时前
GitHub AI4S 项目观察(2026-08-07—2026-08-13)
人工智能·github
FL16238631292 小时前
室内易燃物识别易燃评估室内易燃程度识别分割数据集labelme格式1015张85类别
java·服务器·前端
用户059540174462 小时前
把 AI 长期记忆去重测试从 20 分钟压到 40 秒,重复率从 18% 干到 1.5%
前端·css
AI办公探索者3 小时前
多智能体协作架构:从单Agent到多Agent协同的技术演进
人工智能·ai·架构