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.