| type | Subsystem Design | |||
|---|---|---|---|---|
| title | Virtual Filesystem | |||
| description | Filesystem abstraction, path safety, implementations, and sandbox invariants. | |||
| tags |
|
Implemented
Two-layer filesystem abstraction:
| Layer | Trait/Type | Responsibility |
|---|---|---|
| Backend | FsBackend |
Raw storage operations (minimal contract) |
| POSIX | FileSystem / PosixFs |
POSIX-like semantics enforcement |
FsBackend handles raw storage without enforcing POSIX semantics; wrap with
PosixFs for type-safe behavior. See crates/bashkit/src/fs/ for trait
definitions and implementations.
Do you need a custom filesystem?
│
├─ NO → Use InMemoryFs (default with Bash::new())
│
└─ YES → Is your storage simple (key-value, database, cloud)?
│
├─ YES → Implement FsBackend + wrap with PosixFs
│ (POSIX checks are automatic, less code)
│
└─ NO → Implement FileSystem directly
(full control, you handle all checks)
| Approach | Implement | POSIX Checks | Best For |
|---|---|---|---|
FsBackend + PosixFs |
Raw storage only | Automatic | Databases, cloud, key-value stores |
FileSystem directly |
Everything | Manual | Complex caching, custom semantics |
HashMap<PathBuf, FsEntry>, thread-safe viaRwLock; no persistence- Initial directories:
/,/tmp,/home,/home/user,/dev - Special handling for
/dev/null,/dev/urandom,/dev/random - Mount files at build time via
BashBuilder::mount_text()/mount_readonly_text()
- Copy-on-write layer over another FileSystem, whiteout tracking for deletes
- Useful for: temp modifications, testing, isolation
- Uses the same POSIX-rooted path normalization as
InMemoryFson every host. On Windows,\is accepted as an alternate separator, host drive/UNC/device prefixes are discarded, and VFS lookup remains case-sensitive.
- Mount multiple filesystems at different paths
- Longest-prefix matching for nested mounts
- Always used as outermost FS layer for live mount/unmount support
- File and symlink copies and moves may cross mounts; failed moves restore the previous destination before returning the source-side error
- Static visible tree composed from arbitrary
FileSysteminstances - Builder supports absolute targets, source-root rebasing, and read-only or read-write access per mount
- Longest target-prefix wins deterministically for nested mounts
- Missing ancestors and mount points are visible as synthetic directories;
stat()andread_dir()metadata agree through rebasing - File and symlink copies may cross mounts when the destination is writable
- Cross-mount rename returns
ErrorKind::CrossesDevicesinstead of non-atomic copy-delete; cross-mount directory and FIFO copy is unsupported - Visible paths are normalized before mount selection and source-root joining, preventing traversal, source-root escape, nested-mount escape, and read-only bypass
- Object ownership defines lifetime; there is no command/session lifetime mode
- Wraps another
FileSystem, delegates read/stat/list, denies all mutations withPermissionDenied - Useful for inspection-only tool sessions where even in-memory writes to
/tmp, redirections,cp,mv,mkdir,rm, andchmodmust fail - Python
Bash.mount()/BashTool.mount()and Nodemount()accept a per-mountread_only/readOnlyflag (default writable) that wraps the mounted tree inReadOnlyFs; the recorded handle is the wrapped one soreset()replays the protection. Host-enforced: POSIX mode bits remain metadata-only and are not enforced —chmod 444alone does not stop a write. - Python validates the resolved source against the interpreter's live filesystem
before applying
ReadOnlyFs; wrappers must never hide self-mount identity.
- Direct access to a host directory as an
FsBackend - Two modes:
ReadOnly(safe) andReadWrite(dangerous) - Async backend operations use Tokio filesystem APIs and never perform synchronous host filesystem I/O on the runtime worker
RealFs::openis the async-safe constructor and preserves root validation and canonicalization semantics without blocking a current-thread runtime- The synchronous
RealFs::newconstructor is deprecated and retained only as a migration shim - Path traversal prevented via canonicalization + root prefix check
- New-path writes canonicalize the nearest existing ancestor before attaching a missing suffix, blocking symlink escapes through non-existent subpaths
- Windows drive-relative, drive-absolute, UNC, and device-style virtual paths are normalized into the POSIX VFS root before host joining. Existing-path and missing-descendant checks follow symlinks, junctions, and other reparse points before applying component-aware root containment; host lookup itself retains Windows case-insensitive behavior.
- Windows
RealFs::symlink()does not create a host reparse point; it creates an empty file after applying target-containment validation. Pre-existing host symlinks and junctions are supported and containment-checked. - Replacement write, append, and copy stage a sibling file and rename it only after a complete flush, so partial host I/O retains the prior destination; append streams existing bytes into staging instead of buffering the host file
- Copy and rename act on a symlink entry itself instead of dereferencing it
- Builder:
mount_real_readonly[_at](),mount_real_readwrite(); CLI:--mount-ro/--mount-rw(host:vfssyntax for mount point)
Every Bash instance wraps its filesystem stack in a MountableFs, enabling
post-build bash.mount(path, fs) / bash.unmount(path) without rebuilding
the interpreter.
┌──────────────────────────────────┐
│ FollowFs (symlinks, default) │ ← BashBuilder::follow_symlinks(false)
├──────────────────────────────────┤
│ MountableFs (live mounts) │ ← Bash::mount() / unmount()
├──────────────────────────────────┤
│ ReadOnlyFs (optional) │ ← BashBuilder::readonly_filesystem()
├──────────────────────────────────┤
│ OverlayFs (text mounts) │ ← BashBuilder::mount_text()
├──────────────────────────────────┤
│ MountableFs (real mounts) │ ← BashBuilder::mount_real_*_at()
├──────────────────────────────────┤
│ RootFs (system layout, default) │ ← BashBuilder::rootfs(false) to drop
├──────────────────────────────────┤
│ Base filesystem │ ← InMemoryFs or custom
└──────────────────────────────────┘
Decision: on by default (user decision, 2026-10-06). Agents probe
/etc/os-release, which ls, /proc/cpuinfo, head -c N /dev/zero;
an empty root made those fail in ways real Linux never does.
fs/rootfs.rs wraps the session filesystem with a read-only system layer
(an unlimited, read-only InMemoryFs shared process-wide per username +
hostname; startup cost is a cache lookup, ~10 µs). A path is served from the
system layer only when the session filesystem lacks it, so user files,
mounts and custom filesystems always win.
/etc:os-release(ID=bashkit, crate version),passwd(session user at uid 1000,nobody; no root line, soroot:x:0:0in output always means a host leak),group,hostname,hosts,shells,timezone./proc: staticcpuinfo(VIRTUAL_NPROCCPUs),meminfo,version(VIRTUAL_KERNEL_RELEASE, same asuname -r),loadavg,sys/kernel/hostname, andsys/kernel/random/uuid(a fresh v4 UUID from the OS CSPRNG on every read, like Linux),uptime(seconds since this filesystem was built),mounts(onebashkit-vfsroot, asdfshows).selfand1(the shell,$$=$BASHPID= 1) hold identical static copies:status(uid/gid 1000, no capabilities),comm,cmdline,stat,cgroup(0::/),mounts,exe->/bin/bashandfd/{0,1,2}->/dev/std*(symlinks, stored not followed). Noenviron, no per-job pid directories./bin,/usr/bin: one virtual stub per registered builtin that is not shell-only, answered from a shared name set on lookup and never stored as files (materializing them cost ~2 ms perBashbuild). A stub's content starts withSTUB_MARKER; executing it by path (/usr/bin/env,/bin/ls) dispatches the builtin.bashandshalso get stubs (when script execution is enabled) so/bin/sh -c ...and#!/bin/bashre-enter the in-process interpreter.type/which/command -vreport/usr/bin/NAMEfor non-bash builtins via a PATH search (search_path), and keepbuiltinfor the bash 5.2 set (BASH_BUILTIN_NAMES)./root,/dev/zero(reads return 1 MiB of zeros, writes are discarded; generated inInMemoryFs).
Rules: system-only files cannot be written or removed (PermissionDenied);
creating a file in a system dir (/etc/app.conf) creates the parent in the
session fs first, except under /proc, which refuses new entries like Linux
procfs (echo x > /proc/new, mkdir, touch, ln -s fail with No such file or directory) unless the session fs itself holds the parent directory; mkdir /bin shadows the system dir. Listings of /,
/usr, /dev merge both layers. Usage accounting, snapshots and
fs() writes go to the session fs only, so the layer costs no quota and is
never persisted. Restricted shells turn it off with rootfs(false) (the
logic-only ScriptedTool shell does).
NamespaceFs can be supplied as the base when callers need a bounded,
pre-composed tree. The usual outer MountableFs still enables later live mounts.
Handled at the interpreter level, not filesystem. Security-critical: custom
filesystem implementations cannot intercept /dev/null behavior. Path
normalization handles bypass attempts.
Redirection targets resolved at the interpreter level (dev_fd_alias in
interpreter/mod.rs), like /dev/null: > /dev/stderr is >&2,
> /dev/fd/3 is >&3, < /dev/stdin keeps the current stdin, and
exec 3>/dev/stderr dups fd 2. The target is the descriptor's current
target, so 2>&1 >/dev/stderr sends both streams to stdout, as on Linux.
These paths never reach the VFS: writing them as regular files silently
swallowed agents' echo err > /dev/stderr. Same .. normalization as
/dev/null.
Builtin file operands (tee /dev/stderr, cat /dev/stdin, grep x /dev/fd/0) go through StdStreamsFs (fs/std_streams.rs): when an argument
names one of these paths, the builtin's VFS view is wrapped so reads of fd 0
return the command's stdin and writes to fd 1/2 are captured and appended to
the builtin's stdout/stderr. Writes land after the builtin's own output on the
same stream (tee /dev/stdout prints its copies back to back, as through a
pipe). Only fds 0-2: /dev/fd/63 stays a real file for process
substitution.
StdStreamsFs receives the shared ExecutionBudget and stream ceilings.
Both write and append paths use BudgetedBytes: logical size and planned
capacity are admitted before growth; every write, including empty writes,
charges work and checks cancellation/deadlines. Combined builtin/capture
bytes are admitted before collection, and lossy cached text is built directly
into BudgetedString; byte/text leases remain held through redirects and
result hooks. A refused stream admission returns a resource-limit error even
when an outer redirect would discard the stream.
The wrapper's stdin snapshot and returned fd-0 copies reserve retained capacity.
Because FileSystem::read_file returns a plain Vec, the wrapper retains each
copy's lease (and the lease-vector capacity) conservatively until it drops.
Copy from fd 0 borrows admitted stdin. Copies involving another stream admit
temporary source storage from backend metadata before reading and retain it
through the destination write. Canonical system-device read ceilings apply even
when metadata reports zero; source aliases resolve and normalize back to POSIX
VFS spelling before that admission, including on Windows.
Ordinary copies still delegate to the backend.
Backend metadata/read-size correctness remains the filesystem contract.
Handled in InMemoryFs: every read returns DEV_ZERO_READ_BYTES (1 MiB)
of zeros, writes and appends are discarded like /dev/null. The directory
entry comes from RootFs so base file counts are unchanged.
Handled at filesystem level: return 8192 bytes of random data per read (bounded to prevent memory growth).
Metadata.size must be correct for ls -l, stat, test -s:
- Regular files: actual content length
- Empty files: 0
- Directories: always 0
- Both
stat()andread_dir()must return consistent sizes
All FileSystem implementations MUST enforce:
- No duplicate names (file and dir can't share path)
- Type-safe operations (
write_fileon dir → error) - Parent directory requirement (exception:
mkdir -p) - Failed write/copy/rename leaves contents, entry types, and usage unchanged
- Cross-backend rename restores the destination on failure, or is rejected before mutation when that rollback contract cannot be supported
The VFS is Unix-style on every host: normalize_path rebuilds paths from their
components with /, and every backend runs it on entry. Host path syntax
(drive-relative, drive-absolute, UNC, device) is discarded there
(THREAT[TM-ESC-033]).
std::path::PathBuf::join/push break that rule on Windows: they append
MAIN_SEPARATOR (\) whenever the buffer does not already end in a separator,
so /d/proj + src becomes /d/proj\src. The result still resolves (the
backends normalize \ away), but builtins print these paths verbatim, so
grep -r and du emitted mixed separators such as /d/proj\src\main.rs, and
the read-error path in tail/rev/tac/shuf/sort/uniq/wc did the same
(issue #2425).
InMemoryFs::rename was worse than cosmetic: its directory fan-out wrote
to.join(suffix) straight into the entry map, so on Windows the renamed
children were keyed \-style and every normalized lookup afterwards missed
them.
The same host/VFS mismatch has a second shape: Path::is_absolute() is false
on Windows for a VFS-absolute path like /etc/passwd, because there is no
drive prefix. Two security guards tested it — the RealFs absolute-symlink-target
guard and the tar extraction traversal guard — so neither fired on Windows.
Containment held in both cases via their later checks (canonicalize-and-contain
for RealFs, starts_with(extract_base) for tar), so this was latent fragility
rather than an escape, but the guards now test has_root(), which is true for a
/-rooted path on every host.
Decision: VFS paths are built with crate::fs::vfs_join, never Path::join
(or PathBuf::push), and a guard that asks "is this path absolute?" about a VFS
path asks has_root(), never is_absolute().
It joins with / on every host, keeps Path::join semantics (an absolute
child replaces the base) and leaves ./.. for the backends to normalize.
It is re-exported as bashkit::vfs_join, since custom FileSystem
implementations have the same obligation. Two guards keep it that way:
builtins::tests::no_host_separator_joins_on_vfs_paths— static scan of every workspace crate'ssrc/for thecwd.join(and.join(&entry.name)shapes, with a// host-join-ok: <reason>escape hatch.windows_containment_host_path_hazards_are_still_real— a Windows-only test pinning the platform behavior these rules exist for (Path::joininserting\,is_absolute()being false for/etc/passwd,\and/comparing equal). If std or the platform ever changed, it says so before the rules start looking arbitrary.windows_containment_*unit tests next tovfs_join,grep,du,wcandInMemoryFs::rename, which the Windows CI job runs (cargo test -p bashkit --lib --features realfs windows_containment).
Host paths are the exception and keep using Path::join: RealFs joining
under its host root, and HostMounts mapping a VFS path back to a host path.
There the host separator is the correct one.
tests/support/filesystem_security_conformance.rs is the shared, private
certification helper. The consolidated integration suite runs it against
InMemoryFs, OverlayFs, MountableFs, and read-write NamespaceFs; the
feature-isolated RealFs job runs the same helper through PosixFs<RealFs>.
Wrapper-specific cases additionally certify ReadOnlyFs, mount boundaries,
symlink identity, quota rollback, normalized errors, type-conflict rollback,
archive preflight, and injected partial-write failures. This remains test-only
instead of expanding the public API; external FileSystem authors follow the
trait's atomicity contract.
Interpreter::expand_glob (interpreter/glob.rs) globs every path
component, not just the trailing one, so mounted trees are fully addressable:
cat /skills/*/SKILL.md works against a read-only skills mount the same way it
does in bash.
Rules that fall out of the per-component walk:
- Non-final components only match directories, a regular file sharing the prefix is never descended into.
- The dotfile rule is applied per component: a component matches names starting
with
.only whendotglobis set or that component literally starts with.. - Lookup uses the normalized absolute path while the emitted word is rebuilt
from the caller's spelling, so
./and../prefixes survive expansion. - No match anywhere in the walk falls back to the literal pattern, or to nothing
under
nullglob, same as a trailing-component miss. - A trailing
/keeps only directories and stays on every match (d/*/givesd/e/). **withglobstaris a component of the same walk: it matches zero or more directories (files too when last), visible ones unlessdotglob. As the last component its zero-directory match isd/after a literal prefix (d/**), plaindafter a glob (*/**), and nothing for a bare**; duplicates from several**collapse. The recursive walk is iterative, depth-capped bymax_path_depthand stops at the candidate cap.- THREAT[TM-DOS-095]: the candidate set multiplies per component, so patterns
deeper than
FsLimits::max_path_depthare rejected and the live candidate set is capped atFsLimits::max_file_count.
Known gap: backslash-escaped metacharacters (echo /skills/\*) still expand
instead of staying literal, the backslash is dropped before pathname expansion
runs. Pre-dates per-component expansion and affects trailing components too.
Decision (2026-10-06): follow symlinks like Linux, on by default. Agents and
build tools (node_modules/.bin, current -> releases/v2) expect it; the
old store-only links made cat link fail with "No such file".
FollowFs (fs/follow.rs) is the outermost layer, so resolution sees the
whole composed namespace (mounts, overlays, rootfs). Inner layers never follow:
their stat is an lstat, and they refuse to read or write through a link.
- Opening a path (
read_file,write_file,read_dir,stat,exists,copy,chmod,mkdir -p) follows links in every component.lstat,read_link,remove,rename,symlinkand plainmkdirfollow only the parent components.FileSystem::lstatis a trait method whose default forwards tostat; wrappers that sit aboveFollowFs(ExecutionFileSystem,StdStreamsFs) forward it explicitly. - Builtins that describe a link use
lstat:test -L/-h,lsoperands under-l/-d/-F(and-> targetin long lines),stat(unless-L),file,rm,find(-Pdefault), PythonPath.is_symlink. - Targets are VFS paths: absolute from the VFS root, relative to the link's
directory,
..clamped at/(TM-ESC-002). Writes through a dangling link create the target. - Recursive
findandgrepsharefs::canonicalize, resolving links throughlstat/read_linkwith the same 40-hop cap. It uses VFS joins and root-aware symlink targets on every host. Walkers compare resolved ancestor paths to reject directory cycles; the per-lookup cap alone cannot bound fan-out. - At most 40 links per lookup, then "Too many levels of symbolic links"
(TM-DOS-011).
existsis false for dangling and looping links. - Fast path: ops run on the inner fs first and resolve only on a
link-related error (never on permission or limit errors, so real-mount
containment errors stay as they are) or when
statreports a link. Writes cost one extralstaton the final component.
realfs mounts are recorded as a HostMounts table on the Bash instance:
Bash::host_mounts() lists them, Bash::host_path_for(vfs) maps a VFS path
back to the host path backing it. Mount points and lookup paths use the shared
POSIX VFS normalizer before longest-prefix selection and host joining. Thus
./.. cannot select a mount under their unnormalized spelling or survive in
the suffix passed to the host OS (TM-ESC-034).
Decision: published because embedders that bridge commands to host processes
must map a VFS cwd to a host directory to spawn in, and hand-rolling it is a
trap, a naive string prefix match puts /workspace2 inside /workspace.
HostMounts::resolve matches whole path components and prefers the longest
match, so a specific mount beats a root overlay.
Rules:
- Only mounts that actually applied are recorded. A path skipped by the
allowlist or a failed
canonicalizeis absent, so the table never reports a host path for something that was never mounted. host_pathis the canonicalized host directory, which may differ from the path passed to the builder (symlinks,/tmp→/private/tmpon macOS).- A root overlay mount (
mount_real_readonlywith no VFS path) is recorded at/, so it acts as the catch-all. Without one, an uncovered path resolves toNone. Nonemeans "no mount covers this". Callers must treat it as an error rather than falling back to a default directory: spawning a host process in the wrong directory is worse than refusing to spawn it.HostMounts::newlets an embedder build the table up front. ACommandResolveris passed into the builder, so builtins it produces cannot callBash::host_mounts()on an instance that does not exist yet; sharing oneArcbetween the resolver and themount_real_*calls makes both agree by construction.
Tests: crates/bashkit/tests/integration/host_mounts_tests.rs.
All language bindings must expose the same filesystem concepts:
files: { "/path": "content" } # text files (writable, in-memory)
mounts: [{ host_path, vfs_path?, writable? }] # real FS (read-only by default)
readonly_filesystem: bool # deny all VFS mutations after setup
FileSystem() # standalone in-memory filesystem
FileSystem.real(host_path, writable=false) # standalone real filesystem
# JS requires allowed_mount_paths
Runtime methods:
- host-path mount:
mount(host_path, vfs_path, writable=false) - filesystem mount:
mount(vfs_path, filesystem) unmount(vfs_path)
Native-extension interop is binding-specific but must preserve bashkit-owned filesystem objects when crossing the language runtime boundary:
- Python:
FileSystem.from_capsule(capsule),FileSystem.to_capsule() - Node.js:
FileSystem.fromExternal(external),FileSystem.toExternal()
Interop contract:
- The native Rust contract lives at
bashkit::interop::fsbehind theinteropcargo feature - The cross-addon payload must be a versioned
repr(C)handle + vtable - Do not expose
Arc<dyn FileSystem>or any addon-private Rust layout - Python capsules carry the stable owned handle directly
- Node interop values carry stable handle bytes plus an owner token
- On import, bashkit reconstructs a binding-owned
FileSystemwrapper from the stable handle payload
Safety: real mounts are read-only by default. Text files are writable
(sandboxed) unless the final session is wrapped with readonly_filesystem.
- Real filesystem with chroot: rejected, requires root, not portable, no WASM.
- tokio::fs wrapper: rejected, always hits real FS, can't isolate or virtualize.
- Bashkit Architecture, how the VFS is owned and shared
- Threat Model, path-escape threats the sandbox invariants answer
- Git Support, Git operations layered on the VFS
- SQLite Builtin, VfsIO backend bridging SQLite onto the VFS
- Python Package, binding-side mount API parity