diff --git a/.github/ARCHITECTURE.md b/.github/ARCHITECTURE.md index e436c3e..3caa66e 100644 --- a/.github/ARCHITECTURE.md +++ b/.github/ARCHITECTURE.md @@ -2,15 +2,21 @@ A high-level orientation. The user-facing pitch is in [`README.md`](README.md); the source-tree map is in [`privacycommand/README.md`](privacycommand/README.md). This doc sits between them — what the boxes are, why they're separate, and how data moves between them. -The deeper design docs referenced from the project `README.md` +The deeper design docs referenced from the project `README.md` sit alongside this one: +[`HELPER.md`](../privacycommand/HELPER.md) for the privileged helper, +[`BUILDING.md`](../privacycommand/BUILDING.md) for the two build paths, and +[`docs/GUEST_AGENT.md`](../privacycommand/docs/GUEST_AGENT.md) for VM mode. > **One-line model.** A SwiftUI app drops a `.app` bundle onto a pure-Swift analyzer library, optionally launches the inspected app under a privileged XPC helper for dynamic monitoring, and optionally ships a guest agent into a macOS VM to do the same work in isolation. -> **Maturity note.** Despite the project being six commits old, the codebase is substantial: ~26k LOC of Swift across ~100 files. The analyzer (`Sources/privacycommandCore/Analysis/`) has 29 detector files; monitoring has 11; the app target has 59 SwiftUI files. **The code is largely there; the docs aren't.** This file is part of fixing that. +> **Scale note.** The codebase is substantial: 225 Swift files. The analyzer +> (`Sources/privacycommandCore/Analysis/`) has 41 detector files, monitoring has 12, and +> the app target has 63 SwiftUI files. The [`README.md`](../README.md) carries the +> user-facing pitch; this file covers the internals. --- -## The four targets, and why each pulls its weight +## The targets, and why each pulls its weight ``` ┌──────────────────────────────────────────────────────────────────────┐ @@ -45,7 +51,7 @@ Each target is intentional: |---|---|---| | `privacycommandCore` | `privacycommand/Sources/privacycommandCore/` | Pure-Swift analyzer. AppKit-free. Runs from CLI, tests, GUI, and helper without dragging UI deps into builds that don't need them. | | `privacycommand` (app) | `privacycommand/Sources/privacycommand/` | SwiftUI app target. Views + view-models only. | -| `privacycommandHelper` | `privacycommand/Sources/privacycommandHelper/` | Privileged XPC service installed via `SMAppService.daemon`. Minimal API surface — currently 4 Swift files (`main`, `HelperToolService`, `CodeSignValidator`, `FsUsageRunner`). The source-tree README also references `PfctlKillSwitch.swift` for the network kill switch, but that file isn't committed yet. Validates clients by Team ID on connect. | +| `privacycommandHelper` | `privacycommand/privacycommandHelper/` | Privileged XPC service installed via `SMAppService.daemon`. Minimal API surface — 5 Swift files (`main`, `HelperToolService`, `CodeSignValidator`, `FsUsageRunner`, `PfctlKillSwitch`). Validates clients by Team ID on connect. **Note the path**: this sits beside `Sources/`, not inside it — it is an Xcode-only target and `Package.swift` does not declare it. | | `privacycommandGuestProtocol` | `privacycommand/Sources/privacycommandGuestProtocol/` | Wire format shared between host and guest agent. Lives in its own zero-dependency target so the agent can build without compiling Core. | | `privacycommandGuestAgent` | `privacycommand/Sources/privacycommandGuestAgent/` | The binary that runs inside a macOS VM and ships observations back to the host. | | `auditctl` | `privacycommand/Sources/auditctl/` | CLI front-end for the analyzer, with a witr-style interface. `auditctl ` audits one app (`--short` / `--tree` / `--json` / `--warnings`); a bare `auditctl` (or `-i`) opens an interactive TUI browser of installed apps. Still the fastest end-to-end smoke test. The executable is a thin termios / poll-loop / IO shell — its logic lives in `auditctlKit`. | @@ -59,8 +65,8 @@ Three layers of signal, each with a different cost: | Layer | Where | Privilege | |---|---|---| -| **Static** — entitlements, code-signing, notarization (stapler/spctl/SHA-256), URL schemes, document types, hard-coded domains, embedded launch agents, third-party SDK fingerprints (LaunchDarkly, Firebase, Mixpanel, AdMob, …), feature flags / trial-state strings, secrets and license-key names, anti-analysis signals, dylib hijacking surface, Privacy Manifest cross-check | `Sources/privacycommandCore/Analysis/` (29 detector files: `StaticAnalyzer`, `EntitlementsReader`, `MachOInspector`, `BundleSigningAuditor`, `NotarizationDeepDive`, `SDKFingerprintDetector`, `SecretsScanner`, `RPathAuditor`, `AntiAnalysisDetector`, `PrivacyManifestReader`, …) | **None.** Runs on the user's data without ever touching Apple-granted entitlements. | -| **Dynamic** — file events, network destinations, child processes, pasteboard / camera / microphone / screen-recording activity, USB device interactions, resource usage | `Sources/privacycommandCore/Monitoring/` (11 files: `DynamicMonitor`, `LiveProbeMonitor`, `NetworkMonitor`, `ProcessTracker`, `USBDeviceMonitor`, `ResourceMonitor`, `DeviceUsageProbe`, `VMHostDetection`, `GuestObservationStream`, …) | **Helper required** for `fs_usage`-based file events; Background Task Management audit also goes via the helper to skip the admin prompt. | +| **Static** — entitlements, code-signing, notarization (stapler/spctl/SHA-256), URL schemes, document types, hard-coded domains, embedded launch agents, third-party SDK fingerprints (LaunchDarkly, Firebase, Mixpanel, AdMob, …), feature flags / trial-state strings, secrets and license-key names, anti-analysis signals, dylib hijacking surface, Privacy Manifest cross-check | `Sources/privacycommandCore/Analysis/` (41 detector files: `StaticAnalyzer`, `EntitlementsReader`, `MachOInspector`, `BundleSigningAuditor`, `NotarizationDeepDive`, `SDKFingerprintDetector`, `SecretsScanner`, `RPathAuditor`, `AntiAnalysisDetector`, `PrivacyManifestReader`, …) | **None.** Runs on the user's data without ever touching Apple-granted entitlements. | +| **Dynamic** — file events, network destinations, child processes, pasteboard / camera / microphone / screen-recording activity, USB device interactions, resource usage | `Sources/privacycommandCore/Monitoring/` (12 files: `DynamicMonitor`, `LiveProbeMonitor`, `NetworkMonitor`, `ProcessTracker`, `USBDeviceMonitor`, `ResourceMonitor`, `DeviceUsageProbe`, `VMHostDetection`, `GuestObservationStream`, …) | **Helper required** for `fs_usage`-based file events; Background Task Management audit also goes via the helper to skip the admin prompt. | | **App Store cross-reference** — Mac App Store privacy labels fetched from `apps.apple.com`, displayed next to the static-analysis findings | `Sources/privacycommandCore/Analysis/AppStoreLookup.swift` + `AppStorePrivacyLabelFetcher.swift` | None. Network call is keyed on bundle ID, never user data. | The privacy-stance contract: **all analysis runs locally**. The inspected app's contents never leave the machine. The only outbound traffic is bounded — DNS reverse lookups for destinations the inspected app contacts, App Store privacy-label lookups against `itunes.apple.com`/`apps.apple.com`, and Sparkle appcast fetch from `privacykey.github.io`. @@ -86,10 +92,8 @@ StaticReport (Codable) ─── feeds Dashboard, Static, Telemetry, Background- HelperToolService over XPC Guest agent in VM ├── FsUsageRunner (file events) ├── runs same analyzer locally ├── BackgroundTaskAuditor (sfltool) └── ships observations via - └── pf-anchor kill switch (planned — privacycommandGuestProtocol - referenced in source-tree README - as PfctlKillSwitch.swift but not - yet committed; see WIP doc) + └── PfctlKillSwitch (pf-anchor privacycommandGuestProtocol + network kill switch) │ ▼ Live observations stream into the Monitoring tab @@ -143,7 +147,7 @@ Run reports are persisted on disk for diffing across audits — `Sources/privacy | **Direct download** | DMG with Sparkle 2 in-app updater. Auto-checks **off by default**; user opts in via Settings → Updates. | | **Homebrew cask** | `brew upgrade --cask privacycommand`. privacycommand detects Cask installs and disables Sparkle's installer to stay out of brew's way — see `Sources/privacycommandCore/Updates/`. | -The appcast feed lives on `gh-pages` at `https://privacykey.github.io/privacycommand/appcast.xml`, signed with EdDSA. The Sparkle keypair is per-app, **never shared with another product** — leaking one shouldn't compromise another product's update channel. Full release flow in [`docs/RELEASES.md`](docs/RELEASES.md). +The appcast feed lives on `gh-pages` at `https://privacykey.github.io/privacycommand/appcast.xml`, signed with EdDSA. The Sparkle keypair is per-app, **never shared with another product** — leaking one shouldn't compromise another product's update channel. The pipeline itself lives in [privacykey/gh-workflows](https://github.com/privacykey/gh-workflows); [`.github/workflows/release.yml`](workflows/release.yml) is the thin caller and documents the secret layout. ## Knowledge Base (in-app) @@ -165,6 +169,6 @@ The smallest end-to-end smoke test is `auditctl /System/Applications/Calculator. | Privileged helper bundling + signing + verification | [`privacycommand/HELPER.md`](privacycommand/HELPER.md) | | Guest agent walkthroughs | [`privacycommand/docs/GUEST_AGENT.md`](privacycommand/docs/GUEST_AGENT.md) | | Build workflow (Xcode + SPM) | [`privacycommand/BUILDING.md`](privacycommand/BUILDING.md) | -| Release pipeline + secrets | [`docs/RELEASES.md`](docs/RELEASES.md) | +| Release pipeline + secrets | [`.github/workflows/release.yml`](workflows/release.yml) + [privacykey/gh-workflows](https://github.com/privacykey/gh-workflows) | **Last reviewed:** 29 April 2026. diff --git a/privacycommand/BUILDING.md b/privacycommand/BUILDING.md index b30562e..cbea00e 100644 --- a/privacycommand/BUILDING.md +++ b/privacycommand/BUILDING.md @@ -4,24 +4,46 @@ Two parallel ways to build, both pointed at the same source files. ## 1. Xcode (the primary path) ```bash -cd "MacOS Permissions/privacycommand" +cd privacycommand open privacycommand.xcodeproj ``` -In Xcode: -1. Select the **privacycommand** scheme (top toolbar). -2. **Signing & Capabilities → Team:** pick your personal team (or change `PRODUCT_BUNDLE_IDENTIFIER` from `com.example.privacycommand` to your own reverse-DNS prefix first). -3. **⌘R** to build and run. **⌘U** to run the test bundle (3 tests). +In Xcode, three things before the first build: -The project has two targets: -- `privacycommand` — the SwiftUI app (single target, contains all 31 Swift sources). -- `privacycommandTests` — host-app-loaded XCTest bundle with the 3 unit-test files. +1. **Add the Sparkle package.** File → Add Package Dependencies… → + `https://github.com/sparkle-project/Sparkle`, *Up to Next Major* from `2.9.0`. + Tick the `Sparkle` product on the **privacycommand** target. +2. **Set the app's team.** Select the **privacycommand** target → + Signing & Capabilities → Team. A personal team is fine for development; + distribution needs a Developer ID. +3. **Match the helper's team to the app's.** Select **privacycommandHelper** → + Signing & Capabilities → Team, same team as the app. + + This one is not optional. `CodeSignValidator` requires an Apple anchor plus a + Team ID matching the helper's own, so a mismatch means the XPC connection is + refused at runtime and *every* privileged feature fails — file monitoring, + the BTM audit, and the kill switch. + +Then **⌘R** to build and run, **⌘U** for the test bundle. + +Xcode targets: + +- `privacycommand` — the SwiftUI app (63 Swift sources under `Sources/privacycommand/`). +- `privacycommandCore` — the analyzer (90 sources). +- `privacycommandHelper` — the privileged XPC helper, built from the top-level + `privacycommandHelper/` directory. +- `privacycommandGuestProtocol` — the host/guest wire format. +- `privacycommandTests` — the XCTest bundle. + +Bundle identifiers are `org.privacykey.privacycommand`, plus `.HelperTool` and +`.tests`. The app target depends on the helper, so building the app builds and +embeds the helper first, along with its LaunchDaemon plist. App Sandbox is disabled. Hardened Runtime is on. macOS deployment target is 13.0. Distribution target is Developer ID + notarization (not the App Store). ## 2. Swift Package Manager (CLI smoke test) ```bash -cd "MacOS Permissions/privacycommand" +cd privacycommand swift build .build/debug/auditctl /System/Applications/Calculator.app swift test @@ -52,9 +74,14 @@ The test files do the same thing: #endif ``` -## What I would expect to fail first on a real build +## Common build failures + +In rough order of likelihood: -If anything trips, my best guesses in priority order: +0. **The helper's signing team doesn't match the app's.** This builds fine and + fails at runtime: the app launches, but installing or contacting the helper + is refused and every privileged feature is dead. `CodeSignValidator` requires + an Apple anchor plus a matching Team ID. Set both targets to the same team. 1. **`Darwin` does not expose `` on your SDK version.** Symptom: `Use of unresolved identifier 'proc_listallpids'`. Fix: drop these `@_silgen_name` shims at the top of `Sources/privacycommandCore/Monitoring/ProcessTracker.swift` (or in any one file in the Core target): ```swift @@ -67,7 +94,7 @@ If anything trips, my best guesses in priority order: 3. **`spctl` returning a non-zero exit on first run** while it queries Apple's notarization server. The wrapper handles the parse — it just maps the relevant strings. If you see `notarization = .unknown(...)` for an app you know is notarized, run `spctl -a -vvv ` once at the terminal so its result is cached, then re-run. -4. **First-run signing failure** because the bundle ID `com.example.privacycommand` collides or doesn't match your team. Change `PRODUCT_BUNDLE_IDENTIFIER` in **privacycommand → Build Settings** to e.g. `com..privacycommand`, then **Signing & Capabilities → Team** picks up automatically. +4. **First-run signing failure** because `org.privacykey.privacycommand` can't be provisioned under your team. Change `PRODUCT_BUNDLE_IDENTIFIER` in **Build Settings** to your own reverse-DNS prefix — on the app, the helper (`.HelperTool`) and the test bundle (`.tests`) — then **Signing & Capabilities → Team** picks up automatically. Keep the helper's identifier as a child of the app's. ## If you ever add new Swift files diff --git a/privacycommand/HELPER.md b/privacycommand/HELPER.md index 8c85482..e2d9e62 100644 --- a/privacycommand/HELPER.md +++ b/privacycommand/HELPER.md @@ -9,9 +9,10 @@ and the one signing knob you have to set on first checkout. The `privacycommand.xcodeproj` now contains: - A `privacycommandHelper` target that builds a Mach-O command-line - executable from `Sources/privacycommandHelper/*.swift` (auto-discovered - via Xcode's file-system-synchronized group, so adding/removing files - doesn't require pbxproj edits). + executable from `privacycommandHelper/*.swift` — the directory beside + `Sources/`, not inside it (auto-discovered via Xcode's + file-system-synchronized group, so adding/removing files doesn't require + pbxproj edits). - The helper target is configured with: - `PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.HelperTool` - `CODE_SIGN_ENTITLEMENTS = privacycommand/Resources/privacycommandHelper.entitlements` diff --git a/privacycommand/Sources/privacycommandHelper/CodeSignValidator.swift b/privacycommand/Sources/privacycommandHelper/CodeSignValidator.swift deleted file mode 100644 index 93e8c4a..0000000 --- a/privacycommand/Sources/privacycommandHelper/CodeSignValidator.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Security - -/// Validates that a connecting XPC peer was signed by the same Team ID as the -/// helper itself. Defense-in-depth — SMAppService daemons are already limited -/// to launchd-launched mach services bound to the daemon plist. -enum CodeSignValidator { - - /// Reads our own Team ID at startup. If we can't read our own signature - /// (e.g. unsigned local development build) we fall back to "accept" so - /// the wizard install flow still works on a personal-team dev machine. - static let allowedTeamID: String? = { - let exec = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() - var staticCode: SecStaticCode? - guard SecStaticCodeCreateWithPath(exec as CFURL, [], &staticCode) == errSecSuccess, - let staticCode else { return nil } - var info: CFDictionary? - guard SecCodeCopySigningInformation(staticCode, [], &info) == errSecSuccess, - let dict = info as? [String: Any] else { return nil } - return dict[kSecCodeInfoTeamIdentifier as String] as? String - }() - - static func validateConnection(_ connection: NSXPCConnection) -> Bool { - // Personal-team dev mode: helper isn't team-signed, so we accept. - // Production builds (Developer ID) should always have a Team ID. - guard let allowedTeamID, !allowedTeamID.isEmpty else { - NSLog("[privacycommandHelper] No Team ID — accepting connection (dev mode)") - return true - } - - let pid = connection.processIdentifier - guard pid > 0 else { return false } - - let attrs: NSDictionary = [ - kSecGuestAttributePid: pid as NSNumber - ] - var code: SecCode? - guard SecCodeCopyGuestWithAttributes(nil, attrs, [], &code) == errSecSuccess, - let code else { - return false - } - - // Require Apple anchor + matching Team ID. - let reqString = "anchor apple generic and certificate leaf[subject.OU] = \"\(allowedTeamID)\"" - var requirement: SecRequirement? - guard SecRequirementCreateWithString(reqString as CFString, [], &requirement) == errSecSuccess, - let requirement else { - return false - } - return SecCodeCheckValidity(code, [], requirement) == errSecSuccess - } -} diff --git a/privacycommand/Sources/privacycommandHelper/FsUsageRunner.swift b/privacycommand/Sources/privacycommandHelper/FsUsageRunner.swift deleted file mode 100644 index b56ade9..0000000 --- a/privacycommand/Sources/privacycommandHelper/FsUsageRunner.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation - -/// Spawns `fs_usage(1)` filtered to a PID, parses the output one line at a -/// time, and emits `FileEventWire` values via the supplied callback. -/// -/// `fs_usage` is the supported user-space mechanism for observing -/// file-system syscalls on macOS. Caveats inherited from the tool: -/// - SIP-restricted on Apple-signed binaries. -/// - Lossy under heavy I/O load — events can be dropped silently. -/// - Output is text. The parser here is best-effort and tolerant of new -/// formats Apple may introduce; unparseable lines are dropped. -final class FsUsageRunner { - typealias EventHandler = (FileEventWire) -> Void - typealias LogHandler = (String) -> Void - - private let pid: Int32 - private let onEvent: EventHandler - private let onLog: LogHandler - - private var process: Process? - private var lineBuffer = HelperLineBuffer() - - init(pid: Int32, onEvent: @escaping EventHandler, onLog: @escaping LogHandler) { - self.pid = pid - self.onEvent = onEvent - self.onLog = onLog - } - - func start() throws { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/fs_usage") - process.arguments = ["-w", "-f", "filesys", String(pid)] - let outPipe = Pipe() - process.standardOutput = outPipe - process.standardError = Pipe() - - outPipe.fileHandleForReading.readabilityHandler = { [weak self] fh in - guard let self else { return } - let data = fh.availableData - if data.isEmpty { return } - for line in self.lineBuffer.append(data) { - self.parseAndEmit(line: line) - } - } - - try process.run() - self.process = process - onLog("fs_usage started for pid \(pid)") - } - - func stop() { - process?.terminate() - process = nil - onLog("fs_usage stopped") - } - - // MARK: - Parsing - - /// `fs_usage -w -f filesys` lines look like: - /// 23:14:27.123456 open F=8 (R___) /Users/alice/Documents/foo.txt 0.000123 Slack.123 - /// We extract op, path, and a guess at process name. PID is fixed (we - /// filtered fs_usage to it). - private func parseAndEmit(line: String) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty, - trimmed.first.map({ $0.isNumber }) == true else { return } - - let tokens = line.split(separator: " ", omittingEmptySubsequences: true).map(String.init) - guard tokens.count >= 3 else { return } - - let op = mapOp(tokens[1]) - let pathToken = tokens.last(where: { $0.hasPrefix("/") }) - guard let path = pathToken else { return } - let processName = tokens.last.map { String($0.split(separator: ".").first ?? Substring($0)) } ?? "?" - - let event = FileEventWire( - id: UUID(), - timestamp: Date(), - pid: pid, - processName: processName, - op: op, - path: path, - secondaryPath: nil, - category: "unknown", - risk: "expected", - ruleID: nil - ) - onEvent(event) - } - - private func mapOp(_ raw: String) -> String { - switch raw { - case "open", "open_nocancel", "open_dprotected_np", "openat", "openat_nocancel": return "open" - case "creat": return "create" - case "mkdir": return "mkdir" - case "rmdir": return "rmdir" - case "rename", "renameat", "renameatx_np": return "rename" - case "unlink", "unlinkat": return "unlink" - case "symlink", "symlinkat": return "symlink" - case "link", "linkat": return "link" - case "chmod", "fchmod", "fchmodat": return "chmod" - case "chown", "fchown", "fchownat": return "chown" - case "truncate", "ftruncate": return "truncate" - case "write", "writev", "pwrite": return "write" - case "read", "readv", "pread": return "read" - default: return "other" - } - } -} - -/// Wire-format struct that encodes to JSON identical to `FileEvent` from -/// `privacycommandCore`. Kept separate so the helper target doesn't have -/// to link the full Core library — the encoded JSON is the only contract. -struct FileEventWire: Codable { - let id: UUID - let timestamp: Date - let pid: Int32 - let processName: String - let op: String - let path: String - let secondaryPath: String? - let category: String - let risk: String - let ruleID: String? -} - -/// Newline-delimited line accumulator. Local to the helper so the helper -/// target needs no dependency on the app's `ProcessRunner`. -final class HelperLineBuffer { - private var pending = Data() - private let lock = NSLock() - - func append(_ data: Data) -> [String] { - lock.lock(); defer { lock.unlock() } - pending.append(data) - var lines: [String] = [] - while let nl = pending.firstIndex(of: 0x0A) { - let lineData = pending.prefix(upTo: nl) - pending.removeSubrange(...nl) - if let s = String(data: lineData, encoding: .utf8) { - lines.append(s) - } - } - return lines - } -} diff --git a/privacycommand/Sources/privacycommandHelper/HelperToolService.swift b/privacycommand/Sources/privacycommandHelper/HelperToolService.swift deleted file mode 100644 index 851f50a..0000000 --- a/privacycommand/Sources/privacycommandHelper/HelperToolService.swift +++ /dev/null @@ -1,129 +0,0 @@ -import Foundation - -final class HelperToolListenerDelegate: NSObject, NSXPCListenerDelegate { - func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool { - // 1. Validate the connecting process — must be signed by the same - // Team ID as us. SMAppService daemons are *typically* only reachable - // from the main app, but we belt-and-suspenders this anyway. - guard CodeSignValidator.validateConnection(newConnection) else { - NSLog("[privacycommandHelper] Rejecting connection: signature mismatch") - return false - } - - let exportedInterface = NSXPCInterface(with: HelperToolProtocol.self) - let remoteInterface = NSXPCInterface(with: HelperToolEventReceiver.self) - - let service = HelperToolService(connection: newConnection) - newConnection.exportedInterface = exportedInterface - newConnection.exportedObject = service - newConnection.remoteObjectInterface = remoteInterface - newConnection.invalidationHandler = { [weak service] in service?.invalidate() } - newConnection.interruptionHandler = { [weak service] in service?.invalidate() } - newConnection.resume() - return true - } -} - -final class HelperToolService: NSObject, HelperToolProtocol { - private let connection: NSXPCConnection - private var fsUsageRunner: FsUsageRunner? - - init(connection: NSXPCConnection) { - self.connection = connection - super.init() - } - - func invalidate() { - fsUsageRunner?.stop() - fsUsageRunner = nil - } - - private var remoteReceiver: HelperToolEventReceiver? { - connection.remoteObjectProxy as? HelperToolEventReceiver - } - - // MARK: - HelperToolProtocol - - func helperVersion(reply: @escaping (String, Int) -> Void) { - reply("privacycommandHelper 0.1.0", HelperToolID.protocolVersion) - } - - func startFileMonitor(forPID pid: Int32, reply: @escaping (Bool, String?) -> Void) { - guard fsUsageRunner == nil else { - reply(false, "Already monitoring") - return - } - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - let runner = FsUsageRunner(pid: pid, - onEvent: { [weak self] event in - guard let self else { return } - do { - let data = try encoder.encode(event) - self.remoteReceiver?.helperDidEmitFileEvent(data) - } catch { - self.remoteReceiver?.helperDidEmitLog("encode error: \(error)") - } - }, onLog: { [weak self] msg in - self?.remoteReceiver?.helperDidEmitLog(msg) - }) - fsUsageRunner = runner - do { - try runner.start() - reply(true, nil) - } catch { - fsUsageRunner = nil - reply(false, error.localizedDescription) - } - } - - func stopFileMonitor(reply: @escaping () -> Void) { - fsUsageRunner?.stop() - fsUsageRunner = nil - reply() - } - - func uninstall(reply: @escaping () -> Void) { - // GUI is responsible for SMAppService.unregister(); we just stop work. - invalidate() - reply() - } - - func runSfltoolDumpBTM(reply: @escaping (String?, String?) -> Void) { - // We're already root inside the helper, so `sfltool dumpbtm` - // executes without triggering Authorization Services. - let path = "/usr/bin/sfltool" - guard FileManager.default.isExecutableFile(atPath: path) else { - reply(nil, "sfltool not present at \(path) (pre-macOS-13?)") - return - } - let task = Process() - task.executableURL = URL(fileURLWithPath: path) - task.arguments = ["dumpbtm"] - let outPipe = Pipe() - let errPipe = Pipe() - task.standardOutput = outPipe - task.standardError = errPipe - do { - try task.run() - } catch { - reply(nil, "sfltool launch failed: \(error.localizedDescription)") - return - } - let deadline = Date().addingTimeInterval(8) - while task.isRunning { - if Date() > deadline { task.terminate(); break } - Thread.sleep(forTimeInterval: 0.05) - } - let outData = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() - if let s = String(data: outData, encoding: .utf8), !s.isEmpty { - reply(s, nil) - } else { - let errData = (try? errPipe.fileHandleForReading.readToEnd()) ?? Data() - let errStr = String(data: errData, encoding: .utf8) ?? "" - reply(nil, errStr.isEmpty - ? "sfltool produced no output (status \(task.terminationStatus))" - : "sfltool failed: \(errStr)") - } - } -} diff --git a/privacycommand/Sources/privacycommandHelper/main.swift b/privacycommand/Sources/privacycommandHelper/main.swift deleted file mode 100644 index 0648a6d..0000000 --- a/privacycommand/Sources/privacycommandHelper/main.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Foundation - -// privacycommand privileged helper (root daemon). -// -// Lifecycle: -// - launchd starts us when SMAppService.daemon(...).register() succeeds and -// the user approves in System Settings → General → Login Items. -// - We listen on `HelperToolID.machServiceName` and accept connections only -// from the main app (Team-ID-pinned). -// - For each connection, we expose `HelperToolProtocol` and accept reverse -// calls to push file events back to the GUI. -// -// Process model: -// - We run as `root` under launchd. We do NOT spawn child processes from -// XPC handlers without first sanitizing inputs. The only subprocess we -// spawn is `/usr/bin/fs_usage`, with a fixed argument list. -// -// Security: -// - Connections are validated against the helper's own Team ID. -// - We never expose arbitrary command execution to the GUI. - -let listener = NSXPCListener(machServiceName: HelperToolID.machServiceName) -let delegate = HelperToolListenerDelegate() -listener.delegate = delegate -listener.resume() -RunLoop.current.run()