From 688c937625027bda958eada79c99637313006672 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 11 Jun 2026 12:14:48 +0200 Subject: [PATCH 1/6] Add the virtual<->host PathMapping core with two facades over it Sandbox enforcement has two cooperative jobs - translation and confinement - and until now the translation half lived only inside SwiftBash's MountedFileSystem. Every cooperative caller that isn't a bash builtin (SwiftPorts CLIs, the JS runtime, SwiftScript) resolved paths in the wrong space: Shell.resolve returned virtual spellings it then fed to FileManager/C APIs, and the URL gate checked those virtual spellings against host roots. Cocoanetics/SwiftBash#83 moves the core down here so both facades share one authority: - PathMapping: the mount table (longest-virtual-prefix routing, lexical `..` collapse via the new Shell.normalizePath) translating virtual -> host, plus virtualPath(forHost:) folding host paths back to virtual for display so realpath-style output never leaks the embedder's host layout. - Sandbox.confined(to:home:temporaryDirectory:...): the Facade-B gate. Authorizes a file URL iff its canonical (symlink-resolved) path lands inside one of the mapping's host roots; carries the mapping as Sandbox.pathMapping. Region directories anchor at the `home` mount's host root; temporaryDirectory defaults to the `/tmp` mount's host. - Shell.resolve now translates through sandbox?.pathMapping after the cwd join, so SwiftPorts CLIs / JS / SwiftScript get host paths that do real I/O on the right files with no per-tool rewrite; the same table gates them, so resolution and confinement cannot disagree. Shell.currentDirectory translates identically. Without a mapping (standalone CLIs, rooted/appContainer sandboxes) nothing changes. - Shell.displayPath(for:): the outbound door for anything a script gets to see. Shell.normalizePath is the lexical normaliser SwiftBash carried; it moves down so the mapping (and the subclass) share one implementation. --- Sources/ShellKit/Sandbox/PathMapping.swift | 178 +++++++++ .../ShellKit/Sandbox/Sandbox+Factories.swift | 109 +++++ Sources/ShellKit/Sandbox/Sandbox.swift | 18 + Sources/ShellKit/Shell+Path.swift | 78 ++++ Sources/ShellKit/Shell+Static.swift | 13 +- Sources/ShellKit/Shell.swift | 101 ++++- Tests/ShellKitTests/PathMappingTests.swift | 376 ++++++++++++++++++ 7 files changed, 850 insertions(+), 23 deletions(-) create mode 100644 Sources/ShellKit/Sandbox/PathMapping.swift create mode 100644 Sources/ShellKit/Shell+Path.swift create mode 100644 Tests/ShellKitTests/PathMappingTests.swift diff --git a/Sources/ShellKit/Sandbox/PathMapping.swift b/Sources/ShellKit/Sandbox/PathMapping.swift new file mode 100644 index 0000000..8846b67 --- /dev/null +++ b/Sources/ShellKit/Sandbox/PathMapping.swift @@ -0,0 +1,178 @@ +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? { + let candidates = Self.spellings(of: path) + var best: (virtual: String, rel: String, matched: Int)? + 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 best == nil || root.count > best!.matched { + best = (mount.virtual, rel, root.count) + } + } + } + } + guard let best else { return nil } + if best.rel.isEmpty { return best.virtual } + return best.virtual == "/" + ? "/" + best.rel + : best.virtual + "/" + best.rel + } + + /// 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 + } +} diff --git a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift index 9fa68a5..40caf1d 100644 --- a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift +++ b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift @@ -134,6 +134,115 @@ 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) + }) + } + // MARK: - Authorization helpers (internal but file-scoped sendable) fileprivate static func authorizeUnderRoot( diff --git a/Sources/ShellKit/Sandbox/Sandbox.swift b/Sources/ShellKit/Sandbox/Sandbox.swift index f0fe258..63c81f6 100644 --- a/Sources/ShellKit/Sandbox/Sandbox.swift +++ b/Sources/ShellKit/Sandbox/Sandbox.swift @@ -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 @@ -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 @@ -102,6 +119,7 @@ public struct Sandbox: Sendable { self.userDirectory = userDirectory self.cachesDirectory = cachesDirectory self.homeDirectory = homeDirectory ?? documentsDirectory + self.pathMapping = pathMapping self._authorize = authorize } diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift new file mode 100644 index 0000000..ffe7ffd --- /dev/null +++ b/Sources/ShellKit/Shell+Path.swift @@ -0,0 +1,78 @@ +import Foundation + +extension Shell { + + /// Lexical path normalisation — collapses `.` / `..` / repeated + /// `/` purely as text, never touching the filesystem. **Does not + /// resolve symlinks.** + /// + /// Replaces `NSString.standardizingPath`, which is technically + /// supposed to be lexical but on swift-corelibs-foundation + /// (Linux) follows symlinks too — making a purely textual + /// operation (`cd -L`, mount-table routing, virtual→host + /// translation) suddenly depend on what happens to exist on the + /// host disk. ``PathMapping`` routes every virtual path through + /// this before consulting its mount table, so `..` cannot escape + /// a mount and macOS autofs symlinks (`/home` → + /// `/System/Volumes/Data/home`) cannot hijack the routing. + /// + /// On Windows, backslashes are normalised to forward slashes + /// up front (Win32 path APIs accept both). Drive-letter paths + /// keep their `C:` prefix as the root segment so the result is + /// still a valid Windows path: `C:\Users\foo\..\bar` → `C:/Users/bar`. + public static func normalizePath(_ path: String) -> String { + guard !path.isEmpty else { return "" } + #if os(Windows) + let normalized = path.replacingOccurrences(of: "\\", with: "/") + #else + let normalized = path + #endif + // Split on `/`, tracking whether the path is anchored at the + // root (Unix `/foo`) or at a drive (Windows `C:/foo`). For a + // drive-letter path we keep the `C:` segment in the stack so + // the rebuilt string still stems from that drive. + let isUnixAbsolute = normalized.hasPrefix("/") + var stack: [String] = [] + var driveRoot: String? + var saw: [Substring] = normalized.split( + separator: "/", omittingEmptySubsequences: true) + #if os(Windows) + // Detect a leading `C:` segment (drive root). After we + // record it, the rest of the segments are walked as if the + // path were absolute beneath that drive. + if let first = saw.first, + first.count == 2, + let firstChar = first.first, firstChar.isLetter, + first.last == ":" { + driveRoot = String(first) + saw = Array(saw.dropFirst()) + } + #endif + let anchored = isUnixAbsolute || driveRoot != nil + for seg in saw { + switch seg { + case ".": + continue + case "..": + // For anchored paths, `..` at the root stays at the + // root. For relative paths we let `..` underflow as + // a literal segment so callers can preserve the + // user's intent (rare in practice). + if !stack.isEmpty, stack.last != ".." { + stack.removeLast() + } else if !anchored { + stack.append("..") + } + default: + stack.append(String(seg)) + } + } + if let driveRoot { + return driveRoot + "/" + stack.joined(separator: "/") + } + if isUnixAbsolute { + return "/" + stack.joined(separator: "/") + } + return stack.isEmpty ? "." : stack.joined(separator: "/") + } +} diff --git a/Sources/ShellKit/Shell+Static.swift b/Sources/ShellKit/Shell+Static.swift index ea0183d..1ff552b 100644 --- a/Sources/ShellKit/Shell+Static.swift +++ b/Sources/ShellKit/Shell+Static.swift @@ -36,14 +36,21 @@ public extension Shell { current.positionalParameters } - /// Current working directory. Honours - /// ``Shell/current``'s `environment.workingDirectory` when set, + /// Current working directory, as a URL ready for real I/O. + /// Honours ``Shell/current``'s `environment.workingDirectory` + /// when set — translated to its host spelling under a + /// path-mapped sandbox, exactly like ``Shell/resolve(_:)`` — /// otherwise falls back to the OS CWD via /// `FileManager.default.currentDirectoryPath`. + /// + /// For the *script-visible* CWD string (`pwd`, `process.cwd()`), + /// read `environment.workingDirectory` directly — that one stays + /// in the virtual spelling. static var currentDirectory: URL { let cwd = current.environment.workingDirectory if !cwd.isEmpty { - return URL(fileURLWithPath: cwd, isDirectory: true) + return URL(fileURLWithPath: current.resolve(cwd).path, + isDirectory: true) } return URL( fileURLWithPath: FileManager.default.currentDirectoryPath, diff --git a/Sources/ShellKit/Shell.swift b/Sources/ShellKit/Shell.swift index f575e7d..c6ece20 100644 --- a/Sources/ShellKit/Shell.swift +++ b/Sources/ShellKit/Shell.swift @@ -267,12 +267,27 @@ open class Shell: @unchecked Sendable { // MARK: - Path resolution - /// Resolve a (possibly relative) path string into an absolute - /// `URL`, honouring the shell's current working directory. + /// Resolve a (possibly relative) path string into the absolute + /// `URL` to do real I/O on, honouring the shell's current working + /// directory and — under a path-mapped sandbox — translating the + /// virtual spelling to the host directory that backs it. /// - /// Absolute paths (`/foo/bar`, or `C:\foo` on Windows) come back - /// unchanged. Relative paths resolve against - /// ``environment``'s ``Environment/workingDirectory``. + /// Relative paths resolve against ``environment``'s + /// ``Environment/workingDirectory``. When the bound sandbox + /// carries a ``Sandbox/pathMapping`` (the cooperative-sandbox + /// case: the script-visible path space is virtual), the resolved + /// path is then routed through the mount table, so `/tmp/x` + /// comes back as the per-instance host temp dir's `x` — ready + /// for `FileManager` / C APIs and for ``Sandbox/authorize(_:)``, + /// which gates the same host space. Without a mapping the result + /// is the path as given (absolute) or CWD-joined (relative), + /// unchanged from how it always worked. + /// + /// The returned URL is for *doing I/O*, not for *showing to the + /// script* — display output should keep the user's own spelling + /// or fold a host path back through + /// ``displayPath(for:)-swift.method`` so the embedder's host + /// layout never leaks into the sandbox. /// /// CLI commands resolving relative argv paths should use this /// instead of `URL(fileURLWithPath:)` / `FileManager.default.currentDirectoryPath` @@ -284,24 +299,38 @@ open class Shell: @unchecked Sendable { /// call site for code that doesn't already have a `Shell` /// reference; it routes through ``current``. public func resolve(_ path: String) -> URL { + let url: URL if path.hasPrefix("/") { - return URL(fileURLWithPath: path) - } - #if os(Windows) - if path.count >= 2, - let second = path.dropFirst().first, second == ":" { - return URL(fileURLWithPath: path) - } - #endif - let cwd = environment.workingDirectory - if cwd.isEmpty { - return URL( - fileURLWithPath: FileManager.default.currentDirectoryPath, - isDirectory: true) + url = URL(fileURLWithPath: path) + } else { + #if os(Windows) + if path.count >= 2, + let second = path.dropFirst().first, second == ":" { + return URL(fileURLWithPath: path) + } + #endif + let cwd = environment.workingDirectory + if cwd.isEmpty { + // Host-process CWD fallback: there is no virtual + // path space without an embedder-bound CWD, so the + // mapping never applies here. + return URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true) + .appendingPathComponent(path) + } + url = URL(fileURLWithPath: cwd, isDirectory: true) .appendingPathComponent(path) } - return URL(fileURLWithPath: cwd, isDirectory: true) - .appendingPathComponent(path) + guard let mapping = sandbox?.pathMapping, + let translated = mapping.hostPath(forVirtual: url.path) + else { + // No mapping (or a virtual path outside every mount — + // which the gate then denies / the host reports missing): + // hand back the un-translated resolution. + return url + } + return URL(fileURLWithPath: translated.host) } /// Resolve `path` against ``current``'s working directory. @@ -310,6 +339,38 @@ open class Shell: @unchecked Sendable { public static func resolve(_ path: String) -> URL { current.resolve(path) } + + // MARK: - Display paths + + /// Fold a host filesystem path back to its virtual spelling for + /// display, when the bound sandbox carries a + /// ``Sandbox/pathMapping``. The inverse of ``resolve(_:)``: + /// `resolve` is for doing I/O, `displayPath` is for anything the + /// script gets to *see* — `realpath`-style answers, `--absolute-path` + /// output, `os.tmpdir()`, diagnostics that embed a resolved path. + /// Returns `path` unchanged when no mapping is bound or the path + /// doesn't fall under any mount (nothing to hide). + public func displayPath(for path: String) -> String { + guard let mapping = sandbox?.pathMapping, + let virtual = mapping.virtualPath(forHost: path) + else { return path } + return virtual + } + + /// URL-form convenience for ``displayPath(for:)-swift.method``. + public func displayPath(for url: URL) -> String { + displayPath(for: url.path) + } + + /// Fold `path` through ``current``'s mapping for display. + public static func displayPath(for path: String) -> String { + current.displayPath(for: path) + } + + /// Fold `url` through ``current``'s mapping for display. + public static func displayPath(for url: URL) -> String { + current.displayPath(for: url) + } } // MARK: - InputSource process standard input diff --git a/Tests/ShellKitTests/PathMappingTests.swift b/Tests/ShellKitTests/PathMappingTests.swift new file mode 100644 index 0000000..269ffa9 --- /dev/null +++ b/Tests/ShellKitTests/PathMappingTests.swift @@ -0,0 +1,376 @@ +import Foundation +import Testing +@testable import ShellKit + +/// The virtual↔host mapping core (#SwiftBash 83): lexical +/// translation through the mount table in both directions, plus the +/// two doors over it — `Shell.resolve` (virtual → host, for I/O) and +/// `Shell.displayPath` (host → virtual, for output) — and the +/// `Sandbox.confined(to:)` gate that authorizes the same host space +/// `resolve` produces. +@Suite(.timeLimit(.minutes(1))) struct PathMappingTests { + + // MARK: - Fixtures + + /// On-disk workspace + per-instance temp dir, shaped like the + /// SwiftBash CLI's `--sandbox ` setup. Caller removes both. + private static func makeRoots() throws -> (workspace: URL, temp: URL) { + let base = FileManager.default.temporaryDirectory + let workspace = base.appendingPathComponent( + "shellkit-ws-\(UUID().uuidString)", isDirectory: true) + let temp = base.appendingPathComponent( + "shellkit-tmp-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: temp, withIntermediateDirectories: true) + return (workspace, temp) + } + + private static func mapping(workspace: URL, temp: URL) -> PathMapping { + PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: temp.path) + ]) + } + + // MARK: - Virtual → host translation + + @Test func translatesUnderEachMount() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + #expect(mapping.hostPath(forVirtual: "/batch/a/b.txt")?.host + == workspace.path + "/a/b.txt") + #expect(mapping.hostPath(forVirtual: "/tmp/x")?.host + == temp.path + "/x") + // The mount point itself maps to the host root. + #expect(mapping.hostPath(forVirtual: "/batch")?.host + == workspace.path) + #expect(mapping.hostPath(forVirtual: "/tmp/")?.host + == temp.path) + } + + @Test func longestVirtualPrefixWins() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/", host: "/host/root"), + .init(virtual: "/tmp", host: "/host/tmp") + ]) + #expect(mapping.hostPath(forVirtual: "/tmp/f")?.host == "/host/tmp/f") + #expect(mapping.hostPath(forVirtual: "/tmpfoo")?.host + == "/host/root/tmpfoo") + #expect(mapping.hostPath(forVirtual: "/other")?.host + == "/host/root/other") + #expect(mapping.hostPath(forVirtual: "/")?.host == "/host/root") + } + + @Test func dotDotCollapsesBeforeRouting() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: "/host/ws"), + .init(virtual: "/tmp", host: "/host/tmp") + ]) + // `..` crossing from one mount into another routes to the + // OTHER mount — never to a host path outside both. + #expect(mapping.hostPath(forVirtual: "/tmp/../batch/f")?.host + == "/host/ws/f") + // `..` above the root stays at the root (no mount → nil). + #expect(mapping.hostPath(forVirtual: "/batch/../../etc") == nil) + } + + @Test func unmappedAndRelativePathsReturnNil() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: "/host/ws") + ]) + #expect(mapping.hostPath(forVirtual: "/etc/passwd") == nil) + #expect(mapping.hostPath(forVirtual: "/") == nil) + #expect(mapping.hostPath(forVirtual: "relative/path") == nil) + // Prefix sibling: `/batchwork` must not match `/batch`. + #expect(mapping.hostPath(forVirtual: "/batchwork/f") == nil) + } + + @Test func readOnlyFlagRidesAlong() { + let mapping = PathMapping(mounts: [ + .init(virtual: "/ro", host: "/host/ro", readOnly: true), + .init(virtual: "/rw", host: "/host/rw") + ]) + #expect(mapping.hostPath(forVirtual: "/ro/f")?.mount.readOnly == true) + #expect(mapping.hostPath(forVirtual: "/rw/f")?.mount.readOnly == false) + // Display order is by virtual path, not specificity. + #expect(mapping.mountList.map(\.virtual) == ["/ro", "/rw"]) + } + + // MARK: - Host → virtual folding + + @Test func foldsHostPathsBackToVirtual() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + #expect(mapping.virtualPath(forHost: temp.path + "/probe/f.txt") + == "/tmp/probe/f.txt") + #expect(mapping.virtualPath(forHost: workspace.path) == "/batch") + // Symlink-resolved spelling folds too (macOS reports temp + // paths behind `/var` → `/private/var` in either form). + let resolved = temp.resolvingSymlinksInPath().path + #expect(mapping.virtualPath(forHost: resolved + "/f") == "/tmp/f") + // Outside every mount: nothing to fold. + #expect(mapping.virtualPath(forHost: "/etc/passwd") == nil) + } + + @Test func foldPrefersLongestHostRoot() throws { + // Nested host roots (the Linux case: per-instance temp dir + // under `/tmp` with `/tmp` itself also mounted) fold to the + // more specific mount. + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let nested = temp.appendingPathComponent("inner", isDirectory: true) + try FileManager.default.createDirectory( + at: nested, withIntermediateDirectories: true) + let mapping = PathMapping(mounts: [ + .init(virtual: "/outer", host: temp.path), + .init(virtual: "/inner", host: nested.path) + ]) + #expect(mapping.virtualPath(forHost: nested.path + "/f") + == "/inner/f") + #expect(mapping.virtualPath(forHost: temp.path + "/other") + == "/outer/other") + } + + // MARK: - Shell.resolve translation (Facade B, inbound) + + @Test func resolveTranslatesVirtualPathsUnderMapping() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + var env = Environment() + env.workingDirectory = "/batch" + let shell = Shell(environment: env) + shell.sandbox = .confined(to: mapping, home: "/batch") + + // Absolute virtual paths translate through the table. + #expect(shell.resolve("/tmp/scratch.txt").path + == temp.path + "/scratch.txt") + #expect(shell.resolve("/batch/data.json").path + == workspace.path + "/data.json") + // Relative paths resolve against the VIRTUAL cwd, then + // translate. + #expect(shell.resolve("data.json").path + == workspace.path + "/data.json") + #expect(shell.resolve("../tmp/x").path == temp.path + "/x") + // Virtual paths outside every mount come back untranslated — + // the gate denies them and the host reports them missing. + #expect(shell.resolve("/etc/passwd").path == "/etc/passwd") + + // The static accessors agree under the binding. + try await shell.withCurrent { + #expect(Shell.resolve("/tmp/y").path == temp.path + "/y") + #expect(Shell.currentDirectory.path == workspace.path) + } + } + + @Test func resolveWithoutMappingIsUnchanged() { + var env = Environment() + env.workingDirectory = "/virtual/cwd" + let shell = Shell(environment: env) + // No sandbox at all. + #expect(shell.resolve("/tmp/x").path == "/tmp/x") + #expect(shell.resolve("f").path == "/virtual/cwd/f") + // A sandbox without a mapping (rooted) doesn't translate + // either. + shell.sandbox = .rooted( + at: FileManager.default.temporaryDirectory) + #expect(shell.resolve("/tmp/x").path == "/tmp/x") + } + + // MARK: - Shell.displayPath folding (Facade B, outbound) + + @Test func displayPathFoldsHostToVirtual() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + let shell = Shell() + shell.sandbox = .confined(to: mapping, home: "/batch") + + #expect(shell.displayPath(for: temp.path + "/f") == "/tmp/f") + #expect(shell.displayPath( + for: URL(fileURLWithPath: workspace.path + "/a/b")) + == "/batch/a/b") + // Round trip: resolve → displayPath is the identity on the + // virtual spelling. + #expect(shell.displayPath(for: shell.resolve("/tmp/round")) + == "/tmp/round") + // Unmapped host paths display as-is. + #expect(shell.displayPath(for: "/usr/lib/x") == "/usr/lib/x") + // Without a mapping, identity. + let plain = Shell() + #expect(plain.displayPath(for: temp.path) == temp.path) + } + + // MARK: - Sandbox.confined gate + + @Test func confinedAuthorizesHostSpaceOnly() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + + // Host paths under either mount root pass — existing or not. + try await sandbox.authorize(workspace.appendingPathComponent("new/file")) + try await sandbox.authorize(temp) + try await sandbox.authorize(temp.appendingPathComponent("probe.txt")) + + // The VIRTUAL spelling is not host space: a literal `/tmp/...` + // (the host's shared temp dir!) or `/batch/...` is denied — + // callers reach the gate with `Shell.resolve` output. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/tmp/leak")) + } + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/batch/f")) + } + // Outside both roots. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/etc/passwd")) + } + // Sibling/prefix collision: `extra` must not pass. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize( + URL(fileURLWithPath: temp.path + "extra")) + } + // The shared platform temp root is NOT authorized — only the + // per-instance dir under it. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize( + FileManager.default.temporaryDirectory) + } + } + +#if !os(Windows) + @Test func confinedRejectsSymlinkEscape() async throws { + // A link planted inside a mount pointing outside it resolves + // out of every canonical host root → denied (the FileManager- + // backed caller would otherwise follow it out of the sandbox). + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let link = temp.appendingPathComponent("escape-link").path + try FileManager.default.createSymbolicLink( + atPath: link, withDestinationPath: "/") + let sandbox = Sandbox.confined( + to: Self.mapping(workspace: workspace, temp: temp), + home: "/batch") + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: link)) + } + } + + @Test func confinedAcceptsSymlinkSpelledMountRoot() async throws { + // A mount whose host root is itself reached through a symlink + // still authorizes: both sides canonicalise to the same + // prefix. + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let link = FileManager.default.temporaryDirectory + .appendingPathComponent("shellkit-link-\(UUID().uuidString)") + .path + try FileManager.default.createSymbolicLink( + atPath: link, withDestinationPath: temp.path) + defer { try? FileManager.default.removeItem(atPath: link) } + + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: link) + ]) + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + try await sandbox.authorize( + URL(fileURLWithPath: link).appendingPathComponent("f")) + try await sandbox.authorize(temp.appendingPathComponent("f")) + } +#endif + + @Test func confinedRegionsAndTempDerivation() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + // temporaryDirectory defaults to the `/tmp` mount's host root. + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + #expect(sandbox.temporaryDirectory.standardizedFileURL.path + == temp.standardizedFileURL.path) + // The home mount's host root anchors home/user regions — + // the workspace IS the user's home. + #expect(sandbox.homeDirectory.standardizedFileURL.path + == workspace.standardizedFileURL.path) + #expect(sandbox.userDirectory.standardizedFileURL.path + == workspace.standardizedFileURL.path) + // The mapping rides on the sandbox for the resolve facade. + #expect(sandbox.pathMapping?.mounts.count == 2) + + // Explicit override wins. + let custom = FileManager.default.temporaryDirectory + .appendingPathComponent("custom-\(UUID().uuidString)") + let overridden = Sandbox.confined( + to: mapping, home: "/batch", temporaryDirectory: custom) + #expect(overridden.temporaryDirectory == custom) + } + + @Test func confinedNetworkPolicy() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + // Default: non-file URLs denied. + let offline = Sandbox.confined(to: mapping, home: "/batch") + await #expect(throws: Sandbox.Denial.self) { + try await offline.authorize(URL(string: "https://example.com/")!) + } + + // allowedHosts admits matching hosts. + let allowing = Sandbox.confined( + to: mapping, home: "/batch", + allowedHosts: ["api.example.com"]) + try await allowing.authorize( + URL(string: "https://api.example.com/v1")!) + + // authorizeNetwork override routes non-file URLs; file URLs + // keep using the mapping gate. + struct Blocked: Error {} + let custom = Sandbox.confined( + to: mapping, home: "/batch", + authorizeNetwork: { _ in throw Blocked() }) + await #expect(throws: Blocked.self) { + try await custom.authorize(URL(string: "https://x.example/")!) + } + try await custom.authorize(temp.appendingPathComponent("ok")) + } +} From 48880057e9861e11879b1922314e930bed80b39c Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 11 Jun 2026 12:53:05 +0200 Subject: [PATCH 2/6] lint: split Sandbox+Confined / SandboxConfinedTests; defactor 3-tuple SwiftLint CI: file_length on Sandbox+Factories (confined(to:) moves to its own file, sharing the now-internal authorizeUnderRoots), type_body_length on PathMappingTests (the confined-gate suite moves to SandboxConfinedTests), and large_tuple in virtualPath(forHost:) (the best-match accumulator becomes a small local struct). --- Sources/ShellKit/Sandbox/PathMapping.swift | 28 ++- .../ShellKit/Sandbox/Sandbox+Confined.swift | 113 +++++++++++ .../ShellKit/Sandbox/Sandbox+Factories.swift | 116 +---------- Tests/ShellKitTests/PathMappingTests.swift | 152 --------------- .../ShellKitTests/SandboxConfinedTests.swift | 183 ++++++++++++++++++ 5 files changed, 321 insertions(+), 271 deletions(-) create mode 100644 Sources/ShellKit/Sandbox/Sandbox+Confined.swift create mode 100644 Tests/ShellKitTests/SandboxConfinedTests.swift diff --git a/Sources/ShellKit/Sandbox/PathMapping.swift b/Sources/ShellKit/Sandbox/PathMapping.swift index 8846b67..3f1b3f5 100644 --- a/Sources/ShellKit/Sandbox/PathMapping.swift +++ b/Sources/ShellKit/Sandbox/PathMapping.swift @@ -129,8 +129,15 @@ public struct PathMapping: Sendable { /// 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: (virtual: String, rel: String, matched: Int)? + var best: Fold? for mount in mounts { for root in Self.spellings(of: mount.host) { for candidate in candidates { @@ -142,17 +149,22 @@ public struct PathMapping: Sendable { } else { continue } - if best == nil || root.count > best!.matched { - best = (mount.virtual, rel, root.count) + 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) } } } - guard let best else { return nil } - if best.rel.isEmpty { return best.virtual } - return best.virtual == "/" - ? "/" + best.rel - : best.virtual + "/" + best.rel + return best?.virtual } /// The spellings under which a host path might be reported: diff --git a/Sources/ShellKit/Sandbox/Sandbox+Confined.swift b/Sources/ShellKit/Sandbox/Sandbox+Confined.swift new file mode 100644 index 0000000..ee566af --- /dev/null +++ b/Sources/ShellKit/Sandbox/Sandbox+Confined.swift @@ -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) + }) + } +} diff --git a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift index 40caf1d..be89160 100644 --- a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift +++ b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift @@ -134,116 +134,10 @@ 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) - }) - } - - // 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, @@ -256,7 +150,7 @@ extension Sandbox { allowedHosts: allowedHosts) } - fileprivate static func authorizeUnderRoots( + static func authorizeUnderRoots( url: URL, canonicalRoots: [String], allowedHosts: Set diff --git a/Tests/ShellKitTests/PathMappingTests.swift b/Tests/ShellKitTests/PathMappingTests.swift index 269ffa9..825b590 100644 --- a/Tests/ShellKitTests/PathMappingTests.swift +++ b/Tests/ShellKitTests/PathMappingTests.swift @@ -221,156 +221,4 @@ import Testing let plain = Shell() #expect(plain.displayPath(for: temp.path) == temp.path) } - - // MARK: - Sandbox.confined gate - - @Test func confinedAuthorizesHostSpaceOnly() async throws { - let (workspace, temp) = try Self.makeRoots() - defer { - try? FileManager.default.removeItem(at: workspace) - try? FileManager.default.removeItem(at: temp) - } - let mapping = Self.mapping(workspace: workspace, temp: temp) - let sandbox = Sandbox.confined(to: mapping, home: "/batch") - - // Host paths under either mount root pass — existing or not. - try await sandbox.authorize(workspace.appendingPathComponent("new/file")) - try await sandbox.authorize(temp) - try await sandbox.authorize(temp.appendingPathComponent("probe.txt")) - - // The VIRTUAL spelling is not host space: a literal `/tmp/...` - // (the host's shared temp dir!) or `/batch/...` is denied — - // callers reach the gate with `Shell.resolve` output. - await #expect(throws: Sandbox.Denial.self) { - try await sandbox.authorize(URL(fileURLWithPath: "/tmp/leak")) - } - await #expect(throws: Sandbox.Denial.self) { - try await sandbox.authorize(URL(fileURLWithPath: "/batch/f")) - } - // Outside both roots. - await #expect(throws: Sandbox.Denial.self) { - try await sandbox.authorize(URL(fileURLWithPath: "/etc/passwd")) - } - // Sibling/prefix collision: `extra` must not pass. - await #expect(throws: Sandbox.Denial.self) { - try await sandbox.authorize( - URL(fileURLWithPath: temp.path + "extra")) - } - // The shared platform temp root is NOT authorized — only the - // per-instance dir under it. - await #expect(throws: Sandbox.Denial.self) { - try await sandbox.authorize( - FileManager.default.temporaryDirectory) - } - } - -#if !os(Windows) - @Test func confinedRejectsSymlinkEscape() async throws { - // A link planted inside a mount pointing outside it resolves - // out of every canonical host root → denied (the FileManager- - // backed caller would otherwise follow it out of the sandbox). - let (workspace, temp) = try Self.makeRoots() - defer { - try? FileManager.default.removeItem(at: workspace) - try? FileManager.default.removeItem(at: temp) - } - let link = temp.appendingPathComponent("escape-link").path - try FileManager.default.createSymbolicLink( - atPath: link, withDestinationPath: "/") - let sandbox = Sandbox.confined( - to: Self.mapping(workspace: workspace, temp: temp), - home: "/batch") - await #expect(throws: Sandbox.Denial.self) { - try await sandbox.authorize(URL(fileURLWithPath: link)) - } - } - - @Test func confinedAcceptsSymlinkSpelledMountRoot() async throws { - // A mount whose host root is itself reached through a symlink - // still authorizes: both sides canonicalise to the same - // prefix. - let (workspace, temp) = try Self.makeRoots() - defer { - try? FileManager.default.removeItem(at: workspace) - try? FileManager.default.removeItem(at: temp) - } - let link = FileManager.default.temporaryDirectory - .appendingPathComponent("shellkit-link-\(UUID().uuidString)") - .path - try FileManager.default.createSymbolicLink( - atPath: link, withDestinationPath: temp.path) - defer { try? FileManager.default.removeItem(atPath: link) } - - let mapping = PathMapping(mounts: [ - .init(virtual: "/batch", host: workspace.path), - .init(virtual: "/tmp", host: link) - ]) - let sandbox = Sandbox.confined(to: mapping, home: "/batch") - try await sandbox.authorize( - URL(fileURLWithPath: link).appendingPathComponent("f")) - try await sandbox.authorize(temp.appendingPathComponent("f")) - } -#endif - - @Test func confinedRegionsAndTempDerivation() throws { - let (workspace, temp) = try Self.makeRoots() - defer { - try? FileManager.default.removeItem(at: workspace) - try? FileManager.default.removeItem(at: temp) - } - let mapping = Self.mapping(workspace: workspace, temp: temp) - - // temporaryDirectory defaults to the `/tmp` mount's host root. - let sandbox = Sandbox.confined(to: mapping, home: "/batch") - #expect(sandbox.temporaryDirectory.standardizedFileURL.path - == temp.standardizedFileURL.path) - // The home mount's host root anchors home/user regions — - // the workspace IS the user's home. - #expect(sandbox.homeDirectory.standardizedFileURL.path - == workspace.standardizedFileURL.path) - #expect(sandbox.userDirectory.standardizedFileURL.path - == workspace.standardizedFileURL.path) - // The mapping rides on the sandbox for the resolve facade. - #expect(sandbox.pathMapping?.mounts.count == 2) - - // Explicit override wins. - let custom = FileManager.default.temporaryDirectory - .appendingPathComponent("custom-\(UUID().uuidString)") - let overridden = Sandbox.confined( - to: mapping, home: "/batch", temporaryDirectory: custom) - #expect(overridden.temporaryDirectory == custom) - } - - @Test func confinedNetworkPolicy() async throws { - let (workspace, temp) = try Self.makeRoots() - defer { - try? FileManager.default.removeItem(at: workspace) - try? FileManager.default.removeItem(at: temp) - } - let mapping = Self.mapping(workspace: workspace, temp: temp) - - // Default: non-file URLs denied. - let offline = Sandbox.confined(to: mapping, home: "/batch") - await #expect(throws: Sandbox.Denial.self) { - try await offline.authorize(URL(string: "https://example.com/")!) - } - - // allowedHosts admits matching hosts. - let allowing = Sandbox.confined( - to: mapping, home: "/batch", - allowedHosts: ["api.example.com"]) - try await allowing.authorize( - URL(string: "https://api.example.com/v1")!) - - // authorizeNetwork override routes non-file URLs; file URLs - // keep using the mapping gate. - struct Blocked: Error {} - let custom = Sandbox.confined( - to: mapping, home: "/batch", - authorizeNetwork: { _ in throw Blocked() }) - await #expect(throws: Blocked.self) { - try await custom.authorize(URL(string: "https://x.example/")!) - } - try await custom.authorize(temp.appendingPathComponent("ok")) - } } diff --git a/Tests/ShellKitTests/SandboxConfinedTests.swift b/Tests/ShellKitTests/SandboxConfinedTests.swift new file mode 100644 index 0000000..731787f --- /dev/null +++ b/Tests/ShellKitTests/SandboxConfinedTests.swift @@ -0,0 +1,183 @@ +import Foundation +import Testing +@testable import ShellKit + +/// The `Sandbox.confined(to:)` gate over a ``PathMapping`` — +/// Facade B's boundary (#SwiftBash 83): canonical containment under +/// the mapping's host roots, symlink-escape rejection, region/temp +/// derivation, and the network policy knobs. Translation mechanics +/// live in `PathMappingTests`. +@Suite(.timeLimit(.minutes(1))) struct SandboxConfinedTests { + + /// On-disk workspace + per-instance temp dir, shaped like the + /// SwiftBash CLI's `--sandbox ` setup. Caller removes both. + private static func makeRoots() throws -> (workspace: URL, temp: URL) { + let base = FileManager.default.temporaryDirectory + let workspace = base.appendingPathComponent( + "shellkit-ws-\(UUID().uuidString)", isDirectory: true) + let temp = base.appendingPathComponent( + "shellkit-tmp-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: workspace, withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: temp, withIntermediateDirectories: true) + return (workspace, temp) + } + + private static func mapping(workspace: URL, temp: URL) -> PathMapping { + PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: temp.path) + ]) + } + + @Test func confinedAuthorizesHostSpaceOnly() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + + // Host paths under either mount root pass — existing or not. + try await sandbox.authorize(workspace.appendingPathComponent("new/file")) + try await sandbox.authorize(temp) + try await sandbox.authorize(temp.appendingPathComponent("probe.txt")) + + // The VIRTUAL spelling is not host space: a literal `/tmp/...` + // (the host's shared temp dir!) or `/batch/...` is denied — + // callers reach the gate with `Shell.resolve` output. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/tmp/leak")) + } + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/batch/f")) + } + // Outside both roots. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: "/etc/passwd")) + } + // Sibling/prefix collision: `extra` must not pass. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize( + URL(fileURLWithPath: temp.path + "extra")) + } + // The shared platform temp root is NOT authorized — only the + // per-instance dir under it. + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize( + FileManager.default.temporaryDirectory) + } + } + +#if !os(Windows) + @Test func confinedRejectsSymlinkEscape() async throws { + // A link planted inside a mount pointing outside it resolves + // out of every canonical host root → denied (the FileManager- + // backed caller would otherwise follow it out of the sandbox). + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let link = temp.appendingPathComponent("escape-link").path + try FileManager.default.createSymbolicLink( + atPath: link, withDestinationPath: "/") + let sandbox = Sandbox.confined( + to: Self.mapping(workspace: workspace, temp: temp), + home: "/batch") + await #expect(throws: Sandbox.Denial.self) { + try await sandbox.authorize(URL(fileURLWithPath: link)) + } + } + + @Test func confinedAcceptsSymlinkSpelledMountRoot() async throws { + // A mount whose host root is itself reached through a symlink + // still authorizes: both sides canonicalise to the same + // prefix. + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let link = FileManager.default.temporaryDirectory + .appendingPathComponent("shellkit-link-\(UUID().uuidString)") + .path + try FileManager.default.createSymbolicLink( + atPath: link, withDestinationPath: temp.path) + defer { try? FileManager.default.removeItem(atPath: link) } + + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/tmp", host: link) + ]) + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + try await sandbox.authorize( + URL(fileURLWithPath: link).appendingPathComponent("f")) + try await sandbox.authorize(temp.appendingPathComponent("f")) + } +#endif + + @Test func confinedRegionsAndTempDerivation() throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + // temporaryDirectory defaults to the `/tmp` mount's host root. + let sandbox = Sandbox.confined(to: mapping, home: "/batch") + #expect(sandbox.temporaryDirectory.standardizedFileURL.path + == temp.standardizedFileURL.path) + // The home mount's host root anchors home/user regions — + // the workspace IS the user's home. + #expect(sandbox.homeDirectory.standardizedFileURL.path + == workspace.standardizedFileURL.path) + #expect(sandbox.userDirectory.standardizedFileURL.path + == workspace.standardizedFileURL.path) + // The mapping rides on the sandbox for the resolve facade. + #expect(sandbox.pathMapping?.mounts.count == 2) + + // Explicit override wins. + let custom = FileManager.default.temporaryDirectory + .appendingPathComponent("custom-\(UUID().uuidString)") + let overridden = Sandbox.confined( + to: mapping, home: "/batch", temporaryDirectory: custom) + #expect(overridden.temporaryDirectory == custom) + } + + @Test func confinedNetworkPolicy() async throws { + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + let mapping = Self.mapping(workspace: workspace, temp: temp) + + // Default: non-file URLs denied. + let offline = Sandbox.confined(to: mapping, home: "/batch") + await #expect(throws: Sandbox.Denial.self) { + try await offline.authorize(URL(string: "https://example.com/")!) + } + + // allowedHosts admits matching hosts. + let allowing = Sandbox.confined( + to: mapping, home: "/batch", + allowedHosts: ["api.example.com"]) + try await allowing.authorize( + URL(string: "https://api.example.com/v1")!) + + // authorizeNetwork override routes non-file URLs; file URLs + // keep using the mapping gate. + struct Blocked: Error {} + let custom = Sandbox.confined( + to: mapping, home: "/batch", + authorizeNetwork: { _ in throw Blocked() }) + await #expect(throws: Blocked.self) { + try await custom.authorize(URL(string: "https://x.example/")!) + } + try await custom.authorize(temp.appendingPathComponent("ok")) + } +} From 86c1b557fe14c13c69a8cb31eb423857b6959bfa Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 11 Jun 2026 13:06:04 +0200 Subject: [PATCH 3/6] Environment.synthetic: include /usr/local/bin in the default PATH SwiftBash catalogues the SwiftPorts CLIs (fd, rg, yq, the swift-js / node surface) under the virtual /usr/local/bin tier, and its own runtime env default is /usr/local/bin:/usr/bin:/bin. The synthetic environment - used by exactly the sandboxed runs those ports are registered for - omitted that tier, so `fd` came back "command not found" inside `swift-bash exec --sandbox` while jq (catalogued under /usr/bin) worked. Align the synthetic PATH with the catalogue. --- Sources/ShellKit/Environment/Environment.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/ShellKit/Environment/Environment.swift b/Sources/ShellKit/Environment/Environment.swift index 808a544..c28a878 100644 --- a/Sources/ShellKit/Environment/Environment.swift +++ b/Sources/ShellKit/Environment/Environment.swift @@ -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, From ee0e35a22632d1f2105a6779364dab1f26c3854c Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 11 Jun 2026 13:24:13 +0200 Subject: [PATCH 4/6] resolve: void unmapped paths instead of passing them through Codex P1 on #17: under a mapping, resolve returned paths that matched no mount unchanged - so a script holding (or guessing) the HOST spelling of a mounted directory could address files through it, and the gate authorized it since the location genuinely lies inside a canonical host root. The containment boundary held; the namespace boundary (scripts speak virtual only) didn't - and Facade B disagreed with the bash-side mounted filesystem, which ENOENTs host spellings. resolve now never hands back un-translated text under a mapping: paths outside every mount (including the Windows drive-letter and empty-cwd fallbacks) resolve to a voided location under Shell.unmappedPathSentinel ("/dev/null/unmapped" + the normalised virtual spelling). /dev/null is a file on every POSIX host, so nothing below it can exist or be created, and the gate's containment check rejects it like any other out-of-roots path. displayPath folds the sentinel back to the original spelling so diagnostics stay readable. Tests cover the host-spelling probe (verbatim and symlink-resolved), gate denial, and ..-escape of the sentinel. --- Sources/ShellKit/Shell.swift | 91 ++++++++++++++++++---- Tests/ShellKitTests/PathMappingTests.swift | 52 ++++++++++++- 2 files changed, 126 insertions(+), 17 deletions(-) diff --git a/Sources/ShellKit/Shell.swift b/Sources/ShellKit/Shell.swift index c6ece20..fcd9ae9 100644 --- a/Sources/ShellKit/Shell.swift +++ b/Sources/ShellKit/Shell.swift @@ -289,6 +289,19 @@ open class Shell: @unchecked Sendable { /// ``displayPath(for:)-swift.method`` so the embedder's host /// layout never leaks into the sandbox. /// + /// Under a mapping, a path that matches **no** mount — `/etc/x`, + /// or the *host* spelling of a mounted directory (guessed, or + /// leaked through some diagnostic) — never comes back as given: + /// it resolves to a voided location under + /// ``unmappedPathSentinel`` that cannot exist on disk and that + /// ``Sandbox/authorize(_:)`` rejects. Script-visible text is the + /// only addressing scheme inside the virtual namespace; if host + /// spellings passed through here, anyone holding the host path + /// of a mount could sidestep the no-host-paths boundary (the + /// containment boundary would hold, the namespace one wouldn't — + /// and bash's mounted filesystem, which ENOENTs those spellings, + /// would disagree with this facade). + /// /// CLI commands resolving relative argv paths should use this /// instead of `URL(fileURLWithPath:)` / `FileManager.default.currentDirectoryPath` /// directly so embedders can confine path resolution to whatever @@ -306,30 +319,48 @@ open class Shell: @unchecked Sendable { #if os(Windows) if path.count >= 2, let second = path.dropFirst().first, second == ":" { - return URL(fileURLWithPath: path) + // Drive-letter absolutes live outside the unix-style + // virtual namespace; under a mapping they fall through + // to the unmapped sentinel below. + let driveURL = URL(fileURLWithPath: path) + guard sandbox?.pathMapping != nil else { return driveURL } + return Self.voidedUnmappedPath(path) } #endif let cwd = environment.workingDirectory if cwd.isEmpty { - // Host-process CWD fallback: there is no virtual - // path space without an embedder-bound CWD, so the - // mapping never applies here. - return URL( + // Host-process CWD fallback. Without a mapping this + // is the standalone-CLI behaviour. With one, an empty + // embedder CWD is a misconfiguration — the host-CWD + // join would produce exactly the kind of host-space + // path the namespace must not honour, so it voids. + let hostJoined = URL( fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true) .appendingPathComponent(path) + guard let mapping = sandbox?.pathMapping else { + return hostJoined + } + if let translated = mapping.hostPath(forVirtual: hostJoined.path) { + return URL(fileURLWithPath: translated.host) + } + return Self.voidedUnmappedPath(hostJoined.path) } url = URL(fileURLWithPath: cwd, isDirectory: true) .appendingPathComponent(path) } - guard let mapping = sandbox?.pathMapping, - let translated = mapping.hostPath(forVirtual: url.path) - else { - // No mapping (or a virtual path outside every mount — - // which the gate then denies / the host reports missing): - // hand back the un-translated resolution. + guard let mapping = sandbox?.pathMapping else { + // No mapping: the resolution is the path itself, exactly + // as it always worked. return url } + guard let translated = mapping.hostPath(forVirtual: url.path) else { + // Mapping present but no mount matches: the path does not + // exist in the virtual namespace. Void it — never hand + // back text that the host (or the gate) could interpret + // as a real location. + return Self.voidedUnmappedPath(url.path) + } return URL(fileURLWithPath: translated.host) } @@ -340,6 +371,26 @@ open class Shell: @unchecked Sendable { current.resolve(path) } + /// Prefix under which ``resolve(_:)`` voids paths that exist in + /// no mount of the bound mapping. Rooted under `/dev/null` — a + /// *file* on every POSIX host — so nothing below it can exist or + /// be created (`mkdir -p` included), and no sane mount table + /// roots a host directory there; ``Sandbox/authorize(_:)``'s + /// containment check rejects it like any other out-of-roots path. + /// The original (virtual) spelling rides along as the suffix so a + /// stray diagnostic stays debuggable, and + /// ``displayPath(for:)-swift.method`` folds it back out. + public static let unmappedPathSentinel = "/dev/null/unmapped" + + /// Build the voided URL for an absolute path that matched no + /// mount. Lexically normalised first so the sentinel can't be + /// `..`-escaped back out of `/dev/null`. + private static func voidedUnmappedPath(_ path: String) -> URL { + var normalized = normalizePath(path) + if !normalized.hasPrefix("/") { normalized = "/" + normalized } + return URL(fileURLWithPath: unmappedPathSentinel + normalized) + } + // MARK: - Display paths /// Fold a host filesystem path back to its virtual spelling for @@ -350,10 +401,22 @@ open class Shell: @unchecked Sendable { /// output, `os.tmpdir()`, diagnostics that embed a resolved path. /// Returns `path` unchanged when no mapping is bound or the path /// doesn't fall under any mount (nothing to hide). + /// + /// Voided paths (``unmappedPathSentinel``) fold back to the + /// virtual spelling they were built from, so a diagnostic that + /// routes through here shows the script its own path, not the + /// sentinel. public func displayPath(for path: String) -> String { - guard let mapping = sandbox?.pathMapping, - let virtual = mapping.virtualPath(forHost: path) - else { return path } + guard let mapping = sandbox?.pathMapping else { return path } + if path == Self.unmappedPathSentinel { + return "/" + } + if path.hasPrefix(Self.unmappedPathSentinel + "/") { + return String(path.dropFirst(Self.unmappedPathSentinel.count)) + } + guard let virtual = mapping.virtualPath(forHost: path) else { + return path + } return virtual } diff --git a/Tests/ShellKitTests/PathMappingTests.swift b/Tests/ShellKitTests/PathMappingTests.swift index 825b590..319de4e 100644 --- a/Tests/ShellKitTests/PathMappingTests.swift +++ b/Tests/ShellKitTests/PathMappingTests.swift @@ -170,9 +170,14 @@ import Testing #expect(shell.resolve("data.json").path == workspace.path + "/data.json") #expect(shell.resolve("../tmp/x").path == temp.path + "/x") - // Virtual paths outside every mount come back untranslated — - // the gate denies them and the host reports them missing. - #expect(shell.resolve("/etc/passwd").path == "/etc/passwd") + // Virtual paths outside every mount are VOIDED — never handed + // back as host-interpretable text. The sentinel keeps the + // virtual spelling for diagnostics, can't exist on disk, and + // the gate denies it. + #expect(shell.resolve("/etc/passwd").path + == Shell.unmappedPathSentinel + "/etc/passwd") + #expect(shell.displayPath(for: shell.resolve("/etc/passwd")) + == "/etc/passwd") // The static accessors agree under the binding. try await shell.withCurrent { @@ -181,6 +186,47 @@ import Testing } } + @Test func hostSpellingsDoNotResolveUnderMapping() async throws { + // The namespace boundary, not just the containment boundary: + // a script that GUESSES (or was leaked) the host path of a + // mounted directory must not be able to address files through + // it. Pre-fix, resolve passed the unmapped host spelling + // through unchanged and the gate then authorized it — it IS + // under a canonical host root. Now it voids, the gate denies, + // and Facade B agrees with the bash-side mounted filesystem + // (which reports ENOENT for host spellings). + let (workspace, temp) = try Self.makeRoots() + defer { + try? FileManager.default.removeItem(at: workspace) + try? FileManager.default.removeItem(at: temp) + } + try Data("secret".utf8).write( + to: workspace.appendingPathComponent("secret.txt")) + let mapping = Self.mapping(workspace: workspace, temp: temp) + var env = Environment() + env.workingDirectory = "/batch" + let shell = Shell(environment: env) + shell.sandbox = .confined(to: mapping, home: "/batch") + + for hostSpelling in [ + workspace.path + "/secret.txt", + workspace.resolvingSymlinksInPath().path + "/secret.txt", + temp.path + ] { + let resolved = shell.resolve(hostSpelling) + #expect(resolved.path.hasPrefix(Shell.unmappedPathSentinel), + "host spelling resolved to \(resolved.path)") + #expect(!FileManager.default.fileExists(atPath: resolved.path)) + await #expect(throws: Sandbox.Denial.self) { + try await shell.sandbox!.authorize(resolved) + } + } + // The voided path can't be `..`-escaped back out of the + // sentinel either. + let sneaky = shell.resolve("/nope/../../" + workspace.path) + #expect(sneaky.path.hasPrefix(Shell.unmappedPathSentinel)) + } + @Test func resolveWithoutMappingIsUnchanged() { var env = Environment() env.workingDirectory = "/virtual/cwd" From 8a9680b240c6f6f8d1c7ae2527c59ea22c32f768 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 11 Jun 2026 13:24:52 +0200 Subject: [PATCH 5/6] lint: move path resolution + display into Shell+Path.swift Shell.swift crossed the 400-line file_length cap with the sentinel addition; the resolve/displayPath/sentinel family lives with normalizePath now, which is the better home anyway. --- Sources/ShellKit/Shell+Path.swift | 173 ++++++++++++++++++++++++++++++ Sources/ShellKit/Shell.swift | 169 ----------------------------- 2 files changed, 173 insertions(+), 169 deletions(-) diff --git a/Sources/ShellKit/Shell+Path.swift b/Sources/ShellKit/Shell+Path.swift index ffe7ffd..ccf277c 100644 --- a/Sources/ShellKit/Shell+Path.swift +++ b/Sources/ShellKit/Shell+Path.swift @@ -76,3 +76,176 @@ extension Shell { return stack.isEmpty ? "." : stack.joined(separator: "/") } } + +extension Shell { + + // MARK: - Path resolution + + /// Resolve a (possibly relative) path string into the absolute + /// `URL` to do real I/O on, honouring the shell's current working + /// directory and — under a path-mapped sandbox — translating the + /// virtual spelling to the host directory that backs it. + /// + /// Relative paths resolve against ``environment``'s + /// ``Environment/workingDirectory``. When the bound sandbox + /// carries a ``Sandbox/pathMapping`` (the cooperative-sandbox + /// case: the script-visible path space is virtual), the resolved + /// path is then routed through the mount table, so `/tmp/x` + /// comes back as the per-instance host temp dir's `x` — ready + /// for `FileManager` / C APIs and for ``Sandbox/authorize(_:)``, + /// which gates the same host space. Without a mapping the result + /// is the path as given (absolute) or CWD-joined (relative), + /// unchanged from how it always worked. + /// + /// The returned URL is for *doing I/O*, not for *showing to the + /// script* — display output should keep the user's own spelling + /// or fold a host path back through + /// ``displayPath(for:)-swift.method`` so the embedder's host + /// layout never leaks into the sandbox. + /// + /// Under a mapping, a path that matches **no** mount — `/etc/x`, + /// or the *host* spelling of a mounted directory (guessed, or + /// leaked through some diagnostic) — never comes back as given: + /// it resolves to a voided location under + /// ``unmappedPathSentinel`` that cannot exist on disk and that + /// ``Sandbox/authorize(_:)`` rejects. Script-visible text is the + /// only addressing scheme inside the virtual namespace; if host + /// spellings passed through here, anyone holding the host path + /// of a mount could sidestep the no-host-paths boundary (the + /// containment boundary would hold, the namespace one wouldn't — + /// and bash's mounted filesystem, which ENOENTs those spellings, + /// would disagree with this facade). + /// + /// CLI commands resolving relative argv paths should use this + /// instead of `URL(fileURLWithPath:)` / `FileManager.default.currentDirectoryPath` + /// directly so embedders can confine path resolution to whatever + /// CWD they bound (typically tracked via the script's own `cd` + /// builtin updating `environment.workingDirectory`). + /// + /// The static counterpart ``resolve(_:)`` is the convenient + /// call site for code that doesn't already have a `Shell` + /// reference; it routes through ``current``. + public func resolve(_ path: String) -> URL { + let url: URL + if path.hasPrefix("/") { + url = URL(fileURLWithPath: path) + } else { + #if os(Windows) + if path.count >= 2, + let second = path.dropFirst().first, second == ":" { + // Drive-letter absolutes live outside the unix-style + // virtual namespace; under a mapping they fall through + // to the unmapped sentinel below. + let driveURL = URL(fileURLWithPath: path) + guard sandbox?.pathMapping != nil else { return driveURL } + return Self.voidedUnmappedPath(path) + } + #endif + let cwd = environment.workingDirectory + if cwd.isEmpty { + // Host-process CWD fallback. Without a mapping this + // is the standalone-CLI behaviour. With one, an empty + // embedder CWD is a misconfiguration — the host-CWD + // join would produce exactly the kind of host-space + // path the namespace must not honour, so it voids. + let hostJoined = URL( + fileURLWithPath: FileManager.default.currentDirectoryPath, + isDirectory: true) + .appendingPathComponent(path) + guard let mapping = sandbox?.pathMapping else { + return hostJoined + } + if let translated = mapping.hostPath(forVirtual: hostJoined.path) { + return URL(fileURLWithPath: translated.host) + } + return Self.voidedUnmappedPath(hostJoined.path) + } + url = URL(fileURLWithPath: cwd, isDirectory: true) + .appendingPathComponent(path) + } + guard let mapping = sandbox?.pathMapping else { + // No mapping: the resolution is the path itself, exactly + // as it always worked. + return url + } + guard let translated = mapping.hostPath(forVirtual: url.path) else { + // Mapping present but no mount matches: the path does not + // exist in the virtual namespace. Void it — never hand + // back text that the host (or the gate) could interpret + // as a real location. + return Self.voidedUnmappedPath(url.path) + } + return URL(fileURLWithPath: translated.host) + } + + /// Resolve `path` against ``current``'s working directory. + /// Equivalent to `Shell.current.resolve(path)`; provided for + /// call-site convenience. + public static func resolve(_ path: String) -> URL { + current.resolve(path) + } + + /// Prefix under which ``resolve(_:)`` voids paths that exist in + /// no mount of the bound mapping. Rooted under `/dev/null` — a + /// *file* on every POSIX host — so nothing below it can exist or + /// be created (`mkdir -p` included), and no sane mount table + /// roots a host directory there; ``Sandbox/authorize(_:)``'s + /// containment check rejects it like any other out-of-roots path. + /// The original (virtual) spelling rides along as the suffix so a + /// stray diagnostic stays debuggable, and + /// ``displayPath(for:)-swift.method`` folds it back out. + public static let unmappedPathSentinel = "/dev/null/unmapped" + + /// Build the voided URL for an absolute path that matched no + /// mount. Lexically normalised first so the sentinel can't be + /// `..`-escaped back out of `/dev/null`. + private static func voidedUnmappedPath(_ path: String) -> URL { + var normalized = normalizePath(path) + if !normalized.hasPrefix("/") { normalized = "/" + normalized } + return URL(fileURLWithPath: unmappedPathSentinel + normalized) + } + + // MARK: - Display paths + + /// Fold a host filesystem path back to its virtual spelling for + /// display, when the bound sandbox carries a + /// ``Sandbox/pathMapping``. The inverse of ``resolve(_:)``: + /// `resolve` is for doing I/O, `displayPath` is for anything the + /// script gets to *see* — `realpath`-style answers, `--absolute-path` + /// output, `os.tmpdir()`, diagnostics that embed a resolved path. + /// Returns `path` unchanged when no mapping is bound or the path + /// doesn't fall under any mount (nothing to hide). + /// + /// Voided paths (``unmappedPathSentinel``) fold back to the + /// virtual spelling they were built from, so a diagnostic that + /// routes through here shows the script its own path, not the + /// sentinel. + public func displayPath(for path: String) -> String { + guard let mapping = sandbox?.pathMapping else { return path } + if path == Self.unmappedPathSentinel { + return "/" + } + if path.hasPrefix(Self.unmappedPathSentinel + "/") { + return String(path.dropFirst(Self.unmappedPathSentinel.count)) + } + guard let virtual = mapping.virtualPath(forHost: path) else { + return path + } + return virtual + } + + /// URL-form convenience for ``displayPath(for:)-swift.method``. + public func displayPath(for url: URL) -> String { + displayPath(for: url.path) + } + + /// Fold `path` through ``current``'s mapping for display. + public static func displayPath(for path: String) -> String { + current.displayPath(for: path) + } + + /// Fold `url` through ``current``'s mapping for display. + public static func displayPath(for url: URL) -> String { + current.displayPath(for: url) + } +} diff --git a/Sources/ShellKit/Shell.swift b/Sources/ShellKit/Shell.swift index fcd9ae9..54c8bcf 100644 --- a/Sources/ShellKit/Shell.swift +++ b/Sources/ShellKit/Shell.swift @@ -265,175 +265,6 @@ open class Shell: @unchecked Sendable { return try await Shell.$current.withValue(self) { try await body() } } - // MARK: - Path resolution - - /// Resolve a (possibly relative) path string into the absolute - /// `URL` to do real I/O on, honouring the shell's current working - /// directory and — under a path-mapped sandbox — translating the - /// virtual spelling to the host directory that backs it. - /// - /// Relative paths resolve against ``environment``'s - /// ``Environment/workingDirectory``. When the bound sandbox - /// carries a ``Sandbox/pathMapping`` (the cooperative-sandbox - /// case: the script-visible path space is virtual), the resolved - /// path is then routed through the mount table, so `/tmp/x` - /// comes back as the per-instance host temp dir's `x` — ready - /// for `FileManager` / C APIs and for ``Sandbox/authorize(_:)``, - /// which gates the same host space. Without a mapping the result - /// is the path as given (absolute) or CWD-joined (relative), - /// unchanged from how it always worked. - /// - /// The returned URL is for *doing I/O*, not for *showing to the - /// script* — display output should keep the user's own spelling - /// or fold a host path back through - /// ``displayPath(for:)-swift.method`` so the embedder's host - /// layout never leaks into the sandbox. - /// - /// Under a mapping, a path that matches **no** mount — `/etc/x`, - /// or the *host* spelling of a mounted directory (guessed, or - /// leaked through some diagnostic) — never comes back as given: - /// it resolves to a voided location under - /// ``unmappedPathSentinel`` that cannot exist on disk and that - /// ``Sandbox/authorize(_:)`` rejects. Script-visible text is the - /// only addressing scheme inside the virtual namespace; if host - /// spellings passed through here, anyone holding the host path - /// of a mount could sidestep the no-host-paths boundary (the - /// containment boundary would hold, the namespace one wouldn't — - /// and bash's mounted filesystem, which ENOENTs those spellings, - /// would disagree with this facade). - /// - /// CLI commands resolving relative argv paths should use this - /// instead of `URL(fileURLWithPath:)` / `FileManager.default.currentDirectoryPath` - /// directly so embedders can confine path resolution to whatever - /// CWD they bound (typically tracked via the script's own `cd` - /// builtin updating `environment.workingDirectory`). - /// - /// The static counterpart ``resolve(_:)`` is the convenient - /// call site for code that doesn't already have a `Shell` - /// reference; it routes through ``current``. - public func resolve(_ path: String) -> URL { - let url: URL - if path.hasPrefix("/") { - url = URL(fileURLWithPath: path) - } else { - #if os(Windows) - if path.count >= 2, - let second = path.dropFirst().first, second == ":" { - // Drive-letter absolutes live outside the unix-style - // virtual namespace; under a mapping they fall through - // to the unmapped sentinel below. - let driveURL = URL(fileURLWithPath: path) - guard sandbox?.pathMapping != nil else { return driveURL } - return Self.voidedUnmappedPath(path) - } - #endif - let cwd = environment.workingDirectory - if cwd.isEmpty { - // Host-process CWD fallback. Without a mapping this - // is the standalone-CLI behaviour. With one, an empty - // embedder CWD is a misconfiguration — the host-CWD - // join would produce exactly the kind of host-space - // path the namespace must not honour, so it voids. - let hostJoined = URL( - fileURLWithPath: FileManager.default.currentDirectoryPath, - isDirectory: true) - .appendingPathComponent(path) - guard let mapping = sandbox?.pathMapping else { - return hostJoined - } - if let translated = mapping.hostPath(forVirtual: hostJoined.path) { - return URL(fileURLWithPath: translated.host) - } - return Self.voidedUnmappedPath(hostJoined.path) - } - url = URL(fileURLWithPath: cwd, isDirectory: true) - .appendingPathComponent(path) - } - guard let mapping = sandbox?.pathMapping else { - // No mapping: the resolution is the path itself, exactly - // as it always worked. - return url - } - guard let translated = mapping.hostPath(forVirtual: url.path) else { - // Mapping present but no mount matches: the path does not - // exist in the virtual namespace. Void it — never hand - // back text that the host (or the gate) could interpret - // as a real location. - return Self.voidedUnmappedPath(url.path) - } - return URL(fileURLWithPath: translated.host) - } - - /// Resolve `path` against ``current``'s working directory. - /// Equivalent to `Shell.current.resolve(path)`; provided for - /// call-site convenience. - public static func resolve(_ path: String) -> URL { - current.resolve(path) - } - - /// Prefix under which ``resolve(_:)`` voids paths that exist in - /// no mount of the bound mapping. Rooted under `/dev/null` — a - /// *file* on every POSIX host — so nothing below it can exist or - /// be created (`mkdir -p` included), and no sane mount table - /// roots a host directory there; ``Sandbox/authorize(_:)``'s - /// containment check rejects it like any other out-of-roots path. - /// The original (virtual) spelling rides along as the suffix so a - /// stray diagnostic stays debuggable, and - /// ``displayPath(for:)-swift.method`` folds it back out. - public static let unmappedPathSentinel = "/dev/null/unmapped" - - /// Build the voided URL for an absolute path that matched no - /// mount. Lexically normalised first so the sentinel can't be - /// `..`-escaped back out of `/dev/null`. - private static func voidedUnmappedPath(_ path: String) -> URL { - var normalized = normalizePath(path) - if !normalized.hasPrefix("/") { normalized = "/" + normalized } - return URL(fileURLWithPath: unmappedPathSentinel + normalized) - } - - // MARK: - Display paths - - /// Fold a host filesystem path back to its virtual spelling for - /// display, when the bound sandbox carries a - /// ``Sandbox/pathMapping``. The inverse of ``resolve(_:)``: - /// `resolve` is for doing I/O, `displayPath` is for anything the - /// script gets to *see* — `realpath`-style answers, `--absolute-path` - /// output, `os.tmpdir()`, diagnostics that embed a resolved path. - /// Returns `path` unchanged when no mapping is bound or the path - /// doesn't fall under any mount (nothing to hide). - /// - /// Voided paths (``unmappedPathSentinel``) fold back to the - /// virtual spelling they were built from, so a diagnostic that - /// routes through here shows the script its own path, not the - /// sentinel. - public func displayPath(for path: String) -> String { - guard let mapping = sandbox?.pathMapping else { return path } - if path == Self.unmappedPathSentinel { - return "/" - } - if path.hasPrefix(Self.unmappedPathSentinel + "/") { - return String(path.dropFirst(Self.unmappedPathSentinel.count)) - } - guard let virtual = mapping.virtualPath(forHost: path) else { - return path - } - return virtual - } - - /// URL-form convenience for ``displayPath(for:)-swift.method``. - public func displayPath(for url: URL) -> String { - displayPath(for: url.path) - } - - /// Fold `path` through ``current``'s mapping for display. - public static func displayPath(for path: String) -> String { - current.displayPath(for: path) - } - - /// Fold `url` through ``current``'s mapping for display. - public static func displayPath(for url: URL) -> String { - current.displayPath(for: url) - } } // MARK: - InputSource process standard input From 2707035a0fea671dd67cff7e66bb759561b98b40 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Thu, 11 Jun 2026 13:30:38 +0200 Subject: [PATCH 6/6] test: avoid virtual /tmp mounts in the host-spelling probes On Linux the platform temp root IS /tmp, so a fixture host dir under it prefix-matches a virtual /tmp mount and translates as an ordinary virtual path (into the mount's own backing - harmless by design) instead of voiding. Probe with non-colliding virtual prefixes so the no-mount-matches contract is what's actually exercised on every platform; document the collision semantics in the test. --- Tests/ShellKitTests/PathMappingTests.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Tests/ShellKitTests/PathMappingTests.swift b/Tests/ShellKitTests/PathMappingTests.swift index 319de4e..534b710 100644 --- a/Tests/ShellKitTests/PathMappingTests.swift +++ b/Tests/ShellKitTests/PathMappingTests.swift @@ -195,6 +195,14 @@ import Testing // under a canonical host root. Now it voids, the gate denies, // and Facade B agrees with the bash-side mounted filesystem // (which reports ENOENT for host spellings). + // + // Virtual prefixes here deliberately avoid `/tmp`: on Linux + // the platform temp root IS `/tmp`, so a host spelling under + // it would prefix-match a virtual `/tmp` mount and (by + // design) translate as an ordinary virtual path — into the + // mount's own backing, never the host location, so that + // collision is harmless; the voiding contract under test + // here is about spellings that match NO mount. let (workspace, temp) = try Self.makeRoots() defer { try? FileManager.default.removeItem(at: workspace) @@ -202,7 +210,10 @@ import Testing } try Data("secret".utf8).write( to: workspace.appendingPathComponent("secret.txt")) - let mapping = Self.mapping(workspace: workspace, temp: temp) + let mapping = PathMapping(mounts: [ + .init(virtual: "/batch", host: workspace.path), + .init(virtual: "/scratch", host: temp.path) + ]) var env = Environment() env.workingDirectory = "/batch" let shell = Shell(environment: env)