Tutorials
Sofia Nieves8 min read13 views

How to sandbox an AI agent's file tools in Node.js

If your agent has a read_file tool, you probably guard it with path.resolve plus startsWith. It blocks the obvious traversal and is defeated by a symlink and by a sibling directory sharing the prefix. Both reproduced on Node 24.8.0, with the guard that passes all nine cases.

Flat schematic on charcoal: a rectangular enclosure holds one pale file shape, a thin line crosses the enclosure wall and curves out to a second file shape sitting outside it, and a lower line runs right and stops dead against a solid slate blue block.
Flat schematic on charcoal: a rectangular enclosure holds one pale file shape, a thin line crosses the enclosure wall and curves out to a second file shape sitting outside it, and a lower line runs right and stops dead against a solid slate blue block.
On this page

Quick answer (September 2026). If your agent has a file tool, the guard you almost certainly wrote is path.resolve(BASE, userPath) followed by fullPath.startsWith(BASE). It blocks the obvious ../../etc/passwd, which is why it feels safe, and it is defeated by two ordinary things: a symlink sitting inside the workspace, and a sibling directory whose name merely starts with the same characters. Both are measured below on Node 24.8.0, with the exact output. Both are the bug classes LangChain patched in June 2026. The fix is to canonicalise with fs.realpathSync (walking up to the deepest path that exists, so file creation still works) and then compare with path.relative on a path-segment boundary rather than on a string prefix.

Node.js LangChain Pydantic

The guard almost everyone ships

The moment an agent gets a read_file tool, you need a containment check, and this is the pattern that gets written:

js
const fs = require("node:fs");
const path = require("node:path");

const BASE_DIR = path.resolve("./workspace");

function naiveResolve(userPath) {
  const fullPath = path.resolve(BASE_DIR, userPath);
  if (!fullPath.startsWith(BASE_DIR)) {
    throw new Error("Access denied");
  }
  return fullPath;
}

function readFileTool(args) {
  const p = naiveResolve(args.filePath);
  return fs.readFileSync(p, "utf8");
}

module.exports = { BASE_DIR, naiveResolve, readFileTool };

This is not a straw man. It is the shape taught in the highest-ranking Node tutorial on building agent file tools, Building File-System Tools for AI Agents Using Node.js and MCP, which uses path.resolve plus startsWith three times across its read, write and list tools. It is also, in substance, what LangChain shipped: the June 2026 advisory GHSA-gr75-jv2w-4656 describes, in its own words, "path-prefix authorization checks that compare by string prefix without a path-segment boundary, so a sibling path sharing the prefix is accepted", affecting langchain 1.3.8 and earlier and patched in 1.3.9.

The point is not that any one author got it wrong. The point is that this is the obvious code, it passes the test everybody thinks to run, and it is wrong in two specific ways you can reproduce in about ninety seconds.

Build a workspace you can actually attack

Nothing here needs npm, an API key, or a model. It is Node's standard library and a fixture:

js
const fs = require("node:fs");
const path = require("node:path");

fs.rmSync("workspace", { recursive: true, force: true });
fs.rmSync("workspace-backup", { recursive: true, force: true });
fs.rmSync("private", { recursive: true, force: true });

fs.mkdirSync("workspace", { recursive: true });
fs.mkdirSync("workspace-backup", { recursive: true });
fs.mkdirSync("private", { recursive: true });

fs.writeFileSync("workspace/notes.txt", "a normal file the agent may read\n");
fs.writeFileSync("private/api-keys.txt", "SECRET private key material\n");
fs.writeFileSync("workspace-backup/api-keys.txt", "SECRET backup key material\n");

fs.symlinkSync(path.resolve("private"), path.join("workspace", "data"));

console.log("fixture ready");

Three directories. workspace is what the agent is allowed to touch. private is what it is not. workspace-backup is the sibling that shares a prefix, which is the kind of directory that appears on real machines without anyone planning it: project and project-old, app and app-data, repo and repo-backup.

