Better FS Tools
Filesystems

Filesystems

Choose the filesystem that the tools read and write through, compare what each one can do, and write your own.

The tools do not open files themselves. Each tool gets a filesystem in its fs option, and the filesystem does all access checks. One call to fs.open(path) resolves the path, checks it against the allowed folders and the deny folders, follows or refuses symbolic links, checks the file type, and opens the file. It returns one handle, or an error with a reason. The read tool turns that reason into an error code for the model.

This filesystem is the backend. The read tool needs a FileSystem from @better-fs-tools/fs. The edit, write, and apply_patch tools need a WritableFileSystem, which adds stat, write, and optional stage and remove. Every backend on these pages implements both.

import { memoryFileSystem } from "@better-fs-tools/fs";
import { createReadTool, textOf } from "@better-fs-tools/read";

const read = createReadTool({
  fs: memoryFileSystem({ files: { "/notes.txt": "one\ntwo\nthree\n" } }),
});

console.log(textOf(await read({ path: "/notes.txt", offset: 2 }))); // "2|two\n3|three"

To use other folders for each call, set fs to a function of the call context. See Path handling.

Choose a filesystem

FilesystemPackageUse it for
nodeFileSystem()@better-fs-tools/nodeLocal files on Node 24 or later, or Bun. POSIX only
memoryFileSystem()@better-fs-tools/fsTests, examples, and files that you keep in memory
justBashFileSystem()@better-fs-tools/just-bashA just-bash sandbox, with no real files and no process
cloudflareShellFileSystem()@better-fs-tools/cloudflare-shellA Cloudflare Shell Workspace in a Worker or a Cloudflare Agent
cloudflareComputerFileSystem()@better-fs-tools/cloudflare-computerA Cloudflare Computer workspace. Experimental

A backend is not a bundle and has no tools of its own. Pass its filesystem as fs to any tool or bundle, for example createReadTool(), createFsTools() from @better-fs-tools/write, or createAiSdkFsTools(). Choose an integration compares the bundles and lists the recommended filesystem for each host.

Shared options

Every backend takes the same root options, with the same names:

OptionMeaning
allowedRootsRequired, at least one folder. A path outside every root gives OUTSIDE_ALLOWED_ROOTS
denyRootsFolders or files to refuse, also inside an allowed root. A path inside one gives DANGEROUS_PATH
cwdRelative paths and relative roots resolve against it
symlinks"reject" refuses a link in any part of the path. "follow-within-roots" follows links, and the real path must stay inside the roots
identity"required": the backend gives each file a stable identity, and versions trust it. "none": no identity, and versions use the size and the modification time
idA name for the backend. It appears in result.file.backend and in versions
maxBufferedBytesOnly for backends that hold a whole file in memory. Default 16 MiB. A larger file gives TOO_LARGE

memoryFileSystem() has no allowed roots, because every absolute path is inside. A backend can also narrow an option to the values it supports. Its type then shows which values you can pass.

What each filesystem can do

These tables come from the source of each backend. A backend refuses an unsupported value of symlinks or identity with a TypeError when you create it.

Reads

FilesystemReadsBytes fetched before authorizemaxBufferedBytessymlinksidentity
nodeFileSystem()StreamNoneNoneBoth. Default "follow-within-roots""required" only
memoryFileSystem()From memoryNone. The bytes are already in memory16 MiBLinks do not existBoth. Default "required"
justBashFileSystem()Buffered in open()The whole file16 MiBBoth. Default "reject"Both. Default "none"
cloudflareShellFileSystem()Buffered in open()The whole file16 MiB"reject" only"none" only
cloudflareComputerFileSystem()Stream, started in open()The read request is sent, but no chunk is readNone"reject" only"none" only

Roots and writes

FilesystemMore than one allowed rootdenyRootscwd defaultWrite methodsNew file mode
nodeFileSystem()YesYes, added to /dev, /proc, and /sysprocess.cwd()Always0o666 less the umask, or newFileMode
memoryFileSystem()No allowed rootsYesNone. Paths are absoluteAlways. readOnly: true refuses them0o644
justBashFileSystem()YesYes/Checked at useThe backend's default. 0o644 in InMemoryFs
cloudflareShellFileSystem()YesYesThe first allowed rootChecked at useNone. Shell has no modes
cloudflareComputerFileSystem()YesYesThe first allowed rootChecked at use0o644, the writeFile default

