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
7 changes: 6 additions & 1 deletion Sources/ShellKit/Environment/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,12 @@ public struct Environment: Hashable, Sendable {
workingDirectory: String = "/"
) -> Environment {
let vars: [String: String] = [
"PATH": "/usr/bin:/bin",
// All three virtual bin tiers. `/usr/local/bin` is where
// embedders catalogue the "user-installed" command set
// (SwiftBash slots the SwiftPorts CLIs — fd, rg, … —
// there); omitting it made those unreachable in exactly
// the synthetic-env sandbox runs they exist for.
"PATH": "/usr/local/bin:/usr/bin:/bin",
"HOME": "/home/\(hostInfo.userName)",
"USER": hostInfo.userName,
"LOGNAME": hostInfo.userName,
Expand Down
190 changes: 190 additions & 0 deletions Sources/ShellKit/Sandbox/PathMapping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import Foundation

/// The virtual↔host path mapping a cooperative sandbox is built on:
/// a mount table translating script-visible *virtual* paths (`/`,
/// `/tmp`, `/batch`, …) to the *host* directories that back them.
///
/// This is the single authority both enforcement facades share:
///
/// - **Facade A — a `FileSystem` protocol** (SwiftBash's builtins and
/// any pure-Swift command written against it) routes every
/// operation through the table and confines symlink traversal to
/// each mount's host root.
/// - **Facade B — ``Shell/resolve(_:)``** (SwiftPorts CLIs, the JS
/// runtime, SwiftScript, and other Foundation/C-backed code)
/// translates a virtual path to its host spelling once, up front,
/// and then does real I/O; ``Sandbox/authorize(_:)`` re-checks the
/// same host path against the same mount roots.
///
/// One core, two doors: a path is translated and confined
/// identically no matter which facade it arrives through. Keeping
/// the table here (rather than inside one consumer's filesystem
/// layer) is what stops the two sides from disagreeing about where
/// `/tmp/foo` lives.
///
/// Translation is **purely lexical**: `.` / `..` are collapsed as
/// text before routing (so `..` cannot escape a mount), and no
/// symlinks are consulted. Symlink-escape *confinement* — verifying
/// that the canonical host path still lands inside the mount's host
/// root — is layered on top by the consumers that touch the disk:
/// ``Sandbox/confined(to:home:temporaryDirectory:allowedHosts:authorizeNetwork:)``
/// for Facade B, and the embedder's mounted filesystem for Facade A.
///
/// Mount precedence is "longest virtual prefix wins" — `/tmp/foo`
/// matches a `/tmp` mount before a `/` mount.
public struct PathMapping: Sendable {

/// One mount-table entry: an absolute virtual prefix backed by an
/// absolute host directory.
public struct Mount: Sendable {
/// Virtual prefix this mount answers to. `/` matches every
/// virtual path; `/tmp` matches `/tmp` and `/tmp/...`.
public var virtual: String
/// Absolute host path the mount maps onto.
public var host: String
/// If true, the mount is advertised as read-only. The mapping
/// itself doesn't enforce this (translation has no read/write
/// intent); filesystem layers consult it to reject writes.
public var readOnly: Bool

public init(virtual: String, host: String, readOnly: Bool = false) {
// Normalise lexically: strip `.` / `..` / trailing `/` so
// `/tmp` and `/tmp/` compare equal. Deliberately NOT
// `NSString.standardizingPath`, which consults the host
// disk and resolves symlinks on corelibs-foundation —
// the virtual side must never depend on host disk layout.
self.virtual = Shell.normalizePath(virtual)
self.host = (host as NSString).standardizingPath
self.readOnly = readOnly
}
}

/// Mount table, longest virtual prefix first so the most specific
/// mount wins during translation.
public let mounts: [Mount]

public init(mounts: [Mount]) {
self.mounts = mounts.sorted { $0.virtual.count > $1.virtual.count }
}

/// Mount table ordered by virtual path, for display by a `mount`
/// command (``mounts`` is sorted longest-prefix first internally).
public var mountList: [Mount] {
mounts.sorted { $0.virtual < $1.virtual }
}

// MARK: - Virtual → host

/// Translate an absolute virtual path to the host path that backs
/// it, or `nil` when no mount matches (the path doesn't exist as
/// far as the sandbox is concerned) or `path` isn't absolute.
///
/// The path is normalised lexically (`/tmp/../home/foo` →
/// `/home/foo`) BEFORE routing, so `..` cannot cross out of a
/// mount unseen. Purely textual — no symlink resolution, no disk
/// access; confinement of what the host path *resolves to* is the
/// caller's second step.
public func hostPath(forVirtual path: String) -> (mount: Mount, host: String)? {
let std = Shell.normalizePath(path)
guard std.hasPrefix("/") else { return nil }
for mount in mounts {
if mount.virtual == "/" {
// Root mount — every path lands here unless an earlier
// (more specific) mount matched. Strip the leading `/`
// and append.
let rel = std == "/" ? "" : String(std.dropFirst())
let host = (mount.host as NSString)
.appendingPathComponent(rel)
return (mount, host)
}
if std == mount.virtual {
return (mount, mount.host)
}
if std.hasPrefix(mount.virtual + "/") {
let rel = String(std.dropFirst(mount.virtual.count + 1))
let host = (mount.host as NSString)
.appendingPathComponent(rel)
return (mount, host)
}
}
return nil
}

// MARK: - Host → virtual

/// Fold a host path back to its virtual spelling, or `nil` when
/// it doesn't fall under any mount's host root.
///
/// This is the "no `realpath` leak" direction: anything a script
/// gets to *see* — `$TMPDIR`-derived answers, display output,
/// diagnostics carrying resolved paths — should travel through
/// here (via ``Shell/displayPath(for:)-swift.method``) so the
/// embedder's host directory layout stays out of the sandbox.
///
/// Matching tolerates spelling variance on both sides: the
/// verbatim, lexically-normalised, and symlink-resolved forms of
/// the mount's host root and of `path` are all compared (macOS
/// reports temp paths behind the `/var` → `/private/var` link in
/// either spelling depending on which API produced them). The
/// longest matching host root wins, mirroring the
/// longest-virtual-prefix rule of ``hostPath(forVirtual:)``.
public func virtualPath(forHost path: String) -> String? {
// Best fold so far: the rebuilt virtual spelling plus the
// length of the host root that matched (longer root = more
// specific mount).
struct Fold {
let virtual: String
let matched: Int
}
let candidates = Self.spellings(of: path)
var best: Fold?
for mount in mounts {
for root in Self.spellings(of: mount.host) {
for candidate in candidates {
let rel: String
if candidate == root {
rel = ""
} else if candidate.hasPrefix(root + "/") {
rel = String(candidate.dropFirst(root.count + 1))
} else {
continue
}
if let current = best, root.count <= current.matched {
continue
}
let folded: String
if rel.isEmpty {
folded = mount.virtual
} else if mount.virtual == "/" {
folded = "/" + rel
} else {
folded = mount.virtual + "/" + rel
}
best = Fold(virtual: folded, matched: root.count)
}
}
}
return best?.virtual
}

/// The spellings under which a host path might be reported:
/// verbatim (minus a trailing `/`), lexically normalised, and
/// symlink-resolved. `$TMPDIR`-style values keep the embedder's
/// verbatim spelling while `FileManager` answers carry the
/// canonical one; both must fold.
private static func spellings(of path: String) -> [String] {
var stripped = path
if stripped.count > 1, stripped.hasSuffix("/") {
stripped.removeLast()
}
var result: [String] = []
for candidate in [
stripped,
Shell.normalizePath(stripped),
URL(fileURLWithPath: stripped).resolvingSymlinksInPath().path
] where !result.contains(candidate) {
result.append(candidate)
}
return result
}
}
113 changes: 113 additions & 0 deletions Sources/ShellKit/Sandbox/Sandbox+Confined.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import Foundation

extension Sandbox {

// MARK: - confined(to:)

/// Confinement over a virtual↔host ``PathMapping``: the gate
/// authorizes a file URL iff its canonical (symlink-resolved)
/// path lands inside one of the mapping's host roots.
///
/// This is the Facade-B half of the cooperative sandbox. Callers
/// that do real Foundation/C I/O — SwiftPorts CLIs, the JS
/// runtime, SwiftScript — obtain host paths from
/// ``Shell/resolve(_:)`` (which translates virtual spellings
/// through this same mapping, because the sandbox carries it as
/// ``Sandbox/pathMapping``) and authorize them here. A bash-side
/// mounted filesystem built over the same mapping confines its
/// own traffic identically, so both doors agree on what `/tmp/x`
/// means and on where the boundary is.
///
/// Symlink escapes are rejected by canonicalising both sides: a
/// link planted inside a mount (`ln -s /etc/passwd "$TMPDIR/p"`)
/// resolves outside every canonical host root and is denied.
/// Conversely, a mount whose host root is itself reached through
/// a symlink still authorizes — both the root and the candidate
/// resolve to the same canonical prefix.
///
/// The mapping's `readOnly` flag is *not* enforced here —
/// `authorize(_:)` carries no read/write intent. Filesystem
/// layers that know the intent enforce it.
///
/// - Parameters:
/// - mapping: the mount table to confine to. The sandbox keeps
/// it as ``Sandbox/pathMapping`` so ``Shell/resolve(_:)`` and
/// ``Shell/displayPath(for:)-swift.method`` translate through
/// the very table the gate enforces.
/// - home: virtual path of the mount that anchors the region
/// directories (`homeDirectory`, `documentsDirectory`, …).
/// The regions are host-spelled — consumers hand them to
/// Foundation — and live under that mount's host root.
/// - temporaryDirectory: host dir reported as
/// ``Sandbox/temporaryDirectory``. Defaults to the host root
/// of the mapping's `/tmp` mount; an embedder that doesn't
/// mount `/tmp` gets the platform temp dir *reported* but not
/// *authorized* (nothing outside the mounts ever is — temp
/// access is the embedder's choice, made by mounting it).
/// - allowedHosts: network hosts the default non-file gate
/// admits (deny-all when empty).
/// - authorizeNetwork: embedder override for non-file URLs
/// (e.g. routing host access through a permission prompt).
/// When `nil`, non-file URLs go through `allowedHosts`.
public static func confined(
to mapping: PathMapping,
home: String = "/",
temporaryDirectory: URL? = nil,
allowedHosts: [String] = [],
authorizeNetwork: (@Sendable (URL) async throws -> Void)? = nil
) -> Sandbox {
// Region anchor: the host root backing the virtual `home`.
// (Falling back to the literal string only happens when the
// embedder anchors regions outside its own mapping — a
// misconfiguration that then fails closed at the gate.)
let hostHome = mapping.hostPath(forVirtual: home)?.host ?? home
let homeURL = URL(fileURLWithPath: hostHome, isDirectory: true)
let tmpURL = temporaryDirectory
?? mapping.hostPath(forVirtual: "/tmp")
.map { URL(fileURLWithPath: $0.host, isDirectory: true) }
?? FileManager.default.temporaryDirectory
// Canonicalise every mount's host root once, up front — same
// strategy as `rooted(at:)`. Roots exist by the time the
// sandbox is built (mounting a missing directory is the
// embedder's bug and fails closed: nothing canonicalises
// into a nonexistent root).
let canonicalRoots = mapping.mounts.map {
canonicalizePath($0.host)
?? URL(fileURLWithPath: $0.host).standardizedFileURL.path
}
let allowedHostSet = Set(allowedHosts)
return Sandbox(
documentsDirectory: homeURL.appendingPathComponent("Documents", isDirectory: true),
downloadsDirectory: homeURL.appendingPathComponent("Downloads", isDirectory: true),
libraryDirectory: homeURL.appendingPathComponent("Library", isDirectory: true),
moviesDirectory: homeURL.appendingPathComponent("Movies", isDirectory: true),
musicDirectory: homeURL.appendingPathComponent("Music", isDirectory: true),
picturesDirectory: homeURL.appendingPathComponent("Pictures", isDirectory: true),
sharedPublicDirectory: homeURL.appendingPathComponent("Public", isDirectory: true),
temporaryDirectory: tmpURL,
trashDirectory: homeURL.appendingPathComponent(".Trash", isDirectory: true),
userDirectory: homeURL,
cachesDirectory: homeURL.appendingPathComponent("Library/Caches", isDirectory: true),
// The home mount IS the user's home — `cd` with no
// argument and `~` both land at the workspace.
homeDirectory: homeURL,
pathMapping: mapping,
authorize: { url in
guard url.isFileURL else {
if let authorizeNetwork {
try await authorizeNetwork(url)
return
}
try authorizeUnderRoots(
url: url,
canonicalRoots: [],
allowedHosts: allowedHostSet)
return
}
try authorizeUnderRoots(
url: url,
canonicalRoots: canonicalRoots,
allowedHosts: allowedHostSet)
})
}
}
7 changes: 5 additions & 2 deletions Sources/ShellKit/Sandbox/Sandbox+Factories.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,10 @@ extension Sandbox {
})
}