The symlink is the part worth pausing on. Your agent did not need to create it. A build step, a package manager, a node_modules layout, a dotfile manager, or a previous agent run can all leave a symlink inside a working directory, and the guard above has no idea it is there.

Run four requests through the guard

js
const { readFileTool } = require("./naive.js");

const cases = [
  ["control: a file the agent should read", "notes.txt"],
  ["classic traversal", "../private/api-keys.txt"],
  ["symlink inside the workspace", "data/api-keys.txt"],
  ["sibling directory sharing the prefix", "../workspace-backup/api-keys.txt"]
];

for (const [label, filePath] of cases) {
  let verdict;
  try {
    const body = readFileTool({ filePath });
    verdict = "ALLOWED  " + JSON.stringify(body.trim());
  } catch (err) {
    verdict = "BLOCKED  " + err.message;
  }
  console.log(label.padEnd(38), "|", filePath.padEnd(34), "|", verdict);
}

Running it:

text
control: a file the agent should read  | notes.txt                          | ALLOWED  "a normal file the agent may read"
classic traversal                      | ../private/api-keys.txt            | BLOCKED  Access denied
symlink inside the workspace           | data/api-keys.txt                  | ALLOWED  "SECRET private key material"
sibling directory sharing the prefix   | ../workspace-backup/api-keys.txt   | ALLOWED  "SECRET backup key material"

Two secrets read through a guard that is doing exactly what it was written to do.

Why one was blocked and two were not

It is worth being precise, because the middle result is the reason this guard survives review. Save this as mechanism.js:

js
const fs = require("node:fs");
const path = require("node:path");

const BASE = path.resolve("./workspace");
const viaLink = path.resolve(BASE, "data/api-keys.txt");
const sib = path.resolve(BASE, "../workspace-backup/api-keys.txt");
const rel = path.relative(BASE, path.resolve(BASE, "..hidden.txt"));

console.log("BASE                       :", BASE);
console.log("resolve(BASE, data/x)      :", viaLink);
console.log("realpath of that           :", fs.realpathSync(viaLink));
console.log("-- path.resolve does NOT follow symlinks; realpath does --");
console.log("sibling resolves to        :", sib);
console.log("sib.startsWith(BASE)       :", sib.startsWith(BASE));
console.log("sib.startsWith(BASE+sep)   :", sib.startsWith(BASE + path.sep));
console.log("-- the missing separator is the whole bug --");
console.log("relative for ..hidden.txt  :", JSON.stringify(rel));
console.log("naive rel.startsWith(..)   :", rel.startsWith(".."), " (would wrongly deny)");
console.log("segment-aware check        :", rel !== ".." && !rel.startsWith(".." + path.sep), " (correctly allows)");
text
BASE                       : /tmp/lab/workspace
resolve(BASE, data/x)      : /tmp/lab/workspace/data/api-keys.txt
realpath of that           : /tmp/lab/private/api-keys.txt
-- path.resolve does NOT follow symlinks; realpath does --
sibling resolves to        : /tmp/lab/workspace-backup/api-keys.txt
sib.startsWith(BASE)       : true
sib.startsWith(BASE+sep)   : false
-- the missing separator is the whole bug --
relative for ..hidden.txt  : "..hidden.txt"
naive rel.startsWith(..)   : true  (would wrongly deny)
segment-aware check        : true  (correctly allows)

Classic traversal is genuinely blocked. path.resolve normalises .. segments, so ../private/api-keys.txt becomes a path that does not begin with the base directory, and the check fires. This is the test everyone runs by hand, it passes, and the guard gets shipped. That single true negative is what makes the other two dangerous.

path.resolve is a string operation. It normalises . and .. and makes the path absolute. It does not touch the filesystem, so it cannot know that workspace/data is a symlink pointing at /tmp/lab/private. The resolved string still starts with the base directory, the check passes, and then fs.readFileSync follows the link, because the kernel does what the kernel does.

The prefix check is missing a separator. /tmp/lab/workspace-backup/api-keys.txt really does start with the string /tmp/lab/workspace. Adding path.sep to the comparison flips that true to false, and that one character is the entire LangChain advisory's third bullet.

