Skip to content

Sandbox path enforcement: move the mapping into ShellKit and offer two facades over one core #83

Description

@ronaldmannak

TL;DR

Sandbox enforcement has two separate jobs (interception and confinement). We
can't lean on the OS to do them, so the code must be cooperative. Today the
confinement logic (the virtual↔host path mapping) lives only inside bash's
MountedFileSystem, so every cooperative caller that isn't a bash builtin —
the SwiftPorts CLIs, the JS runtime, SwiftScript — resolves paths in the wrong
space and is either denied or pointed at the host. The fix is to move the
mapping + confinement core into ShellKit, and expose it through two
facades
: a FileSystem protocol (for new Swift code + builtins) and a
resolve() function (for legacy / Foundation / C-backed code like SwiftPorts).


The two problems

A sandbox has to solve two distinct things:

  1. API bypass (interception). Making sure all I/O actually goes through
    the sandbox — FileManager, Data(contentsOf:), fwrite, raw POSIX. The
    risk here is code that reaches a Foundation/C API directly and never touches
    our layer.
  2. Path discipline (confinement). Making sure the file calls that do go
    through the layer can't read or write outside the sandbox boundary.

Two recurring sub-concerns sit under these:

  • realpath must stay internal and never leak. Only the virtual path
    should ever be returned to the script. SwiftBash's MountedFileSystem
    deliberately returns virtual paths from canonicalize, but the
    FileManager-backed layers (SwiftPorts, ShellKit) can surface host paths in
    display output / error messages — a risk to guard, and one that grows the
    moment resolve() starts returning host paths (see Option B below).
  • /tmp — currently the shared host temp dir, tracked separately
    (per-instance isolation issue).

Why the OS can't do it for us

  • Apple's App Sandbox would solve both problems, but there's no public
    API
    to spin up a nested sandbox inside an already-sandboxed app.
  • FSKit (macOS-only) creates virtual volumes. That addresses problem 1
    (interception — I/O to the volume is mediated), but: the volume shows up in
    Finder (may be unwanted), it's macOS-only, and it does not solve
    problem 2 — code can still read/write outside the volume via the app
    sandbox.

Given those limits, one thing is certain: the code must be cooperative.
That's acceptable here, because (a) the bundled commands can be audited/scanned
by agents, and (b) LLM-generated bash can only invoke what the shell shipped
with. Cooperation gives us a tractable handle on problem 2. It does not
fully close problem 1 — a non-cooperative or buggy call that bypasses the
layer is only truly stopped by an OS-level sandbox. So: layer the cooperative
enforcement and keep an OS sandbox underneath when the platform offers one.

Two mechanisms for path discipline (problem 2)

There are two ways for cooperative code to respect the boundary:

Option A — a FileSystem protocol. Code calls fs.readData(path) /
fs.openWrite(path) instead of Foundation.

  • Does not intercept Data(contentsOf:) / fwrite — anything not rewritten
    bypasses it, so all file code must move onto it.
  • Enables new capabilities: in-memory FS, zip-backed FS, etc.
  • Hard to misuse once adopted (typed boundary).

Option B — a resolve() function. Code calls resolve(virtualURL) → hostURL
before each Foundation/C call, and converts any returned path back to virtual.

  • Works with legacy code and C libraries that must have a real path/fd.
  • Error-prone: every call site must remember to resolve; and any path a tool
    returns must be mapped back to virtual, or piping/scripting (and realpath
    hygiene) breaks.

Neither is perfect. Legacy / Foundation / C code needs B. New Swift code can
use A. To support both kinds of caller, you need both
— which we do.

The decision

Move the mapping + confinement core down into ShellKit, and make A and B two
thin facades over that one core:

                ┌──────────────────────────────────────────────┐
                │   ShellKit: ONE mapping + confinement core     │
                │   • virtual↔host translation (mount table)     │
                │   • boundary check + symlink-escape rejection  │
                │   • returns virtual paths outward (no leak)    │
                └───────────────┬───────────────┬───────────────┘
                                │               │
                 Facade A:  FileSystem      Facade B:  resolve()
                 (typed, opt-in)            (URL→host URL, +back)
                                │               │
            bash builtins,  new Swift     SwiftPorts (fd/rg/jq/git),
            in-memory/zip   tools         JS fs.*, SwiftScript, legacy

The critical invariant: a path is translated and confined identically whether
it arrives via the FileSystem protocol or via resolve().
One core, two
doors. That's what prevents today's "two authorities that disagree" bug from
reappearing.

Who uses which

  • Option A (FileSystem) — bash builtins (already do) and any new
    pure-Swift command. Wins: fewer bugs, in-memory backends.
  • Option B (resolve()) — SwiftPorts CLIs and the JS runtime. They already
    funnel everything through Shell.resolve / Shell.currentDirectory, so if
    resolve() returns the translated host URL (and they map returned paths
    back to virtual for display), they get correct, confined paths with no
    per-tool rewrite
    .
  • Not refactored onto A: SwiftPorts. The C-backed ports (libgit2, zlib,
    sqlite, mmap) can't use an async Swift FileSystem at all, and the pure-Swift
    ports are upstream code we don't want to fork. B is the right fit for them.

What's broken today (for context)

  • The mapping lives only in MountedFileSystem
    (Sources/BashInterpreter/FileSystems/MountedFileSystem.swift). ShellKit —
    the shared substrate — has no notion of it; its model is "real disk + URL
    gate."
  • ShellKit's Shell.resolve(_:) resolves relative paths against the virtual
    environment.workingDirectory (e.g. /batch) and returns a URL built from
    that virtual string — with no host translation.
  • SwiftPorts consume it directly (e.g. fd
    FdKit/FdCommand/FdExecutable.swift; rg, jq, git likewise) and then do
    real FileManager/C I/O on that virtual string. The JS bridge calls
    FileManager.default directly (Sources/SwiftJSCore/Modules+FS.swift).
  • The URL gate (Sources/BashInterpreter/API/Sandbox+BashWorkspace.swift) is
    rooted at the real host path but receives virtual paths, so it only
    passes by string coincidence (the /tmp carve-out + identity mount).

Consequences:

  • Workspace access is broken for every non-builtin caller. With
    --sandbox <hostdir> --workspace /batch, virtual /batch ≠ host <hostdir>:
    fd/rg/jq/git/JS resolve to literal /batch (nonexistent) and the
    host-rooted gate denies it. Only bash builtins reach the workspace.
  • /tmp works on Linux by accident (there NSTemporaryDirectory() is
    /tmp); on macOS/iOS bash's /tmp and a port's /tmp diverge.

Scope / where the change lands

  • ShellKit (primary): the mapping + confinement core; resolve(_:) returns
    the translated host URL; Sandbox.authorize(_:) runs on that same host path;
    symlink-escape rejection lives here so it covers FileManager-backed callers.
  • SwiftBash (adapt): MountedFileSystem becomes a consumer of the shared
    core (Facade A); ExecCommand configures the mapping once; the
    Sandbox.bashWorkspace temp carve-out / identity-mount hacks can be retired.
  • SwiftPorts / SwiftScript / JS (adapt lightly, Facade B): keep using
    Shell.resolve, now host-translated; ensure returned/displayed paths are
    mapped back to virtual so realpath hygiene and piping hold.

Out of scope

The pluggable FileSystem backends (in-memory / zip) stay in
BashInterpreter. Only the mapping + confinement core moves to ShellKit, plus
the two facades over it. C-backed tools keep using real paths via resolve().

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions