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#