Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,9 @@ splitting big types — `Shell` lives in `API/Shell.swift` plus ~15
Substring assertions hide whitespace bugs and ordering issues
that exact-string assertions catch.
- **Sandbox tests use `MountedFileSystem`** with a real temp dir
mounted at `/batch` and host `/tmp` mounted at `/tmp` (the same
shape `swift-bash exec --sandbox` installs). Clean up the temp
dir in `defer`.
mounted at `/batch` and a per-instance temp dir mounted at `/tmp`
(the same shape `swift-bash exec --sandbox` installs). Clean up
the temp dirs in `defer`.
- **Never re-test the parser from interpreter tests.** Parser
behaviour belongs in `BashSyntax` tests; interpreter tests
exercise execution against pre-parsed shapes.
Expand Down
55 changes: 40 additions & 15 deletions Docs/Sandboxing.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,17 @@ axes at once:

- `fileSystem = MountedFileSystem` with mounts on real disk:
- virtual `/batch` (or `--workspace`) → host `PATH` (read-write)
- virtual `/tmp` → host `NSTemporaryDirectory()` (read-write)
- the host's real temp dir → itself, when it isn't `/tmp` (so
`$TMPDIR/foo` and `/tmp/foo` reach the same files on macOS,
iOS, Windows)
- `TMPDIR = NSTemporaryDirectory()` in the script's environment
- `sandbox = Sandbox.bashWorkspace(workspace: workspace)` (URL gate for SwiftPorts CLIs)
- virtual `/tmp` → a per-instance `swiftbash-<UUID>` dir created
under host `NSTemporaryDirectory()` (read-write), removed when
the script ends — concurrent instances and other host processes
can't see each other's scratch (#82)
- the per-instance dir → itself (so `$TMPDIR/foo` and `/tmp/foo`
reach the same files on every platform)
- `TMPDIR = <per-instance dir>` (host spelling) in the script's
environment
- `sandbox = Sandbox.bashWorkspace(workspace:temporaryDirectory:)`
(URL gate for SwiftPorts CLIs, accepting the workspace and the
per-instance temp dir)
- `networkConfig = nil` (deny-all)
- `hostInfo = .synthetic`
- Process table is always virtual (no flag needed)
Expand All @@ -126,27 +131,46 @@ $ swift-bash exec --sandbox /tmp/work script.sh

The script sees `/batch` as its workspace, can `cd /batch && ls`,
write files there (and they persist at `/tmp/work` on the host), and
can use `/tmp` as scratch — writes through either virtual path land
on real disk so FileManager-backed callers (SwiftPorts CLIs,
SwiftScript bridges) see them immediately. The script can't reach
`/Users/`, `~/Documents`, `/etc/passwd`, or anything else on the host.
It can't make network requests. `whoami` says "user". `hostname` says
"sandbox". Issues #48 / #49.
can use `/tmp` as scratch — writes land on real disk in the
instance's own temp dir so FileManager-backed callers (SwiftPorts
CLIs, SwiftScript bridges) see them immediately via `$TMPDIR`-spelled
paths. The script can't reach `/Users/`, `~/Documents`,
`/etc/passwd`, or anything else on the host. It can't make network
requests. `whoami` says "user". `hostname` says "sandbox".
Issues #48 / #49 / #82.

