Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions .github/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
┌──────────────────────────────────────────────────────────────────────┐
Expand Down Expand Up @@ -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 <name-or-path>` 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`. |
Expand All @@ -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`.
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.
51 changes: 39 additions & 12 deletions privacycommand/BUILDING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `<libproc.h>` 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
Expand All @@ -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 <app>` 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.<yourdomain>.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

Expand Down
7 changes: 4 additions & 3 deletions privacycommand/HELPER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down

This file was deleted.

Loading