Internals: the filesystem
How a path a guest writes becomes a file the host opens, what the jail actually checks, and the cases where the answer is "refused" rather than a file.
This page explains behaviour rather than listing signatures — for those, see the runtime:fs reference.
Every path is resolved, then confined
A path arrives as a string, a file: URL or a file() handle. It is resolved against the runtime's base directory, and its real location — symlinks followed, . and .. collapsed — is checked to be inside the canonicalized project root. A path that escapes is refused with ERR_JAIL_ESCAPE.
For a path that does not exist yet, the deepest existing ancestor is canonicalized and checked, and the remaining components are reattached. Those components are literal: a .. that cannot be resolved because nothing is there yet is rejected rather than assumed harmless, so a not-yet-created path cannot climb out through a directory that does not exist.
This is re-done on every call, never cached. A path validated once can become an escape later — the filesystem is mutable, and replacing a directory with a symlink between two calls is the entire attack. Caching the resolved path would turn one check into a permanent grant.
--allow-read / --allow-write are applied after canonicalization, never before. A symlink is a name for a file somewhere else, so judging the name the guest wrote would let --allow-read=./data admit ./data/link-to-etc/passwd — the hole the jail exists to close, reopened one level in.
Inside the jail a list narrows; outside it, a named path is added. The jail is the boundary a guest can never move — no path a program writes, no symlink it follows, no .. it constructs. What the command line names is a different act by a different party: --allow-read=/etc/letsencrypt/live/example.com is the deployment operator saying that subtree is part of this run, which is the only way a server reaches a certificate the project root does not contain.
Each access kind carries its own root set — the jail first, then whatever its own flag named outside it — so --allow-read on a directory does not make it writable, and a path in neither set is ERR_JAIL_ESCAPE as before. Module resolution is unaffected: the loader detects its own root and never consults these lists, so a granted path makes bytes readable, not code importable.
The check and the use name the same directory, because they are the same descriptor. Resolving a path and then handing that path to a syscall is two lookups of one name, and between them the filesystem is mutable — a component replaced by a symlink after the check is followed by the use, and the bytes land wherever it now points. So the resolution ends by opening the parent directory, and the operation runs relative to that descriptor: openat, mkdirat, unlinkat, renameat. A descriptor refers to an inode, not a name, so renaming or replacing the directory afterwards changes nothing about where the write goes.
Each step of the walk below the root is opened NOFOLLOW. A canonical path has no symlinks in it, so a component that is one has been swapped since the check, and it is refused as an escape rather than followed. Recursive mkdir and remove descend the same way, one descriptor at a time — a recursive delete redirected halfway is the one outcome nobody can undo.
Three things this does not cover, said here rather than left to be discovered. Windows has no *at family, keeps the path-based behaviour, and keeps the race. Hard links are invisible to NOFOLLOW — a hard link is the file — which is why there is no link() operation. And timing is not a boundary this or any filesystem can hold: durations are data-dependent through the page cache and the disk, and the jail's claim is that a guest cannot reach outside it, not that it cannot measure inside it.
A symlink you write may point anywhere; one you follow may not. The target handed to symlink() is the data stored in the link — the same value readLink() returns unresolved — and it is not confined: it may be relative, may dangle, and may name a path outside the jail. Only the link's own location is jailed, as a write. The asymmetry is the point: creating one stores a string, and every read through it goes back through canonicalize-then-confine above, so a link out of the jail is one the program that made it cannot follow. Which leaves the case it exists for available — a test that needs a dependency resolving outside the project, reproduced without shelling out to ln.
The root is not a target
Reading the root is ordinary — stat("."), readDir("."), realPath(".") all work, and so does writing an entry inside it. Two things are refused:
An empty path. Path::join("") is the path itself, so an empty argument resolved to the root and the operation ran against it — remove("", { recursive: true }) deleted the entire project directory. No operation intends an empty path, and Node's fs rejects it too, so it fails with ERR_INVALID_PATH.
A mutation whose resolved target is the root, however it is spelled — ., ./, data/.., or the root's own absolute path. Removing, renaming, truncating or chmoding the root destroys the sandbox the program is running in, and is never a coherent request from inside it.
The guard is on the resolved target, which is what keeps it from catching the ordinary cases: a new entry in the root resolves to root/<name>, not to root, so makeTempDir() — which defaults to the base directory — still works.
A path must name what it says it names
Two refusals that are not about the root, but about a path describing something the filesystem disagrees with.
A trailing separator on something that is not a directory. POSIX reads file.txt/ as "this name must be a directory" and the kernel refuses it with ENOTDIR — which is what Node and Bun surface, because they hand the path to the syscall untouched. Resolution here canonicalizes, and that drops the separator, so every operation but readDir treated file.txt/ and file.txt as the same path. The requirement is now checked against the resolved target, which fails with ERR_NOT_DIRECTORY; a path that does not exist is left alone, so mkdir("newdir/") still means what it says.
A copy onto the file being copied. fs::copy opens the destination truncating before it reads the source, so copy(p, p) emptied the file and reported success with 0 bytes copied. Path equality is not enough to catch it — two hardlinks to one inode have different names and truncating either destroys the other — so identity is decided by device/inode on Unix, and the call fails with ERR_SAME_FILE. Deno refuses the same call; Node treats it as a no-op, which is safe but reports success for what is almost certainly a caller bug.
Temporary entries stay inside
makeTempDir and makeTempFile default to the base directory, not the OS temp directory. The OS temp directory is outside the root, so writing there would be the one filesystem call that escapes the jail.
The name comes from the host's temp-file machinery rather than being composed by the caller: a guessable name in a shared directory is a symlink-attack invitation, and that is not something each call site should have to re-derive. Nothing is cleaned up automatically — what you create, you remove.
Two doors, one filesystem
runtime:fs is asynchronous; runtime:wasi is synchronous, because WASI's syscalls are. They are separate implementations of the same confinement: the same root jail, the same scope lists, the same refusals. A WASI guest is not a way around --allow-read, and a policy that differed between the two would be a bug wearing a feature's clothes.
Capabilities
Reads need FileRead, mutations need FileWrite. copy needs both: it reads one path and writes another, and gating it on the write alone would let a guest with no read access duplicate a file it cannot see into somewhere it can reach by another route.
The capability is checked before the jail, so a denied run learns nothing about the layout of a filesystem it may not read.
What it costs
All operations are asynchronous — there are no sync variants, and no callbacks. A write resolves only once the bytes are flushed, not when they are handed to the host: await write(p, data) followed by read(p) is ordinary code, and resolving early would make that return a truncated file.
Failures carry a stable code (ERR_NOT_FOUND, ERR_ALREADY_EXISTS, ERR_JAIL_ESCAPE, ERR_INVALID_PATH, ERR_NOT_DIRECTORY, ERR_SAME_FILE, …), which is the contract to branch on; messages are prose and may be reworded.
See also
runtime:fsreference — signatures, options and error codesruntime:pathinternals — the path computation that precedes all of thisSecurity model — capabilities and the denial flags