bashkit

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.

MountableFs · live mounts ReadOnlyFs · optional OverlayFs · text mounts MountableFs · real mounts base · InMemoryFs or custom
<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:

LayerTraitResponsibility
BackendFsBackendRaw storage operations and failure-atomic mutations
POSIXFileSystem / PosixFsPOSIX-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#

ImplementationPurpose
InMemoryFsDefault (Bash::new()). HashMap-backed, thread-safe, no persistence. Seeds /, /tmp, /home, /home/user, /dev.
OverlayFsCopy-on-write over another filesystem, with whiteout tracking for deletes.
MountableFsMount multiple filesystems at different paths (longest-prefix match). Always the outermost layer, enabling live mounts.
NamespaceFsCompose a static visible tree from rebased filesystem subtrees, with per-mount access and synthetic ancestors.
ReadOnlyFsDelegates 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, and executeSync cannot await — it reports the suspension instead of blocking.
  • files is rejected alongside fs. 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.

  • /dev/null is handled at the interpreter level (not the filesystem), so a custom backend can’t intercept it. /dev/urandom / /dev/random return 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#