"Checked at use" means that the backend object can leave out its write methods. Reads still work. A write then gives UNSUPPORTED_BACKEND. The backend checks this when the write runs, not when you create the filesystem.

FilesystemcompareAndSwapAtomic replaceKeeps the modestage()remove()
nodeFileSystem()Yes, in one processYesYesYesYes
memoryFileSystem()YesYesYesYesYes
justBashFileSystem()NoNoYesNoYes
cloudflareShellFileSystem()NoNoNo modesNoYes
cloudflareComputerFileSystem()NoYesYesNoYes

When compareAndSwap is false, the write tools check the file again just before the write. A change between that check and the write is not seen. Without stage(), apply_patch writes each file in turn, and undoes the files it wrote when a later file fails.

Errors

A backend returns a reason, and the tools turn it into an error code:

ReasonCodeWhen
outside-allowed-rootsOUTSIDE_ALLOWED_ROOTSThe path, or the target of a link, is outside every allowed root
dangerous-pathDANGEROUS_PATHThe path is inside a deny root
deniedDENIEDThe backend refused the path, for example a link under symlinks: "reject"
too-largeTOO_LARGEThe file is over maxBufferedBytes
unsupportedUNSUPPORTED_BACKENDThe backend object has no method for the operation

A read-only view

readOnlyFileSystem(fs) from @better-fs-tools/fs works over every backend. It keeps the read members and drops the write methods. A read tool works as before, and a write tool throws a TypeError when you create it. See Memory filesystem.

Buffered reads and authorize

The read tool runs authorize after fs.open(), and before it reads any content byte. The order is:

  1. fs.open(path) resolves the path, checks the roots, and opens the file.
  2. authorize gets the path, the size, and the modification time. It can refuse the read.
  3. The tool reads the bytes from the handle, and passes them to the classifiers, the converters, and the model.

On just-bash and Cloudflare Shell, open() reads the whole file into memory. When authorize refuses that file, its bytes have already left the backend. authorize stops the bytes from reaching the model. It does not stop them from leaving the backend.

What leaves the backend before authorize runs:

FilesystemBefore authorize
nodeFileSystem()No content. open() opens a file descriptor
memoryFileSystem()No content. The bytes are already in memory
justBashFileSystem()The whole file, read with readFileBuffer()
cloudflareShellFileSystem()The whole file, read with readFileBytes()
cloudflareComputerFileSystem()The readFile request. No chunk is read until authorize allows it

On every backend, the tool passes no content byte to a classifier, a converter, or the model until authorize allows the read.

If a refused file must not leave the backend, refuse it in the backend. Put it in denyRoots, or leave it out of allowedRoots. The backend then refuses the path before it reads anything:

import { InMemoryFs } from "just-bash";
import { justBashFileSystem } from "@better-fs-tools/just-bash";
import { createReadTool, denyPaths, textOf } from "@better-fs-tools/read";

const bash = new InMemoryFs({
  "/workspace/src/index.ts": "export const a = 1;\n",
  "/workspace/.env": "TOKEN=secret\n",
});

// authorize refuses .env, but open() has already copied the whole file out of the backend.
const read = createReadTool({
  fs: justBashFileSystem(bash, { cwd: "/workspace", allowedRoots: ["/workspace"] }),
  authorize: denyPaths(["**/.env"]),
});

// denyRoots refuses .env inside the backend, before any byte is read.
const safeRead = createReadTool({
  fs: justBashFileSystem(bash, {
    cwd: "/workspace",
    allowedRoots: ["/workspace"],
    denyRoots: ["/workspace/.env"],
  }),
});

console.log(textOf(await read({ path: ".env" })));
console.log(textOf(await safeRead({ path: ".env" })));
[read:denied] .env was refused by policy (the path matches a denied pattern).
[read:dangerous-path] .env is refused by policy (/workspace/.env). Choose another path.

The model sees a refusal in both cases. Only the second backend keeps the file inside.

denyRoots takes folders and files, with no glob patterns. Use authorize for patterns, and denyRoots for the files that must stay in the backend. Permissions explains authorize.

The write tools do not have this problem. They run authorize after fs.stat(), which reads no content, and before they open the file.

A bash tool does not use the backend. The allowed roots and the deny roots do not limit what a command can read.