Until the path-mapping core lands in ShellKit (#83), bash builtins
and FileManager-backed callers agree on temp paths through the
**host** spelling only: bash scripts may say `/tmp/foo` or
`$TMPDIR/foo` interchangeably, but a SwiftPorts CLI / SwiftScript /
JS call must be handed `$TMPDIR/foo` — a literal `/tmp/foo` argument
to those is denied, because they would do real I/O on the host's
shared `/tmp` instead of the instance's temp dir.

For embedders not using the CLI, mirror the same setup:

```swift
let workspace = NSHomeDirectory() + "/Documents/scratch"
// Per-instance scratch dir backing virtual /tmp — create it up
// front, remove it when the session ends (#82).
let tempHost = URL(fileURLWithPath: NSTemporaryDirectory(),
isDirectory: true)
.appendingPathComponent("myapp-\(UUID().uuidString)",
isDirectory: true)
try FileManager.default.createDirectory(
at: tempHost, withIntermediateDirectories: true)
let shell = Shell(
environment: {
var env = Environment.empty()
env.workingDirectory = "/batch"
env["TMPDIR"] = tempHost.path
return env
}(),
fileSystem: MountedFileSystem(
mounts: [
.init(virtual: "/batch", host: workspace),
.init(virtual: "/tmp", host: NSTemporaryDirectory())
.init(virtual: "/tmp", host: tempHost.path),
.init(virtual: tempHost.path, host: tempHost.path)
],
backing: RealFileSystem())
)
Expand All @@ -173,8 +197,9 @@ state changes, not memory-level exploitation of the runtime itself.

**Inside the mounts**, writes persist to real disk (the CLI's choice
for `--sandbox`). The workspace is wherever the user pointed
`--sandbox`; `/tmp` resolves to `NSTemporaryDirectory()` on the host,
shared with other processes per the platform's convention.
`--sandbox`; `/tmp` resolves to a per-instance dir under
`NSTemporaryDirectory()` that no other sandbox instance or host
process shares, and that is removed when the script ends (#82).

**Out of scope:**
- Memory-corruption attacks against Swift runtime / Foundation.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,9 @@ differently from a native shell. None of these are bugs:
- **The filesystem is a chroot-style mount table.** `/` is the
read-only sandbox root; the workspace (`/batch` under `--sandbox`,
`/home` in document apps) is writable; `/tmp` is writable scratch on
the host temp dir; everything else returns *No such file or
directory*. `mount` prints the table.
a per-instance dir under the host temp dir (removed when the script
ends); everything else returns *No such file or directory*. `mount`
prints the table.
- **Identity is synthetic by default** — `whoami` → `user`, `id` →
`uid=1000(user) gid=1000(users)`, and `uname` a Darwin-flavoured
kernel string over a generic Unix layout. `stat` / `ls -l` report
Expand Down
14 changes: 6 additions & 8 deletions Sources/BashCommandKit/Commands/MountCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,12 @@ public struct MountCommand: ParsableBashCommand {
for mount in table {
// Resolve the mountpoint to its virtual spelling before
// printing. Some mounts exist only as a real-path alias: the
// CLI mounts `$TMPDIR`'s host path (e.g. `/var/folders/…/T`)
// next to `/tmp` so `$TMPDIR` resolves, and printing that
// virtual verbatim would leak a host path. When another mount
// exposes this one's `virtual` (itself a host path) under a
// cleaner name, print that name; the de-dup below then folds
// the alias into `/tmp`. A genuine `/tmp → /tmp` mount (Linux,
// where `NSTemporaryDirectory()` normalises to `/tmp`) has no
// such alias and stays visible.
// CLI mounts `$TMPDIR`'s host path (the per-instance
// `…/swiftbash-<UUID>` dir) next to `/tmp` so `$TMPDIR`
// resolves, and printing that virtual verbatim would leak a
// host path. When another mount exposes this one's `virtual`
// (itself a host path) under a cleaner name, print that
// name; the de-dup below then folds the alias into `/tmp`.
let mountPoint = table.first {
$0.host == mount.virtual && $0.virtual != mount.virtual
}?.virtual ?? mount.virtual
Expand Down
113 changes: 63 additions & 50 deletions Sources/BashInterpreter/API/Sandbox+BashWorkspace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,50 @@ import ShellKit
extension ShellKit.Sandbox {

/// Build the URL gate the SwiftBash sandbox CLI pairs with the
/// real-disk ``MountedFileSystem`` mounting `workspace` and the
/// platform's temp dir.
/// real-disk ``MountedFileSystem`` mounting `workspace` and a
/// per-instance temp dir.
///
/// The gate accepts two virtual roots: the `workspace` mount and
/// the host's real temp dir (``Foundation.NSTemporaryDirectory()``),
/// addressable as either `/tmp` (the Unix-y virtual path scripts
/// expect) or its true platform path. Bash builtins write through
/// `Shell.fileSystem` to the same host directories; SwiftPorts CLIs
/// (fd, rg, jq, …) and the SwiftScript interpreter resolve the
/// same paths through ``Shell/currentDirectory`` /
/// The gate accepts two roots: the `workspace` mount and the
/// sandbox's temp dir, in the dir's **host** spelling (the same
/// value `$TMPDIR` carries inside the sandbox). Bash builtins
/// write through `Shell.fileSystem` to the same host directories;
/// SwiftPorts CLIs (fd, rg, jq, …) and the SwiftScript interpreter
/// resolve the same paths through ``Shell/currentDirectory`` /
/// ``Shell/resolve(_:)`` and authorise them here. Because both
/// sides land on the same real-disk files, `cd /tmp; mkdir foo;
/// echo > foo/x; fd x foo` finds the file (#48 / #55) — and the
/// same works on hosts where `/tmp` doesn't exist (Windows) or
/// isn't writable (Android emulator) because the host backing is
/// always the platform's real temp dir (#58).
/// sides land on the same real-disk files, `cd "$TMPDIR"; mkdir
/// foo; echo > foo/x; fd x foo` finds the file (#48 / #55) — and
/// the same works on hosts where `/tmp` doesn't exist (Windows) or
/// isn't writable (Android emulator) because the backing always
/// lives under the platform's real temp root (#58).
///
/// The gate does NOT accept the virtual `/tmp` spelling: callers
/// that reach it carry no virtual→host translation (that lands
/// with #83), so a literal `/tmp/...` here would do real I/O on
/// the host's `/tmp` — a directory shared with every other
/// process — instead of this sandbox's temp dir (#82). Until #83,
/// FileManager-backed callers reach scratch via `$TMPDIR`-spelled
/// paths only; bash builtins use `/tmp` as usual through the
/// mount table.
///
/// The temp carve-out checks **both** the unresolved standardised
/// path (what the script asked for) and the symlink-resolved path
/// (what `FileManager` would actually read) — without the second
/// check a script's `ln -s /etc/passwd /tmp/p` would let
/// check a script's `ln -s /etc/passwd "$TMPDIR/p"` would let
/// FileManager-backed bridges follow the link out of the sandbox.
/// The bash-side `MountedFileSystem.canonicalGate` already rejects
/// this; the URL gate has to match.
///
/// The returned sandbox's `temporaryDirectory` is the host's real
/// temp dir (the same value `$TMPDIR` carries inside the sandbox)
/// so ``Shell/temporaryDirectory`` agrees with bash, scripts using
/// `$TMPDIR`, and FileManager-backed callers.
/// - Parameter temporaryDirectory: the host dir reported as
/// ``Shell/temporaryDirectory``. Defaults to
/// `NSTemporaryDirectory()` (the CLI's choice); an embedder that
/// isolates `/tmp` per session (e.g. a per-document subdir) passes
/// its own. The `/tmp` carve-out still covers it as long as it
/// sits under one of the accepted temp prefixes.
/// ``Shell/temporaryDirectory`` and accepted by the temp
/// carve-out; it should exist when this factory runs so its
/// symlink-resolved spelling is computed correctly. The CLI
/// passes its per-instance `swiftbash-<UUID>` dir (#82).
/// Defaults to `NSTemporaryDirectory()` — note that the default
/// shares the platform temp root with every other process and
/// sandbox instance.
/// - Parameter authorizeNetwork: embedder policy for non-file URLs
/// (e.g. routing host access through a permission prompt) while
/// still reusing this file-URL gate — `/tmp` carve-out included.
/// still reusing this file-URL gate — temp carve-out included.
/// When `nil`, non-file URLs are denied (the base gate's
/// `allowedHosts: []`), matching the CLI's offline default.
public static func bashWorkspace(
Expand All @@ -53,6 +59,7 @@ extension ShellKit.Sandbox {
isDirectory: true)
let tmpURL = temporaryDirectory
?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let tempPrefixes = Self.temporaryPrefixes(for: tmpURL)
let baseSandbox = ShellKit.Sandbox.rooted(
at: workspaceURL,
allowedHosts: [])
Expand Down Expand Up @@ -84,39 +91,44 @@ extension ShellKit.Sandbox {
try await baseSandbox.authorize(url)
} catch let denial as ShellKit.Sandbox.Denial {
// File URL denied by the workspace root — allow it
// only if it lands under an accepted temp prefix.
// only if it lands under the sandbox's temp dir.
// Compare against `standardizedFileURL` so
// `/tmp/./foo` and `/tmp/foo` agree.
// `$TMPDIR/./foo` and `$TMPDIR/foo` agree.
let unresolved = url.standardizedFileURL.path
guard Self.pathIsInTemp(unresolved) else { throw denial }
guard Self.pathIsInTemp(unresolved,
prefixes: tempPrefixes)
else { throw denial }
// Canonical (symlink-resolved) path must stay in a
// temp prefix too — defends against a bash-staged
// `ln -s /etc/passwd /tmp/p` escape.
// `ln -s /etc/passwd "$TMPDIR/p"` escape.
let resolved = url.resolvingSymlinksInPath()
.standardizedFileURL.path
if !Self.pathIsInTemp(resolved) { throw denial }
if !Self.pathIsInTemp(resolved,
prefixes: tempPrefixes) {
throw denial
}
}
})
}

