Virtual filesystem#
Every Bashkit script runs against an in-memory virtual filesystem (VFS), not
the host disk. cat, ls, cp, redirections, mkdir — they all work exactly
as a script expects, but the bytes live in memory and disappear when the
interpreter is dropped. Path traversal like ../../../etc/passwd is normalised
away, and symlinks are stored but never followed. The host is invisible by
default; you grant access deliberately, never by accident.
This is the foundation of the security model: there is no real filesystem to escape to unless you mount one.
The layering stack#
A Bash instance composes its filesystem from layers. Each layer wraps the one
below it, so you can stack read-only enforcement, text mounts, and host mounts
over an in-memory base — and swap mounts at runtime.
<g font-size="11" fill="#404040">
<text x="338" y="42">Bash::mount() / unmount()</text>
<text x="338" y="94">readonly_filesystem()</text>
<text x="338" y="146">mount_text()</text>
<text x="338" y="198">mount_real_*_at()</text>
<text x="338" y="250">Bash::new()</text>
</g>
<g stroke="#0a1636" stroke-opacity="0.25">
<line x1="180" y1="60" x2="180" y2="68"/>
<line x1="180" y1="112" x2="180" y2="120"/>
<line x1="180" y1="164" x2="180" y2="172"/>
<line x1="180" y1="216" x2="180" y2="224"/>
</g>
Two-layer trait model#
Internally the VFS splits raw storage from POSIX semantics:
| Layer | Trait | Responsibility |
|---|---|---|
| Backend | FsBackend | Raw storage operations and failure-atomic mutations |
| POSIX | FileSystem / PosixFs | POSIX-like validation (no duplicate names, type-safe ops, parent-dir rules) |
If you want a custom backend (a database, object store, key-value store),
implement the small FsBackend and wrap it in PosixFs — the POSIX checks come
for free. Implement FileSystem directly only when you need full control over
semantics.
PosixFs validates operations before delegating them, but it cannot make a raw
backend mutation atomic. A custom FsBackend must make failed write, copy,
and rename operations failure-atomic; a direct FileSystem has the same
obligation for write_file, copy, and rename. Errors must leave source and
destination entries, bytes, types, and reported usage unchanged. A wrapper
moving entries between independent backends must restore the previous
destination on failure or reject the move before mutation, usually with
ErrorKind::CrossesDevices.
verify_filesystem_requirements() is a structural smoke check for root access
and path normalization. It does not mutate the filesystem or certify failure
atomicity, symlink behavior, quota accounting, or error normalization; custom
adapter tests remain responsible for those invariants.
Built-in implementations#
| Implementation | Purpose |
|---|---|
| InMemoryFs | Default (Bash::new()). HashMap-backed, thread-safe, no persistence. Seeds /, /tmp, /home, /home/user, /dev. |
| OverlayFs | Copy-on-write over another filesystem, with whiteout tracking for deletes. |
| MountableFs | Mount multiple filesystems at different paths (longest-prefix match). Always the outermost layer, enabling live mounts. |
| NamespaceFs | Compose a static visible tree from rebased filesystem subtrees, with per-mount access and synthetic ancestors. |
| ReadOnlyFs | Delegates reads, denies every mutation with PermissionDenied — even writes to /tmp, cp, mv, rm, chmod. For inspection-only sessions. |
RealFs (realfs feature) | Direct access to a host directory. Read-only (safe) or read-write (dangerous); path traversal blocked by canonicalisation + root-prefix checks. |
Mounting host directories#
Real host access is opt-in and read-only by default. The realfs feature
adds builder and CLI entry points:
use bashkit::Bash;
let mut bash = Bash::builder()
.mount_real_readonly("/host/data") // visible read-only inside the VFS
.build();
bashkit --mount-ro /host/data:/data -c 'ls /data' # read-only
bashkit --mount-rw /host/out:/out -c 'echo hi > /out/f' # writable (dangerous)
To freeze a session — including in-memory writes — wrap it with
readonly_filesystem().
Composing a static namespace#
NamespaceFs creates an intentionally bounded path tree instead of a fallback
root plus live mounts. Each source may be rebased and independently read-only or
read-write:
use bashkit::{Bash, FileSystem, InMemoryFs, NamespaceFs};
use std::path::Path;
use std::sync::Arc;
let repository = Arc::new(InMemoryFs::new());
repository.mkdir(Path::new("/repo/src"), true).await?;
repository.write_file(Path::new("/repo/src/lib.rs"), b"source").await?;
let output = Arc::new(InMemoryFs::new());
let namespace = NamespaceFs::builder()
.mount_readonly_from("/src", repository, "/repo/src")?
.mount_readwrite("/build", output)?
.build();
let mut bash = Bash::builder().fs(Arc::new(namespace)).build();
assert_eq!(bash.exec("cat /src/lib.rs").await?.stdout, "source");
Nested targets use longest-prefix precedence. Missing ancestors and mount points are visible as directories. Files and symlinks can be copied across mounts; cross-mount rename reports a typed cross-device error because copy-delete is not atomic.
Host-backed filesystem (JS)#
The wasm bindings accept an fs object, so scripts run directly against storage
you own — a Durable Object, an OPFS handle, IndexedDB — instead of the in-memory
VFS. Nothing is copied in or diffed back out: every read and write during the run
is a call into your object.
const bash = new Bash({ cwd: "/workspace", fs: myHost });
const r = await bash.execute("grep -rl TODO . | head -5");
Seven methods are required — read, write, mkdir, remove, stat,
readDir, exists — and each may return its value directly or as a Promise.
append, copy, rename, and chmod are optional and synthesized from the
required primitives when omitted. Your host implements raw storage only; POSIX
semantics (parent-directory checks, “is a directory”, symlink resolution) are
enforced above it. Throw an Error carrying a code (ENOENT, EEXIST,
EACCES, …) so bash reports the failure the way a real shell does.
Two contract notes:
execute()only. A host call can suspend the interpreter, andexecuteSynccannot await — it reports the suspension instead of blocking.filesis rejected alongsidefs. Seeding writes through the VFS synchronously, which a promise-returning host can never satisfy.
The host object is the security boundary and is yours to scope (mount root,
allowlist, read-only): paths are normalized before any host call, so traversal
cannot select a path you did not expose, but the sandbox reaches whatever the
object exposes. Reads and writes bypass the in-memory quotas. See
@everruns/bashkit-wasm
for the full method table and error-code list.
Special files and symlinks#
/dev/nullis handled at the interpreter level (not the filesystem), so a custom backend can’t intercept it./dev/urandom//dev/randomreturn bounded random data.- Symlinks are stored but never followed — this closes symlink-escape (TM-ESC-002) and symlink-loop DoS (TM-DOS-011).
Binding parity#
Every language binding exposes the same concepts, so the model is identical from Rust, Python, and Node:
files: { "/path": "content" } # writable in-memory text files
mounts: [{ host_path, vfs_path?, writable? }] # real FS (read-only by default)
readonly_filesystem: bool # deny all VFS mutations after setup
The wasm bindings additionally accept fs — an embedder-supplied filesystem
that replaces the in-memory VFS entirely. See
Host-backed filesystem (JS) above.
See also#
- Security — the boundaries built on top of the VFS.
- Live mounts — attach and detach filesystems at runtime.
- Filesystem namespaces — compose and rebase static mount trees.
- Snapshotting — serialise and restore VFS + shell state.
- Spec:
knowledge/foundations/vfs.md.