Building a Secure File Reader for a Coding Agent: Five Boundaries That Prevent Path Traversal
Developing a Coding Agent from Scratch (Part 13): Implementing a Secure Read File Tool
The Coding Agent has been published to npm. You can try it out to see what the final product will look like. GitHub: github.com — stars are welcome. npm: @di-code/coding-agent - npm. Install and use it directly with npm install -g @di-code/coding-agent.
This article is the thirteenth in the "Developing a Coding Agent from Scratch" series.
In the previous article, we completed the CLI, print mode, and JSONL mode. Users can now ask the Agent questions from the command line, and the Agent can call the Faux Provider to get deterministic answers. However, the Agent at this stage can only "chat"; it cannot actually view files in the project.
In this article, we will implement the first local tool: read.
Once complete, callers can read files like this:
const readTool = createReadTool("D:\\pi\\di-code");
const result = await readTool.execute("call-1", {
path: "packages/ai/src/index.ts",
offset: 1,
limit: 20,
});
The tool will return lines 1 through 20 of the file. If there is more content after that, the end of the result will tell the model which offset to use next to continue reading.
However, read cannot simply be a single call to readFile(). A Coding Agent reads real files on the user's computer, so it must simultaneously solve these problems:
- Which directories can the Agent read?
- Can
../escape the project root directory? - Can a symbolic link inside the root directory point to an external file?
- Will a large file fill up the model's context all at once?
- Should Chinese characters be counted by character count or UTF-8 byte count?
- Can binary files be returned as text?
- How should the tool end when the operation has been cancelled?
Therefore, what this article implements is not a "function that can read files," but a file reading tool with clear permission boundaries and output boundaries.
The Position of the read Tool in the Project
The dependency direction of the current project is:
ai <- agent <- coding-agent
The responsibilities of the three packages differ:
| Package | Responsibility |
|---|---|
@di-code/ai |
Defines messages, tool results, and provider-agnostic common protocols |
@di-code/agent |
Responsible for the Agent Loop, parameter validation, tool calls, and error feedback |
@di-code/coding-agent |
Responsible for real product capabilities like the file system and CLI |
read will access the Node.js file system, so it must be placed in coding-agent and cannot be placed in the generic agent package.
This article only creates two files:
di-code/
packages/
coding-agent/
src/
core/
tools/
read.ts
test/
read-tool.test.ts
read.ts holds the tool implementation, and read-tool.test.ts uses a temporary directory to verify normal reading and various security boundaries.
This article will not yet connect the tool to the CLI, nor will it implement the complete flow of "model requests read -> tool executes -> result sent back -> model answers." Here, we will first make the standalone read tool correct; end-to-end wiring will be left for a subsequent article.
What Steps Does a Single Read Go Through?
Assume the allowed root directory for reading is:
D:\pi\di-code
The model requests:
{
"path": "packages/ai/src/index.ts",
"offset": 10,
"limit": 20
}
The tool does not immediately read the file but executes the following process in sequence:
flowchart TD
A[Receive path offset limit] --> B[Check cancellation and parameters]
B --> C[Resolve input path to absolute path]
C --> D[Check lexical path does not escape root directory]
D --> E[Get target real path via realpath]
E --> F[Check again that real path does not escape root directory]
F --> G[Read as Buffer]
G --> H[Check if it looks like a binary file]
H --> I[Split into lines by UTF-8 text]
I --> J[Apply offset and limit]
J --> K[Apply max lines and max bytes]
K --> L[Return text and continuation hint]
The easiest thing to overlook here are the two path checks:
- Before reading, check the path resolved from user input to prevent
..or absolute paths outside the root. - After
realpath(), check again to prevent symbolic links inside the root directory from pointing to external files.
Concrete examples will be used later to explain why neither check can be omitted.
Defining the Parameters for the read Tool
Create the file:
di-code/packages/coding-agent/src/core/tools/read.ts
First, add the imports and default limits:
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() is responsible for reading the file, and realpath() is responsible for getting the true location the file points to. Functions from node:path are used to resolve and judge paths.
By default, at most the following are returned:
- 2000 lines;
- 50 KiB, which is
50 * 1024bytes.
Both limits exist simultaneously. As soon as either one is hit first, the tool stops adding more content.
Next, define the parameters the model can pass in:
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>;
The three fields represent:
| Field | Meaning | Example |
|---|---|---|
path |
The file path to read | "src/index.ts" |
offset |
The line number to start from, starting at 1 | 10 |
limit |
The maximum number of lines to read | 20 |
For example, a file has 100 lines:
{
"path": "notes.txt",
"offset": 10,
"limit": 3
}
This means read lines 10, 11, and 12. The next time reading continues, offset: 13 should be used.
Why does offset start at 1 instead of 0? Because editors, terminal error messages, and humans discussing code usually say "line 1." The tool uses 1-based line numbers externally, which can reduce errors when the model converts. Only when accessing JavaScript arrays is it converted to a 0-based index.
Then define the configuration and public types when creating the tool:
export interface ReadToolOptions {
readonly maxLines?: number;
readonly maxBytes?: number;
}
export type ReadTool = AgentTool<typeof readParameters>;
offset and limit are parameters for a single tool call; maxLines and maxBytes are safety limits set when the application creates the tool. The model can request to read fewer lines, but it cannot cancel the total limits set by the application through parameters.
Why Check Parameters After Schema Validation
When the Agent Loop calls the tool normally, it validates parameters against readParameters. But test code, future SDKs, or other internal code might also call execute() directly.
Therefore, the tool must still perform defensive checks internally:
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`);
}
}
This function accepts a field name and a number:
- Allows passing when no value is given, because these parameters are optional;
- Rejects when a value is given but is not an integer;
- Rejects when less than 1.
For example:
assertPositiveInteger("offset", 1); // passes
assertPositiveInteger("offset", 2.5); // throws error
assertPositiveInteger("offset", 0); // throws error
TypeScript types can only help us check code during development and cannot replace runtime validation. JSON generated by the model, CLI parameters, and disk data are all external inputs; the program must re-confirm whether they are valid at runtime.
Correctly Splitting Text into Lines
Next, define the text window used internally by the tool:
interface TextWindow {
readonly content: string;
readonly startLine: number;
readonly endLine: number;
readonly totalLines: number;
readonly truncatedBy: "limit" | "lines" | "bytes" | null;
}
TextWindow can be understood as "the file slice prepared to return this time." It not only saves the text but also remembers:
- Which line it starts from;
- Which line it ends at;
- How many total lines the file has;
- Why truncation occurred.
This information will be used to generate the next reading hint.
Then implement the line splitting function:
function splitLines(text: string): string[] {
if (text.length === 0) return [];
const lines = text.split("\n");
if (text.endsWith("\n")) lines.pop();
return lines;
}
Why can't we just write text.split("\n")? Look at this file:
line 1\n
line 2\n
Direct splitting would yield:
["line 1", "line 2", ""]
The final empty string is not an extra 3rd line of content but a separation result produced by the trailing newline character, so it needs to be removed.
But genuine empty lines inside the file should still be preserved. For example:
line 1
line 3
Should yield:
["line 1", "", "line 3"]
Additionally, an empty file should be 0 lines, so splitLines("") directly returns an empty array.
Implementing offset and limit
Now implement the text window actively requested by the user:
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,
};
}
Observe this code with a 4-line file:
line 1
line 2
line 3
line 4
The call parameters are:
{ offset: 2, limit: 2 }
The calculation process is as follows:
offset = 2
startIndex = offset - 1 = 1
available = ["line 2", "line 3", "line 4"]
selected = ["line 2", "line 3"]
endLine = 2 + 2 - 1 = 3
Ultimately returns lines 2 to 3, and next time should continue from line 4.
If offset has already exceeded the end of the file, the tool cannot return a seemingly successful empty string. Explicitly throwing an error makes it easier for the model to discover that it used the wrong line number.
Adding a Continuation Hint for the Model
When the user's limit truncates content, add a hint at the end of the result:
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.]`;
}
For example, after reading lines 2 and 3, the result is:
line 2
line 3
[1 more lines in file. Use offset=4 to continue.]
This hint is mainly for the model to see. The model does not need to re-guess how much content the file has left, nor does it need to calculate the next line number itself.
Note that the tool result only returns text and does not directly write to stdout. Whether it is ultimately displayed as plain text or JSONL should be decided by the CLI output layer.
Restricting Files to Only Be Within the Allowed Root Directory
The most important security rule for the file reading tool is: The target file must be located inside allowedRoot.
Assume the root directory is:
D:\pi\di-code
The following paths should be allowed:
packages\ai\src\index.ts
D:\pi\di-code\package.json
The following paths should be rejected:
..\secret.txt
D:\other-project\config.json
Why startsWith Cannot Be Used Directly
A seemingly simple approach is:
target.startsWith(root)
But it cannot correctly determine directory relationships. For example:
root = C:\work
target = C:\work-secret\password.txt
The string of target indeed starts with C:\work, but work-secret is not a subdirectory of work.
The correct way is to use path.relative() to calculate the directory relationship between two paths:
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");
}
The result of relative(root, target) can be understood like this:
| Result | Meaning |
|---|---|
"" |
target is the root itself |
"src\\index.ts" |
target is inside root |
"..\\secret.txt" |
target is outside root |
| Absolute path | The two paths are not under the same directly comparable root |
sep is the path separator for the current operating system. Windows is usually \, Unix is usually /. Use Node's path API, do not manually concatenate separators for a specific system.
First Check: Prevent .. and Absolute Paths Outside the Root
Implement path resolution:
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;
}
Look at the first half first:
const rootReal = await realpath(allowedRoot);
const candidate = resolve(rootReal, inputPath);
assertInsideRoot(rootReal, candidate);
resolve() will place the relative path under the root directory and also normalize . and ...
For example:
root = D:\pi\di-code
inputPath = packages\ai\src\index.ts
candidate = D:\pi\di-code\packages\ai\src\index.ts
If the input is:
..\secret.txt
After resolution, it becomes:
D:\pi\secret.txt
The first assertInsideRoot() will reject it before reading.
The check must be placed before realpath() of the target file. Otherwise, an external non-existent file might first throw ENOENT, thus exposing whether the external path exists, and also turning a permission error into an incomprehensible "file does not exist."
Second Check: Prevent Symbolic Link Escape
Only the first check is still insufficient. Consider the following directory:
D:\pi\di-code\link.txt
-> D:\private\secret.txt
From the string path perspective, link.txt is located inside the root directory, so the first check will pass. But the file it truly points to is located outside the root directory.
Therefore, we must also execute:
const targetReal = await realpath(candidate);
assertInsideRoot(rootReal, targetReal);
realpath() will resolve symbolic links and get the true location of the target. The second check will discover that D:\private\secret.txt is not inside the allowed root directory and throw:
Path is outside the allowed root
The risks handled by the two checks are different:
| Check | What it prevents |
|---|---|
Lexical path check of candidate |
.., absolute paths outside the root, and information leakage before reading |
Real path check of targetReal |
Symbolic links inside the root pointing to files outside the root |
Keeping only one of them would leave a vulnerability.
Reading as Buffer, Not Directly as String
After the path passes the checks, use the following code to read the file:
const buffer = await readFile(absolutePath);
"utf8" is not passed here, so what is returned is a Buffer, i.e., raw bytes.
The reason for this is: before interpreting the file as text, we need to first determine if it looks like a binary file, and also control the output based on UTF-8 byte count.
If we wrote from the start:
await readFile(absolutePath, "utf8");
The raw bytes would immediately be decoded into a JavaScript string, making binary judgment more difficult afterwards.
Rejecting Obvious Binary Files
The ordinary text context of a Coding Agent is not suitable for directly receiving images, archives, or executable files. A simple and common judgment method is to check if a NUL byte, i.e., the value 0, appears at the beginning of the file.
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;
}
Here, only the first 8 KiB is checked, not the entire file:
- Most common binary formats will quickly have a NUL appear;
- The tool has already read the file into memory, but there is no need to fully traverse a large file again;
- This is a practical judgment of "does it look like binary," not a complete file format identifier.
When used:
if (containsNulByte(buffer)) {
throw new Error("Binary files are not supported by read");
}
This article only supports UTF-8 text. Image reading, MIME detection, and specialized binary tools are outside the current scope.
Why Truncate by UTF-8 Byte Count
The .length of a JavaScript string is not the UTF-8 byte count.
For example:
"a".length; // 1
Buffer.byteLength("a", "utf8"); // 1
"中".length; // 1
Buffer.byteLength("中", "utf8"); // 3
If .length is used to calculate output size, a large amount of Chinese text will be severely underestimated. Here, we uniformly use:
Buffer.byteLength(text, "utf8")
Only Return Complete Lines
The tool cannot cut off a line of code just to fit exactly at 50 KiB. For example, the following content:
export function createSomethingImportant(
If truncated to:
export function createSome
The model might mistakenly believe that an incomplete identifier genuinely exists in the file. Therefore, our rule is: Calculate the size before adding a whole line; if it doesn't fit, stop, and do not return a partial line.
Implement output limits:
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,
};
}
There are several noteworthy details here.
First, before the second line starts, the 1 byte of the newline character must be calculated:
const separatorBytes = selected.length === 0 ? 0 : 1;
There is no newline character before the first line; each subsequent line has a \n between it and the previous line.
Second, endLine must be recalculated based on the number of lines actually returned. If the user requests 100 lines, but the byte limit only accommodates 2 lines, then next time it should continue from line 3, not from line 101.
Third, if the first line itself exceeds the limit, the tool chooses to throw an error:
A single line exceeds the read byte limit
If empty text is returned and a hint is given to still read from the same offset, the model will get the same empty result again next time, potentially forming an infinite retry.
Formatting the Byte Limit Hint
To make the hint easier to read, add a small function:
function formatByteLimit(maxBytes: number): string {
if (maxBytes % 1024 === 0) return `${maxBytes / 1024} KiB`;
return `${maxBytes} bytes`;
}
For example:
51200 -> 50 KiB
125 -> 125 bytes
When content is truncated due to the byte limit, the result might be:
line 1
line 2
[Showing lines 1-2 of 100 (50 KiB limit). Use offset=3 to continue.]
This way, the model knows the truncation is not the end of the file, but that the tool's output budget has been exhausted.
Handling Cancellation
The tool's execute() can receive an AbortSignal. When the caller has already cancelled the current request, the read tool should not continue to start new file operations.
This article fixedly checks at two points in time:
if (signal?.aborted) throw new Error("Operation aborted");
const absolutePath = await resolveAllowedFile(parameters.path, allowedRoot);
if (signal?.aborted) throw new Error("Operation aborted");
The first check occurs before all work, and the second check occurs after asynchronous path resolution and before actually reading the file.
This can handle:
- Cancellation before the tool is called;
- Cancellation occurring while waiting for
realpath().
The current version does not promise to forcibly interrupt an already started disk read at any arbitrary moment. Cancellation semantics should be designed uniformly with the entire tool system; one cannot fabricate the ability to "stop synchronous code at any time" within a single tool.
Assembling createReadTool
Now connect the previous helpers into a true tool factory:
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) }];
},
};
}
The complete control flow can be summarized as:
Create tool
-> Determine maxLines/maxBytes
-> Validate tool configuration
Each execute
-> Check cancellation
-> Validate path/offset/limit
-> Check lexical path
-> Check real path
-> Read Buffer
-> Reject binary
-> Convert to UTF-8 and split lines
-> Apply offset/limit
-> Apply line/byte limits
-> Return ToolResultContent[]
execute() returns ToolResultContent[]:
[{ type: "text", text: "file content" }]
Do not wrap another layer of { content: [...] }, because the contract of AgentTool.execute() in the current project is to directly return an array of content blocks.
When the tool encounters an error, it also does not need to create an isError: true message itself. It just needs to throw an exception, and the Agent Loop will be responsible for converting the exception into an error tool result. This way, the file tool does not need to understand message history and loop control.
Writing Tests for the read Tool
Create the file:
di-code/packages/coding-agent/test/read-tool.test.ts
Tests should not read real files in the repository but should create a temporary directory for each test:
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 simulates the project directory allowed for reading, and outside simulates a directory outside the project. They are deleted after each test, so tests do not pollute each other.
Testing Ordinary Text
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" },
]);
});
This test proves that relative paths are resolved based on allowedRoot, and Chinese UTF-8 text can be returned normally.
Testing offset and limit
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.]",
},
]);
});
Here, not only check which lines are returned, but also check the remaining line count and next offset in the continuation hint.
Testing Paths Outside the Root
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");
});
The same test covers two escape methods:
- Directly passing an absolute path outside the root;
- Using a relative path containing
...
The target file is intentionally not created to prove that the permission check occurs before reading and the target's realpath(), rather than reporting ENOENT first.
Testing Symbolic Link Escape
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");
});
Creating symbolic links on Windows may require developer mode or extra permissions. In formal testing, you can only skip the fixture when receiving EPERM or EACCES, but ultimately this security branch must still be executed in an environment that can indeed create symbolic links.
Testing UTF-8 Byte Limit for Chinese
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.]",
);
});
One "中" occupies 3 UTF-8 bytes, so 20 of them are 60 bytes. Two lines plus the newline character in between total 121 bytes, which can fit within 125 bytes; adding the third line would exceed the limit, so only the first two lines are returned.
This example can directly prove that the implementation does not mistakenly use string character count.
What Other Behaviors Need to Be Covered
Complete tests should also include:
- Empty file returns empty text;
- Trailing newline at end of file does not count as an extra line;
- Error thrown when
offsetexceeds end of file; offset: 0andlimit: 0are rejected;- Empty path is rejected;
- Missing file inside root preserves Node's
ENOENT; - File containing NUL bytes is rejected;
Operation abortedthrown when cancelled before call;maxLinesandmaxBytesmust be positive integers;- Correct next offset given after line limit truncation;
- Clear error thrown when the first line exceeds the byte limit.
These tests are not just for improving coverage. Each one corresponds to a boundary that a caller might genuinely encounter, or a file system security risk.
Running Verification
In PowerShell, enter the project directory:
Set-Location D:\pi\di-code
First, run the targeted tests for the read tool:
npm test --workspace @di-code/coding-agent -- --run read-tool
The current complete test file should collect 19 tests and all pass:
Test Files 1 passed
Tests 19 passed
Then check the formatting of these two files:
npx biome check packages/coding-agent/src/core/tools/read.ts packages/coding-agent/test/read-tool.test.ts
Then run the full tests and build for coding-agent:
npm test --workspace @di-code/coding-agent
npm run build --workspace @di-code/coding-agent
npx tsc --noEmit -p tsconfig.json
If the root npm run check fails due to line ending formats of previous files, distinguish between "issues in newly added files in this article" and "pre-existing issues." Do not casually format unrelated files just to make the check green.
Common Errors
Missing File Outside Root Returns ENOENT
The reason is usually only checking the path after realpath(target). When a file outside the root does not exist, realpath() has already failed first.
The correction direction is to first perform a lexical containment check on the candidate obtained from resolve(root, input), and then get the target's real path.
Symbolic Links Can Read Files Outside the Root
The reason is usually only checking candidate and not checking the result of realpath(candidate).
A lexical path being inside the root does not mean the real target is also inside the root; the two checks cannot be merged.
Using startsWith to Determine Subdirectory
C:\work-secret also starts with C:\work. Directory containment is not a plain string prefix relationship; relative() should be used.
offset Reads One Line Less or More
External line numbers start from 1, array indices start from 0:
const startIndex = offset - 1;
The next offset is:
const nextOffset = endLine + 1;
Extra Empty Line at End of File
"a\nb\n".split("\n") will produce a trailing empty string. Only when the original text ends with a newline, delete this last element produced by the separator.
Chinese File Exceeds Budget
Do not use line.length to calculate bytes. Use:
Buffer.byteLength(line, "utf8")
Also remember to add the 1 byte for the newline character starting from the second line.
Next offset Skips Content After Truncation
After the user's limit, maxLines and maxBytes still need to be applied. endLine must be recalculated based on the lines ultimately actually returned, and cannot be directly calculated based on the user's requested limit.
Returning a Partial Line
First calculate the byte count after adding the complete line, and only push() after confirming it does not exceed the budget. Do not concatenate the entire string first and then slice by bytes.
Tool Constructs isError Itself
The read tool is only responsible for returning content or throwing an exception. Protocol conversion for error messages belongs to the Agent Loop, not to the file system tool.
Security Boundary Review
Up to this point, the read tool has established five layers of boundaries:
| Boundary | Function |
|---|---|
| Parameter boundary | path is non-empty, offset, limit, and configuration limits must be valid |
| Lexical path boundary | Reject .. and absolute paths outside the root |
| Real path boundary | Reject symbolic links inside the root pointing to files outside the root |
| Content type boundary | Reject obvious binary content |
| Output budget boundary | Limit line count and UTF-8 byte count, do not return partial lines |
Additionally, AbortSignal provides clear cancellation semantics across asynchronous boundaries.
These restrictions are not extra decorations. The tools of a Coding Agent possess real permissions, and model output is untrusted input. The tool layer must convert the model's "requests" into "operations" constrained by application rules, and cannot directly hand model-generated paths to the operating system.
Summary
This article implemented a secure read tool that can be used independently:
- Used TypeBox to define
path, 1-basedoffset, andlimit. - Used defensive checks inside the tool to avoid direct calls bypassing the schema.
- Used
splitLines()to correctly handle empty files, internal empty lines, and trailing newlines. - Applied the user's offset/limit first, then applied application-level line and byte limits.
- Used UTF-8 byte count to calculate the output budget, and only returned complete lines.
- Used two checks of lexical path and real path to prevent
.., absolute paths outside the root, and symbolic link escapes. - Rejected obvious binary files at the Buffer stage.
- Returned the correct next offset upon truncation, allowing the model to continue paginated reading.
- The read tool only returns
ToolResultContent[]or throws an error; message feedback is still the responsibility of the Agent Loop.
Now we have the first tool that truly touches the local environment, but it has not yet been connected to the Agent's complete runtime flow. The next article will register read into AgentSession and use the Faux Provider to verify the following end-to-end chain:
User asks a question
-> Model requests read
-> Agent validates and executes read
-> File content is sent back to the model as tool_result
-> Model gives a final answer based on the file content