/// Path prefixes the bash sandbox's URL gate treats as "inside
/// the temp mount":
///
/// - `/tmp` — the Unix-y virtual path scripts use.
/// - `/private/tmp` — what `/tmp` symlink-resolves to on macOS.
/// - The platform's real temp dir (`NSTemporaryDirectory()`),
/// plus its symlink-resolved spelling, so callers using
/// `$TMPDIR` (which carries the same real path) also pass.
///
/// Computed once at first use; Foundation's temp dir is stable
/// for the lifetime of the process.
private static let temporaryPrefixes: [String] = {
var prefixes: [String] = ["/tmp", "/private/tmp"]
let raw = NSTemporaryDirectory()
let normalized = (raw as NSString).standardizingPath
/// Path prefixes the URL gate treats as "inside this sandbox's
/// temp dir": the dir's verbatim spelling, its normalised one,
/// and its symlink-resolved one (macOS' temp root lives behind
/// the `/var → /private/var` link, so `FileManager`'s canonical
/// paths carry the `/private` form while `$TMPDIR` carries the
/// bare one). The verbatim spelling matters because
/// `standardizingPath` may rewrite it — swift-corelibs-foundation
/// follows symlinks, Darwin strips `/private` — while callers
/// hand the gate paths built from the `$TMPDIR` value exactly as
/// the embedder spelled it. Derived per instance — a sibling
/// sandbox's temp dir under the same platform root never
/// matches (#82).
private static func temporaryPrefixes(for tempDir: URL) -> [String] {
var prefixes: [String] = []
let normalized = (tempDir.path as NSString).standardizingPath
let resolved = URL(fileURLWithPath: normalized)
.resolvingSymlinksInPath().path
Comment thread
odrobnik marked this conversation as resolved.
for path in [normalized, resolved] {
for path in [tempDir.path, normalized, resolved] {
let stripped: String = {
if path.count > 1 && path.hasSuffix("/") {
return String(path.dropLast())
Expand All @@ -128,12 +140,13 @@ extension ShellKit.Sandbox {
}
}
return prefixes
}()
}

/// Whether `path` (unresolved or canonical) names a location under
/// any accepted temp prefix — see ``temporaryPrefixes``.
private static func pathIsInTemp(_ path: String) -> Bool {
for prefix in temporaryPrefixes {
/// any accepted temp prefix — see ``temporaryPrefixes(for:)``.
private static func pathIsInTemp(_ path: String,
prefixes: [String]) -> Bool {
for prefix in prefixes {
if path == prefix || path.hasPrefix(prefix + "/") {
return true
}
Expand Down
Loading
Loading