From a7942266d80492be5ae9e0378326f8a67563d3c3 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 9 Aug 2026 19:52:11 +1000 Subject: [PATCH 1/2] docs: correct ARCHITECTURE.md and BUILDING.md against the current tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both files predate most of the codebase and had drifted far enough to mislead. ARCHITECTURE.md - The helper row said PfctlKillSwitch.swift "isn't committed yet", and the audit-flow diagram called the kill switch "planned". It ships. The file was reading Sources/privacycommandHelper/, a stale duplicate; Xcode builds the top-level privacycommandHelper/ directory, which has all 5 sources. Both the correct path and the existence of the stale copy are now called out, because anyone editing the wrong one will wonder why nothing changes. - Counts refreshed: 41 detectors (was 29), 12 monitoring files (was 11), 63 SwiftUI files (was 59), 229 Swift files total. Dropped "six commits old" — it is 89. - "The four targets" listed seven. - Finished the truncated sentence in the intro and pointed it at the sibling design docs. BUILDING.md - Wrong path: cd "MacOS Permissions/privacycommand" — that folder is long gone. - Wrong bundle ID: com.example.privacycommand. It is org.privacykey.privacycommand, with .HelperTool and .tests alongside. - Wrong target list: claimed two targets and "all 31 Swift sources" in one app target. There are five Xcode targets; the app alone has 63 sources. - The Sparkle package dependency was missing entirely — a fresh checkout does not build without it, and the README documented it while BUILDING.md didn't. - Added the helper Team ID requirement as failure mode 0. It is the most likely thing to go wrong, it builds cleanly and only fails at runtime, and it takes out every privileged feature at once. - Retitled the speculative "What I would expect to fail first on a real build" section, written before the project had been built, to describe what actually trips people. User-facing documentation now lives at privacykey/docs-privacycommand; these two files stay internal. --- .github/ARCHITECTURE.md | 25 +++++++++++-------- privacycommand/BUILDING.md | 51 +++++++++++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/.github/ARCHITECTURE.md b/.github/ARCHITECTURE.md index e436c3e..5e56407 100644 --- a/.github/ARCHITECTURE.md +++ b/.github/ARCHITECTURE.md @@ -2,15 +2,22 @@ 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: 229 Swift files. The analyzer +> (`Sources/privacycommandCore/Analysis/`) has 41 detector files, monitoring has 12, and +> the app target has 63 SwiftUI files. User-facing documentation now lives at +> [docs-privacycommand](https://github.com/privacykey/docs-privacycommand); this file +> covers the internals. --- -## The four targets, and why each pulls its weight +## The targets, and why each pulls its weight ``` ┌──────────────────────────────────────────────────────────────────────┐ @@ -45,7 +52,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**: a stale copy of this target also exists at `privacycommand/Sources/privacycommandHelper/` without `PfctlKillSwitch.swift`. Xcode builds the top-level directory; the `Sources/` copy is vestigial and should be deleted. | | `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 +66,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 +93,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 diff --git a/privacycommand/BUILDING.md b/privacycommand/BUILDING.md index b30562e..c78c7d8 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/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/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 From 74aa084293e3b15c447a54cf58352630a4193574 Mon Sep 17 00:00:00 2001 From: Adam Date: Mon, 10 Aug 2026 00:09:50 +1000 Subject: [PATCH 2/2] refactor: delete the stale helper tree, and fix the docs that pointed at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sources/privacycommandHelper/ was a dead copy of the helper target. Verified before removing: - Package.swift declares no helper target at all — it is Xcode-only, and the Xcode project references the top-level privacycommandHelper/ directory. - Diffing the two trees: CodeSignValidator, FsUsageRunner and main were byte identical; HelperToolService was 29 lines behind; PfctlKillSwitch.swift existed only in the live tree. Nothing unique was lost. - The only remaining references were prose, all corrected here. It had already caused real confusion: ARCHITECTURE.md described the dead tree and concluded the network kill switch was unimplemented. README.md, while here: - The privileged-helper row pointed at the deleted path. - [`architecture.md`](ARCHITECTURE.md) was a broken link — the file is at .github/ARCHITECTURE.md. - docs/RELEASES.md does not exist and never has. Both the README and ARCHITECTURE.md linked it twice. Pointed at the real release pipeline instead: privacykey/gh-workflows, with .github/workflows/release.yml as the thin caller that documents the secret layout. - "Related products" listed only privacycommand itself. It now names the siblings it was presumably meant to. - Added a Documentation section pointing at the new docs site. HELPER.md said the target builds from Sources/privacycommandHelper/*.swift. Corrected to the directory beside Sources/, which is what Xcode actually compiles. --- .github/ARCHITECTURE.md | 6 +- README.md | 19 ++- privacycommand/HELPER.md | 7 +- .../CodeSignValidator.swift | 52 ------- .../privacycommandHelper/FsUsageRunner.swift | 146 ------------------ .../HelperToolService.swift | 129 ---------------- .../Sources/privacycommandHelper/main.swift | 26 ---- 7 files changed, 21 insertions(+), 364 deletions(-) delete mode 100644 privacycommand/Sources/privacycommandHelper/CodeSignValidator.swift delete mode 100644 privacycommand/Sources/privacycommandHelper/FsUsageRunner.swift delete mode 100644 privacycommand/Sources/privacycommandHelper/HelperToolService.swift delete mode 100644 privacycommand/Sources/privacycommandHelper/main.swift diff --git a/.github/ARCHITECTURE.md b/.github/ARCHITECTURE.md index 5e56407..d4e6d88 100644 --- a/.github/ARCHITECTURE.md +++ b/.github/ARCHITECTURE.md @@ -52,7 +52,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/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**: a stale copy of this target also exists at `privacycommand/Sources/privacycommandHelper/` without `PfctlKillSwitch.swift`. Xcode builds the top-level directory; the `Sources/` copy is vestigial and should be deleted. | +| `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`. | @@ -148,7 +148,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) @@ -170,6 +170,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/README.md b/README.md index d1ff009..aee5dc8 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,18 @@ privacycommand is a SwiftUI app backed by a pure-Swift analyzer library and an o |---|---|---| | Analyzer logic | [`privacycommand/Sources/privacycommandCore/`](privacycommand/Sources/privacycommandCore/) | Headless, AppKit-free; runs from CLI, tests, GUI | | App UI | [`privacycommand/Sources/privacycommand/`](privacycommand/Sources/privacycommand/) | SwiftUI views + view-models | -| Privileged helper | [`privacycommand/Sources/privacycommandHelper/`](privacycommand/Sources/privacycommandHelper/) | XPC service installed via `SMAppService.daemon` | +| Privileged helper | [`privacycommand/privacycommandHelper/`](privacycommand/privacycommandHelper/) | XPC service installed via `SMAppService.daemon`. Beside `Sources/`, not inside it | | Guest agent (VM) | [`privacycommand/Sources/privacycommandGuestAgent/`](privacycommand/Sources/privacycommandGuestAgent/) | Runs in-VM, ships observations to the host | -Read [`architecture.md`](ARCHITECTURE.md) for the longer version. +Read [`ARCHITECTURE.md`](.github/ARCHITECTURE.md) for the longer version, and +[`BUILDING.md`](privacycommand/BUILDING.md) to get it compiling. + +## Documentation + +Full documentation — reading the report, monitored runs, VM mode, the `auditctl` +CLI, and the privileged helper — lives at +**[docs.privacycommand.privacykey.org](https://docs.privacycommand.privacykey.org)** +([source](https://github.com/privacykey/docs-privacycommand)). ## Updates @@ -122,7 +130,7 @@ Updates ship through two channels that share the same DMG: 1. **Direct downloads** receive in-app updates via Sparkle 2. Auto-checks are off by default — opt in via Settings → Updates. 2. **Homebrew** users update via `brew upgrade --cask privacycommand`. privacycommand detects Cask installs and disables Sparkle's installer to stay out of `brew`'s way. -The appcast feed is hosted on `gh-pages` at `https://privacykey.github.io/privacycommand/appcast.xml` and signed with EdDSA — see [`docs/RELEASES.md`](docs/RELEASES.md) for the release flow. +The appcast feed is hosted on `gh-pages` at `https://privacykey.github.io/privacycommand/appcast.xml` and signed with EdDSA. The release pipeline — test gate, sign, notarize, DMG, appcast, GitHub Release, Homebrew cask — lives in [privacykey/gh-workflows](https://github.com/privacykey/gh-workflows); [`.github/workflows/release.yml`](.github/workflows/release.yml) is the thin caller and documents the secret layout. ## Privacy & telemetry posture @@ -150,9 +158,10 @@ If you find a security issue, please **don't** open a public issue. Email `secur ## Related products -privacycommand is a **privacykey** project +privacycommand is a **privacykey** project. -- **[privacycommand](https://github.com/privacykey/privacycommand)** — macOS forensic permission auditor _(this repo)_ +- **[privacytracker](https://github.com/privacykey/privacytracker)** — watches iOS App Store privacy labels and tells you when an app quietly starts collecting more +- **[mantis](https://github.com/privacykey/mantis)** — self-hostable tripwire keys: mint a URL, get told when something fetches it ## License 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()