Write your own filesystem

Implement FileSystem from @better-fs-tools/fs for reads. Implement WritableFileSystem to add the write tools.

A read backend has:

  • id, capabilities.identity, and paths, the path helpers.
  • open(path, { signal }). It resolves the path, checks the roots and the type, and opens the file. It returns { ok: true, file } or { ok: false, error }. It does not throw for an expected failure.
  • A handle with info, a single-use bytes() stream, verify(), which tells whether the file changed after the open, and close().
  • An optional list(path, { limit }). Without it, the read tool makes no name suggestions and no folder listings.

When open() refuses a folder, a FIFO, a socket, or a device, return a not-a-file error with its kind. For a folder, also return its target, so the folder listing works.

A writable backend also has writeCapabilities, stat(), and write(), and optional stage() and remove(). The backend keeps the policy, because the tools do not check roots or links themselves. Each write method must apply the same roots, deny roots, and symlink rules as open(). A write follows a link inside the roots to its real path, or refuses it. It never replaces the link. stat().version must equal open().info.version for the same file, and must change with every change of the bytes. Set writeCapabilities.compareAndSwap to true only when the backend checks the precondition next to the write.

Check it with the conformance suites

runFileSystemConformance() checks the read contract. runWritableFileSystemConformance() checks the write contract. It creates and removes files in scratchDirectory, so give it an empty folder:

import {
  memoryFileSystem,
  runFileSystemConformance,
  runWritableFileSystemConformance,
} from "@better-fs-tools/fs";

// Replace the memory filesystem with your own adapter.
const fs = memoryFileSystem({
  files: { "/a.txt": "alpha\n" },
  directories: ["/dir", "/scratch"],
  denyRoots: ["/secret"],
});

const read = await runFileSystemConformance(fs, {
  existingFile: { path: "/a.txt", bytes: new TextEncoder().encode("alpha\n") },
  missingPath: "/missing.txt",
  directoryPath: "/dir",
  refusedPath: "/secret/key.pem",
  listDirectory: "/",
});

const write = await runWritableFileSystemConformance(fs, {
  scratchDirectory: "/scratch",
  refusedPath: "/secret/new.txt",
});

for (const check of [...read.checks, ...write.checks]) {
  if (!check.ok) console.log(`${check.name}: ${check.detail}`);
}
console.log(read.passed && write.passed); // true when the adapter keeps both contracts

Each report has adapter, passed, and checks. Each check has a name, ok, and a detail when it fails.

Read fixtureRequiredHolds
existingFileYesA readable file, as { path, bytes } with its exact bytes
missingPathYesA path that does not exist, inside an allowed folder
directoryPathNoA folder. open() must refuse it as not-a-file with kind directory
refusedPathNoA path that the backend's policy refuses
mutateNoA function that changes existingFile, so verify() must report a change
listDirectoryNoA folder to list, when the backend has list()
Write fixtureRequiredHolds
scratchDirectoryYesAn empty folder inside the roots
directoryPathNoA folder to refuse as a write target. Default scratchDirectory
refusedPathNoA path that the backend's policy refuses for writes

POSIX path helpers

Paths are POSIX only in this release. @better-fs-tools/fs has three helpers that do not need node:path, so a backend for a Worker can use them:

HelperDoes
posixPathsdirname, basename, and join. Set it as the backend's paths
resolvePosix(base, value)Resolves value against base from the text alone. It removes . and .., and does not follow links
containsPosix(root, candidate)True when candidate is root or is inside it
import { containsPosix, posixPaths, resolvePosix } from "@better-fs-tools/fs";

console.log(resolvePosix("/workspace", "src/../docs/./a.md")); // "/workspace/docs/a.md"
console.log(resolvePosix("/workspace", "../../etc/passwd")); // "/etc/passwd"
console.log(containsPosix("/workspace", "/workspace/docs/a.md")); // true
console.log(containsPosix("/workspace", "/workspace-old/a.md")); // false
console.log(posixPaths.join("/workspace", "a.md")); // "/workspace/a.md"
console.log(posixPaths.dirname("/workspace/docs/a.md")); // "/workspace/docs"
console.log(posixPaths.basename("/workspace/docs/a.md")); // "a.md"

resolvePosix() and containsPosix() see only text. When the backend has links, resolve the real path and check it against the roots again before you open the file.

On this page