The last three lines of that output are a preview of the trap in the other direction, and they are explained when the fixed guard is built below.

The fix everyone reaches for, and the wall it hits

The natural response is to canonicalise with fs.realpathSync and compare against that:

js
const fs = require("node:fs");
const path = require("node:path");

const BASE_DIR = fs.realpathSync(path.resolve("./workspace"));

function realpathResolve(userPath) {
  const target = path.resolve(BASE_DIR, userPath);
  const real = fs.realpathSync(target);
  if (real !== BASE_DIR && !real.startsWith(BASE_DIR + path.sep)) {
    throw new Error("Access denied");
  }
  return real;
}

for (const p of ["notes.txt", "data/api-keys.txt", "../workspace-backup/api-keys.txt", "report.md"]) {
  try {
    console.log(p.padEnd(34), "|", "OK      ", realpathResolve(p));
  } catch (err) {
    console.log(p.padEnd(34), "|", "THREW   ", err.code || "", err.message.split("\n")[0]);
  }
}

This closes both holes. It also breaks your agent:

text
notes.txt                          | OK       /tmp/lab/workspace/notes.txt
data/api-keys.txt                  | THREW     Access denied
../workspace-backup/api-keys.txt   | THREW     Access denied
report.md                          | THREW    ENOENT ENOENT: no such file or directory, lstat '/tmp/lab/workspace/report.md'

fs.realpathSync resolves a path that exists. An agent writing a new file is, by definition, naming one that does not. So the strict version denies every create, and the usual next move is to wrap it in a try and fall back to the unchecked path on ENOENT, which quietly restores the original vulnerability for exactly the operation that writes to disk.

The fix is to canonicalise the deepest ancestor that does exist, then re-attach the part that does not.

A guard that handles all nine cases

js
const fs = require("node:fs");
const path = require("node:path");

function deepestExistingAncestor(target) {
  let current = target;
  while (!fs.existsSync(current)) {
    const parent = path.dirname(current);
    if (parent === current) {
      return current;
    }
    current = parent;
  }
  return current;
}

function containedIn(baseReal, candidate) {
  const rel = path.relative(baseReal, candidate);
  if (rel === "") {
    return true;
  }
  if (path.isAbsolute(rel)) {
    return false;
  }
  return rel !== ".." && !rel.startsWith(".." + path.sep);
}

function safeResolve(baseDir, userPath) {
  const baseReal = fs.realpathSync(path.resolve(baseDir));
  const target = path.resolve(baseReal, userPath);

  const anchor = deepestExistingAncestor(target);
  const anchorReal = fs.realpathSync(anchor);
  const remainder = path.relative(anchor, target);
  const resolved = remainder === "" ? anchorReal : path.join(anchorReal, remainder);

  if (!containedIn(baseReal, resolved)) {
    throw new Error("Access denied: " + userPath + " resolves outside the workspace");
  }
  return resolved;
}

module.exports = { safeResolve, containedIn, deepestExistingAncestor };

Three details carry the weight.

The base is canonicalised too. If ./workspace is itself reached through a symlink, comparing a real path against a non-real base rejects everything. Both sides have to be real.

path.relative instead of startsWith. path.relative returns a path made of segments, so the boundary problem disappears. The sibling directory now produces ../workspace-backup/api-keys.txt, which starts with a .. segment and is refused.

The .. test checks for a segment, not a prefix. rel.startsWith("..") would also reject a legitimate file named ..hidden.txt, whose relative path is the string "..hidden.txt". Comparing against ".." + path.sep (and the bare ".." case) keeps that file readable. path.isAbsolute(rel) catches the Windows case where the target lands on a different drive and no relative path exists.

Verify it

Do not take the guard on trust. Run the cases:

js
const path = require("node:path");
const { safeResolve } = require("./safe-path.js");

const BASE = "./workspace";

