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
| Filesystem | Package | Use it for |
|---|---|---|
nodeFileSystem() | @better-fs-tools/node | Local files on Node 24 or later, or Bun. POSIX only |
memoryFileSystem() | @better-fs-tools/fs | Tests, examples, and files that you keep in memory |
justBashFileSystem() | @better-fs-tools/just-bash | A just-bash sandbox, with no real files and no process |
cloudflareShellFileSystem() | @better-fs-tools/cloudflare-shell | A Cloudflare Shell Workspace in a Worker or a Cloudflare Agent |
cloudflareComputerFileSystem() | @better-fs-tools/cloudflare-computer | A 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:
| Option | Meaning |
|---|---|
allowedRoots | Required, at least one folder. A path outside every root gives OUTSIDE_ALLOWED_ROOTS |
denyRoots | Folders or files to refuse, also inside an allowed root. A path inside one gives DANGEROUS_PATH |
cwd | Relative 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 |
id | A name for the backend. It appears in result.file.backend and in versions |
maxBufferedBytes | Only 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
| Filesystem | Reads | Bytes fetched before authorize | maxBufferedBytes | symlinks | identity |
|---|---|---|---|---|---|
nodeFileSystem() | Stream | None | None | Both. Default "follow-within-roots" | "required" only |
memoryFileSystem() | From memory | None. The bytes are already in memory | 16 MiB | Links do not exist | Both. Default "required" |
justBashFileSystem() | Buffered in open() | The whole file | 16 MiB | Both. Default "reject" | Both. Default "none" |
cloudflareShellFileSystem() | Buffered in open() | The whole file | 16 MiB | "reject" only | "none" only |
cloudflareComputerFileSystem() | Stream, started in open() | The read request is sent, but no chunk is read | None | "reject" only | "none" only |
Roots and writes
| Filesystem | More than one allowed root | denyRoots | cwd default | Write methods | New file mode |
|---|---|---|---|---|---|
nodeFileSystem() | Yes | Yes, added to /dev, /proc, and /sys | process.cwd() | Always | 0o666 less the umask, or newFileMode |
memoryFileSystem() | No allowed roots | Yes | None. Paths are absolute | Always. readOnly: true refuses them | 0o644 |
justBashFileSystem() | Yes | Yes | / | Checked at use | The backend's default. 0o644 in InMemoryFs |
cloudflareShellFileSystem() | Yes | Yes | The first allowed root | Checked at use | None. Shell has no modes |
cloudflareComputerFileSystem() | Yes | Yes | The first allowed root | Checked at use | 0o644, 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.
| Filesystem | compareAndSwap | Atomic replace | Keeps the mode | stage() | remove() |
|---|---|---|---|---|---|
nodeFileSystem() | Yes, in one process | Yes | Yes | Yes | Yes |
memoryFileSystem() | Yes | Yes | Yes | Yes | Yes |
justBashFileSystem() | No | No | Yes | No | Yes |
cloudflareShellFileSystem() | No | No | No modes | No | Yes |
cloudflareComputerFileSystem() | No | Yes | Yes | No | Yes |
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:
| Reason | Code | When |
|---|---|---|
outside-allowed-roots | OUTSIDE_ALLOWED_ROOTS | The path, or the target of a link, is outside every allowed root |
dangerous-path | DANGEROUS_PATH | The path is inside a deny root |
denied | DENIED | The backend refused the path, for example a link under symlinks: "reject" |
too-large | TOO_LARGE | The file is over maxBufferedBytes |
unsupported | UNSUPPORTED_BACKEND | The 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:
fs.open(path)resolves the path, checks the roots, and opens the file.authorizegets the path, the size, and the modification time. It can refuse the read.- 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:
| Filesystem | Before 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, andpaths, 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-usebytes()stream,verify(), which tells whether the file changed after the open, andclose(). - 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 contractsEach report has adapter, passed, and checks. Each check has a name, ok, and a detail when it fails.
| Read fixture | Required | Holds |
|---|---|---|
existingFile | Yes | A readable file, as { path, bytes } with its exact bytes |
missingPath | Yes | A path that does not exist, inside an allowed folder |
directoryPath | No | A folder. open() must refuse it as not-a-file with kind directory |
refusedPath | No | A path that the backend's policy refuses |
mutate | No | A function that changes existingFile, so verify() must report a change |
listDirectory | No | A folder to list, when the backend has list() |
| Write fixture | Required | Holds |
|---|---|---|
scratchDirectory | Yes | An empty folder inside the roots |
directoryPath | No | A folder to refuse as a write target. Default scratchDirectory |
refusedPath | No | A 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:
| Helper | Does |
|---|---|
posixPaths | dirname, 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.
Tool definitions
Connect the read, edit, write, apply_patch, and bash tools to a framework of your own, and change the tool name, the parameter names, and the descriptions with a signature.
Node filesystem
The Node backends. Read and write local files with nodeFileSystem, and set its roots, deny roots, symlink policy, hard-link policy, and file modes.