// MARK: - Authorization helpers (internal but file-scoped sendable)
// MARK: - Authorization helpers
//
// `authorizeUnderRoots` is internal (not fileprivate): the
// path-mapping gate in `Sandbox+Confined.swift` shares it.

fileprivate static func authorizeUnderRoot(
url: URL,
Expand All @@ -147,7 +150,7 @@ extension Sandbox {
allowedHosts: allowedHosts)
}

fileprivate static func authorizeUnderRoots(
static func authorizeUnderRoots(
url: URL,
canonicalRoots: [String],
allowedHosts: Set<String>
Expand Down
18 changes: 18 additions & 0 deletions Sources/ShellKit/Sandbox/Sandbox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@ public struct Sandbox: Sendable {
/// User caches directory.
public let cachesDirectory: URL

// MARK: - Path mapping

/// The virtual↔host ``PathMapping`` this sandbox confines to, when
/// it was built over one (``confined(to:home:temporaryDirectory:allowedHosts:authorizeNetwork:)``).
///
/// When set, ``Shell/resolve(_:)`` translates virtual paths to
/// their host spelling through this table before returning, and
/// ``Shell/displayPath(for:)-swift.method`` folds host paths back
/// to virtual for display — the same table ``authorize(_:)``
/// gates against, so resolution and confinement can't disagree.
/// `nil` for sandboxes without a virtual path space
/// (``rooted(at:allowedHosts:)``, ``appContainer(id:allowedHosts:)``):
/// there, paths mean the same thing on both sides and `resolve`
/// passes them through untranslated.
public let pathMapping: PathMapping?

// MARK: - URL gate

private let _authorize: @Sendable (URL) async throws -> Void
Expand Down Expand Up @@ -88,6 +104,7 @@ public struct Sandbox: Sendable {
userDirectory: URL,
cachesDirectory: URL,
homeDirectory: URL? = nil,
pathMapping: PathMapping? = nil,
authorize: @escaping @Sendable (URL) async throws -> Void
) {
self.documentsDirectory = documentsDirectory
Expand All @@ -102,6 +119,7 @@ public struct Sandbox: Sendable {
self.userDirectory = userDirectory
self.cachesDirectory = cachesDirectory
self.homeDirectory = homeDirectory ?? documentsDirectory
self.pathMapping = pathMapping
self._authorize = authorize
}

Expand Down
Loading
Loading