Skip to content

Latest commit

 

History

History
486 lines (414 loc) · 25.6 KB

vfs.md

File metadata and controls

486 lines (414 loc) · 25.6 KB
type Subsystem Design
title Virtual Filesystem
description Filesystem abstraction, path safety, implementations, and sandbox invariants.
tags
bashkit
filesystem
sandbox

Virtual Filesystem Design

Status

Implemented

Decision

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.

Which Trait Should I Implement?

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

Implementations

InMemoryFs

  • HashMap<PathBuf, FsEntry>, thread-safe via RwLock; 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()

OverlayFs

  • 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 InMemoryFs on every host. On Windows, \ is accepted as an alternate separator, host drive/UNC/device prefixes are discarded, and VFS lookup remains case-sensitive.

MountableFs

  • 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

NamespaceFs

  • Static visible tree composed from arbitrary FileSystem instances
  • 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() and read_dir() metadata agree through rebasing
  • File and symlink copies may cross mounts when the destination is writable
  • Cross-mount rename returns ErrorKind::CrossesDevices instead 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

ReadOnlyFs

  • Wraps another FileSystem, delegates read/stat/list, denies all mutations with PermissionDenied
  • Useful for inspection-only tool sessions where even in-memory writes to /tmp, redirections, cp, mv, mkdir, rm, and chmod must fail
  • Python Bash.mount() / BashTool.mount() and Node mount() accept a per-mount read_only / readOnly flag (default writable) that wraps the mounted tree in ReadOnlyFs; the recorded handle is the wrapped one so reset() replays the protection. Host-enforced: POSIX mode bits remain metadata-only and are not enforced — chmod 444 alone 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.

RealFs (Optional, realfs feature)

  • Direct access to a host directory as an FsBackend
  • Two modes: ReadOnly (safe) and ReadWrite (dangerous)
  • Async backend operations use Tokio filesystem APIs and never perform synchronous host filesystem I/O on the runtime worker
  • RealFs::open is the async-safe constructor and preserves root validation and canonicalization semantics without blocking a current-thread runtime
  • The synchronous RealFs::new constructor 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:vfs syntax for mount point)

Live Mount/Unmount

Every Bash instance wraps its filesystem stack in a MountableFs, enabling post-build bash.mount(path, fs) / bash.unmount(path) without rebuilding the interpreter.

FS Layering Stack

┌──────────────────────────────────┐
│  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
└──────────────────────────────────┘

Root Filesystem Layout (RootFs)

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, so root:x:0:0 in output always means a host leak), group, hostname, hosts, shells, timezone.
  • /proc: static cpuinfo (VIRTUAL_NPROC CPUs), meminfo, version (VIRTUAL_KERNEL_RELEASE, same as uname -r), loadavg, sys/kernel/hostname, and sys/kernel/random/uuid (a fresh v4 UUID from the OS CSPRNG on every read, like Linux), uptime (seconds since this filesystem was built), mounts (one bashkit-vfs root, as df shows). self and 1 (the shell, $$ = $BASHPID = 1) hold identical static copies: status (uid/gid 1000, no capabilities), comm, cmdline, stat, cgroup (0::/), mounts, exe -> /bin/bash and fd/{0,1,2} -> /dev/std* (symlinks, stored not followed). No environ, 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 per Bash build). A stub's content starts with STUB_MARKER; executing it by path (/usr/bin/env, /bin/ls) dispatches the builtin. bash and sh also get stubs (when script execution is enabled) so /bin/sh -c ... and #!/bin/bash re-enter the in-process interpreter. type/which/ command -v report /usr/bin/NAME for non-bash builtins via a PATH search (search_path), and keep builtin for the bash 5.2 set (BASH_BUILTIN_NAMES).
  • /root, /dev/zero (reads return 1 MiB of zeros, writes are discarded; generated in InMemoryFs).

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.

Special Device Files

/dev/null

Handled at the interpreter level, not filesystem. Security-critical: custom filesystem implementations cannot intercept /dev/null behavior. Path normalization handles bypass attempts.

/dev/stdin, /dev/stdout, /dev/stderr, /dev/fd/N

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.

/dev/zero

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.

/dev/urandom and /dev/random

Handled at filesystem level: return 8192 bytes of random data per read (bounded to prevent memory growth).

File Size Reporting

Metadata.size must be correct for ls -l, stat, test -s:

  • Regular files: actual content length
  • Empty files: 0
  • Directories: always 0
  • Both stat() and read_dir() must return consistent sizes

POSIX Semantics Contract

All FileSystem implementations MUST enforce:

  1. No duplicate names (file and dir can't share path)
  2. Type-safe operations (write_file on dir → error)
  3. Parent directory requirement (exception: mkdir -p)
  4. Failed write/copy/rename leaves contents, entry types, and usage unchanged
  5. Cross-backend rename restores the destination on failure, or is rejected before mutation when that rollback contract cannot be supported

Path Separators Are Always /

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's src/ for the cwd.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::join inserting \, 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 to vfs_join, grep, du, wc and InMemoryFs::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.

Security Conformance Certification

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.

Pathname Expansion Against the VFS

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 when dotglob is 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/*/ gives d/e/).
  • ** with globstar is a component of the same walk: it matches zero or more directories (files too when last), visible ones unless dotglob. As the last component its zero-directory match is d/ after a literal prefix (d/**), plain d after a glob (*/**), and nothing for a bare **; duplicates from several ** collapse. The recursive walk is iterative, depth-capped by max_path_depth and stops at the candidate cap.
  • THREAT[TM-DOS-095]: the candidate set multiplies per component, so patterns deeper than FsLimits::max_path_depth are rejected and the live candidate set is capped at FsLimits::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.

Symlink Handling

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, symlink and plain mkdir follow only the parent components. FileSystem::lstat is a trait method whose default forwards to stat; wrappers that sit above FollowFs (ExecutionFileSystem, StdStreamsFs) forward it explicitly.
  • Builtins that describe a link use lstat: test -L/-h, ls operands under -l/-d/-F (and -> target in long lines), stat (unless -L), file, rm, find (-P default), Python Path.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 find and grep share fs::canonicalize, resolving links through lstat/read_link with 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). exists is 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 stat reports a link. Writes cost one extra lstat on the final component.

Host Mount Table (HostMounts)

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 canonicalize is absent, so the table never reports a host path for something that was never mounted.
  • host_path is the canonicalized host directory, which may differ from the path passed to the builder (symlinks, /tmp → /private/tmp on macOS).
  • A root overlay mount (mount_real_readonly with no VFS path) is recorded at /, so it acts as the catch-all. Without one, an uncovered path resolves to None.
  • None means "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::new lets an embedder build the table up front. A CommandResolver is passed into the builder, so builtins it produces cannot call Bash::host_mounts() on an instance that does not exist yet; sharing one Arc between the resolver and the mount_real_* calls makes both agree by construction.

Tests: crates/bashkit/tests/integration/host_mounts_tests.rs.

Binding API Parity

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::fs behind the interop cargo 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 FileSystem wrapper 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.

Alternatives Considered

  • Real filesystem with chroot: rejected, requires root, not portable, no WASM.
  • tokio::fs wrapper: rejected, always hits real FS, can't isolate or virtualize.

See also