Virtual File System Providers¶
Gondolin exposes a programmable Virtual File System (VFS) to the guest. The VFS is backed by host-side providers (JavaScript objects) and is mounted into the guest via FUSE.
Implementation note: Gondolin does not use Node's VFS implementation unchanged. We vendor a snapshot and maintain local patches needed for real-Linux behavior in the VM/FUSE/RPC path (for example hard-link and stat semantics, plus RealFS hardening). In code, these changes are marked with
XXX(patch)comments underhost/vendor/node-vfs. See also the upstreaming discussion/patch context in node#61478.
This page documents the built-in providers shipped with the JavaScript SDK and common patterns for safely sharing a workspace with a sandboxed VM.
Overview¶
A VFS setup has three layers:
- The guest runs a FUSE filesystem ("sandboxfs")
- The host translates VFS operations into RPC calls
- A host-side provider (or a stack of providers) implements the filesystem
In the SDK you configure this via VM.create({ vfs: { ... } }):
import { VM, MemoryProvider } from "@earendil-works/gondolin";
const vm = await VM.create({
vfs: {
mounts: {
"/": new MemoryProvider(),
},
},
});
Mounts And Paths¶
Mount Map¶
vfs.mounts is a map from an absolute POSIX path to a provider.
- Keys must be absolute POSIX paths (for example:
/workspace,/cache) - Mounts other than
/are exposed at their path in the guest (via bind mounts) - The special mount
/controls the root of the underlying FUSE mount (atfuseMount) - Providers receive absolute POSIX paths rooted at
/within their mount
If you provide more than one mount, Gondolin routes operations to the provider with the longest matching mount prefix.
FUSE Mount Point¶
Under the hood Gondolin mounts the VFS once (default mount point: /data) and
then bind-mounts individual entries into their configured locations.
You can change the underlying FUSE mount point:
const vm = await VM.create({
vfs: {
fuseMount: "/vfs",
mounts: {
"/workspace": new MemoryProvider(),
},
},
});
Practical consequences:
- Every VFS path is always reachable under the
fuseMountpath as well - Mounts like
/workspaceare typically visible at both/workspaceand/data/workspace(or/vfs/workspaceif you changedfuseMount)
Built-In Providers¶
The SDK exports several providers from @earendil-works/gondolin.
Memory Provider¶
MemoryProvider is an in-memory filesystem.
- Fast, disposable, and isolated from the host
- Useful for scratch space, build artifacts, temporary caches
- Not included in disk checkpoints and lost when the VM closes
Common pattern:
- Mount a writable in-memory workspace at
/workspacefor code generation - Mount a durable host directory at
/outfor artifacts
Real FS Provider¶
RealFSProvider(hostPath) exposes a directory from the host filesystem.
- Reads and writes affect the host directory
- Symlinks that escape the exposed directory are blocked for operations that follow symlinks (open, stat, readdir, etc.)
- Dangling symlinks are also blocked for follow-style operations (strict fail-closed behavior)
- Operations that act on the symlink entry itself (lstat, readlink, unlink) are allowed
- Use this for persistence (outputs, caches) or for sharing a source tree
Example:
import path from "node:path";
import { VM, RealFSProvider } from "@earendil-works/gondolin";
const repoDir = path.resolve(".");
const vm = await VM.create({
vfs: {
mounts: {
"/workspace": new RealFSProvider(repoDir),
},
},
});
Readonly Provider¶
ReadonlyProvider(backend) wraps another provider and rejects mutations.
Use cases:
- Expose configuration, templates, or fixtures that the guest must not modify
- Mount a host directory for reads while keeping the guest from changing it
Example:
import path from "node:path";
import { VM, RealFSProvider, ReadonlyProvider } from "@earendil-works/gondolin";
const configDir = path.resolve("./config");
const vm = await VM.create({
vfs: {
mounts: {
"/config": new ReadonlyProvider(new RealFSProvider(configDir)),
},
},
});
Shadow Provider¶
ShadowProvider(backend, options) wraps another provider and selectively hides
paths.
- Read-ish operations behave as if the entry does not exist (ENOENT)
- Shadowed entries are omitted from
readdirresults - Write operations can either be denied or redirected to an in-memory upper layer
The shadow policy is a callback:
import { ShadowProvider } from "@earendil-works/gondolin";
const provider = new ShadowProvider(backend, {
shouldShadow: ({ path }) => path === "/.env" || path.startsWith("/secrets/"),
});
Shadow Provider Options¶
shouldShadow: policy callback that returnstruefor shadowed pathswriteMode:"deny"(default) or"tmpfs"tmpfs: provider used as the upper layer whenwriteModeis"tmpfs"(default: a newMemoryProvider)denySymlinkBypass: iftrue, also consultrealpath()to block trivial symlink bypasses (default:true)denyWriteErrno: errno used for denied write operations (default:EACCES)
createShadowPathPredicate([...]) is a convenience helper that shadows exact
paths and their children via prefix matching. It does not support globs.
Blocking Access To .env¶
If you mount a host working tree into the guest, you usually want to ensure that local secret files are not readable.
Example: mount the repo at /workspace, but hide /.env and /.npmrc:
import path from "node:path";
import {
VM,
RealFSProvider,
ShadowProvider,
createShadowPathPredicate,
} from "@earendil-works/gondolin";
const repoDir = path.resolve(".");
const hideSecrets = createShadowPathPredicate(["/.env", "/.npmrc"]);
const workspace = new ShadowProvider(new RealFSProvider(repoDir), {
shouldShadow: hideSecrets,
// Default writeMode is "deny"
});
const vm = await VM.create({
vfs: {
mounts: {
"/workspace": workspace,
},
},
});
Notes:
- Shadow paths are interpreted as absolute VFS paths rooted at
/ createShadowPathPredicate([".env"])also works; it normalizes to"/.env"- By default
denySymlinkBypassis enabled to block trivial symlink bypasses
Hiding Node Modules But Allowing Installs¶
A common workflow is to mount a repository into /workspace but avoid using the
host node_modules. This lets you run npm install (or pnpm install) inside
the VM from scratch.
To do that, shadow /node_modules but set writeMode: "tmpfs" so the guest can
create its own node_modules directory in memory:
import path from "node:path";
import {
VM,
RealFSProvider,
ShadowProvider,
createShadowPathPredicate,
} from "@earendil-works/gondolin";
const repoDir = path.resolve(".");
const base = new RealFSProvider(repoDir);
// First: deny reads/writes to host secret files
const secrets = new ShadowProvider(base, {
shouldShadow: createShadowPathPredicate(["/.env", "/.npmrc"]),
writeMode: "deny",
});
// Then: hide the host node_modules, but let the guest write its own
const noHostNodeModules = new ShadowProvider(secrets, {
shouldShadow: createShadowPathPredicate(["/node_modules"]),
writeMode: "tmpfs",
});
const vm = await VM.create({
vfs: {
mounts: {
"/workspace": noHostNodeModules,
},
},
});
await vm.exec("cd /workspace && npm install");
With writeMode: "tmpfs":
- Reads from
/workspace/node_modulesbehave like it is empty (unless the guest has created files there) - Writes go to an in-memory layer and do not touch the host directory
This is also useful for hiding other large host directories of potentially the
wrong architecture (for example: .git, .venv, dist) while still allowing
the guest to create its own.
VFS Hooks¶
Gondolin also supports basic hooks around VFS operations with before and after
callbacks:
import { VM, MemoryProvider } from "@earendil-works/gondolin";
const vm = await VM.create({
vfs: {
mounts: { "/": new MemoryProvider() },
hooks: {
before: (ctx) => console.log("vfs before", ctx.op, ctx.path),
after: (ctx) => console.log("vfs after", ctx.op, ctx.path),
},
},
});
Hooks are useful for:
- Auditing what the guest accessed
- Collecting metrics
- Debugging unexpected file reads
Provider Composition¶
Providers are designed to be stackable.
Typical stacks:
ReadonlyProvider(new RealFSProvider(...))ShadowProvider(new RealFSProvider(...), ...)- Multiple
ShadowProviderlayers to apply different policies (deny secrets, tmpfs for build outputs)
Rule of thumb: put the most security-sensitive policy (for example, blocking secrets) closest to the real host filesystem provider.
Custom Providers¶
If you need behavior beyond the built-ins (filtering, synthetic files, virtual directories, content generation), you can implement a custom provider.
Recommended starting points:
- Extend
VirtualProviderClassfor a full read/write provider - Extend
ReadonlyVirtualProviderfor a synchronous, read-only provider
Gotchas¶
Do Not Hide CA Certificates By Accident¶
Gondolin injects its MITM CA certificate at /etc/gondolin/mitm/ca.crt unless
you explicitly mount your own provider at /etc/gondolin or
/etc/gondolin/mitm.
Guest init scripts use that cert to build a merged runtime trust bundle at
/run/gondolin/ca-certificates.crt. If you mount a custom provider at / that
hides distro CA bundles, public TLS verification may still fail unless you
provide your own trust store.
Disk Checkpoints Do Not Include VFS Data¶
Disk checkpoints capture the VM root disk. Data written to VFS mounts is backed by the provider (memory or host filesystem) and is not part of checkpoints.