const cases = [
  ["read a normal file", "notes.txt", "allow"],
  ["classic traversal", "../private/api-keys.txt", "deny"],
  ["symlink inside the workspace", "data/api-keys.txt", "deny"],
  ["sibling sharing the prefix", "../workspace-backup/api-keys.txt", "deny"],
  ["create a file that does not exist yet", "report.md", "allow"],
  ["create in a new nested directory", "out/build/log.txt", "allow"],
  ["absolute path outside", path.resolve("private/api-keys.txt"), "deny"],
  ["the workspace root itself", ".", "allow"],
  ["a filename that merely starts with two dots", "..hidden.txt", "allow"]
];

let pass = 0;
let fail = 0;

for (const [label, input, expected] of cases) {
  let actual;
  try {
    safeResolve(BASE, input);
    actual = "allow";
  } catch (err) {
    actual = "deny";
  }
  const ok = actual === expected;
  if (ok) { pass += 1; } else { fail += 1; }
  console.log((ok ? "PASS" : "FAIL").padEnd(5), label.padEnd(40), "expected", expected.padEnd(6), "got", actual);
}

console.log("");
console.log("passed " + pass + " of " + (pass + fail));
process.exit(fail === 0 ? 0 : 1);
text
PASS  read a normal file                       expected allow  got allow
PASS  classic traversal                        expected deny   got deny
PASS  symlink inside the workspace             expected deny   got deny
PASS  sibling sharing the prefix               expected deny   got deny
PASS  create a file that does not exist yet    expected allow  got allow
PASS  create in a new nested directory         expected allow  got allow
PASS  absolute path outside                    expected deny   got deny
PASS  the workspace root itself                expected allow  got allow
PASS  a filename that merely starts with two dots expected allow  got allow

passed 9 of 9

It exits non-zero on failure, so it belongs in CI next to your other tests. A containment guard with no test is a comment.

Three things this guard does not do

Shipping the limits matters more than shipping the guard, because each of these is a way to have safeResolve in your codebase and still be exposed.

1. It only helps if you use the path it returns. The guard returns a canonical path. Calling safeResolve(base, userPath) for the check and then passing the original userPath to fs.readFileSync makes the whole thing decorative, and the code still reads as though it is guarded. Wire the tool so the raw argument is impossible to use downstream.

2. It is a check at one instant, not a lock. Between the check and the open, the path can change. Save this as limits.js:

js
const fs = require("node:fs");
const path = require("node:path");
const { safeResolve } = require("./safe-path.js");

const p = path.join("workspace", "swap.txt");
fs.rmSync(p, { force: true });
fs.writeFileSync(p, "innocent\n");
const okPath = safeResolve("./workspace", "swap.txt");
console.log("check passed  :", okPath);
fs.rmSync(p, { force: true });
fs.symlinkSync(path.resolve("private/api-keys.txt"), p);
console.log("after swap    :", fs.readFileSync(okPath, "utf8").trim());

const linkPath = safeResolve("./workspace", "planted");
fs.rmSync(linkPath, { force: true });
fs.symlinkSync(path.resolve("private"), linkPath);
try {
  safeResolve("./workspace", "planted/api-keys.txt");
  console.log("read via planted link: ALLOWED");
} catch (err) {
  console.log("read via planted link: BLOCKED " + err.message);
}
text
check passed  : /tmp/lab/workspace/swap.txt
after swap    : SECRET private key material
read via planted link: BLOCKED Access denied: planted/api-keys.txt resolves outside the workspace

The first two lines are the failure. swap.txt passed containment, was then replaced by a symlink to the private file, and the subsequent read returned the secret. This is the classic time-of-check to time-of-use race. If an untrusted process shares the filesystem with your agent, path validation cannot close it, and you need an actual sandbox (see below).

3. A symlink the agent plants itself is blocked, which is worth knowing. That is the third line. Because this guard calls realpath on every single call rather than caching, an agent that writes a symlink inside the workspace and then tries to read through it on a later turn is refused.

That is a real property of this design and a reason not to "optimise" the realpath call away by caching the resolution.

