Building a Secure File Reader for a Coding Agent: Five Boundaries That Prevent Path Traversal
LLM-powered coding tools that read local files are a path-traversal disaster waiting to happen. This implementation shows exactly where the checks must go — before `realpath()` to avoid information leaks, and after `realpath()` to catch symlink escapes — and why `startsWith` is never a directory-containment check.
The di-code coding agent now has a local `read` tool that goes far beyond a simple `readFile()` wrapper. It enforces a strict allowed root directory, prevents `../` and absolute-path escapes, and resolves symbolic links to block realpath traversal. Before returning any content, the tool checks for NUL bytes to reject binary files and applies dual output caps: a maximum line count and a 50 KiB UTF-8 byte budget that never splits a line mid-character.
Every read operation follows a sequenced pipeline: cancel check, parameter validation, lexical path containment, realpath containment, Buffer read, binary detection, UTF-8 line splitting, user-requested offset/limit, and finally the application-level line and byte ceilings. Truncated results include an explicit continuation hint with the correct next offset so the model can paginate without guessing.
Tests cover empty files, trailing newlines, Chinese-character byte counting, symlink escapes, and cancellation via AbortSignal. The tool throws on errors and returns plain `ToolResultContent[]` — error-to-message conversion stays in the Agent Loop, keeping file-system code decoupled from conversation state.
Most "secure file reader" implementations for LLM tools stop at one path check; the double check here — lexical then realpath — closes a symlink escape that a single check misses.
Rejecting a path before `realpath()` isn't just about security — it prevents leaking whether an external file exists, which is an information-disclosure vector many tools ignore.
The decision to count UTF-8 bytes rather than characters is easy to overlook but critical: a 50 KiB cap measured in characters could silently balloon to 150 KiB of actual bytes with CJK text.
Returning a precise next offset in truncation hints shifts pagination logic from the model to the tool, reducing the chance the model invents a wrong line number.