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:
- 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.
- 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().
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
FileSystemprotocol (for new Swift code + builtins) and aresolve()function (for legacy / Foundation / C-backed code like SwiftPorts).The two problems
A sandbox has to solve two distinct things:
the sandbox —
FileManager,Data(contentsOf:),fwrite, raw POSIX. Therisk here is code that reaches a Foundation/C API directly and never touches
our layer.
through the layer can't read or write outside the sandbox boundary.
Two recurring sub-concerns sit under these:
realpathmust stay internal and never leak. Only the virtual pathshould ever be returned to the script. SwiftBash's
MountedFileSystemdeliberately returns virtual paths from
canonicalize, but theFileManager-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
API to spin up a nested sandbox inside an already-sandboxed app.
(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
FileSystemprotocol. Code callsfs.readData(path)/fs.openWrite(path)instead of Foundation.Data(contentsOf:)/fwrite— anything not rewrittenbypasses it, so all file code must move onto it.
Option B — a
resolve()function. Code callsresolve(virtualURL) → hostURLbefore each Foundation/C call, and converts any returned path back to virtual.
returns must be mapped back to virtual, or piping/scripting (and
realpathhygiene) 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:
The critical invariant: a path is translated and confined identically whether
it arrives via the FileSystem protocol or via
resolve(). One core, twodoors. That's what prevents today's "two authorities that disagree" bug from
reappearing.
Who uses which
FileSystem) — bash builtins (already do) and any newpure-Swift command. Wins: fewer bugs, in-memory backends.
resolve()) — SwiftPorts CLIs and the JS runtime. They alreadyfunnel everything through
Shell.resolve/Shell.currentDirectory, so ifresolve()returns the translated host URL (and they map returned pathsback to virtual for display), they get correct, confined paths with no
per-tool rewrite.
sqlite, mmap) can't use an async Swift
FileSystemat all, and the pure-Swiftports are upstream code we don't want to fork. B is the right fit for them.
What's broken today (for context)
MountedFileSystem(
Sources/BashInterpreter/FileSystems/MountedFileSystem.swift). ShellKit —the shared substrate — has no notion of it; its model is "real disk + URL
gate."
Shell.resolve(_:)resolves relative paths against the virtualenvironment.workingDirectory(e.g./batch) and returns a URL built fromthat virtual string — with no host translation.
fd→FdKit/FdCommand/FdExecutable.swift;rg,jq,gitlikewise) and then doreal
FileManager/C I/O on that virtual string. The JS bridge callsFileManager.defaultdirectly (Sources/SwiftJSCore/Modules+FS.swift).Sources/BashInterpreter/API/Sandbox+BashWorkspace.swift) isrooted at the real host path but receives virtual paths, so it only
passes by string coincidence (the
/tmpcarve-out + identity mount).Consequences:
--sandbox <hostdir> --workspace /batch, virtual/batch≠ host<hostdir>:fd/rg/jq/git/JS resolve to literal/batch(nonexistent) and thehost-rooted gate denies it. Only bash builtins reach the workspace.
/tmpworks on Linux by accident (thereNSTemporaryDirectory()is/tmp); on macOS/iOS bash's/tmpand a port's/tmpdiverge.Scope / where the change lands
resolve(_:)returnsthe translated host URL;
Sandbox.authorize(_:)runs on that same host path;symlink-escape rejection lives here so it covers FileManager-backed callers.
MountedFileSystembecomes a consumer of the sharedcore (Facade A);
ExecCommandconfigures the mapping once; theSandbox.bashWorkspacetemp carve-out / identity-mount hacks can be retired.Shell.resolve, now host-translated; ensure returned/displayed paths aremapped back to virtual so
realpathhygiene and piping hold.Out of scope
The pluggable
FileSystembackends (in-memory / zip) stay inBashInterpreter. Only the mapping + confinement core moves to ShellKit, plusthe two facades over it. C-backed tools keep using real paths via
resolve().