diff --git a/Sources/ShellKit/Sandbox/Sandbox.swift b/Sources/ShellKit/Sandbox/Sandbox.swift index a6d595d..f0fe258 100644 --- a/Sources/ShellKit/Sandbox/Sandbox.swift +++ b/Sources/ShellKit/Sandbox/Sandbox.swift @@ -114,7 +114,7 @@ public struct Sandbox: Sendable { /// guaranteed to succeed. Callers MAY inspect it for diagnostics /// or opt-in recovery; ShellKit / SwiftPorts internals never /// inspect it and never retry. - public struct Denial: Error, Sendable { + public struct Denial: Error, Sendable, CustomStringConvertible, LocalizedError { public let url: URL public let reason: String public let suggestion: URL? @@ -124,5 +124,23 @@ public struct Sandbox: Sendable { self.reason = reason self.suggestion = suggestion } + + /// Human-readable description that intentionally omits both + /// `url` and `suggestion`. The default `String(describing:)` + /// dump (which ArgumentParser's `fullMessage(for:)` falls + /// through to for unrecognised error types) would otherwise + /// expose the embedder's host sandbox root — for an + /// app-as-sandbox embedder that means the iOS container path + /// (`/Users/.../Containers/.../Documents/Foo.bar/...`) ends + /// up in user-visible stderr on a single denied call. + /// + /// Callers needing the URLs read `.url` and `.suggestion` + /// directly. The reason is the only safe-to-display string. + public var description: String { reason } + + /// Same surface for `LocalizedError` consumers — keeps + /// `(error as NSError).localizedDescription` and + /// `error.localizedDescription` in sync with `description`. + public var errorDescription: String? { reason } } } diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index 7e12c2e..950f182 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -122,6 +122,37 @@ import Testing // expected } } + + /// Default `String(describing:)` for a struct dumps every stored + /// property — including `suggestion`, which carries the host + /// sandbox root. ArgumentParser's `fullMessage(for:)` falls + /// through to that for unrecognised error types, so a denied + /// `gh issue list` would otherwise leak the embedder's + /// container path. Pin both `description` and + /// `errorDescription` so any consumer (string interpolation, + /// `localizedDescription`, ArgumentParser) gets the reason + /// only. + @Test func denialDescriptionDoesNotLeakUrls() { + let denial = Sandbox.Denial( + url: URL(fileURLWithPath: "/secret/host/root/Documents/Untitled.foo"), + reason: "host 'github.com' is not in the sandbox allowlist", + suggestion: URL(fileURLWithPath: "/secret/host/root/Documents/Untitled.foo/home")) + + let viaInterpolation = "\(denial)" + let viaDescribing = String(describing: denial) + let viaLocalized = denial.errorDescription ?? "" + + for s in [viaInterpolation, viaDescribing, viaLocalized] { + #expect(s.contains("host 'github.com' is not in the sandbox allowlist"), + "expected reason in: \(s)") + #expect(!s.contains("/secret/host/root"), + "host path leaked into: \(s)") + #expect(!s.contains("suggestion"), + "field name leaked into: \(s)") + #expect(!s.contains("Denial("), + "struct shape leaked into: \(s)") + } + } } @Suite struct ArgumentParserBridgeTests {