One thing deliberately not claimed here: every measurement above was taken on Linux with Node 24.8.0. The LangChain advisory notes that its own fix includes making path validation operating-system portable, and case-insensitive filesystems and Windows drive letters add cases this article did not test.

When to stop writing path code

Path containment is the floor, not the ceiling. It stops an agent reading files it was never meant to see. It does nothing about CPU, memory, network egress, or a tool that shells out.

If your agent runs generated code, or you have untrusted input reaching it, the honest answer is process-level or kernel-level isolation: a container with a read-only root and a single mounted volume, or one of the sandboxes built for this. In Python, Pydantic AI Harness ships a FileSystem component that does the containment described here for you, with glob allow and deny filtering on top, and if you are already in that ecosystem there is no reason to hand-roll it.

Node has no equivalent battery-included component today, which is exactly why the path.resolve plus startsWith pattern keeps getting written. If you are going to write it, write the version that passes nine cases instead of the version that passes one.

If you want to see how a shipped agent draws this line, two of its knobs are worth reading: what blockReadsOutsideWorkingDirectories actually blocks and how allowUnsandboxedCommands changes the shell prompt.

Reproducing this

Node 24.8.0, no dependencies. Save the eight files above in one directory as setup.js, naive.js, attack.js, mechanism.js, realpath-trap.js, safe-path.js, verify.js and limits.js, then:

text
node setup.js
node attack.js
node mechanism.js
node realpath-trap.js
node verify.js
node limits.js

The fixture creates a real symlink, so run it somewhere disposable. Every output block in this article was produced by these exact files.

S

Written by

Sofia Nieves

Frequently asked questions

Is path.resolve plus startsWith actually insecure?

It is insufficient, and in two specific ways. Measured on Node 24.8.0, it correctly blocks the classic ../../ traversal, which is why it passes the test most people run by hand. It allows a read through a symlink that sits inside the workspace, because path.resolve is a string operation that never touches the filesystem. It also allows a sibling directory whose name merely starts with the same characters, because comparing a string prefix without a path separator treats /workspace-backup as being inside /workspace. Both are the bug classes LangChain patched in advisory GHSA-gr75-jv2w-4656 in June 2026.

Why not just call fs.realpathSync and compare that?

It closes both holes and breaks file creation. fs.realpathSync resolves a path that exists, and an agent writing a new file is by definition naming one that does not, so the strict version throws ENOENT on every create. The usual fix is to catch ENOENT and fall back to the unchecked path, which silently restores the original vulnerability for exactly the operation that writes to disk. Canonicalise the deepest ancestor that does exist instead, then re-attach the remainder.

Why use path.relative rather than adding path.sep to the startsWith check?

Adding the separator does fix the sibling-directory case. path.relative is preferable because it returns a path made of segments, so the boundary is structural rather than something you have to remember to append, and it also gives you the absolute-path case for free on Windows, where a target on a different drive has no relative path at all. Test for the .. segment rather than the .. prefix, or you will wrongly deny a legitimate file called ..hidden.txt.

Does this stop an agent that creates its own symlinks?

Yes, provided you call realpath on every request rather than caching the resolution. Measured: an agent that writes a symlink inside the workspace pointing at a private directory and then reads through it on a later turn is refused, because the guard re-canonicalises each time. This is a reason not to optimise the realpath call away.

Does a path guard replace a sandbox?

No. It is a check at one instant, not a lock. Measured: a file that passes containment can be replaced by a symlink before the read happens, and the read then returns the file outside the workspace. That is the classic time-of-check to time-of-use race, and path validation cannot close it. A guard also does nothing about CPU, memory, network egress or a tool that shells out. If untrusted input reaches your agent, use process-level or kernel-level isolation as well.

Is there a library that does this for me?

In Python, Pydantic AI Harness ships a FileSystem component that resolves and containment-checks every path including symlinks, with glob allow and deny filtering on top. In Node there is no equivalent battery-included component today, which is a large part of why the path.resolve plus startsWith pattern keeps being written by hand.