From d79ff717bc970d05e4499b589879581904d572a3 Mon Sep 17 00:00:00 2001 From: "Adel E." Date: Mon, 17 Aug 2026 02:40:25 +0300 Subject: [PATCH] Harden local integrations and model downloads --- .github/ISSUE_TEMPLATE/bug_report.yml | 4 +- .github/workflows/ci.yml | 15 +- CONTRIBUTING.md | 3 +- Sources/Halen/App/AppCoordinator.swift | 4 +- Sources/Halen/App/SettingsView.swift | 2 +- .../Halen/Inference/LlamaCpp/ModelSpec.swift | 4 +- .../External/ExternalPluginAdapter.swift | 28 ++- .../Halen/Plugins/External/HostBridge.swift | 68 +++--- .../Halen/Plugins/External/PluginHost.swift | 14 +- .../Plugins/External/PluginInstance.swift | 33 ++- .../Plugins/External/PluginManifest.swift | 90 +++++-- .../Plugins/External/WebSocketBridge.swift | 231 +++++++++++++----- Sources/Halen/Plugins/PluginRegistry.swift | 12 +- .../Halen/Plugins/Store/PluginInstaller.swift | 214 ++++++++++++++-- .../Plugins/Store/PluginRegistryIndex.swift | 101 +++++++- .../Plugins/Store/PluginStoreModel.swift | 17 +- .../Halen/Plugins/Store/PluginStoreView.swift | 23 ++ Sources/Halen/Support/Log.swift | 105 +++++--- Tests/HalenTests/LogRedactTests.swift | 47 ++++ Tests/HalenTests/ModelDownloaderTests.swift | 37 +++ Tests/HalenTests/PluginManifestTests.swift | 16 +- Tests/HalenTests/PluginSecurityTests.swift | 181 ++++++++++++++ Tests/HalenTests/WebSocketBridgeTests.swift | 87 +++++++ Vendor/LLAMA_CPP_COMMIT | 1 + browser-extension/README.md | 54 ++-- browser-extension/background.js | 143 +++++++++++ browser-extension/content.js | 123 +++------- browser-extension/manifest.json | 6 +- browser-extension/popup.js | 44 ++-- docs/RELEASING.md | 33 ++- docs/wiki/privacy.md | 12 +- plugin-registry.json | 27 +- plugin-registry.schema.md | 16 +- plugins/desktop-buddy/halen-plugin.json | 6 +- plugins/mother/halen-plugin.json | 6 +- plugins/mother/plugin.py | 0 plugins/reasoning-compactor/halen-plugin.json | 6 +- plugins/reasoning-compactor/plugin.py | 0 scripts/build-app.sh | 27 +- scripts/fetch-assets.sh | 38 ++- scripts/notarize.sh | 3 +- scripts/package-dmg.sh | 28 ++- scripts/test-release-verifiers.sh | 55 +++++ scripts/verify-llama-framework.sh | 36 +++ scripts/verify-macho-paths.sh | 49 ++++ 45 files changed, 1615 insertions(+), 434 deletions(-) create mode 100644 Tests/HalenTests/PluginSecurityTests.swift create mode 100644 Vendor/LLAMA_CPP_COMMIT create mode 100644 browser-extension/background.js mode change 100644 => 100755 plugins/mother/plugin.py mode change 100644 => 100755 plugins/reasoning-compactor/plugin.py create mode 100755 scripts/test-release-verifiers.sh create mode 100755 scripts/verify-llama-framework.sh create mode 100755 scripts/verify-macho-paths.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b50a094..9c7d771 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -92,9 +92,9 @@ body: attributes: label: Relevant log lines description: | - Halen writes diagnostic output to `/tmp/halen-trace.log`. The last 200 lines around the bug are usually plenty. + Halen writes diagnostic output to `~/Library/Application Support/Halen/halen-trace.log`. The last 200 lines around the bug are usually plenty. ```bash - tail -n 200 /tmp/halen-trace.log + tail -n 200 "$HOME/Library/Application Support/Halen/halen-trace.log" ``` Redact anything sensitive before pasting — Halen tries to scrub PII but trust your eyes over ours. render: text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af072e4..64c901e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: swift --version xcodebuild -version + - name: Test release verifiers + run: ./scripts/test-release-verifiers.sh + - name: Cache llama.xcframework # Keyed on the pinned llama.cpp tag — bumping Vendor/LLAMA_CPP_VERSION # invalidates the cache and forces a rebuild. Build is ~10 min cold, @@ -43,8 +46,10 @@ jobs: id: cache-llama uses: actions/cache@v4 with: - path: Vendor/llama.xcframework - key: llama-xcframework-${{ runner.os }}-${{ hashFiles('Vendor/LLAMA_CPP_VERSION') }} + path: | + Vendor/llama.xcframework + Vendor/llama.xcframework.provenance + key: llama-xcframework-${{ runner.os }}-${{ hashFiles('Vendor/LLAMA_CPP_VERSION', 'Vendor/LLAMA_CPP_COMMIT', 'scripts/verify-llama-framework.sh') }} - name: Cache SwiftPM build uses: actions/cache@v4 @@ -66,6 +71,9 @@ jobs: - name: Build (debug) run: swift build -c debug + - name: Verify vendored provenance + run: ./scripts/verify-llama-framework.sh + - name: Test run: swift test -c debug --enable-code-coverage @@ -73,3 +81,6 @@ jobs: # Release config catches a different class of warning/error than debug. # Skip running tests at -O so CI stays under 15 min on the macos-14 runner. run: swift build -c release + + - name: Verify release Mach-O paths + run: ./scripts/verify-macho-paths.sh "$(swift build -c release --show-bin-path)/halen" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3396cea..32dd9c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -103,7 +103,8 @@ turned on, organised by feature folder. A few specifics: - **No force-unwraps in production paths.** Guard, return, log. The `HalenSupportDirectory.root` resolver exists specifically because every `.first!` we used to have was a crash waiting on someone's edge case. -- **`Log` for diagnostics, not `print`.** Lines under `/tmp/halen-trace.log` +- **`Log` for diagnostics, not `print`.** Lines under + `~/Library/Application Support/Halen/halen-trace.log` are how we debug; `print` is invisible in a release build. - **Tests for non-trivial logic.** If you fix a bug, a regression test is the price of admission. If you add a feature with branches, cover the diff --git a/Sources/Halen/App/AppCoordinator.swift b/Sources/Halen/App/AppCoordinator.swift index ae75741..ec70759 100644 --- a/Sources/Halen/App/AppCoordinator.swift +++ b/Sources/Halen/App/AppCoordinator.swift @@ -248,7 +248,7 @@ final class AppCoordinator { pluginHost = host for (dir, manifest) in host.discoverManifests() { let adapter = ExternalPluginAdapter(manifest: manifest, pluginDir: dir, host: host) - registry.register(adapter) + registry.register(adapter, defaultEnabled: false) } host.startEventDispatcher() @@ -286,7 +286,7 @@ final class AppCoordinator { } guard !registry.contains(manifest.id) else { return } let adapter = ExternalPluginAdapter(manifest: manifest, pluginDir: directory, host: pluginHost) - registry.register(adapter) + registry.register(adapter, defaultEnabled: false) } private func startEventLogger() { diff --git a/Sources/Halen/App/SettingsView.swift b/Sources/Halen/App/SettingsView.swift index e928d0b..0201f17 100644 --- a/Sources/Halen/App/SettingsView.swift +++ b/Sources/Halen/App/SettingsView.swift @@ -39,7 +39,7 @@ struct SettingsView: View { @AppStorage(OverlayController.dotStyleKey) private var overlayDotStyle: String = "solid" /// Two-way binding to the WS bridge's enabled preference. Toggling /// here also calls into the bridge to actually start/stop it live. - @AppStorage(WebSocketBridge.enabledKey) private var webSocketEnabled: Bool = true + @AppStorage(WebSocketBridge.enabledKey) private var webSocketEnabled: Bool = false /// Persisted Ollama endpoint. The TextField edits `ollamaURLDraft` and /// only writes through to this key on commit — typing "http://localh" /// mid-edit shouldn't put a half-URL into UserDefaults. diff --git a/Sources/Halen/Inference/LlamaCpp/ModelSpec.swift b/Sources/Halen/Inference/LlamaCpp/ModelSpec.swift index c6a19ea..043dc0b 100644 --- a/Sources/Halen/Inference/LlamaCpp/ModelSpec.swift +++ b/Sources/Halen/Inference/LlamaCpp/ModelSpec.swift @@ -70,7 +70,7 @@ extension ModelSpec { bundleResourceName: "gemma-4-E4B-it-IQ4_XS", displayName: "Gemma 4 E4B (IQ4_XS)", sourceURL: URL(string: - "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-IQ4_XS.gguf" + "https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/653803f092503c04a65164346f3208a36e707693/gemma-4-E4B-it-IQ4_XS.gguf" )!, expectedSize: 4_715_414_688, // ~4.72 GB expectedSHA256: "eb29c8519c4c07b880fb9cae7ff13ee2e30c5f38516268920ab85c04df6d52a2", @@ -97,7 +97,7 @@ extension ModelSpec { bundleResourceName: "qwen2.5-0.5b-instruct-q4_k_m", displayName: "Qwen 2.5 0.5B (Q4_K_M)", sourceURL: URL(string: - "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf" + "https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/9217f5db79a29953eb74d5343926648285ec7e67/qwen2.5-0.5b-instruct-q4_k_m.gguf" )!, expectedSize: 491_400_032, // ~491 MB expectedSHA256: "74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db", diff --git a/Sources/Halen/Plugins/External/ExternalPluginAdapter.swift b/Sources/Halen/Plugins/External/ExternalPluginAdapter.swift index 97acab8..4229436 100644 --- a/Sources/Halen/Plugins/External/ExternalPluginAdapter.swift +++ b/Sources/Halen/Plugins/External/ExternalPluginAdapter.swift @@ -56,9 +56,8 @@ final class ExternalPluginAdapter: HalenPlugin { /// Marketplace detail view for an external plugin. Shows the manifest fields /// the user might want to verify before trusting the plugin — id, version, /// the actual executable that runs, declared permissions, where it lives on -/// disk. Permissions are surfaced even though the host doesn't enforce them -/// yet (informational v1), because they're the user's only signal of what -/// surface area the plugin is asking for. +/// disk. Permissions are surfaced because enabling is the user's approval +/// action and the host enforces this exact closed set. @MainActor private struct ExternalPluginDetailView: View { let manifest: PluginManifest @@ -147,7 +146,7 @@ private struct ExternalPluginDetailView: View { GlassCard { VStack(alignment: .leading, spacing: 8) { cardLabel("Declared permissions") - let perms = manifest.permissions ?? [] + let perms = manifest.permissions if perms.isEmpty { Text("This plugin declared no permissions.") .font(.system(size: 11)) @@ -158,12 +157,12 @@ private struct ExternalPluginDetailView: View { Image(systemName: "checkmark.shield") .font(.system(size: 10)) .foregroundStyle(.secondary) - Text(perm) + Text(perm.rawValue) .font(.system(size: 12, design: .monospaced)) } } } - Text("Permission enforcement is informational in v1 — the host trusts any installed plugin. A sandboxed exec ladder is on the roadmap.") + Text("These permissions gate Halen's plugin API. A plugin is still local executable code and is not an OS sandbox; enable only code you trust.") .font(.system(size: 10)) .foregroundStyle(.tertiary) .fixedSize(horizontal: false, vertical: true) @@ -180,6 +179,23 @@ private struct ExternalPluginDetailView: View { .font(.system(size: 12)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) + + Divider().padding(.vertical, 2) + cardLabel("Data subscriptions") + if manifest.events.isEmpty { + Text("This plugin receives no Halen event data.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } else { + ForEach(manifest.events.map(\.rawValue).sorted(), id: \.self) { topic in + Text(topic) + .font(.system(size: 12, design: .monospaced)) + } + } + Text("Subscriptions can include typed text, focused-app identity, caret location, or findings. Review them as data-access grants.") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Sources/Halen/Plugins/External/HostBridge.swift b/Sources/Halen/Plugins/External/HostBridge.swift index 927d1d3..4759e91 100644 --- a/Sources/Halen/Plugins/External/HostBridge.swift +++ b/Sources/Halen/Plugins/External/HostBridge.swift @@ -3,17 +3,9 @@ import AppKit import ApplicationServices import UserNotifications -/// Single source of truth for every plugin/extension → host JSON-RPC method -/// the host exposes. Both transports (stdio via `PluginHost` and WebSocket -/// via `WebSocketBridge`) delegate every incoming request to -/// `HostBridge.dispatch(...)` so the API surface is identical regardless of -/// how a client arrived. -/// -/// This used to live duplicated in `PluginHost.handleIncoming` and -/// `WebSocketBridge.dispatch`, and the two had already drifted: the WS path -/// hardcoded `temperature: 0.4`, didn't accept `stop`/`taskKind`/`maxTokens`, -/// and was missing `ax/replaceRange` + `ui/toast` entirely. Consolidating -/// closes that class of bug. +/// Single source of truth for every external stdio plugin → host JSON-RPC +/// method. The browser WebSocket is deliberately notification-only and never +/// reaches this dispatcher. @MainActor final class HostBridge { private let services: HalenServices @@ -28,15 +20,14 @@ final class HostBridge { /// The one dispatch site. Returns the `result` payload or throws an /// `RPCErrorObject` the transport then encodes back to the caller. /// - /// `grantedPermissions` is the calling client's permission set — for a - /// stdio plugin, its manifest's `permissions`; for the WebSocket bridge, - /// empty (the browser extension has no privileged grants). Sensitive - /// methods (currently `calendar/*`) are gated on it. The text/AX/inference - /// methods stay ungated for now — tightening those is a separate security - /// pass that would need every existing plugin to declare permissions. + /// `grantedPermissions` is the stdio plugin manifest's permission set. + /// Every exposed capability is mapped to one closed permission here. func dispatch(method: String, params: RPCValue?, grantedPermissions: Set) async throws -> RPCValue { + if let permission = Self.requiredPermission(for: method) { + try require(permission, in: grantedPermissions, for: method) + } switch method { case "inference/complete": return try await inferenceComplete(params: params) @@ -49,10 +40,8 @@ final class HostBridge { case "ui/prompt": return await uiPrompt(params: params) case "calendar/upcomingEvents": - try require("calendar", in: grantedPermissions, for: method) return try await calendarUpcomingEvents(params: params) case "calendar/createEvent": - try require("calendar", in: grantedPermissions, for: method) return try await calendarCreateEvent(params: params) case "profile/getToneProfile": return profileGet(params: params) @@ -66,16 +55,35 @@ final class HostBridge { } } + nonisolated static func requiredPermission(for method: String) -> PluginPermission? { + switch method { + case "inference/complete": return .inference + case "ax/readSelection": return .axRead + case "ax/replaceRange": return .axWrite + case "ui/toast": return .notifications + case "ui/prompt": return .uiPrompt + case "calendar/upcomingEvents", "calendar/createEvent": return .calendar + case "profile/getToneProfile", "profile/listToneProfiles": return .profilesRead + case "profile/setToneProfile": return .profilesWrite + default: return nil + } + } + + nonisolated static func isAuthorized(method: String, grantedPermissions: Set) -> Bool { + guard let required = requiredPermission(for: method) else { return true } + return grantedPermissions.contains(required.rawValue) + } + /// Throw `permissionDenied` unless `permission` is in the caller's grant /// set. The plugin declared (or didn't) the permission in its manifest; /// the marketplace install sheet is where the user actually consents. - private func require(_ permission: String, + private func require(_ permission: PluginPermission, in granted: Set, for method: String) throws { - guard granted.contains(permission) else { + guard granted.contains(permission.rawValue) else { throw RPCErrorObject( code: PluginRPC.ErrorCode.permissionDenied.rawValue, - message: "\(method) requires the `\(permission)` permission — declare it in halen-plugin.json", + message: "\(method) requires the `\(permission.rawValue)` permission — declare it in halen-plugin.json", data: nil) } } @@ -164,10 +172,9 @@ final class HostBridge { let title = params?.objectValue?["title"]?.stringValue ?? "Halen" let body = params?.objectValue?["body"]?.stringValue ?? "" // `ui/toast` posts a real system notification (it used to only log). - // No permission gate: a notification is low-risk and the user can - // silence Halen's notifications in System Settings. Authorisation is - // requested lazily — the first toast triggers the one-time prompt. - Log.info("toast: \(title): \(body)") + // The dispatch table gates this on `notifications`. System + // authorisation is requested lazily on first use. + Log.info(Log.redactedToastDescription(title: title, body: body)) Task { await Self.postNotification(title: title, body: body) } return .object(["ok": true] as [String: Any?]) } @@ -191,8 +198,7 @@ final class HostBridge { /// Interactive popup. Unlike `ui/toast` this *blocks* the plugin's RPC /// call until the user picks an action (or dismisses / it times out). - /// Ungated — like `ui/toast`, a popup is an annoyance at worst, not a - /// privilege; marketplace curation is the real gate on hostile plugins. + /// The dispatch table gates this on the separate `ui.prompt` permission. private func uiPrompt(params: RPCValue?) async -> RPCValue { let obj = params?.objectValue let title = obj?["title"]?.stringValue ?? "Halen" @@ -217,10 +223,8 @@ final class HostBridge { // on every classification. Exposing the store over RPC lets an // external plugin edit the *same* data the in-process readers see. // - // Ungated for now. A future security pass might gate writes on a - // `profiles` permission, but for v0.2.0 the data is per-user - // preference (formal vs casual register, not a privacy-sensitive - // signal) and the marketplace is the trust boundary. + // Reads and writes are separately gated by `profiles.read` and + // `profiles.write` in the central dispatch table. private func profileGet(params: RPCValue?) -> RPCValue { let bundleId = params?.objectValue?["bundleId"]?.stringValue ?? "" diff --git a/Sources/Halen/Plugins/External/PluginHost.swift b/Sources/Halen/Plugins/External/PluginHost.swift index 17a5107..29d32ff 100644 --- a/Sources/Halen/Plugins/External/PluginHost.swift +++ b/Sources/Halen/Plugins/External/PluginHost.swift @@ -66,8 +66,8 @@ final class PluginHost { func spawn(at dir: URL, manifest: PluginManifest) async { guard !instances.contains(where: { $0.manifest.id == manifest.id }) else { return } // The plugin's granted permission set — what it declared in its - // manifest. `HostBridge` gates sensitive methods (calendar/*) on it. - let granted = Set(manifest.permissions ?? []) + // manifest. `HostBridge` gates every exposed host method on it. + let granted = Set(manifest.permissions.map(\.rawValue)) let pluginId = manifest.id // captured by the per-instance handler // Captured separately because the conflict registry surfaces a // human label (manifest name), not the dotted reverse-DNS id. @@ -76,11 +76,15 @@ final class PluginHost { handler: { [bridge, weak self] method, params in // Per-plugin methods (hotkey/*) need plugin identity to route // fired events back; intercept them here before falling - // through to the shared bridge. Every other RPC goes through - // the single `HostBridge` shared with the WebSocket transport, - // so the surface is identical and can't drift. + // through to the centralized `HostBridge`. The WebSocket bridge + // is notification-only and has no route to these RPC methods. switch method { case "hotkey/register", "hotkey/unregister": + guard granted.contains(PluginPermission.hotkeys.rawValue) else { + throw RPCErrorObject(code: PluginRPC.ErrorCode.permissionDenied.rawValue, + message: "\(method) requires the `hotkeys` permission", + data: nil) + } guard let self else { throw RPCErrorObject(code: PluginRPC.ErrorCode.internalError.rawValue, message: "Plugin host shutting down", data: nil) diff --git a/Sources/Halen/Plugins/External/PluginInstance.swift b/Sources/Halen/Plugins/External/PluginInstance.swift index e0ab4d2..b4ad34c 100644 --- a/Sources/Halen/Plugins/External/PluginInstance.swift +++ b/Sources/Halen/Plugins/External/PluginInstance.swift @@ -76,18 +76,34 @@ final class PluginInstance { Log.info("PluginInstance[\(manifest.id)]: spawned pid=\(process.processIdentifier)") // Handshake: initialize → wait for response → send initialized. + let granted = Set(manifest.permissions) + var capabilities: [String: Any?] = [:] + if granted.contains(.inference) { + capabilities["inference"] = ["streaming": false, + "tiers": ["small", "medium", "large"]] as [String: Any] + } + if granted.contains(.axRead) || granted.contains(.axWrite) { + capabilities["ax"] = ["read": granted.contains(.axRead), + "write": granted.contains(.axWrite)] as [String: Any] + } + if granted.contains(.notifications) || granted.contains(.uiPrompt) { + capabilities["ui"] = ["toast": granted.contains(.notifications), + "prompt": granted.contains(.uiPrompt)] as [String: Any] + } + if granted.contains(.calendar) { capabilities["calendar"] = true } + if granted.contains(.profilesRead) || granted.contains(.profilesWrite) { + capabilities["profiles"] = ["read": granted.contains(.profilesRead), + "write": granted.contains(.profilesWrite)] as [String: Any] + } + if granted.contains(.hotkeys) { capabilities["hotkeys"] = true } + let initParams = RPCValue.object([ "protocolVersion": manifest.halenApiVersion, "hostInfo": [ "name": "Halen", "version": "0.1.0" ], - "capabilities": [ - "inference": ["streaming": false, - "tiers": ["small", "medium", "large"]], - "ax": ["read": true, "write": true], - "ui": ["toast": true] - ] + "capabilities": capabilities ] as [String: Any?]) _ = try await call(method: "initialize", params: initParams) try send(notification: "notifications/initialized") @@ -213,7 +229,8 @@ final class PluginInstance { /// Same as `send(notification:)` but for event topics. Filters by the /// manifest's `events` allowlist so plugins only get what they asked for. func deliver(event topic: String, payload: RPCValue) { - guard manifest.events?.contains(topic) ?? false else { return } + guard let eventTopic = PluginEventTopic(rawValue: topic), + manifest.events.contains(eventTopic) else { return } let params = RPCValue.object([ "topic": .string(topic), "payload": payload @@ -243,7 +260,7 @@ final class PluginInstance { let id = manifest.id stderrTask = installLineReader(on: stderrPipe.fileHandleForReading) { [weak self] line in guard self?.isRunning == true else { return } - Log.info("plugin[\(id)] \(line)") + Log.info(Log.redactedPluginStderrDescription(pluginID: id, line: line)) } } diff --git a/Sources/Halen/Plugins/External/PluginManifest.swift b/Sources/Halen/Plugins/External/PluginManifest.swift index fe6df43..9d0ab03 100644 --- a/Sources/Halen/Plugins/External/PluginManifest.swift +++ b/Sources/Halen/Plugins/External/PluginManifest.swift @@ -19,9 +19,8 @@ struct PluginManifest: Codable, Equatable { /// refuses to load plugins whose `halenApiVersion` it doesn't recognise. let halenApiVersion: String - /// Path (absolute or relative to the manifest directory) of the - /// executable to launch — typically a script interpreter (`/usr/bin/python3`) - /// or a compiled binary. Validated to exist + be executable before spawn. + /// Relative path inside the manifest directory of the executable to + /// launch. Validated to be contained, regular, non-symlink, and executable. let executable: String let args: [String]? let env: [String: String]? @@ -29,14 +28,11 @@ struct PluginManifest: Codable, Equatable { /// Event topics this plugin wants pushed to it. Anything not in the list /// is filtered before reaching the plugin's stdin — saves the plugin /// process the wakeups and avoids accidental data leakage. - let events: [String]? + let events: [PluginEventTopic] - /// User-visible permission declarations. Surfaced in the marketplace - /// "Install" sheet so the user sees what the plugin is asking for before - /// they enable it. **Today informational only** — the host trusts the - /// plugin once enabled. Real enforcement (sandbox-exec profiles, per- - /// permission method gating) is a follow-on. - let permissions: [String]? + /// Closed, host-enforced permission declarations. Unknown values make + /// manifest decoding fail rather than silently becoming a grant. + let permissions: [PluginPermission] /// SF Symbol the marketplace renders for the plugin row. let icon: String? @@ -54,12 +50,15 @@ struct PluginManifest: Codable, Equatable { static func discoverAll(under root: URL) -> [(URL, PluginManifest)] { let fm = FileManager.default guard let entries = try? fm.contentsOfDirectory(at: root, - includingPropertiesForKeys: [.isDirectoryKey], + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles]) else { return [] } var results: [(URL, PluginManifest)] = [] for entry in entries { + if (try? entry.resourceValues(forKeys: [.isSymbolicLinkKey]).isSymbolicLink) == true { + continue + } var isDir: ObjCBool = false guard fm.fileExists(atPath: entry.path, isDirectory: &isDir), isDir.boolValue else { continue } let manifestURL = entry.appending(path: "halen-plugin.json") @@ -67,6 +66,10 @@ struct PluginManifest: Codable, Equatable { do { let data = try Data(contentsOf: manifestURL) let manifest = try JSONDecoder().decode(PluginManifest.self, from: data) + guard entry.lastPathComponent == manifest.id else { + throw ManifestError.directoryNameMismatch(expected: manifest.id, + found: entry.lastPathComponent) + } try manifest.validate(at: entry) results.append((entry, manifest)) } catch { @@ -108,15 +111,10 @@ struct PluginManifest: Codable, Equatable { /// Validate that `pluginDir.appending(path: relative).standardized` stays /// inside `pluginDir.standardized`. Defends against a manifest that ships /// `executable: "../../../usr/bin/python3"` and trusts us not to look. - /// Absolute paths bypass this — the user installed the plugin, so an - /// explicit absolute path is taken at face value (still surfaced to the - /// user via the install sheet's permissions list). + /// Validation additionally rejects absolute paths and resolves symlinks. static func isExecutablePathContained(_ candidate: URL, in pluginDir: URL) -> Bool { - // Compare standardized representations — `standardized` resolves - // `..` and `.` components without hitting the filesystem, so symlink - // shenanigans inside the plugin dir are still permitted (they're a - // legitimate way to point at a venv binary) but lexical escapes - // outside the dir are caught. + // Compare standardized representations first; validate(at:) then + // resolves the filesystem path and rejects symlinks and special files. let candidateStd = candidate.standardized.path let baseStd = pluginDir.standardized.path return candidateStd == baseStd || candidateStd.hasPrefix(baseStd + "/") @@ -130,14 +128,12 @@ struct PluginManifest: Codable, Equatable { throw ManifestError.invalidID(id) } let exec = resolvedExecutable(in: pluginDir) - // Relative paths must stay within pluginDir. Absolute paths are - // user-trusted (the user dragged the plugin into place; surfacing - // an absolute path in the install sheet is the UX gate). let executablePath = (executable as NSString).expandingTildeInPath - if !executablePath.hasPrefix("/") { - guard Self.isExecutablePathContained(exec, in: pluginDir) else { - throw ManifestError.executableOutsidePluginDir(exec.path) - } + guard !executablePath.hasPrefix("/") else { + throw ManifestError.absoluteExecutable(executablePath) + } + guard Self.isExecutablePathContained(exec, in: pluginDir) else { + throw ManifestError.executableOutsidePluginDir(exec.path) } let fm = FileManager.default guard fm.fileExists(atPath: exec.path) else { @@ -146,15 +142,51 @@ struct PluginManifest: Codable, Equatable { guard fm.isExecutableFile(atPath: exec.path) else { throw ManifestError.notExecutable(exec.path) } + let canonicalBase = pluginDir.resolvingSymlinksInPath().standardized.path + let canonicalExec = exec.resolvingSymlinksInPath().standardized.path + guard canonicalExec.hasPrefix(canonicalBase + "/") else { + throw ManifestError.executableOutsidePluginDir(canonicalExec) + } + let values = try exec.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true else { + throw ManifestError.specialExecutable(exec.path) + } } } +enum PluginPermission: String, Codable, CaseIterable, Hashable, Sendable { + case inference + case axRead = "ax.read" + case axWrite = "ax.write" + case notifications + case uiPrompt = "ui.prompt" + case calendar + case profilesRead = "profiles.read" + case profilesWrite = "profiles.write" + case hotkeys +} + +/// Closed set of host data streams a plugin may request. These subscriptions +/// are surfaced separately from callable API permissions because they grant +/// ongoing access to user activity and text. +enum PluginEventTopic: String, Codable, CaseIterable, Hashable, Sendable { + case textPause = "text.pause" + case caretMoved = "caret.moved" + case appFocused = "app.focused" + case hotkeyFired = "hotkey.fired" + case findingDetected = "finding.detected" + case findingCleared = "finding.cleared" +} + enum ManifestError: Error, LocalizedError, Equatable { case unsupportedApiVersion(String) case executableMissing(String) case notExecutable(String) case invalidID(String) case executableOutsidePluginDir(String) + case absoluteExecutable(String) + case specialExecutable(String) + case directoryNameMismatch(expected: String, found: String) var errorDescription: String? { switch self { @@ -168,6 +200,12 @@ enum ManifestError: Error, LocalizedError, Equatable { return "Plugin id \"\(id)\" is invalid (must be non-empty, contain no path separators, no `..` segments, ≤128 chars)" case .executableOutsidePluginDir(let path): return "Plugin executable resolves outside the plugin directory: \(path)" + case .absoluteExecutable(let path): + return "Plugin executable must be relative to its plugin directory: \(path)" + case .specialExecutable(let path): + return "Plugin executable must be a regular, non-symlink file: \(path)" + case .directoryNameMismatch(let expected, let found): + return "Plugin directory must be named \"\(expected)\", not \"\(found)\"" } } } diff --git a/Sources/Halen/Plugins/External/WebSocketBridge.swift b/Sources/Halen/Plugins/External/WebSocketBridge.swift index af5c4f5..489422c 100644 --- a/Sources/Halen/Plugins/External/WebSocketBridge.swift +++ b/Sources/Halen/Plugins/External/WebSocketBridge.swift @@ -8,10 +8,8 @@ import Observation /// today, eventually VS Code / Slack extension / iOS companion — speak the /// same event-and-RPC protocol as out-of-process plugins. /// -/// Bound to `127.0.0.1` only: no external interfaces, no auth needed in v0 -/// because the loopback constraint plus single-user macOS is the trust -/// boundary. (A future iteration adds a handshake token written to disk and -/// passed by the client on connect.) +/// Bound to `127.0.0.1`, origin checked during the HTTP upgrade, and paired +/// with a per-install token before any application message is accepted. /// /// Wire format: NDJSON-shaped JSON-RPC 2.0 messages — same `RPCMessage` and /// `RPCValue` types the stdio plugin host uses, just delivered as WebSocket @@ -25,13 +23,12 @@ final class WebSocketBridge { nonisolated static let defaultPort: UInt16 = 50765 /// UserDefaults key controlling whether the bridge is started at launch. - /// Default ON — installed clients (browser extension, future companions) - /// can't function without it, and binding to loopback-only keeps the - /// trust boundary tight. + /// Default OFF: the browser extension is optional, so new installs should + /// not expose a listener until the user explicitly enables the bridge. nonisolated static let enabledKey = "halen.websocketBridge.enabled" nonisolated static var isEnabledInDefaults: Bool { - UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true + UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? false } /// Maximum bytes accepted per incoming WebSocket frame. JSON-RPC requests @@ -41,6 +38,9 @@ final class WebSocketBridge { /// can't OOM us by streaming a multi-GB frame. Clients exceeding this /// are disconnected. nonisolated static let maxIncomingFrameBytes = 256 * 1024 + nonisolated static let maxClients = 16 + nonisolated static let maxPendingHandshakes = 4 + nonisolated static let handshakeTimeout: Duration = .seconds(5) /// Cap on the number of topics a single client can subscribe to. Loose /// enough to never restrict a legitimate client (there are 3 valid @@ -55,7 +55,6 @@ final class WebSocketBridge { nonisolated static let maxInjectedTextLength = 32 * 1024 private let services: HalenServices - private let bridge: HostBridge private let port: UInt16 private var listener: NWListener? @@ -73,16 +72,18 @@ final class WebSocketBridge { private final class Client: Identifiable { let id = UUID() let connection: NWConnection - /// `nil` until the client has sent a valid `subscribe` notification. - /// Unauthenticated clients can connect (so the popup's ping-and-close - /// liveness check works) but get no events and can't inject any. - var subscribedTopics: Set? + // Authentication happens during the HTTP upgrade. A Client exists + // only for a socket whose extension origin and pairing subprotocol + // were accepted by Network.framework. + let isAuthenticated = true + var isReady = false + var subscribedTopics: Set = [] + var handshakeDeadline: Task? init(_ connection: NWConnection) { self.connection = connection } } init(services: HalenServices, port: UInt16 = WebSocketBridge.defaultPort) { self.services = services - self.bridge = HostBridge(services: services) self.port = port } @@ -117,7 +118,10 @@ final class WebSocketBridge { func stop() { subscriptionTask?.cancel() subscriptionTask = nil - for client in clients { client.connection.cancel() } + for client in clients { + client.handshakeDeadline?.cancel() + client.connection.cancel() + } clients.removeAll() clientCount = 0 listener?.cancel() @@ -126,8 +130,27 @@ final class WebSocketBridge { } private func makeListener() throws -> NWListener { + guard let pairingToken = BridgeTokenStore.tokenOrCreate() else { + throw WebSocketBridgeError.authenticationUnavailable + } + let expectedSubprotocol = WebSocketBridgePolicy.pairingSubprotocol(token: pairingToken) let wsOptions = NWProtocolWebSocket.Options() wsOptions.autoReplyPing = true + wsOptions.maximumMessageSize = Self.maxIncomingFrameBytes + wsOptions.setClientRequestHandler(.main) { subprotocols, headers in + let origins = headers + .filter { $0.name.caseInsensitiveCompare("Origin") == .orderedSame } + .map(\.value) + let accepted = WebSocketBridgePolicy.isAllowedHandshake( + origins: origins, + offeredSubprotocols: subprotocols, + expectedSubprotocol: expectedSubprotocol + ) + return NWProtocolWebSocket.Response( + status: accepted ? .accept : .reject, + subprotocol: accepted ? expectedSubprotocol : nil + ) + } let params = NWParameters.tcp params.allowLocalEndpointReuse = true // Filters incoming connections to loopback interfaces. The underlying @@ -144,6 +167,21 @@ final class WebSocketBridge { // MARK: - Per-client private func accept(_ connection: NWConnection) { + let pendingCount = clients.lazy.filter { !$0.isReady }.count + switch WebSocketBridgePolicy.admission(currentCount: clients.count, + pendingCount: pendingCount) { + case .accept: + break + case .evictPending: + if let oldestPending = clients.first(where: { !$0.isReady }) { + Log.warn("WebSocketBridge: evicting incomplete handshake for newer client") + removeClient(oldestPending) + } + case .reject: + Log.warn("WebSocketBridge: rejected client — ceiling of \(Self.maxClients) reached") + connection.cancel() + return + } let client = Client(connection) clients.append(client) clientCount = clients.count @@ -157,6 +195,12 @@ final class WebSocketBridge { Task { @MainActor [weak self] in guard let self else { return } switch state { + case .ready: + if let c = self.clients.first(where: { $0.id == clientID }) { + c.isReady = true + c.handshakeDeadline?.cancel() + c.handshakeDeadline = nil + } case .failed, .cancelled: if let c = self.clients.first(where: { $0.id == clientID }) { self.removeClient(c) @@ -166,6 +210,14 @@ final class WebSocketBridge { } } } + client.handshakeDeadline = Task { @MainActor [weak self] in + try? await Task.sleep(for: Self.handshakeTimeout) + guard !Task.isCancelled, let self, + let pending = self.clients.first(where: { $0.id == clientID }), + !pending.isReady else { return } + Log.warn("WebSocketBridge: incomplete handshake timed out") + self.removeClient(pending) + } connection.start(queue: .main) receive(on: client) } @@ -192,6 +244,7 @@ final class WebSocketBridge { } self.handleIncoming(data: data, from: resolved) } + guard self.clients.contains(where: { $0.id == clientID }) else { return } if error != nil { self.removeClient(resolved) } else { @@ -202,6 +255,8 @@ final class WebSocketBridge { } private func removeClient(_ client: Client) { + client.handshakeDeadline?.cancel() + client.handshakeDeadline = nil client.connection.cancel() clients.removeAll { $0.id == client.id } clientCount = clients.count @@ -225,7 +280,7 @@ final class WebSocketBridge { // Filter to clients that authenticated AND subscribed to this topic. // Unauthenticated or wrong-topic clients receive nothing — that's the // whole point of the subscribe-with-token handshake. - let targets = clients.filter { $0.subscribedTopics?.contains(topic) == true } + let targets = clients.filter { $0.isReady && $0.isAuthenticated && $0.subscribedTopics.contains(topic) } guard !targets.isEmpty else { return } let msg = RPCMessage(method: "event/\(topic)", params: .object(["topic": .string(topic), "payload": payload])) @@ -259,34 +314,27 @@ final class WebSocketBridge { Log.warn("WebSocketBridge: dropped malformed message from \(client.id.uuidString.prefix(8))") return } - if msg.isRequest { - Task { @MainActor in await self.handleRequest(msg, from: client) } - } else if msg.isNotification { + switch WebSocketBridgePolicy.disposition( + isAuthenticated: client.isAuthenticated, + isRequest: msg.isRequest, + method: msg.method + ) { + case .subscribe: + handleSubscribe(msg, from: client) + case .notification: handleNotification(msg, from: client) + case .rejectRequest: + rejectRequest(msg, from: client) + case .reject: + Log.debug("WebSocketBridge: rejected \(msg.method ?? "message") from client \(client.id.uuidString.prefix(8))") } - // Responses to our outbound requests would land here — we don't - // currently make any, but the dispatcher is ready when we do. + // Responses are ignored: this notification-only transport never + // issues outbound requests. } private func handleNotification(_ msg: RPCMessage, from client: Client) { guard let method = msg.method else { return } - // Subscription handshake: client posts `{token, topics: [...]}`. - // Without it, the client is connected but ignored for everything - // below — the auth gate that loopback-only binding doesn't give us. - if method == "subscribe" { - handleSubscribe(msg, from: client) - return - } - - // Every method below requires an authenticated subscription. Unauth'd - // clients can liveness-ping (popup) but can neither receive events - // nor inject them into the EventBus. - guard client.subscribedTopics != nil else { - Log.debug("WebSocketBridge: ignored \(method) from unauthenticated client \(client.id.uuidString.prefix(8))") - return - } - // Clients can inject events (the browser extension's main use case). // Publishing onto the EventBus means every in-process plugin reacts // uniformly, with no per-client wiring. @@ -341,23 +389,17 @@ final class WebSocketBridge { return out } - /// Validate the client's `subscribe` notification against the persisted - /// token; on success, record the requested topics so `broadcast(...)` - /// fan-out can filter on them. + /// Validate the authenticated client's `subscribe` notification and record + /// the requested topics so `broadcast(...)` fan-out can filter on them. /// - /// Shape: `subscribe { token: "...", topics: ["text.pause", "app.focused"] }`. + /// Shape: `subscribe { topics: ["text.pause", "app.focused"] }`. /// Topics not in the bridge's set of emitted topics are dropped silently. private func handleSubscribe(_ msg: RPCMessage, from client: Client) { guard let params = msg.params?.objectValue, - let providedToken = params["token"]?.stringValue, let topicsAny = params["topics"]?.arrayValue else { - Log.warn("WebSocketBridge: bad subscribe payload from \(client.id.uuidString.prefix(8))") - return - } - guard let expected = BridgeTokenStore.tokenOrCreate(), - providedToken == expected else { - Log.warn("WebSocketBridge: rejected subscribe from \(client.id.uuidString.prefix(8)) — token mismatch") + Log.warn("WebSocketBridge: bad subscribe payload from \(client.id.uuidString.prefix(8)) — disconnecting") + removeClient(client) return } // Cap the input list before the Set/intersection pass — a malicious @@ -365,6 +407,7 @@ final class WebSocketBridge { // the main actor for no useful purpose. if topicsAny.count > Self.maxSubscribeTopics { Log.warn("WebSocketBridge: rejected subscribe from \(client.id.uuidString.prefix(8)) — \(topicsAny.count) topics (cap \(Self.maxSubscribeTopics))") + removeClient(client) return } let valid: Set = ["text.pause", "caret.moved", "app.focused"] @@ -374,35 +417,91 @@ final class WebSocketBridge { Log.info("WebSocketBridge: \(client.id.uuidString.prefix(8)) subscribed to [\(topicList)]") } - private func handleRequest(_ msg: RPCMessage, from client: Client) async { - guard let id = msg.id, let method = msg.method else { return } - do { - // Single source of truth for every host method, shared with - // PluginHost. The WS transport now gets the full surface for - // free (ax/replaceRange, ui/toast — previously missing here). - // No granted permissions — the browser extension has no - // privileged grants, so gated methods (calendar/*) are denied. - let result = try await bridge.dispatch(method: method, params: msg.params, - grantedPermissions: []) - send(RPCMessage(id: id, result: result), to: [client]) - } catch let error as RPCErrorObject { - send(RPCMessage(id: id, error: error), to: [client]) - } catch { - send(RPCMessage(id: id, error: RPCErrorObject( - code: PluginRPC.ErrorCode.internalError.rawValue, - message: error.localizedDescription, data: nil - )), to: [client]) + private func rejectRequest(_ msg: RPCMessage, from client: Client) { + guard let id = msg.id else { return } + // Browser clients deliberately have an empty RPC capability set. The + // extension is an event source, not a path to AX/inference/UI APIs. + send(RPCMessage(id: id, error: RPCErrorObject( + code: PluginRPC.ErrorCode.permissionDenied.rawValue, + message: "WebSocket clients have no RPC capabilities", data: nil + )), to: [client]) + } +} + +/// Pure admission/message policy kept outside Network.framework so the +/// security boundary can be pinned by fast unit tests. +enum WebSocketBridgePolicy { + enum Admission: Equatable { + case accept + case evictPending + case reject + } + + enum Disposition: Equatable { + case subscribe + case notification + case rejectRequest + case reject + } + + static func isAllowedBrowserOrigin(_ origin: String?) -> Bool { + guard let origin, origin != "null", + let components = URLComponents(string: origin), + let scheme = components.scheme?.lowercased(), + let host = components.host, !host.isEmpty else { return false } + return ["chrome-extension", "moz-extension", "safari-web-extension"].contains(scheme) + } + + /// HTTP permits repeated headers in general, but a WebSocket upgrade has + /// exactly one security origin. Reject duplicates instead of trusting the + /// first value and leaving interpretation differences between layers. + static func isAllowedBrowserOrigins(_ origins: [String]) -> Bool { + origins.count == 1 && isAllowedBrowserOrigin(origins[0]) + } + + static func pairingSubprotocol(token: String) -> String { + "halen.\(token)" + } + + static func isAllowedHandshake(origins: [String], + offeredSubprotocols: [String], + expectedSubprotocol: String) -> Bool { + isAllowedBrowserOrigins(origins) + && offeredSubprotocols == [expectedSubprotocol] + } + + static func canAcceptClient(currentCount: Int) -> Bool { + currentCount < WebSocketBridge.maxClients + } + + static func admission(currentCount: Int, pendingCount: Int) -> Admission { + if pendingCount >= WebSocketBridge.maxPendingHandshakes { return .evictPending } + if currentCount >= WebSocketBridge.maxClients { + return pendingCount > 0 ? .evictPending : .reject } + return .accept + } + + static func disposition(isAuthenticated: Bool, + isRequest: Bool, + method: String?) -> Disposition { + if isRequest { return .rejectRequest } + guard let method else { return .reject } + if !isAuthenticated { return .reject } + return method == "subscribe" ? .subscribe : .notification } } enum WebSocketBridgeError: Error, LocalizedError { case invalidPort(UInt16) + case authenticationUnavailable var errorDescription: String? { switch self { case .invalidPort(let port): return "Halen WebSocket bridge: invalid port \(port)" + case .authenticationUnavailable: + return "Halen WebSocket bridge: pairing token unavailable" } } } diff --git a/Sources/Halen/Plugins/PluginRegistry.swift b/Sources/Halen/Plugins/PluginRegistry.swift index 5157056..533c08b 100644 --- a/Sources/Halen/Plugins/PluginRegistry.swift +++ b/Sources/Halen/Plugins/PluginRegistry.swift @@ -12,14 +12,16 @@ final class PluginRegistry { private let defaults = UserDefaults.standard - /// Add a plugin. Honors the previously-saved enabled state (default: enabled). - func register(_ plugin: any HalenPlugin) { + /// Add a plugin. Built-ins preserve their established defaults; callers + /// registering external code pass `defaultEnabled: false` so install and + /// discovery never execute code before the user explicitly enables it. + func register(_ plugin: any HalenPlugin, defaultEnabled: Bool = true) { guard !plugins.contains(where: { $0.id == plugin.id }) else { Log.warn("PluginRegistry: \(plugin.id) already registered — skipping") return } plugins.append(plugin) - let enabled = readPersistedEnabled(plugin.id) + let enabled = readPersistedEnabled(plugin.id, fallback: defaultEnabled) enabledStates[plugin.id] = enabled if enabled { plugin.start() @@ -71,7 +73,7 @@ final class PluginRegistry { plugins.lazy.filter { self.isEnabled($0.id) }.count } - private func readPersistedEnabled(_ id: String) -> Bool { + private func readPersistedEnabled(_ id: String, fallback: Bool = true) -> Bool { // Explicit user choice takes precedence over the default-off list — // someone who deliberately enabled VoiceDictation and quit should // get VoiceDictation on next launch even though it's off by default @@ -128,7 +130,7 @@ final class PluginRegistry { return true } - return !Self.defaultDisabledPluginIds.contains(id) + return fallback && !Self.defaultDisabledPluginIds.contains(id) } /// Returns `true` if any of `anyOf` was persisted as enabled, `false` diff --git a/Sources/Halen/Plugins/Store/PluginInstaller.swift b/Sources/Halen/Plugins/Store/PluginInstaller.swift index 07f67c8..3dcc4f4 100644 --- a/Sources/Halen/Plugins/Store/PluginInstaller.swift +++ b/Sources/Halen/Plugins/Store/PluginInstaller.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit /// Downloads, unpacks, and validates an external plugin from a registry entry. /// @@ -17,6 +18,10 @@ import Foundation /// so a registry entry can't smuggle a plugin under a different identity. enum PluginInstaller { + static let maxCompressedBytes: Int64 = 25 * 1024 * 1024 + static let maxExtractedBytes: Int64 = 100 * 1024 * 1024 + static let maxExtractedFiles = 2_048 + enum InstallError: LocalizedError { case insecureURL case downloadFailed(String) @@ -24,6 +29,11 @@ enum PluginInstaller { case manifestMissing case manifestInvalid(String) case idMismatch(expected: String, found: String) + case metadataMismatch(String) + case archiveSizeMismatch(expected: Int64, found: Int64) + case archiveHashMismatch + case unsafeArchive(String) + case invalidID(String) case alreadyInstalled var errorDescription: String? { @@ -40,6 +50,16 @@ enum PluginInstaller { return "Plugin manifest is invalid — \(detail)" case .idMismatch(let expected, let found): return "Manifest id \"\(found)\" does not match registry id \"\(expected)\"." + case .metadataMismatch(let field): + return "Embedded manifest \(field) does not match the authenticated registry entry." + case .archiveSizeMismatch(let expected, let found): + return "Archive size mismatch (expected \(expected) bytes, received \(found))." + case .archiveHashMismatch: + return "Archive SHA-256 does not match the authenticated registry entry." + case .unsafeArchive(let detail): + return "Archive rejected — \(detail)" + case .invalidID(let id): + return "Registry plugin id is invalid: \(id)" case .alreadyInstalled: return "This plugin is already installed." } @@ -56,6 +76,8 @@ enum PluginInstaller { /// Full install pipeline. Runs entirely off the main actor — only file and /// network I/O. The caller registers the returned plugin on the main actor. static func install(_ entry: PluginRegistryEntry) async throws -> Installed { + guard PluginManifest.isValidID(entry.id) else { throw InstallError.invalidID(entry.id) } + try entry.validate() guard let url = URL(string: entry.downloadURL), url.scheme?.lowercased() == "https" else { throw InstallError.insecureURL @@ -77,10 +99,12 @@ enum PluginInstaller { defer { try? fm.removeItem(at: scratch) } let zipURL = scratch.appending(path: "plugin.zip") - try await download(from: url, to: zipURL) + try await download(from: url, to: zipURL, expectedSize: entry.archiveSize) + try verifyArchive(zipURL, entry: entry) let extractDir = scratch.appending(path: "unpacked", directoryHint: .isDirectory) try fm.createDirectory(at: extractDir, withIntermediateDirectories: true) + try preflightArchive(zipURL) try extract(zip: zipURL, into: extractDir) // Locate the plugin directory: the manifest is either at the extract @@ -102,6 +126,15 @@ enum PluginInstaller { guard manifest.id == entry.id else { throw InstallError.idMismatch(expected: entry.id, found: manifest.id) } + guard manifest.version == entry.version else { + throw InstallError.metadataMismatch("version") + } + guard manifest.permissions == entry.permissions else { + throw InstallError.metadataMismatch("permissions") + } + guard manifest.events == entry.events else { + throw InstallError.metadataMismatch("events") + } do { try manifest.validate(at: pluginRoot) } catch { @@ -132,31 +165,72 @@ enum PluginInstaller { /// Delete an installed plugin's directory. Caller is responsible for /// unregistering it from `PluginRegistry` first (which stops the process). - static func remove(id: String, directory: URL) throws { + @MainActor + static func remove(id: String, directory _: URL) throws { let fm = FileManager.default - let installRoot = directory.deletingLastPathComponent() - // Containment guard: only ever delete inside the canonical install root. - guard directory.standardized.path.hasPrefix(installRoot.standardized.path + "/") || - directory.deletingLastPathComponent().lastPathComponent == "Plugins" else { - throw InstallError.extractionFailed("refusing to delete outside the plugin install root") - } + let installRoot = PluginHost.installRoot + let directory = try removalURL(for: id, installRoot: installRoot) + let canonicalRoot = installRoot.resolvingSymlinksInPath().standardized.path + let parent = directory.deletingLastPathComponent().resolvingSymlinksInPath().standardized.path + guard parent == canonicalRoot else { throw InstallError.unsafeArchive("refusing deletion outside install root") } if fm.fileExists(atPath: directory.path) { try fm.removeItem(at: directory) } Log.info("PluginInstaller: removed \(id)") } + static func removalURL(for id: String, installRoot: URL) throws -> URL { + guard PluginManifest.isValidID(id) else { throw InstallError.invalidID(id) } + return installRoot.appending(path: id, directoryHint: .isDirectory) + } + // MARK: - Steps - private static func download(from url: URL, to file: URL) async throws { + private static func download(from url: URL, to file: URL, + expectedSize: Int64) async throws { var request = URLRequest(url: url) request.timeoutInterval = 60 do { - let (tempURL, response) = try await URLSession.shared.download(for: request) - if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let http = response as? HTTPURLResponse else { + throw InstallError.downloadFailed("server did not return HTTP") + } + if !(200...299).contains(http.statusCode) { throw InstallError.downloadFailed("server returned HTTP \(http.statusCode)") } - try FileManager.default.moveItem(at: tempURL, to: file) + guard response.url?.scheme?.lowercased() == "https" else { + throw InstallError.insecureURL + } + if response.expectedContentLength >= 0, + response.expectedContentLength != expectedSize { + throw InstallError.archiveSizeMismatch( + expected: expectedSize, found: response.expectedContentLength) + } + + guard FileManager.default.createFile(atPath: file.path, contents: nil, + attributes: [.posixPermissions: 0o600]) else { + throw InstallError.downloadFailed("could not create temporary archive") + } + let handle = try FileHandle(forWritingTo: file) + defer { try? handle.close() } + var buffer = Data() + buffer.reserveCapacity(64 * 1024) + var received: Int64 = 0 + for try await byte in bytes { + received += 1 + guard received <= expectedSize, received <= maxCompressedBytes else { + throw InstallError.unsafeArchive("compressed size limit exceeded") + } + buffer.append(byte) + if buffer.count == 64 * 1024 { + try handle.write(contentsOf: buffer) + buffer.removeAll(keepingCapacity: true) + } + } + if !buffer.isEmpty { try handle.write(contentsOf: buffer) } + guard received == expectedSize else { + throw InstallError.archiveSizeMismatch(expected: expectedSize, found: received) + } } catch let error as InstallError { throw error } catch { @@ -164,6 +238,92 @@ enum PluginInstaller { } } + static func verifyArchive(_ file: URL, entry: PluginRegistryEntry) throws { + let attributes = try FileManager.default.attributesOfItem(atPath: file.path) + let size = (attributes[.size] as? NSNumber)?.int64Value ?? -1 + guard size <= maxCompressedBytes else { throw InstallError.unsafeArchive("compressed size limit exceeded") } + guard size == entry.archiveSize else { + throw InstallError.archiveSizeMismatch(expected: entry.archiveSize, found: size) + } + let handle = try FileHandle(forReadingFrom: file) + defer { try? handle.close() } + var hasher = SHA256() + while true { + let data = try handle.read(upToCount: 1024 * 1024) ?? Data() + if data.isEmpty { break } + hasher.update(data: data) + } + let actual = hasher.finalize().map { String(format: "%02x", $0) }.joined() + guard actual == entry.archiveSHA256.lowercased() else { throw InstallError.archiveHashMismatch } + } + + /// Inspect the ZIP central directory before extraction so declared file + /// count, expanded bytes, paths, and Unix entry types fail before `ditto` + /// can write them. Exact archive hashing makes this metadata part of the + /// reviewed artifact; post-extraction checks remain defense in depth. + static func preflightArchive(_ zip: URL) throws { + let summary = try runZipInfo("-t", zip: zip) + let listing = try runZipInfo("-l", zip: zip) + let names = try runZipInfo("-1", zip: zip) + try validateArchiveIndex(summary: summary, listing: listing, names: names) + } + + static func validateArchiveIndex(summary: String, listing: String, names: String, + maxFiles: Int = maxExtractedFiles, + maxBytes: Int64 = maxExtractedBytes) throws { + let regex = try NSRegularExpression(pattern: #"([0-9]+) files?, ([0-9]+) bytes uncompressed"#) + let range = NSRange(summary.startIndex.. String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/zipinfo") + process.arguments = [option, zip.path] + process.environment = ProcessInfo.processInfo.environment.merging(["LC_ALL": "C"]) { _, fixed in fixed } + let output = Pipe() + process.standardOutput = output + process.standardError = output + do { try process.run() } catch { + throw InstallError.unsafeArchive("could not inspect ZIP central directory") + } + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + guard process.terminationStatus == 0, + let text = String(data: data, encoding: .utf8) else { + throw InstallError.unsafeArchive("invalid ZIP central directory") + } + return text + } + /// Unpack `zip` into `dir` using the system `ditto` tool. `ditto` rejects /// absolute and `..` traversal entries, so the archive cannot write outside /// `dir`. We re-verify containment afterwards as belt-and-suspenders. @@ -186,14 +346,40 @@ enum PluginInstaller { throw InstallError.extractionFailed(detail.trimmingCharacters(in: .whitespacesAndNewlines)) } - // Belt-and-suspenders: confirm nothing escaped `dir`. + // Belt-and-suspenders: confirm containment, type, count, and expanded + // byte limits. Symlinks and device/socket/FIFO entries are forbidden. let fm = FileManager.default let base = dir.standardized.path - if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: nil) { + let canonicalBase = dir.resolvingSymlinksInPath().standardized.path + var fileCount = 0 + var extractedBytes: Int64 = 0 + let keys: [URLResourceKey] = [.isRegularFileKey, .isDirectoryKey, + .isSymbolicLinkKey, .fileSizeKey] + if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: keys) { for case let item as URL in enumerator { guard item.standardized.path.hasPrefix(base + "/") else { throw InstallError.extractionFailed("archive entry escaped the extraction directory") } + guard item.resolvingSymlinksInPath().standardized.path.hasPrefix(canonicalBase + "/") else { + throw InstallError.unsafeArchive("symbolic link escaped the extraction directory") + } + fileCount += 1 + guard fileCount <= maxExtractedFiles else { + throw InstallError.unsafeArchive("file count limit exceeded") + } + let values = try item.resourceValues(forKeys: Set(keys)) + guard values.isSymbolicLink != true else { + throw InstallError.unsafeArchive("symbolic links are not allowed") + } + guard values.isRegularFile == true || values.isDirectory == true else { + throw InstallError.unsafeArchive("special files are not allowed") + } + if values.isRegularFile == true { + extractedBytes += Int64(values.fileSize ?? 0) + guard extractedBytes <= maxExtractedBytes else { + throw InstallError.unsafeArchive("expanded size limit exceeded") + } + } } } } diff --git a/Sources/Halen/Plugins/Store/PluginRegistryIndex.swift b/Sources/Halen/Plugins/Store/PluginRegistryIndex.swift index d09f962..a17e76e 100644 --- a/Sources/Halen/Plugins/Store/PluginRegistryIndex.swift +++ b/Sources/Halen/Plugins/Store/PluginRegistryIndex.swift @@ -1,4 +1,5 @@ import Foundation +import CryptoKit /// Codable mirror of `plugin-registry.json` — the curated index of installable /// external plugins fetched over HTTPS from the Halen repo. This is *only* an @@ -14,13 +15,86 @@ struct PluginRegistryIndex: Codable { let halenApiVersion: String? let plugins: [PluginRegistryEntry] - static let supportedSchemaVersion = 1 + static let supportedSchemaVersion = 2 + static let maxResponseBytes = 512 * 1024 + + /// Updated only with an app release after reviewing the exact registry. + /// A network response with any byte changed is rejected before decoding. + static let expectedSHA256 = "ff767ec18df80bfbd32b19fa3e97afd3ad434cff01d08efc209732ee9e344a69" + + static func decodeAuthenticated(_ data: Data, + expectedSHA256 expectedDigest: String = PluginRegistryIndex.expectedSHA256) throws -> Self { + let actual = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + guard actual == expectedDigest.lowercased() else { + throw RegistryError.authenticationFailed + } + let index = try JSONDecoder().decode(Self.self, from: data) + guard index.schemaVersion == supportedSchemaVersion else { + throw RegistryError.unsupportedSchema(index.schemaVersion) + } + guard Set(index.plugins.map(\.id)).count == index.plugins.count else { + throw RegistryError.invalidEntry("duplicate plugin id") + } + for entry in index.plugins { try entry.validate() } + return index + } + + /// Stream into a small bounded buffer before authenticating. The registry + /// is currently under 1 KiB; a 512 KiB ceiling leaves ample growth room + /// without letting a compromised endpoint exhaust app memory first. + static func fetchAuthenticated(from url: URL) async throws -> Self { + guard url.scheme?.lowercased() == "https" else { + throw RegistryError.invalidResponse("registry URL is not HTTPS") + } + var request = URLRequest(url: url) + request.timeoutInterval = 20 + request.cachePolicy = .reloadIgnoringLocalCacheData + let (bytes, response) = try await URLSession.shared.bytes(for: request) + guard let http = response as? HTTPURLResponse, + (200...299).contains(http.statusCode) else { + throw RegistryError.invalidResponse("registry server returned a non-success response") + } + guard response.url?.scheme?.lowercased() == "https" else { + throw RegistryError.invalidResponse("registry redirected outside HTTPS") + } + if response.expectedContentLength > Int64(maxResponseBytes) { + throw RegistryError.responseTooLarge + } + var data = Data() + data.reserveCapacity(min(maxResponseBytes, + max(0, Int(response.expectedContentLength)))) + for try await byte in bytes { + guard data.count < maxResponseBytes else { + throw RegistryError.responseTooLarge + } + data.append(byte) + } + return try decodeAuthenticated(data) + } enum CodingKeys: String, CodingKey { case schemaVersion, halenApiVersion, plugins } } +enum RegistryError: LocalizedError, Equatable { + case authenticationFailed + case unsupportedSchema(Int) + case invalidEntry(String) + case invalidResponse(String) + case responseTooLarge + + var errorDescription: String? { + switch self { + case .authenticationFailed: return "Plugin registry authentication failed. Update Halen to receive a reviewed registry." + case .unsupportedSchema(let version): return "Plugin registry schema v\(version) is unsupported." + case .invalidEntry(let detail): return "Plugin registry entry is invalid: \(detail)" + case .invalidResponse(let detail): return "Plugin registry request failed: \(detail)." + case .responseTooLarge: return "Plugin registry response exceeded the safety limit." + } + } +} + /// One installable plugin as advertised by the registry. Field semantics are /// documented in `plugin-registry.schema.md`. struct PluginRegistryEntry: Codable, Identifiable, Equatable { @@ -35,12 +109,37 @@ struct PluginRegistryEntry: Codable, Identifiable, Equatable { let sourceURL: String /// HTTPS URL of a zip of the plugin directory. let downloadURL: String + /// Authenticated metadata for the exact downloadable bytes. + let archiveSHA256: String + let archiveSize: Int64 + /// Must exactly match the embedded manifest. + let permissions: [PluginPermission] + let events: [PluginEventTopic] /// Marks an illustrative seed entry; the Store shows an "Example" tag. let isExample: Bool? var iconName: String { icon ?? "puzzlepiece.extension" } var isExampleEntry: Bool { isExample ?? false } + func validate() throws { + guard PluginManifest.isValidID(id) else { throw RegistryError.invalidEntry("invalid id \(id)") } + guard archiveSize > 0, archiveSize <= PluginInstaller.maxCompressedBytes else { + throw RegistryError.invalidEntry("archiveSize out of bounds for \(id)") + } + let hashChars = CharacterSet(charactersIn: "0123456789abcdef") + guard archiveSHA256 == archiveSHA256.lowercased(), archiveSHA256.count == 64, + archiveSHA256.lowercased().unicodeScalars.allSatisfy(hashChars.contains) else { + throw RegistryError.invalidEntry("archiveSHA256 is not 64 lowercase hex characters for \(id)") + } + guard let download = URL(string: downloadURL), download.scheme?.lowercased() == "https", + let source = URL(string: sourceURL), source.scheme?.lowercased() == "https" else { + throw RegistryError.invalidEntry("URLs must use HTTPS for \(id)") + } + guard Set(events).count == events.count, Set(permissions).count == permissions.count else { + throw RegistryError.invalidEntry("duplicate permissions or events for \(id)") + } + } + /// `category` mapped onto the in-app enum; defaults to `.productivity` /// for an absent or unrecognised value (category is informational only — /// the dropdown no longer groups by it). diff --git a/Sources/Halen/Plugins/Store/PluginStoreModel.swift b/Sources/Halen/Plugins/Store/PluginStoreModel.swift index 1424538..644f13b 100644 --- a/Sources/Halen/Plugins/Store/PluginStoreModel.swift +++ b/Sources/Halen/Plugins/Store/PluginStoreModel.swift @@ -69,22 +69,13 @@ final class PluginStoreModel { return } do { - var request = URLRequest(url: url) - request.timeoutInterval = 20 - request.cachePolicy = .reloadIgnoringLocalCacheData - let (data, response) = try await URLSession.shared.data(for: request) - if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { - fetchState = .failed("Registry server returned HTTP \(http.statusCode).") - return - } - let index = try JSONDecoder().decode(PluginRegistryIndex.self, from: data) - guard index.schemaVersion == PluginRegistryIndex.supportedSchemaVersion else { - fetchState = .failed("Registry schema v\(index.schemaVersion) isn't supported by this build of Halen.") - return - } + let index = try await PluginRegistryIndex.fetchAuthenticated(from: url) available = index.plugins fetchState = .loaded Log.info("PluginStore: registry loaded — \(index.plugins.count) entr\(index.plugins.count == 1 ? "y" : "ies")") + } catch let error as RegistryError { + Log.warn("PluginStore: registry rejected — \(error.localizedDescription)") + fetchState = .failed(error.localizedDescription) } catch let error as DecodingError { Log.warn("PluginStore: registry decode failed — \(error)") fetchState = .failed("The plugin registry is malformed and couldn't be read.") diff --git a/Sources/Halen/Plugins/Store/PluginStoreView.swift b/Sources/Halen/Plugins/Store/PluginStoreView.swift index dc6a8f0..a1611e1 100644 --- a/Sources/Halen/Plugins/Store/PluginStoreView.swift +++ b/Sources/Halen/Plugins/Store/PluginStoreView.swift @@ -290,6 +290,16 @@ private struct InstalledPluginRow: View { .foregroundStyle(.secondary) .lineLimit(2) .fixedSize(horizontal: false, vertical: true) + if let external = plugin as? ExternalPluginAdapter { + Text("Permissions: \(external.manifest.permissions.map(\.rawValue).joined(separator: ", "))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text("Receives: \(external.manifest.events.map(\.rawValue).joined(separator: ", "))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } Spacer(minLength: 4) @@ -360,6 +370,14 @@ private struct AvailablePluginRow: View { Text("by \(entry.author) · v\(entry.version)") .font(.caption2) .foregroundStyle(.tertiary) + Text("Permissions: \(entry.permissions.map(\.rawValue).joined(separator: ", "))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text("Receives: \(entry.events.map(\.rawValue).joined(separator: ", "))") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 4) @@ -381,6 +399,11 @@ private struct AvailablePluginRow: View { .accessibilityLabel("Install failed: \(message)") } + Text("Installed plugins start disabled. Enabling is your approval; Halen gates its plugin API to the permissions shown above.") + .font(.caption2) + .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) + if let source = URL(string: entry.sourceURL) { Button { NSWorkspace.shared.open(source) diff --git a/Sources/Halen/Support/Log.swift b/Sources/Halen/Support/Log.swift index 76f50b3..0652377 100644 --- a/Sources/Halen/Support/Log.swift +++ b/Sources/Halen/Support/Log.swift @@ -1,5 +1,6 @@ import Foundation import OSLog +import Darwin enum Log { static let logger = Logger(subsystem: "com.dadiani.halen", category: "halen") @@ -12,40 +13,76 @@ enum Log { /// /// Path: `~/Library/Application Support/Halen/halen-trace.log` — /// per-user, persists across reboots, no permission collision on - /// multi-user Macs. Falls back to `/tmp/halen-trace.log` only if - /// Application Support is somehow unreachable. + /// multi-user Macs. If Application Support cannot be secured, file + /// mirroring is disabled; sensitive logs must never fall back to /tmp. /// /// Path resolution is inlined here (not via `HalenSupportDirectory`) /// to avoid a static-init cycle: `HalenSupportDirectory.root` calls /// `Log.error` on failure, and that would re-enter this initializer /// on first failed access. + private static let maxTraceBytes: off_t = 4 * 1024 * 1024 + private static let traceHandle: FileHandle? = { let fm = FileManager.default - let dir: URL - if let support = fm.urls(for: .applicationSupportDirectory, - in: .userDomainMask).first { - dir = support.appending(path: "Halen") - try? fm.createDirectory(at: dir, withIntermediateDirectories: true) - } else { - dir = URL(fileURLWithPath: "/tmp", isDirectory: true) - } - let path = dir.appending(path: "halen-trace.log").path - // Soft-rotate: if the existing file is over 4 MB, roll it. - if let attrs = try? fm.attributesOfItem(atPath: path), - let size = attrs[.size] as? Int64, size > 4 * 1024 * 1024 { - try? fm.moveItem(atPath: path, toPath: path + ".old") + guard let support = fm.urls(for: .applicationSupportDirectory, + in: .userDomainMask).first else { return nil } + return openSecureTraceFile(in: support.appending(path: "Halen")) + }() + + /// Creates/opens the trace file without following a final-component + /// symlink. Internal for focused filesystem security tests. + static func openSecureTraceFile(in directory: URL) -> FileHandle? { + let fm = FileManager.default + do { + try fm.createDirectory(at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } catch { return nil } + + let directoryFD = open(directory.path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directoryFD >= 0 else { return nil } + defer { close(directoryFD) } + var directoryStat = stat() + guard fstat(directoryFD, &directoryStat) == 0, + (directoryStat.st_mode & S_IFMT) == S_IFDIR, + directoryStat.st_uid == geteuid() else { return nil } + guard fchmod(directoryFD, 0o700) == 0 else { return nil } + + let fd = openat(directoryFD, "halen-trace.log", + O_WRONLY | O_APPEND | O_CREAT | O_CLOEXEC | O_NOFOLLOW, + 0o600) + guard fd >= 0 else { return nil } + + var fileStat = stat() + guard fstat(fd, &fileStat) == 0, + (fileStat.st_mode & S_IFMT) == S_IFREG, + fileStat.st_uid == geteuid(), + fchmod(fd, 0o600) == 0 else { + close(fd) + return nil } - if !fm.fileExists(atPath: path) { - fm.createFile(atPath: path, contents: nil) + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } + + /// Keep one bounded file rather than racing a predictable `.old` path. + /// All production calls run on traceQueue; internal for focused tests. + static func writeBounded(_ data: Data, to handle: FileHandle, + maxBytes: off_t = maxTraceBytes) throws { + guard maxBytes > 0 else { return } + let fd = handle.fileDescriptor + var fileStat = stat() + guard fstat(fd, &fileStat) == 0, + (fileStat.st_mode & S_IFMT) == S_IFREG, + fileStat.st_uid == geteuid() else { return } + if fileStat.st_size > maxBytes || off_t(data.count) > maxBytes - fileStat.st_size { + guard ftruncate(fd, 0) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } } - let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) - // `seekToEnd()` is marked `@discardableResult` upstream but the - // strict toolchain on CI flagged the `try?` swallow as "result of - // 'try?' is unused" (warnings-as-errors). Bind to `_` to silence it; - // we genuinely don't care about the returned offset. - _ = try? handle?.seekToEnd() - return handle - }() + // A single oversized record cannot be allowed to defeat the bound. + let bounded = data.count > Int(maxBytes) ? data.suffix(Int(maxBytes)) : data[...] + try handle.write(contentsOf: Data(bounded)) + } private static let traceFormatter: DateFormatter = { let f = DateFormatter() @@ -59,10 +96,12 @@ enum Log { private static func appendTrace(_ level: String, _ message: String) { guard let handle = traceHandle else { return } - let ts = traceFormatter.string(from: Date()) - let line = "\(ts) [\(level)] \(message)\n" - guard let data = line.data(using: .utf8) else { return } - traceQueue.async { try? handle.write(contentsOf: data) } + traceQueue.async { + let ts = traceFormatter.string(from: Date()) + let line = "\(ts) [\(level)] \(message)\n" + guard let data = line.data(using: .utf8) else { return } + try? writeBounded(data, to: handle) + } } static func info(_ message: String) { @@ -98,4 +137,12 @@ enum Log { let hash = sha256Hex(text).prefix(8) return "" } + + static func redactedToastDescription(title: String, body: String) -> String { + "toast: title=\(redact(title)) body=\(redact(body))" + } + + static func redactedPluginStderrDescription(pluginID: String, line: String) -> String { + "plugin[\(pluginID)] stderr=\(redact(line))" + } } diff --git a/Tests/HalenTests/LogRedactTests.swift b/Tests/HalenTests/LogRedactTests.swift index f6ce419..2f26939 100644 --- a/Tests/HalenTests/LogRedactTests.swift +++ b/Tests/HalenTests/LogRedactTests.swift @@ -34,4 +34,51 @@ final class LogRedactTests: XCTestCase { XCTAssertTrue(r.hasPrefix("<")) XCTAssertTrue(r.hasSuffix(">")) } + + func testSecureTraceFileUsesPrivateModes() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let handle = try XCTUnwrap(Log.openSecureTraceFile(in: root)) + defer { try? handle.close() } + let dirMode = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: root.path)[.posixPermissions] as? NSNumber) + let fileMode = try XCTUnwrap(FileManager.default.attributesOfItem(atPath: root.appending(path: "halen-trace.log").path)[.posixPermissions] as? NSNumber) + XCTAssertEqual(dirMode.intValue & 0o777, 0o700) + XCTAssertEqual(fileMode.intValue & 0o777, 0o600) + } + + func testSecureTraceFileRejectsSymlinkAndNonRegularFile() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appending(path: UUID().uuidString) + try fm.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? fm.removeItem(at: root) } + let trace = root.appending(path: "halen-trace.log") + try fm.createSymbolicLink(at: trace, withDestinationURL: root.appending(path: "target")) + XCTAssertNil(Log.openSecureTraceFile(in: root)) + try fm.removeItem(at: trace) + try fm.createDirectory(at: trace, withIntermediateDirectories: false) + XCTAssertNil(Log.openSecureTraceFile(in: root)) + } + + func testBoundedWriteTruncatesWithoutRotation() throws { + let fm = FileManager.default + let root = fm.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? fm.removeItem(at: root) } + let handle = try XCTUnwrap(Log.openSecureTraceFile(in: root)) + defer { try? handle.close() } + try Log.writeBounded(Data(repeating: 0x41, count: 12), to: handle, maxBytes: 16) + try Log.writeBounded(Data(repeating: 0x42, count: 8), to: handle, maxBytes: 16) + let content = try Data(contentsOf: root.appending(path: "halen-trace.log")) + XCTAssertEqual(content, Data(repeating: 0x42, count: 8)) + XCTAssertFalse(fm.fileExists(atPath: root.appending(path: "halen-trace.log.old").path)) + } + + func testSensitiveCallSiteDescriptionsRedactPayloads() { + let toast = Log.redactedToastDescription(title: "private title", body: "private body") + XCTAssertFalse(toast.contains("private title")) + XCTAssertFalse(toast.contains("private body")) + let stderr = Log.redactedPluginStderrDescription(pluginID: "example", line: "secret stderr") + XCTAssertTrue(stderr.contains("plugin[example]")) + XCTAssertFalse(stderr.contains("secret stderr")) + } } diff --git a/Tests/HalenTests/ModelDownloaderTests.swift b/Tests/HalenTests/ModelDownloaderTests.swift index f50521e..5928dbd 100644 --- a/Tests/HalenTests/ModelDownloaderTests.swift +++ b/Tests/HalenTests/ModelDownloaderTests.swift @@ -69,3 +69,40 @@ final class ContentRangeParserTests: XCTestCase { XCTAssertEqual(parsed?.total, 1) } } + +final class ModelSpecIntegrityTests: XCTestCase { + func testDownloadURLsUseImmutableRevisions() { + let specs = [ModelSpec.gemma4E4B_IQ4_XS, ModelSpec.qwen25_05B_Q4_K_M] + + for spec in specs { + XCTAssertNotNil(spec.sourceURL.path.range( + of: #"/resolve/[0-9a-f]{40}/"#, + options: .regularExpression + ), "\(spec.displayName) must download from an immutable commit") + XCTAssertNotNil(spec.expectedSHA256) + XCTAssertTrue(spec.expectedSize > 0) + } + } + + func testGemmaPinMatchesKnownArtifact() { + let spec = ModelSpec.gemma4E4B_IQ4_XS + + XCTAssertTrue(spec.sourceURL.path.contains( + "/resolve/653803f092503c04a65164346f3208a36e707693/" + )) + XCTAssertEqual(spec.expectedSize, 4_715_414_688) + XCTAssertEqual(spec.expectedSHA256, + "eb29c8519c4c07b880fb9cae7ff13ee2e30c5f38516268920ab85c04df6d52a2") + } + + func testQwenPinMatchesKnownArtifact() { + let spec = ModelSpec.qwen25_05B_Q4_K_M + + XCTAssertTrue(spec.sourceURL.path.contains( + "/resolve/9217f5db79a29953eb74d5343926648285ec7e67/" + )) + XCTAssertEqual(spec.expectedSize, 491_400_032) + XCTAssertEqual(spec.expectedSHA256, + "74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db") + } +} diff --git a/Tests/HalenTests/PluginManifestTests.swift b/Tests/HalenTests/PluginManifestTests.swift index 72fd7be..6e55049 100644 --- a/Tests/HalenTests/PluginManifestTests.swift +++ b/Tests/HalenTests/PluginManifestTests.swift @@ -83,8 +83,8 @@ final class PluginManifestValidateTests: XCTestCase { version: "1.0", halenApiVersion: "0.1", executable: "../../../../bin/sh", // path traversal - args: nil, env: nil, events: nil, - permissions: nil, icon: nil, category: nil + args: nil, env: nil, events: [], + permissions: [], icon: nil, category: nil ) XCTAssertThrowsError(try manifest.validate(at: tmp)) { err in @@ -106,8 +106,8 @@ final class PluginManifestValidateTests: XCTestCase { version: "1.0", halenApiVersion: "0.1", executable: "run.sh", - args: nil, env: nil, events: nil, - permissions: nil, icon: nil, category: nil + args: nil, env: nil, events: [], + permissions: [], icon: nil, category: nil ) XCTAssertThrowsError(try manifest.validate(at: tmp)) { err in @@ -127,8 +127,8 @@ final class PluginManifestValidateTests: XCTestCase { name: "Future", summary: nil, version: "1.0", halenApiVersion: "9.99", executable: "run.sh", - args: nil, env: nil, events: nil, - permissions: nil, icon: nil, category: nil + args: nil, env: nil, events: [], + permissions: [], icon: nil, category: nil ) XCTAssertThrowsError(try manifest.validate(at: tmp)) { err in @@ -158,8 +158,8 @@ final class PluginManifestValidateTests: XCTestCase { name: "Good", summary: nil, version: "1.0", halenApiVersion: "0.1", executable: "bin/run.sh", - args: nil, env: nil, events: nil, - permissions: nil, icon: nil, category: nil + args: nil, env: nil, events: [], + permissions: [], icon: nil, category: nil ) XCTAssertNoThrow(try manifest.validate(at: tmp)) diff --git a/Tests/HalenTests/PluginSecurityTests.swift b/Tests/HalenTests/PluginSecurityTests.swift new file mode 100644 index 0000000..d268d5a --- /dev/null +++ b/Tests/HalenTests/PluginSecurityTests.swift @@ -0,0 +1,181 @@ +import XCTest +import CryptoKit +import SwiftUI +@testable import Halen + +final class PluginRegistryAuthenticationTests: XCTestCase { + func testCheckedInRegistryMatchesCompiledAuthenticationPin() throws { + let repositoryRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let data = try Data(contentsOf: repositoryRoot.appending(path: "plugin-registry.json")) + let index = try PluginRegistryIndex.decodeAuthenticated(data) + XCTAssertTrue(index.plugins.isEmpty, + "Unavailable archives must not be advertised with placeholder hashes") + } + + func testAuthenticatedV2DecodesAndTamperFails() throws { + let data = Data(#"{"schemaVersion":2,"plugins":[]}"#.utf8) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + XCTAssertNoThrow(try PluginRegistryIndex.decodeAuthenticated(data, expectedSHA256: digest)) + + var tampered = data + tampered.append(0x20) + XCTAssertThrowsError(try PluginRegistryIndex.decodeAuthenticated(tampered, + expectedSHA256: digest)) { + XCTAssertEqual($0 as? RegistryError, .authenticationFailed) + } + } + + func testAuthenticatedV1IsRejected() throws { + let data = Data(#"{"schemaVersion":1,"plugins":[]}"#.utf8) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + XCTAssertThrowsError(try PluginRegistryIndex.decodeAuthenticated(data, + expectedSHA256: digest)) { + XCTAssertEqual($0 as? RegistryError, .unsupportedSchema(1)) + } + } + + func testUnknownManifestPermissionIsRejected() { + let json = #"{"id":"com.test.p","name":"P","version":"1","halenApiVersion":"0.1","executable":"run","events":[],"permissions":["filesystem.all"]}"# + XCTAssertThrowsError(try JSONDecoder().decode(PluginManifest.self, from: Data(json.utf8))) + } + + func testUnknownEventSubscriptionIsRejected() { + let json = #"{"id":"com.test.p","name":"P","version":"1","halenApiVersion":"0.1","executable":"run","events":["secret.stream"],"permissions":[]}"# + XCTAssertThrowsError(try JSONDecoder().decode(PluginManifest.self, from: Data(json.utf8))) + } +} + +final class PluginArchiveVerificationTests: XCTestCase { + func testArchiveIndexRejectsBombTraversalAndSpecialEntries() throws { + XCTAssertNoThrow(try PluginInstaller.validateArchiveIndex( + summary: "2 files, 20 bytes uncompressed, 10 bytes compressed: 50%", + listing: "-rw-r--r-- file.txt\ndrwxr-xr-x folder/", + names: "file.txt\nfolder/")) + XCTAssertThrowsError(try PluginInstaller.validateArchiveIndex( + summary: "1 file, 101 bytes uncompressed, 10 bytes compressed: 90%", + listing: "-rw-r--r-- huge", names: "huge", maxFiles: 10, maxBytes: 100)) + XCTAssertThrowsError(try PluginInstaller.validateArchiveIndex( + summary: "1 file, 1 bytes uncompressed, 1 bytes compressed: 0%", + listing: "-rw-r--r-- ../escape", names: "../escape")) + XCTAssertThrowsError(try PluginInstaller.validateArchiveIndex( + summary: "1 file, 1 bytes uncompressed, 1 bytes compressed: 0%", + listing: "lrwxr-xr-x link", names: "link")) + } + + func testArchiveHashAndSizeMustBothMatch() throws { + let file = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + let bytes = Data("archive".utf8) + try bytes.write(to: file) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + + XCTAssertNoThrow(try PluginInstaller.verifyArchive(file, entry: entry(size: 7, hash: digest))) + XCTAssertThrowsError(try PluginInstaller.verifyArchive(file, entry: entry(size: 8, hash: digest))) + XCTAssertThrowsError(try PluginInstaller.verifyArchive(file, + entry: entry(size: 7, hash: String(repeating: "0", count: 64)))) + } + + func testRemovalURLAlwaysUsesCanonicalRootAndValidatedID() throws { + let root = URL(fileURLWithPath: "/tmp/Halen/Plugins") + XCTAssertEqual(try PluginInstaller.removalURL(for: "com.test.p", installRoot: root), + root.appending(path: "com.test.p", directoryHint: .isDirectory)) + XCTAssertThrowsError(try PluginInstaller.removalURL(for: "../escape", installRoot: root)) + } + + private func entry(size: Int64, hash: String) -> PluginRegistryEntry { + PluginRegistryEntry(id: "com.test.p", name: "P", summary: "P", author: "T", + version: "1", icon: nil, category: nil, + sourceURL: "https://example.com/source", + downloadURL: "https://example.com/p.zip", + archiveSHA256: hash, archiveSize: size, + permissions: [], events: [], isExample: nil) + } +} + +@MainActor +final class PluginPermissionMappingTests: XCTestCase { + func testEveryHostMethodMapsToClosedPermission() { + let expected: [String: PluginPermission] = [ + "inference/complete": .inference, + "ax/readSelection": .axRead, + "ax/replaceRange": .axWrite, + "ui/toast": .notifications, + "ui/prompt": .uiPrompt, + "calendar/upcomingEvents": .calendar, + "calendar/createEvent": .calendar, + "profile/getToneProfile": .profilesRead, + "profile/listToneProfiles": .profilesRead, + "profile/setToneProfile": .profilesWrite, + ] + for (method, permission) in expected { + XCTAssertEqual(HostBridge.requiredPermission(for: method), permission) + XCTAssertFalse(HostBridge.isAuthorized(method: method, grantedPermissions: [])) + XCTAssertTrue(HostBridge.isAuthorized(method: method, + grantedPermissions: [permission.rawValue])) + } + XCTAssertNil(HostBridge.requiredPermission(for: "unknown")) + } +} + +final class PluginManifestHardeningTests: XCTestCase { + func testAbsoluteExecutableRejected() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + XCTAssertThrowsError(try manifest(executable: "/bin/sh").validate(at: dir)) { + guard case ManifestError.absoluteExecutable = $0 else { return XCTFail("\($0)") } + } + } + + func testSymlinkExecutableRejected() throws { + let dir = try tempDir() + defer { try? FileManager.default.removeItem(at: dir) } + let target = dir.appending(path: "real") + try "#!/bin/sh\n".write(to: target, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: target.path) + try FileManager.default.createSymbolicLink(at: dir.appending(path: "run"), + withDestinationURL: target) + XCTAssertThrowsError(try manifest(executable: "run").validate(at: dir)) + } + + private func manifest(executable: String) -> PluginManifest { + PluginManifest(id: "com.test.p", name: "P", summary: nil, version: "1", + halenApiVersion: "0.1", executable: executable, args: nil, env: nil, + events: [], permissions: [], icon: nil, category: nil) + } + + private func tempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } +} + +@MainActor +final class ExternalPluginDefaultStateTests: XCTestCase { + private final class StubPlugin: HalenPlugin { + let id: String + var name = "Stub"; var summary = "Stub"; var icon = "puzzlepiece" + var category: PluginCategory = .productivity + var starts = 0 + init(id: String) { self.id = id } + func start() { starts += 1 } + func stop() {} + } + + func testExternalRegistrationDefaultsDisabled() { + let id = "com.test.\(UUID().uuidString)" + let defaultsKey = "plugin.\(id).enabled" + UserDefaults.standard.removeObject(forKey: defaultsKey) + defer { UserDefaults.standard.removeObject(forKey: defaultsKey) } + let plugin = StubPlugin(id: id) + let registry = PluginRegistry() + registry.register(plugin, defaultEnabled: false) + XCTAssertFalse(registry.isEnabled(id)) + XCTAssertEqual(plugin.starts, 0) + registry.toggle(id) + XCTAssertEqual(plugin.starts, 1) + } +} diff --git a/Tests/HalenTests/WebSocketBridgeTests.swift b/Tests/HalenTests/WebSocketBridgeTests.swift index 8bbdc5f..9ed4aaa 100644 --- a/Tests/HalenTests/WebSocketBridgeTests.swift +++ b/Tests/HalenTests/WebSocketBridgeTests.swift @@ -55,3 +55,90 @@ final class TruncateUTF16Tests: XCTestCase { XCTAssertEqual(WebSocketBridge.truncateUTF16(composed, maxUnits: 2), composed) } } + +final class WebSocketBridgePolicyTests: XCTestCase { + func testBridgeDefaultsOffWithoutPersistedChoice() { + let previous = UserDefaults.standard.object(forKey: WebSocketBridge.enabledKey) + defer { + if let previous { + UserDefaults.standard.set(previous, forKey: WebSocketBridge.enabledKey) + } else { + UserDefaults.standard.removeObject(forKey: WebSocketBridge.enabledKey) + } + } + UserDefaults.standard.removeObject(forKey: WebSocketBridge.enabledKey) + XCTAssertFalse(WebSocketBridge.isEnabledInDefaults) + } + + func testUnauthenticatedRequestCannotDispatch() { + XCTAssertEqual(WebSocketBridgePolicy.disposition( + isAuthenticated: false, isRequest: true, method: "inference/complete"), + .rejectRequest) + } + + func testNoApplicationMessageIsAllowedBeforeHandshakeAuthentication() { + XCTAssertEqual(WebSocketBridgePolicy.disposition( + isAuthenticated: false, isRequest: false, method: "subscribe"), .reject) + XCTAssertEqual(WebSocketBridgePolicy.disposition( + isAuthenticated: false, isRequest: false, method: "event/text.pause"), .reject) + } + + func testAuthenticatedNotificationsAndSubscriptionAllowedButRequestsRemainDenied() { + XCTAssertEqual(WebSocketBridgePolicy.disposition( + isAuthenticated: true, isRequest: false, method: "event/text.pause"), .notification) + XCTAssertEqual(WebSocketBridgePolicy.disposition( + isAuthenticated: true, isRequest: false, method: "subscribe"), .subscribe) + XCTAssertEqual(WebSocketBridgePolicy.disposition( + isAuthenticated: true, isRequest: true, method: "ui/toast"), .rejectRequest) + } + + func testBrowserExtensionOriginsAllowed() { + XCTAssertTrue(WebSocketBridgePolicy.isAllowedBrowserOrigin("chrome-extension://abcdefghijklmnop")) + XCTAssertTrue(WebSocketBridgePolicy.isAllowedBrowserOrigin("moz-extension://addon-id")) + XCTAssertTrue(WebSocketBridgePolicy.isAllowedBrowserOrigin("safari-web-extension://com.example.halen")) + } + + func testMissingNullAndWebOriginsRejected() { + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigin(nil)) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigin("null")) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigin("http://localhost")) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigin("https://example.com")) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigin("chrome-extension://")) + } + + func testUpgradeRequiresExactlyOneAllowedOrigin() { + XCTAssertTrue(WebSocketBridgePolicy.isAllowedBrowserOrigins([ + "chrome-extension://abcdefghijklmnop" + ])) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigins([])) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedBrowserOrigins([ + "chrome-extension://abcdefghijklmnop", + "https://example.com" + ])) + } + + func testUpgradeRequiresExactPairingSubprotocol() { + let expected = WebSocketBridgePolicy.pairingSubprotocol(token: "abc123") + let origin = ["chrome-extension://abcdefghijklmnop"] + XCTAssertTrue(WebSocketBridgePolicy.isAllowedHandshake( + origins: origin, offeredSubprotocols: [expected], expectedSubprotocol: expected)) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedHandshake( + origins: origin, offeredSubprotocols: [], expectedSubprotocol: expected)) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedHandshake( + origins: origin, offeredSubprotocols: ["halen.wrong"], expectedSubprotocol: expected)) + XCTAssertFalse(WebSocketBridgePolicy.isAllowedHandshake( + origins: origin, offeredSubprotocols: [expected, "extra"], expectedSubprotocol: expected)) + } + + func testClientCeilingDecision() { + XCTAssertTrue(WebSocketBridgePolicy.canAcceptClient(currentCount: 15)) + XCTAssertFalse(WebSocketBridgePolicy.canAcceptClient(currentCount: 16)) + } + + func testPendingHandshakesCannotPermanentlyOccupySlots() { + XCTAssertEqual(WebSocketBridgePolicy.admission(currentCount: 3, pendingCount: 3), .accept) + XCTAssertEqual(WebSocketBridgePolicy.admission(currentCount: 4, pendingCount: 4), .evictPending) + XCTAssertEqual(WebSocketBridgePolicy.admission(currentCount: 16, pendingCount: 1), .evictPending) + XCTAssertEqual(WebSocketBridgePolicy.admission(currentCount: 16, pendingCount: 0), .reject) + } +} diff --git a/Vendor/LLAMA_CPP_COMMIT b/Vendor/LLAMA_CPP_COMMIT new file mode 100644 index 0000000..783cdcf --- /dev/null +++ b/Vendor/LLAMA_CPP_COMMIT @@ -0,0 +1 @@ +9ed6e19b9d7e14a71a19622287b2dcd495a828b8 diff --git a/browser-extension/README.md b/browser-extension/README.md index 66eec0b..b0189d3 100644 --- a/browser-extension/README.md +++ b/browser-extension/README.md @@ -9,27 +9,23 @@ Gmail, Google Docs, Notion, ChatGPT.app's input, etc. ## How it works ``` -┌──────────────────┐ ws://127.0.0.1:50765 ┌─────────────────────┐ -│ Chrome / Arc / │ event/text.pause ──────► │ Halen.app │ -│ Edge — DOM │ │ WebSocketBridge │ -│ input/textarea │ │ ↓ │ -│ contenteditable │ │ EventBus │ -└──────────────────┘ │ ↓ │ - │ SnippetExpander │ - │ TypoFixer │ - │ SentimentGuard │ - │ ↓ │ - │ AX write fails │ - │ ↓ │ - │ clipboard + ⌘V │ - └─────────────────────┘ - │ - ▼ - (paste lands in DOM) +┌──────────────────┐ runtime message ┌────────────────┐ +│ Browser tabs │ event/text.pause ►│ MV3 background │ +│ DOM edit fields │ │ service worker │ +└──────────────────┘ └───────┬────────┘ + │ one authenticated WebSocket + ▼ + ┌─────────────────────┐ + │ Halen WebSocketBridge│ + │ EventBus → plugins │ + │ clipboard + ⌘V │ + └─────────────────────┘ ``` -Same plugins, same protocol shape — the browser tab is just another event -source. Write-back relies on Halen's existing clipboard-and-⌘V fallback +All tabs send events to one MV3 background service worker, which owns the +single authenticated WebSocket. The token is presented as a WebSocket +subprotocol, so the connection opens only after authentication. Write-back relies on Halen's +clipboard-and-⌘V fallback because synthesised ⌘V works perfectly in Chromium text fields. The bridge is authenticated: loopback binding alone isn't a trust boundary @@ -42,7 +38,8 @@ token** before it can send or receive events. 2. Toggle on **Developer mode** (top-right) 3. Click **Load unpacked** 4. Pick the `browser-extension/` directory in this repo -5. **Pair it.** Click the extension's toolbar icon to open its popup, then +5. In **Halen Settings → Browser bridge**, enable the bridge. +6. **Pair it.** Click the extension's toolbar icon to open its popup, then paste the pairing token from **Halen Settings → Browser bridge**. Until the token matches, the connection opens but Halen ignores its events. @@ -70,11 +67,22 @@ Google Doc. Halen's SnippetExpander fires, the AX write fails (silently), the clipboard fallback kicks in, ⌘V is synthesised, and your signature lands in the field. +## Security and lifecycle + +- Halen accepts WebSocket upgrades only from Chrome, Firefox, or Safari + extension origins; ordinary web pages and origin-less clients are rejected. +- The background worker authenticates during the WebSocket upgrade and keeps + the one socket alive with periodic traffic. Chrome 116+ is required for reliable MV3 + WebSocket liveness. +- The paired extension can publish supported events only. It receives no host + RPC capabilities. +- Text is windowed around the caret to 32K UTF-16 units, and the background + worker refuses events over 192 KiB before retaining or sending them. + ## Limitations of v0 -- Each browser tab opens its own WebSocket connection — Halen accepts an - unbounded number; if this becomes a problem we'll move to one - service-worker-owned connection that proxies for all tabs. +- One service-worker-owned connection proxies events for all tabs. Halen caps + the bridge at 16 simultaneous clients across installed browser profiles. - The extension is one-way today: events go up, writes come back via the ⌘V clipboard fallback. Future: direct `extension/replaceSelection` RPC so writes preserve undo history and avoid clobbering the clipboard. diff --git a/browser-extension/background.js b/browser-extension/background.js new file mode 100644 index 0000000..a8177a4 --- /dev/null +++ b/browser-extension/background.js @@ -0,0 +1,143 @@ +// Halen for Web — the extension's single WebSocket owner. +// Content scripts and the popup communicate with this MV3 service worker via +// chrome.runtime messaging; tabs never connect to the native bridge directly. + +const HALEN_HOST = "ws://127.0.0.1:50765/"; +const STORAGE_KEY = "halenBridgeToken"; +const SUBSCRIBE_TOPICS = ["text.pause"]; +const RECONNECT_INITIAL_MS = 2_000; +const RECONNECT_MAX_MS = 30_000; +const KEEPALIVE_MS = 20_000; +const MAX_EVENT_BYTES = 192 * 1024; + +let socket = null; +let token = ""; +let reconnectDelay = RECONNECT_INITIAL_MS; +let reconnectTimer = null; +let keepaliveTimer = null; +let status = "disconnected"; +let pendingEvent = null; // latest unsent typing event; deliberately bounded to one + +function send(method, params) { + if (!socket || socket.readyState !== WebSocket.OPEN || !token) return false; + try { + socket.send(JSON.stringify({ jsonrpc: "2.0", method, params })); + return true; + } catch (_) { + return false; + } +} + +function scheduleReconnect() { + if (reconnectTimer) return; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, reconnectDelay); + reconnectDelay = Math.min(RECONNECT_MAX_MS, Math.round(reconnectDelay * 1.6)); +} + +function disconnect() { + clearInterval(keepaliveTimer); + keepaliveTimer = null; + if (socket) { + const old = socket; + socket = null; + try { old.close(); } catch (_) {} + } + status = "disconnected"; +} + +function connect() { + if (!/^[0-9a-f]{64}$/.test(token)) { + status = "unpaired"; + return; + } + if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) return; + status = "connecting"; + try { + // The pairing token is a WebSocket subprotocol so Halen authenticates the + // HTTP upgrade itself. `open` therefore doubles as the authentication ack. + socket = new WebSocket(HALEN_HOST, [`halen.${token}`]); + } catch (_) { + socket = null; + status = "disconnected"; + scheduleReconnect(); + return; + } + const currentSocket = socket; + + currentSocket.addEventListener("open", () => { + if (socket !== currentSocket) return; + reconnectDelay = RECONNECT_INITIAL_MS; + send("subscribe", { topics: SUBSCRIBE_TOPICS }); + status = "connected"; + if (pendingEvent) { + const event = pendingEvent; + pendingEvent = null; + send(event.method, event.params); + } + // Chrome 116+ keeps an MV3 worker alive when WebSocket traffic occurs. + // A small notification below maintains the one shared connection. Halen + // safely ignores it because it is outside the event namespace. + clearInterval(keepaliveTimer); + keepaliveTimer = setInterval(() => send("extension/keepalive", {}), KEEPALIVE_MS); + }); + currentSocket.addEventListener("close", () => { + if (socket !== currentSocket) return; + socket = null; + clearInterval(keepaliveTimer); + keepaliveTimer = null; + status = "disconnected"; + scheduleReconnect(); + }); + currentSocket.addEventListener("error", () => {}); + currentSocket.addEventListener("message", () => {}); +} + +function reloadTokenAndReconnect() { + chrome.storage.local.get([STORAGE_KEY], (result) => { + token = (result && typeof result[STORAGE_KEY] === "string") ? result[STORAGE_KEY] : ""; + disconnect(); + connect(); + }); +} + +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (!message || typeof message.type !== "string") return false; + if (message.type === "halen:event") { + if (message.method !== "event/text.pause") { + sendResponse({ sent: false, queued: false }); + return false; + } + const encodedBytes = new TextEncoder().encode(JSON.stringify({ + jsonrpc: "2.0", method: message.method, params: message.params + })).byteLength; + if (encodedBytes > MAX_EVENT_BYTES) { + sendResponse({ sent: false, queued: false }); + return false; + } + const sent = send(message.method, message.params); + if (!sent) pendingEvent = { method: message.method, params: message.params }; + sendResponse({ sent, queued: !sent }); + if (status === "disconnected" || status === "unpaired") connect(); + return false; + } + if (message.type === "halen:status") { + sendResponse({ status, paired: Boolean(token) }); + if (status === "disconnected") connect(); + return false; + } + if (message.type === "halen:reconnect") { + reloadTokenAndReconnect(); + sendResponse({ ok: true }); + return false; + } + return false; +}); + +chrome.storage.onChanged.addListener((changes, area) => { + if (area === "local" && changes[STORAGE_KEY]) reloadTokenAndReconnect(); +}); + +reloadTokenAndReconnect(); diff --git a/browser-extension/content.js b/browser-extension/content.js index f4598cc..471cb91 100644 --- a/browser-extension/content.js +++ b/browser-extension/content.js @@ -6,7 +6,8 @@ // Google Docs, Notion, ChatGPT.app's own input, anything Chromium-based. // // Architecture: -// * Connect to Halen's WS server on 127.0.0.1:50765. +// * Send DOM events to the extension's background service worker. +// * The worker owns the sole WS connection to Halen on 127.0.0.1:50765. // * Listen for `input`/`focusin` on inputs/textareas/contenteditables. // * After a 600 ms debounce, send `event/text.pause` upstream — same shape // Halen's native CaretObserver emits. @@ -15,98 +16,22 @@ // does not need its own write path — that's the whole point of the // fallback existing. // -// Connection lifecycle: each tab opens its own WS to Halen. If Halen isn't -// running, the connection fails and we retry with exponential backoff up to -// 30 s. Closing or reloading the tab also closes the socket — no leaks. +// Connection lifecycle is centralized in background.js, avoiding one native +// bridge client per tab and surviving ordinary tab navigation/reloads. (() => { - const HALEN_HOST = "ws://127.0.0.1:50765/"; - const STORAGE_KEY = "halenBridgeToken"; const PAUSE_DEBOUNCE_MS = 600; - const RECONNECT_INITIAL_MS = 2_000; - const RECONNECT_MAX_MS = 30_000; - // Topics we want from the host. Browser tabs don't care about app.focused - // (that's about *native* app switches) or caret.moved (DOM tracks its own). - const SUBSCRIBE_TOPICS = ["text.pause"]; + const MAX_TEXT_UTF16 = 32 * 1024; - let socket = null; - let reconnectDelay = RECONNECT_INITIAL_MS; let pauseTimer = null; let lastSent = null; // last { text, caretOffset } we sent — for dedup - let token = null; - - // --- Token sync ----------------------------------------------------------- - - function loadToken(callback) { - if (typeof chrome === "undefined" || !chrome.storage) { - callback(""); - return; - } - chrome.storage.local.get([STORAGE_KEY], (result) => { - callback((result && typeof result[STORAGE_KEY] === "string") ? result[STORAGE_KEY] : ""); - }); - } - - // Re-pair when the user updates the token in the popup. The simplest - // reliable thing is to drop the current socket; the reconnect logic - // then opens a fresh one and re-subscribes with the new value. - if (typeof chrome !== "undefined" && chrome.storage && chrome.storage.onChanged) { - chrome.storage.onChanged.addListener((changes, area) => { - if (area !== "local" || !changes[STORAGE_KEY]) return; - token = changes[STORAGE_KEY].newValue || ""; - if (socket) { - try { socket.close(); } catch (_) {} - } - }); - } - - // --- Connection ----------------------------------------------------------- - - function connect() { - try { - socket = new WebSocket(HALEN_HOST); - } catch (e) { - scheduleReconnect(); - return; - } - socket.addEventListener("open", () => { - reconnectDelay = RECONNECT_INITIAL_MS; - // Halen rejects every method (events, RPCs) until we send `subscribe` - // with a matching token. Without it we're connected but invisible. - if (token) { - send("subscribe", { token, topics: SUBSCRIBE_TOPICS }); - console.debug("[Halen] connected + subscribed"); - } else { - console.warn("[Halen] connected but no token configured — open the popup and paste from Halen Settings"); - } - }); - socket.addEventListener("close", () => { - socket = null; - scheduleReconnect(); - }); - socket.addEventListener("error", () => { - // The close event will fire too; rely on that for reconnect scheduling. - }); - socket.addEventListener("message", () => { - // The host may push events (caret.moved from other apps, etc.) — the - // extension currently has no use for them. Drop quietly. Future work: - // route inbound `extension/replaceSelection` calls to write back into - // the DOM directly instead of relying on the ⌘V fallback. - }); - } - - function scheduleReconnect() { - setTimeout(connect, reconnectDelay); - reconnectDelay = Math.min(RECONNECT_MAX_MS, Math.round(reconnectDelay * 1.6)); - } function send(method, params) { - if (!socket || socket.readyState !== WebSocket.OPEN) return; - try { - socket.send(JSON.stringify({ jsonrpc: "2.0", method, params })); - } catch (e) { - // Socket racing closed — let the close handler reconnect. - } + chrome.runtime.sendMessage({ type: "halen:event", method, params }, () => { + // Reading lastError prevents a noisy console warning if an extension + // update momentarily restarts the service worker. + void chrome.runtime.lastError; + }); } // --- DOM helpers ---------------------------------------------------------- @@ -175,14 +100,28 @@ // so its EventBus, plugin caches and cooldowns all key off something // sensible per site. return { - appBundleId: "web/" + location.hostname, - appName: document.title || location.hostname + appBundleId: ("web/" + location.hostname).slice(0, 255), + appName: (document.title || location.hostname).slice(0, 512) }; } + function windowAroundCaret(field) { + const text = field.text; + const caret = Math.max(0, Math.min(field.caretOffset, text.length)); + if (text.length <= MAX_TEXT_UTF16) return { text, caretOffset: caret }; + let start = Math.max(0, caret - Math.floor(MAX_TEXT_UTF16 / 2)); + let end = Math.min(text.length, start + MAX_TEXT_UTF16); + start = Math.max(0, end - MAX_TEXT_UTF16); + // JavaScript indices are UTF-16 units; never cut a surrogate pair. + if (start > 0 && /[\uDC00-\uDFFF]/.test(text[start])) start += 1; + if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end -= 1; + return { text: text.slice(start, end), caretOffset: caret - start }; + } + function emitPause() { - const field = readEditable(document.activeElement); - if (!field) return; + const fullField = readEditable(document.activeElement); + if (!fullField) return; + const field = windowAroundCaret(fullField); // Cheap dedup so a focus-without-typing doesn't re-fire the event over // and over. The host has its own dedup too, but saving the round trip // is free. @@ -215,10 +154,4 @@ document.addEventListener("input", scheduleEmit, true); document.addEventListener("focusin", scheduleEmit, true); - // Load token first so the very first connect attempt can include it in - // its `subscribe` notification — no wasted unauth'd round-trip. - loadToken((t) => { - token = t; - connect(); - }); })(); diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index a27d581..3f8db29 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -3,7 +3,7 @@ "name": "Halen for Web", "version": "0.1.0", "description": "Lets Halen see typing in browser text fields so its plugins (snippets, typo-fixer, sentiment-guard) work on Slack, Discord, Gmail, Docs and everywhere else macOS Accessibility can't reach.", - "minimum_chrome_version": "109", + "minimum_chrome_version": "116", "permissions": ["storage"], "host_permissions": [""], @@ -15,6 +15,10 @@ "128": "icons/icon128.png" }, + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ { "matches": [""], diff --git a/browser-extension/popup.js b/browser-extension/popup.js index 03f5211..014066e 100644 --- a/browser-extension/popup.js +++ b/browser-extension/popup.js @@ -1,8 +1,6 @@ // Quick liveness check for the Halen WebSocket bridge plus the token-pairing // UI. Runs every time the user clicks the toolbar action. -const HALEN_HOST = "ws://127.0.0.1:50765/"; -const TIMEOUT_MS = 1500; const STORAGE_KEY = "halenBridgeToken"; const dot = document.getElementById("dot"); @@ -20,31 +18,15 @@ function resolve(state, msg) { text.textContent = msg; } -// --- liveness ping ---------------------------------------------------------- - -let socket; -try { - socket = new WebSocket(HALEN_HOST); -} catch (e) { +// Ask the single background connection for liveness; the popup never creates +// a second WebSocket (and therefore never consumes an extra server slot). +chrome.runtime.sendMessage({ type: "halen:status" }, (reply) => { + if (chrome.runtime.lastError || !reply) return resolve("fail", "Halen status unavailable"); + if (reply.status === "connected") return resolve("ok", "Connected to Halen"); + if (reply.status === "unpaired") return resolve("warn", "Pairing token required"); + if (reply.status === "connecting") return resolve("warn", "Connecting to Halen…"); resolve("fail", "Halen not reachable"); -} - -if (socket) { - socket.addEventListener("open", () => { - resolve("ok", "Connected to Halen"); - socket.close(); - }); - socket.addEventListener("error", () => { - resolve("fail", "Halen not reachable"); - }); - - setTimeout(() => { - if (!resolved) { - resolve("warn", "Halen didn't respond in time"); - try { socket.close(); } catch (_) {} - } - }, TIMEOUT_MS); -} +}); // --- token pairing ---------------------------------------------------------- @@ -61,10 +43,16 @@ chrome.storage.local.get([STORAGE_KEY], (result) => { saveButton.addEventListener("click", () => { const token = (tokenInput.value || "").trim(); - chrome.storage.local.set({ [STORAGE_KEY]: token }, showSaved); + chrome.storage.local.set({ [STORAGE_KEY]: token }, () => { + chrome.runtime.sendMessage({ type: "halen:reconnect" }); + showSaved(); + }); }); clearButton.addEventListener("click", () => { tokenInput.value = ""; - chrome.storage.local.remove([STORAGE_KEY], showSaved); + chrome.storage.local.remove([STORAGE_KEY], () => { + chrome.runtime.sendMessage({ type: "halen:reconnect" }); + showSaved(); + }); }); diff --git a/docs/RELEASING.md b/docs/RELEASING.md index ce75b72..23a44a9 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -94,23 +94,31 @@ the `store-credentials` command above. What it does: -1. `swift build -c release` — produces an optimized `halen` binary -2. Detects iCloud-synced parents → stages bundle to `/tmp/halen-build/` +1. Rebuilds the vendored llama framework from scratch from the full immutable + commit in + `Vendor/LLAMA_CPP_COMMIT` (tag `b9145` resolves to + `9ed6e19b9d7e14a71a19622287b2dcd495a828b8`), then verifies its source marker + and deterministic tree digest. Distribution builds never accept a + pre-existing framework plus a self-written marker. +2. `swift build -c release` — produces an optimized `halen` binary +3. Detects iCloud-synced parents → stages the bundle in a unique private + directory under `/private/tmp/` (iCloud's fileprovider keeps re-stamping `com.apple.FinderInfo`, which codesign rejects) -3. Assembles `Halen.app` with `Contents/{MacOS,Resources,Frameworks}/` -4. Embeds `Vendor/llama.xcframework`'s framework into `Contents/Frameworks/` -5. Re-stamps the framework binary's `@rpath` to `@executable_path/../Frameworks` -6. Signs the framework with Hardened Runtime + secure timestamp -7. Signs the app with Hardened Runtime + secure timestamp + +4. Assembles `Halen.app` with `Contents/{MacOS,Resources,Frameworks}/` +5. Embeds `Vendor/llama.xcframework`'s framework into `Contents/Frameworks/` +6. Re-stamps the framework binary's `@rpath` to `@executable_path/../Frameworks` +7. Signs the framework with Hardened Runtime + secure timestamp +8. Signs the app with Hardened Runtime + secure timestamp + `Resources/Halen.entitlements` (mic, calendar) -8. `codesign --verify --strict` — must pass before proceeding +9. Rejects development/absolute Mach-O load paths and `LC_RPATH` entries +10. `codesign --verify --strict` — must pass before proceeding Always pass `SIGN_IDENTITY=` on this machine — the bare cert name matches three certificates and codesign refuses ambiguity. Outputs: -- `/tmp/halen-build/Halen.app` (the real bundle, on iCloud-synced machines) +- a unique `/private/tmp/halen-build.*/Halen.app` (on iCloud-synced machines) - `build/Halen.app` → symlink to the staging path ### `scripts/notarize.sh` @@ -137,13 +145,16 @@ What it does: 1. Refuses to package an unstapled .app (otherwise the DMG would clear Gatekeeper but the app inside would still warn after install) -2. Assembles a staging folder: `Halen.app` + `Applications` symlink +2. Uses a unique `mktemp` staging directory outside iCloud, strips xattrs, + and deep/strict-verifies the staged app 3. `hdiutil create -format UDZO` → compressed read-only DMG 4. Signs the DMG with the same Developer ID Application cert + secure timestamp (Hardened Runtime doesn't apply to DMGs — they have no Mach-Os of their own) 5. `xcrun notarytool submit` → wait → `stapler staple` the DMG itself 6. Verifies: `spctl --assess --type open`, `stapler validate` +7. Mounts the finished DMG read-only, then deep/strict-verifies the mounted + app, its Mach-O paths, and its stapled ticket before detaching The DMG must be notarized separately from the .app inside. Apple's notary ticketing the .app makes the *app* trusted on launch, but @@ -194,7 +205,7 @@ if it persists, see the troubleshooting table below. | `Stapler is incapable of working with Alias files` | iCloud-staging put a symlink at `build/Halen.app` and stapler can't follow it | Already handled — both scripts now `readlink` first. If you see this, `git pull` for the symlink-follow fix | | `notarytool: No Keychain password item found for profile: halen-notary` | Profile never stored, or was deleted | Re-run `xcrun notarytool store-credentials "halen-notary" …` from Prerequisites §3 | | Notary returns `Invalid` | Almost always: missing Hardened Runtime, missing secure timestamp, or unsigned nested binary | `xcrun notarytool log --keychain-profile halen-notary` — Apple returns line-itemed reasons | -| `resource fork, Finder information, or similar detritus not allowed` | iCloud re-stamped `com.apple.FinderInfo` between `xattr -cr` and `codesign` | Already handled by staging to `/tmp/halen-build/`. If it recurs, `sudo killall securityd` to unwedge codesign, then retry | +| `resource fork, Finder information, or similar detritus not allowed` | iCloud re-stamped `com.apple.FinderInfo` between `xattr -cr` and `codesign` | Already handled by staging in a unique private directory under `/private/tmp/`. If it recurs, `sudo killall securityd` to unwedge codesign, then retry | | `errSecInternalComponent` from codesign | `securityd` is wedged | `sudo killall securityd` (it respawns), then retry | | App opens but TCC permissions don't carry over after rebuild | Bundle was signed by a different identity than last time | Don't switch identities mid-development. If you must, run `scripts/reset-permissions.sh` so TCC re-prompts cleanly | diff --git a/docs/wiki/privacy.md b/docs/wiki/privacy.md index 567754b..91dc45c 100644 --- a/docs/wiki/privacy.md +++ b/docs/wiki/privacy.md @@ -117,7 +117,9 @@ or calendar data:** telemetry. - **Browser extension bridge.** If you enable the WebSocket bridge for the optional browser extension, Halen listens on `127.0.0.1:50765` (loopback only) - so the extension can forward typing events from browser text fields. It only + so the extension can forward typing events from browser text fields. Upgrade + requests require both a browser-extension origin and the pairing-token + subprotocol, and the transport exposes events only—no host RPC methods. It only *accepts* inbound connections; it never dials out. Apart from the update check above, there is **no other outbound network code** @@ -163,8 +165,12 @@ never persisted outside macOS's own EventKit store. ## Telemetry **There is none.** No analytics, no usage metrics, no error reporting, -no remote feature flags. Logging goes to stderr and the unified system -log via the small `Log` helper in +no remote feature flags. Diagnostics go to the unified system log and a +bounded private trace file under Halen's per-user Application Support +directory (`0700` directory, `0600` file). Unsafe symlinks and non-regular +files are rejected, with no shared `/tmp` fallback. User-supplied toast text +and plugin stderr are stored only as length-plus-hash fingerprints via the +small `Log` helper in [`Sources/Halen/Support/Log.swift`](../../Sources/Halen/Support/Log.swift). Nothing is uploaded. diff --git a/plugin-registry.json b/plugin-registry.json index cf3f026..d3e1dd1 100644 --- a/plugin-registry.json +++ b/plugin-registry.json @@ -1,29 +1,6 @@ { "_comment": "Halen Plugin Store curated index. Fetched over HTTPS from https://raw.githubusercontent.com/lukataylo/halen/main/plugin-registry.json by the in-app Plugin Store. Schema is documented in plugin-registry.schema.md (sibling file).", - "schemaVersion": 1, + "schemaVersion": 2, "halenApiVersion": "0.1", - "plugins": [ - { - "id": "com.halen.reasoning-compactor", - "name": "Reasoning Compactor", - "summary": "Compacts verbose LLM reasoning on-device to save tokens — ⌃⌥K to compact a selection, works with any model's chain-of-thought.", - "author": "Halen Labs", - "version": "1.0.0", - "icon": "rectangle.compress.vertical", - "category": "productivity", - "sourceURL": "https://github.com/lukataylo/halen/tree/main/plugins/reasoning-compactor", - "downloadURL": "https://github.com/lukataylo/halen/releases/download/v0.1.0-alpha/com.halen.reasoning-compactor.zip" - }, - { - "id": "com.halen.mother", - "name": "Mother", - "summary": "Hardcore local discipline. Keeps you off the apps and sites you blocked — and means it.", - "author": "Halen Labs", - "version": "1.0.0", - "icon": "lock.shield", - "category": "focus", - "sourceURL": "https://github.com/lukataylo/halen/tree/main/plugins/mother", - "downloadURL": "https://github.com/lukataylo/halen/releases/download/v0.1.0-alpha/com.halen.mother.zip" - } - ] + "plugins": [] } diff --git a/plugin-registry.schema.md b/plugin-registry.schema.md index b24af71..4cec83a 100644 --- a/plugin-registry.schema.md +++ b/plugin-registry.schema.md @@ -13,12 +13,16 @@ unpacks that zip, validates the embedded `halen-plugin.json` manifest with the same `PluginManifest.validate(at:)` used for hand-installed plugins, and only then registers the plugin. A bad manifest aborts the install. +The checked-in registry remains empty until real release archives have been +published and their exact size and SHA-256 values have been reviewed. Example +plugin source remains under `plugins/`, but unavailable URLs are not advertised. + ## Top-level object | Field | Type | Required | Notes | |-------------------|----------|----------|-------| | `_comment` | string | no | Human note, ignored by the parser. | -| `schemaVersion` | integer | yes | Registry schema version. Current: `1`. The host ignores registries whose `schemaVersion` it does not understand. | +| `schemaVersion` | integer | yes | Registry schema version. Current: `2`. The host rejects registries whose `schemaVersion` it does not understand. | | `halenApiVersion` | string | no | Plugin protocol version this registry targets. Informational. | | `plugins` | array | yes | List of plugin entries (see below). | @@ -35,6 +39,10 @@ then registers the plugin. A bad manifest aborts the install. | `category` | string | no | One of `writing` / `voice` / `scheduling` / `focus` / `productivity`. Informational only — the dropdown no longer groups by category. | | `sourceURL` | string | yes | HTTPS URL of the plugin's source repo, shown as "View source". | | `downloadURL` | string | yes | HTTPS URL of a **zip of the plugin directory**. The zip's top level (or a single top-level folder) must contain `halen-plugin.json`. | +| `archiveSHA256` | string | yes | Lowercase SHA-256 of the exact zip bytes. | +| `archiveSize` | integer | yes | Exact zip byte count; must be positive and within the app's compressed-size cap. | +| `permissions` | string array | yes | Closed permission list; must exactly match the embedded manifest. | +| `events` | string array | yes | Event allow-list; must exactly match the embedded manifest. | | `isExample` | boolean | no | `true` marks an illustrative seed entry. The Store renders an "Example" tag and the entry is otherwise treated normally. | ## Download zip layout @@ -54,10 +62,12 @@ com.example.hello.zip ## Security -- HTTPS only. The Store rejects non-HTTPS `downloadURL`s. +- The app contains the SHA-256 of the exact reviewed registry bytes. A byte-level change requires an app update and is rejected before JSON decoding. +- HTTPS only. The Store rejects non-HTTPS URLs. +- Archive size and SHA-256 are checked before extraction. Compressed bytes, expanded bytes, and file count are capped. - Nothing in the zip is executed during install — the Store only unpacks and validates. The plugin process is spawned later, by `PluginHost`, only if the user enables the plugin. -- Path-traversal entries in the zip are rejected during extraction. +- Path-traversal entries, symlinks, and special files in the zip are rejected during extraction. - The manifest is validated with `PluginManifest.validate(at:)` before the plugin is registered; a failure deletes the unpacked directory. diff --git a/plugins/desktop-buddy/halen-plugin.json b/plugins/desktop-buddy/halen-plugin.json index be139e2..74d4b00 100644 --- a/plugins/desktop-buddy/halen-plugin.json +++ b/plugins/desktop-buddy/halen-plugin.json @@ -4,10 +4,10 @@ "summary": "A friendly Gemma-powered character that lives on your desktop. Ask anything, rewrite the selection, react to your typing tone, nudge before meetings.", "version": "0.1.0", "halenApiVersion": "0.1", - "executable": "/usr/bin/python3", - "args": ["plugin.py"], + "executable": "plugin.py", + "args": [], "events": ["text.pause", "hotkey.fired"], - "permissions": ["calendar", "inference", "ax.write", "ax.read"], + "permissions": ["calendar", "inference", "ax.write", "ax.read", "hotkeys"], "icon": "face.smiling", "category": "productivity" } diff --git a/plugins/mother/halen-plugin.json b/plugins/mother/halen-plugin.json index 64a7d20..1580fed 100644 --- a/plugins/mother/halen-plugin.json +++ b/plugins/mother/halen-plugin.json @@ -4,10 +4,10 @@ "summary": "Hardcore local discipline. Keeps you off the apps and sites you blocked — and means it.", "version": "1.0.0", "halenApiVersion": "0.1", - "executable": "/usr/bin/python3", - "args": ["plugin.py"], + "executable": "plugin.py", + "args": [], "events": ["app.focused"], - "permissions": ["notifications"], + "permissions": ["notifications", "ui.prompt"], "icon": "lock.shield", "category": "focus" } diff --git a/plugins/mother/plugin.py b/plugins/mother/plugin.py old mode 100644 new mode 100755 diff --git a/plugins/reasoning-compactor/halen-plugin.json b/plugins/reasoning-compactor/halen-plugin.json index 3186489..d76d533 100644 --- a/plugins/reasoning-compactor/halen-plugin.json +++ b/plugins/reasoning-compactor/halen-plugin.json @@ -4,10 +4,10 @@ "summary": "Shrinks verbose LLM chain-of-thought on-device — ⌃⌥K to compact a selection.", "version": "1.0.0", "halenApiVersion": "0.1", - "executable": "/usr/bin/python3", - "args": ["plugin.py"], + "executable": "plugin.py", + "args": [], "events": ["text.pause", "hotkey.fired"], - "permissions": ["inference", "ax.read", "notifications"], + "permissions": ["inference", "ax.read", "notifications", "hotkeys"], "icon": "rectangle.compress.vertical", "category": "productivity" } diff --git a/plugins/reasoning-compactor/plugin.py b/plugins/reasoning-compactor/plugin.py old mode 100644 new mode 100755 diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 4f05886..7f82e89 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -21,6 +21,16 @@ if [[ "$DIST" == "1" ]]; then # keychain; codesign matches this as a substring. Override with # SIGN_IDENTITY=... if you have more than one and the match is ambiguous. SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application}" + # Release provenance inputs must themselves come from the checked-in + # release policy, not local edits or untracked marker files. + PROVENANCE_INPUTS=(Vendor/LLAMA_CPP_VERSION Vendor/LLAMA_CPP_COMMIT + scripts/fetch-assets.sh scripts/verify-llama-framework.sh) + if ! git diff --quiet -- "${PROVENANCE_INPUTS[@]}" \ + || ! git diff --cached --quiet -- "${PROVENANCE_INPUTS[@]}" \ + || ! git ls-files --error-unmatch Vendor/LLAMA_CPP_COMMIT >/dev/null 2>&1; then + echo "error: DIST requires clean, committed llama provenance inputs" >&2 + exit 1 + fi else CONFIG="${CONFIG:-debug}" # Stable across rebuilds so granted TCC (Accessibility, etc.) permissions @@ -28,13 +38,23 @@ else SIGN_IDENTITY="${SIGN_IDENTITY:-Apple Development: luka dadiani (75R33YUT6M)}" fi +# Distribution builds never trust a pre-existing binary or its adjacent +# provenance marker: rebuild directly from the immutable upstream commit in +# the clean, committed policy above. Development builds may reuse a verified +# cache for iteration. +if [[ "$DIST" == "1" ]]; then + SKIP_GGUF=1 REBUILD_LLAMA=1 "$ROOT/scripts/fetch-assets.sh" +else + "$ROOT/scripts/verify-llama-framework.sh" +fi + # When the repo lives inside an iCloud-synced folder (Documents, Desktop) # the fileprovider keeps re-stamping `com.apple.FinderInfo` on the assembled # bundle and its nested framework. codesign rejects that as # "resource fork, Finder information, or similar detritus not allowed", and # nothing short of staging the build outside iCloud reliably escapes the # race. So: detect iCloud (parent dir tagged with the fileprovider xattr) -# and stage to /tmp/halen-build/ when present. A `build/Halen.app` symlink +# and stage to a unique private temporary directory. A `build/Halen.app` symlink # at the canonical location keeps `run-dev.sh` and the user's muscle memory # pointing at the right place. # @@ -47,7 +67,8 @@ fi if [[ -n "${OUT_DIR:-}" ]]; then APP_DIR="$OUT_DIR/Halen.app" elif [[ "$icloud_detected" == "1" ]]; then - APP_DIR="/tmp/halen-build/Halen.app" + BUILD_STAGE="$(mktemp -d /private/tmp/halen-build.XXXXXX)" + APP_DIR="$BUILD_STAGE/Halen.app" echo "→ iCloud-synced parent detected — staging to $APP_DIR" else APP_DIR="$ROOT/build/Halen.app" @@ -96,6 +117,7 @@ done # with a dyld "Library not loaded" error. LLAMA_FW_SRC="$ROOT/Vendor/llama.xcframework/macos-arm64/llama.framework" if [[ -d "$LLAMA_FW_SRC" ]]; then + "$ROOT/scripts/verify-llama-framework.sh" echo "→ embedding llama.framework" mkdir -p "$FRAMEWORKS" ditto "$LLAMA_FW_SRC" "$FRAMEWORKS/llama.framework" @@ -142,6 +164,7 @@ fi # Each codesign call is preceded by its own `xattr -cr` to defeat iCloud's # FinderInfo re-stamping (see the staging block at the top of this script). echo "→ signing with: $SIGN_IDENTITY" +"$ROOT/scripts/verify-macho-paths.sh" "$APP_DIR" # Pre-flight: codesign prompts for keychain access on every signed binary # unless the signing key's partition list authorises `codesign:`. This diff --git a/scripts/fetch-assets.sh b/scripts/fetch-assets.sh index 0cd20ce..5b8dbba 100755 --- a/scripts/fetch-assets.sh +++ b/scripts/fetch-assets.sh @@ -19,7 +19,7 @@ cd "$ROOT" # flows that rely on the in-app ModelDownloader to fetch on first use). # --------------------------------------------------------------------------- GGUF_PATH="assets/Models/gemma-4-E4B-it-IQ4_XS.gguf" -GGUF_URL="https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-IQ4_XS.gguf" +GGUF_URL="https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/653803f092503c04a65164346f3208a36e707693/gemma-4-E4B-it-IQ4_XS.gguf" GGUF_SHA="eb29c8519c4c07b880fb9cae7ff13ee2e30c5f38516268920ab85c04df6d52a2" if [[ "${SKIP_GGUF:-0}" == "1" ]]; then @@ -41,18 +41,24 @@ else fi # --------------------------------------------------------------------------- -# 2. llama.cpp xcframework — built from the pinned tag, macOS arm64 only +# 2. llama.cpp xcframework — built from the immutable pinned commit # --------------------------------------------------------------------------- -if [[ -d "Vendor/llama.xcframework" ]]; then - echo "✓ Vendor/llama.xcframework (present)" +if [[ -d "Vendor/llama.xcframework" ]] && [[ "${REBUILD_LLAMA:-0}" != "1" ]] \ + && ./scripts/verify-llama-framework.sh; then + echo "✓ Vendor/llama.xcframework (verified cache hit)" else TAG="$(cat Vendor/LLAMA_CPP_VERSION)" - echo "→ building Vendor/llama.xcframework from llama.cpp $TAG" + COMMIT="$(tr -d '[:space:]' < Vendor/LLAMA_CPP_COMMIT)" + echo "→ building Vendor/llama.xcframework from llama.cpp $TAG ($COMMIT)" WORK="$(mktemp -d)" - git clone --filter=blob:none https://github.com/ggml-org/llama.cpp.git "$WORK/llama.cpp" + trap 'rm -rf "$WORK"' EXIT + git init -q "$WORK/llama.cpp" ( cd "$WORK/llama.cpp" - git checkout "$TAG" + git remote add origin https://github.com/ggml-org/llama.cpp.git + git fetch --depth 1 origin "$COMMIT" + git checkout -q --detach FETCH_HEAD + [[ "$(git rev-parse HEAD)" == "$COMMIT" ]] || { echo "error: fetched unexpected llama.cpp commit" >&2; exit 1; } # macOS-only (arm64) trim of the upstream multi-platform script: keep its # options + assembly functions (lines 1-402), append just the macOS path. sed -n '1,402p' build-xcframework.sh > build-macos-only.sh @@ -86,8 +92,26 @@ INNER plutil -replace AvailableLibraries.0.LibraryIdentifier -string "macos-arm64" "$XCF/Info.plist" plutil -remove AvailableLibraries.0.SupportedArchitectures.1 "$XCF/Info.plist" ) + rm -rf Vendor/llama.xcframework Vendor/llama.xcframework.provenance cp -R "$WORK/llama.cpp/build-apple/llama.xcframework" Vendor/llama.xcframework + digest="$({ + while IFS= read -r entry; do + rel="${entry#Vendor/llama.xcframework/}" + if [[ -L "$entry" ]]; then + hash="$(printf '%s' "$(readlink "$entry")" | shasum -a 256 | awk '{print $1}')" + printf 'L %s %s\n' "$hash" "$rel" + else + hash="$(shasum -a 256 "$entry" | awk '{print $1}')" + printf 'F %s %s\n' "$hash" "$rel" + fi + done < <(find Vendor/llama.xcframework \( -type f -o -type l \) -print | LC_ALL=C sort) + } | shasum -a 256 | awk '{print $1}')" + printf 'source=%s\ncommit=%s\ntree_sha256=%s\n' \ + 'https://github.com/ggml-org/llama.cpp.git' "$COMMIT" "$digest" \ + > Vendor/llama.xcframework.provenance + ./scripts/verify-llama-framework.sh rm -rf "$WORK" + trap - EXIT echo "✓ Vendor/llama.xcframework (arm64)" fi diff --git a/scripts/notarize.sh b/scripts/notarize.sh index 63c950e..42167d4 100755 --- a/scripts/notarize.sh +++ b/scripts/notarize.sh @@ -13,7 +13,8 @@ cd "$ROOT" APP_DIR="$ROOT/build/Halen.app" # When the build was staged outside the iCloud-synced tree, `build/Halen.app` -# is a symlink to /tmp/halen-build/Halen.app. `xcrun stapler` refuses to +# may be a symlink to a unique `/private/tmp/halen-build.*/Halen.app`. +# `xcrun stapler` refuses to # work through alias files ("Stapler is incapable of working with Alias # files"), and `ditto` archives the symlink itself, not the bundle. Resolve # to the real path up front so every step below operates on the bundle. diff --git a/scripts/package-dmg.sh b/scripts/package-dmg.sh index a6b765e..83de6bc 100755 --- a/scripts/package-dmg.sh +++ b/scripts/package-dmg.sh @@ -65,20 +65,31 @@ fi VERSION="$(plutil -extract CFBundleShortVersionString raw -o - "$INFO_PLIST")" DMG_PATH="$ROOT/build/Halen-$VERSION.dmg" -STAGING="$ROOT/build/dmg-staging" +STAGING="$(mktemp -d /private/tmp/halen-dmg.XXXXXX)" +MOUNT_POINT="" +cleanup() { + if [[ -n "$MOUNT_POINT" ]] && mount | grep -Fq "on $MOUNT_POINT "; then + hdiutil detach "$MOUNT_POINT" >/dev/null || true + fi + rm -rf "$STAGING" +} +trap cleanup EXIT VOLNAME="Halen $VERSION" # --- assemble layout ------------------------------------------------------- echo "→ assembling DMG staging at $STAGING" -rm -rf "$STAGING" "$DMG_PATH" -mkdir -p "$STAGING" +rm -f "$DMG_PATH" # Copy the .app — `ditto` preserves the code signature and any extended # attributes (including the stapled notarization ticket). `cp -R` would # work for the bits but ditto is the path Apple recommends for signed # bundles. ditto "$APP_DIR" "$STAGING/Halen.app" +xattr -cr "$STAGING/Halen.app" +codesign --verify --deep --strict --verbose=2 "$STAGING/Halen.app" +xcrun stapler validate "$STAGING/Halen.app" +"$ROOT/scripts/verify-macho-paths.sh" "$STAGING/Halen.app" # Drag-target: a symlink to /Applications next to Halen.app turns the DMG # into a one-gesture install — the user drags Halen onto Applications and @@ -122,9 +133,20 @@ echo "→ verifying" spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG_PATH" xcrun stapler validate "$DMG_PATH" +echo "→ mounting read-only and verifying packaged app" +MOUNT_POINT="$(mktemp -d /private/tmp/halen-dmg-mount.XXXXXX)" +hdiutil attach -readonly -nobrowse -mountpoint "$MOUNT_POINT" "$DMG_PATH" >/dev/null +codesign --verify --deep --strict --verbose=2 "$MOUNT_POINT/Halen.app" +"$ROOT/scripts/verify-macho-paths.sh" "$MOUNT_POINT/Halen.app" +xcrun stapler validate "$MOUNT_POINT/Halen.app" +hdiutil detach "$MOUNT_POINT" >/dev/null +rmdir "$MOUNT_POINT" +MOUNT_POINT="" + # --- cleanup --------------------------------------------------------------- rm -rf "$STAGING" +trap - EXIT echo echo "✓ packaged $DMG_PATH" diff --git a/scripts/test-release-verifiers.sh b/scripts/test-release-verifiers.sh new file mode 100755 index 0000000..5fb2ce2 --- /dev/null +++ b/scripts/test-release-verifiers.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +FIXTURE="$(mktemp -d /private/tmp/halen-release-tests.XXXXXX)" +trap 'rm -rf "$FIXTURE"' EXIT + +mkdir -p "$FIXTURE/llama.xcframework" +printf 'fixture' > "$FIXTURE/llama.xcframework/file" +file_sha="$(shasum -a 256 "$FIXTURE/llama.xcframework/file" | awk '{print $1}')" +tree_sha="$(printf 'F %s %s\n' "$file_sha" file | shasum -a 256 | awk '{print $1}')" +printf 'source=%s\ncommit=%s\ntree_sha256=%s\n' \ + 'https://github.com/ggml-org/llama.cpp.git' \ + "$(tr -d '[:space:]' < "$ROOT/Vendor/LLAMA_CPP_COMMIT")" "$tree_sha" \ + > "$FIXTURE/provenance" +"$ROOT/scripts/verify-llama-framework.sh" \ + "$FIXTURE/llama.xcframework" "$FIXTURE/provenance" >/dev/null +sed 's#https://github.com/ggml-org/llama.cpp.git#https://example.invalid/llama.cpp.git#' \ + "$FIXTURE/provenance" > "$FIXTURE/wrong-source" +if "$ROOT/scripts/verify-llama-framework.sh" \ + "$FIXTURE/llama.xcframework" "$FIXTURE/wrong-source" >/dev/null 2>&1; then + echo "error: provenance verifier accepted an unexpected source repository" >&2 + exit 1 +fi +printf 'tampered' >> "$FIXTURE/llama.xcframework/file" +if "$ROOT/scripts/verify-llama-framework.sh" \ + "$FIXTURE/llama.xcframework" "$FIXTURE/provenance" >/dev/null 2>&1; then + echo "error: provenance verifier accepted a modified tree" >&2 + exit 1 +fi + +cp /bin/ls "$FIXTURE/safe-mach-o" +"$ROOT/scripts/verify-macho-paths.sh" "$FIXTURE/safe-mach-o" >/dev/null +safe_index=0 +for safe_rpath in /usr/lib/swift '@loader_path' '@executable_path/../Frameworks'; do + safe_index=$((safe_index + 1)) + cp /bin/ls "$FIXTURE/safe-rpath-$safe_index" + install_name_tool -add_rpath "$safe_rpath" "$FIXTURE/safe-rpath-$safe_index" + "$ROOT/scripts/verify-macho-paths.sh" "$FIXTURE/safe-rpath-$safe_index" >/dev/null +done +install_name_tool -add_rpath /tmp/developer-build "$FIXTURE/safe-mach-o" +if "$ROOT/scripts/verify-macho-paths.sh" "$FIXTURE/safe-mach-o" >/dev/null 2>&1; then + echo "error: Mach-O verifier accepted an absolute development rpath" >&2 + exit 1 +fi + +cp /bin/ls "$FIXTURE/traversal-mach-o" +install_name_tool -add_rpath '@loader_path/../x' \ + "$FIXTURE/traversal-mach-o" +if "$ROOT/scripts/verify-macho-paths.sh" "$FIXTURE/traversal-mach-o" >/dev/null 2>&1; then + echo "error: Mach-O verifier accepted a traversal rpath" >&2 + exit 1 +fi + +echo "✓ release verifier regression tests passed" diff --git a/scripts/verify-llama-framework.sh b/scripts/verify-llama-framework.sh new file mode 100755 index 0000000..eeb1a19 --- /dev/null +++ b/scripts/verify-llama-framework.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +XCF="${1:-$ROOT/Vendor/llama.xcframework}" +MARKER="${2:-$ROOT/Vendor/llama.xcframework.provenance}" +PIN="$(tr -d '[:space:]' < "$ROOT/Vendor/LLAMA_CPP_COMMIT")" +EXPECTED_SOURCE="https://github.com/ggml-org/llama.cpp.git" + +[[ "$PIN" =~ ^[0-9a-f]{40}$ ]] || { echo "error: invalid llama.cpp commit pin" >&2; exit 1; } +[[ -d "$XCF" ]] || { echo "error: missing $XCF" >&2; exit 1; } +[[ -f "$MARKER" ]] || { echo "error: missing provenance marker $MARKER" >&2; exit 1; } + +tree_digest() { + local tree="$1" entry rel digest + while IFS= read -r entry; do + rel="${entry#"$tree"/}" + if [[ -L "$entry" ]]; then + digest="$(printf '%s' "$(readlink "$entry")" | shasum -a 256 | awk '{print $1}')" + printf 'L %s %s\n' "$digest" "$rel" + else + digest="$(shasum -a 256 "$entry" | awk '{print $1}')" + printf 'F %s %s\n' "$digest" "$rel" + fi + done < <(find "$tree" \( -type f -o -type l \) -print | LC_ALL=C sort) +} + +ACTUAL_DIGEST="$(tree_digest "$XCF" | shasum -a 256 | awk '{print $1}')" +MARKER_COMMIT="$(awk -F= '$1 == "commit" { print $2 }' "$MARKER")" +MARKER_DIGEST="$(awk -F= '$1 == "tree_sha256" { print $2 }' "$MARKER")" +MARKER_SOURCE="$(awk -F= '$1 == "source" { sub(/^source=/, ""); print }' "$MARKER")" + +[[ "$MARKER_SOURCE" == "$EXPECTED_SOURCE" ]] || { echo "error: unexpected llama source marker" >&2; exit 1; } +[[ "$MARKER_COMMIT" == "$PIN" ]] || { echo "error: llama source marker does not match pinned commit" >&2; exit 1; } +[[ "$MARKER_DIGEST" == "$ACTUAL_DIGEST" ]] || { echo "error: llama.xcframework tree digest mismatch" >&2; exit 1; } +echo "✓ verified llama.xcframework provenance ($PIN, $ACTUAL_DIGEST)" diff --git a/scripts/verify-macho-paths.sh b/scripts/verify-macho-paths.sh new file mode 100755 index 0000000..a0d273c --- /dev/null +++ b/scripts/verify-macho-paths.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET="${1:?usage: verify-macho-paths.sh }" +[[ -e "$TARGET" ]] || { echo "error: missing target $TARGET" >&2; exit 1; } +fail=0 + +verify_one() { + local binary="$1" path + while IFS= read -r path; do + if [[ "/$path/" == *"/../"* ]]; then + echo "error: traversal component in load path for $binary: $path" >&2 + fail=1 + continue + fi + case "$path" in + @rpath/*|@loader_path/*|@executable_path/*|/System/Library/*|/usr/lib/*) ;; + *) echo "error: unsafe development load path in $binary: $path" >&2; fail=1 ;; + esac + done < <(otool -L "$binary" | + sed -nE 's/^[[:space:]]*([^[:space:]]+)[[:space:]]+\(compatibility version.*$/\1/p') + + while IFS= read -r path; do + # SwiftPM emits the system Swift runtime paths below, and app bundles + # conventionally reach Contents/Frameworks from Contents/MacOS with + # this one exact parent hop. They are fixed, release-safe locations. + case "$path" in + /usr/lib/swift|@loader_path|@executable_path/../Frameworks) continue ;; + esac + if [[ "/$path/" == *"/../"* ]]; then + echo "error: traversal component in LC_RPATH for $binary: $path" >&2 + fail=1 + continue + fi + case "$path" in + @rpath/*|@loader_path/*|@executable_path/*) ;; + *) echo "error: unsafe LC_RPATH in $binary: $path" >&2; fail=1 ;; + esac + done < <(otool -l "$binary" | awk '$1 == "cmd" { rpath=($2 == "LC_RPATH") } rpath && $1 == "path" { print $2; rpath=0 }') +} + +while IFS= read -r candidate; do + if file -b "$candidate" | grep -q 'Mach-O'; then + verify_one "$candidate" + fi +done < <(find "$TARGET" -type f -print 2>/dev/null || printf '%s\n' "$TARGET") + +[[ "$fail" == 0 ]] || exit 1 +echo "✓ Mach-O load paths are relocatable and release-safe: $TARGET"