diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9272f81 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# llms-full.txt is generated from the pages by scripts/build-llms.mjs and mirrors +# every content change, so GitHub collapses it in diffs. llms.txt stays visible. +llms-full.txt linguist-generated=true diff --git a/.mintignore b/.mintignore new file mode 100644 index 0000000..5373731 --- /dev/null +++ b/.mintignore @@ -0,0 +1,3 @@ +# Build output. A stale export must not be picked up as pages or copied into the next export. +dist/ +export.zip diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7eb0cf3..f01c9ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,7 @@ to document one that doesn't work the way you've said. ```bash npm run check +npm run llms ``` This validates that every page in `docs.json` exists and every page on disk is @@ -40,7 +41,7 @@ reachable from the navigation. CI additionally runs a link check. 1. Create the `.mdx` file with `title` and `description` frontmatter. 2. Add it to the right group in `docs.json`. -3. Run `npm run check`. +3. Run `npm run llms`, then `npm run check`. ## Security diff --git a/README.md b/README.md index 2ed7c23..897458e 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Production is a Cloudflare Worker serving the static export as assets ```bash npm run dev # mint dev, on http://localhost:3000 npm run check # validate docs.json navigation against the files on disk +npm run llms # regenerate llms.txt and llms-full.txt after editing a page ``` `npm run check` is the fast gate — it catches a page listed in `docs.json` with @@ -25,9 +26,25 @@ docs.json navigation, theme, SEO introduction.mdx … the Guide tab, one file per page develop/ the Develop tab images/ logo/ assets -scripts/ check-docs.mjs +scripts/ check-docs.mjs, build-llms.mjs +llms.txt AI-readable index of every page (generated, committed) +llms-full.txt the whole site as one Markdown file (generated, committed) ``` +## AI-readable copies + +The site follows the [llms.txt](https://llmstxt.org) convention. `llms.txt` at the +repository root indexes every page with its one-line description, and +`llms-full.txt` is the whole site as one Markdown file. Both are committed, so +they read fine straight from GitHub, and both are served from the site root. The +build also writes every page as plain Markdown beside its HTML, so appending +`.md` to any page URL returns the Markdown. + +`scripts/build-llms.mjs` generates all of it from `docs.json` and each page's +frontmatter, using only Node built-ins. Run `npm run llms` after editing a page +and commit the result; `npm run check` fails when the committed copies are out +of date. + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md). Docs for the app itself live in the diff --git a/about-these-docs.mdx b/about-these-docs.mdx index 389b0ac..6deeeaa 100644 --- a/about-these-docs.mdx +++ b/about-these-docs.mdx @@ -40,6 +40,20 @@ outbound calls, what the helper can do, what the kill switch touches — that's not a docs typo, it's a security issue. Please follow [Security](/security) and don't open a public issue. +## Reading these docs with an AI assistant + +This site follows the [llms.txt](https://llmstxt.org) convention, so you can hand +it to whichever assistant you use and ask it to walk you through an install or a +report: + +- `/llms.txt` is an index of every page with a one-line summary. +- `/llms-full.txt` is the whole site in one Markdown file — the thing to paste in + when you want the assistant to have everything. +- Appending `.md` to any page's URL returns that page as plain Markdown. + +Both files are generated from these pages, so they carry exactly the same +caveats as the pages themselves. + ## Conventions - **privacycommand** is one word, all lowercase, including at the start of a diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..6138363 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,2096 @@ +# privacycommand documentation + +> Forensic permission audits for macOS apps — what a bundle is entitled to, what it ships, and what it actually does when you run it. + +The full text of every page on https://docs.privacycommand.privacykey.org, in navigation order. Each page starts with a level-one heading and a Source line giving its canonical URL. + +Site notice: privacycommand is pre-1.0 and under active development — the report format may still shift between releases. [Release notes →](https://github.com/privacykey/privacycommand/releases) + +# Introduction +Source: https://docs.privacycommand.privacykey.org/introduction + +What privacycommand tells you about a macOS app, and what it deliberately doesn't. + +macOS will tell you what an app is *allowed* to do. Open System Settings and +you'll see which apps hold Camera, Microphone, Full Disk Access. What it won't +tell you is what any of them actually *does* with that permission — or what a +bundle carries that never asks permission at all. + +privacycommand closes that gap. Drop a `.app` bundle (or a `.dmg`) onto it and +it produces a forensic report: what the binary is entitled to, which analytics +SDKs it ships, which domains are hard-coded in it, what it registered to launch +at login — and, if you let it, what it reaches for while it runs. + +> **Note** +> +> **Everything runs locally.** The inspected app's contents never leave your +> machine, and no report is ever uploaded. privacycommand does make a handful of +> outbound calls — reverse DNS, App Store privacy labels, its own update feed, +> and, only if you ask it to fetch a pending update, that vendor's feed and +> download or a `brew fetch` of the incoming cask. All five are enumerated in +> [Security](https://docs.privacycommand.privacykey.org/security). + +## The two halves + +**Static analysis** happens without running anything. It's safe on a bundle you +don't trust, it's fast, and it's the bulk of the report — entitlements, +code-signing and notarization, URL schemes, embedded helpers, SDK fingerprints, +hard-coded hosts, Apple's Privacy Manifest checked against what the binary +actually links. Start at [Static analysis](https://docs.privacycommand.privacykey.org/static-analysis). + +**Dynamic analysis** means launching the app under observation and watching +what it does: file events, network destinations, child processes, pasteboard +and camera and microphone access. That needs the +[privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper), and for anything you genuinely don't +trust, it should happen inside [VM mode](https://docs.privacycommand.privacykey.org/vm-mode). + +## What it won't do + +privacycommand reports. It doesn't remediate, quarantine, or score apps against +a compliance framework, and it makes no claim to detect malware — a determined +adversary can hide from static analysis, and dynamic analysis only sees the code +paths you happen to exercise. + +A clean report means "nothing in the signals we extract looked noteworthy". It +does not mean the app is safe. + +## Where to start + +**[Install it](https://docs.privacycommand.privacykey.org/installation)** + +Homebrew, a signed DMG, or from source. + +**[Run your first audit](https://docs.privacycommand.privacykey.org/first-audit)** + +Drop a bundle in and read what comes back. + +**[Audit a whole fleet](https://docs.privacycommand.privacykey.org/batch-scan)** + +Triage every app in `/Applications` at once. + +**[Script it](https://docs.privacycommand.privacykey.org/auditctl)** + +`auditctl`, the CLI over the same analyzer. + +## Requirements + +macOS 13 or newer, Apple Silicon or Intel. The privileged helper and VM mode are +optional — the core report works without either. + +# Installation +Source: https://docs.privacycommand.privacykey.org/installation + +Homebrew, a signed DMG, or a build from source — and which update channel each one uses. + +macOS 13 or newer, Apple Silicon or Intel. privacycommand is free and MIT-licensed. + +## Homebrew + +The recommended route. + +```bash +brew install privacykey/tap/privacycommand +``` + +The tap lives at [privacykey/homebrew-tap](https://github.com/privacykey/homebrew-tap). +Keep it current with: + +```bash +brew upgrade --cask privacycommand +``` + +> **Note** +> +> When privacycommand detects it's running from a Homebrew Caskroom it disables +> its own in-app updater, so `brew` stays the single source of truth for the +> on-disk version. You won't get two updaters fighting. + +## Direct download + +Grab the signed and notarized `.dmg` from +[the latest release](https://github.com/privacykey/privacycommand/releases/latest) +and drag the app into `/Applications`. + +Direct installs update through [Sparkle 2](https://sparkle-project.org). +Automatic checks are **off by default** — opt in under **Settings → Updates**. +See [Updating](https://docs.privacycommand.privacykey.org/updating) for how the appcast is signed and verified. + +## Build from source + +```bash +git clone https://github.com/privacykey/privacycommand.git +cd privacycommand/privacycommand +open privacycommand.xcodeproj +``` + +You'll need to add the Sparkle package dependency and set a signing team before +the first build — the full walkthrough is in +[Build from source](https://docs.privacycommand.privacykey.org/develop/build-from-source). + +## First launch + +The first launch opens a five-step wizard rather than the inspector: welcome, +static analysis, network monitoring, file monitoring, and a closing step. It's +explanatory — no account, no sign-in — and **Skip onboarding** at the bottom +left jumps straight past it. You only see it once; after that privacycommand +opens to an empty inspector. **Help → Show Onboarding…** replays it, as does +**Settings → General → Onboarding → Replay**. + +The fourth step, *File monitoring (optional)*, is where the +[privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper) comes up. It shows the helper's current +status and offers to install it — so you are asked about it during first launch, +not only the first time you reach for a feature that needs it. Skipping the step +installs nothing, and the static report never needs it. + +Past the wizard, drop a `.app` bundle or a `.dmg` onto the window and the +[first audit](https://docs.privacycommand.privacykey.org/first-audit) begins immediately. + +## Verifying what you installed + +privacycommand audits macOS apps, so it's fair to point it at itself: + +```bash +codesign -dv --verbose=4 /Applications/privacycommand.app +spctl --assess --type execute -vv /Applications/privacycommand.app +``` + +You can also drop `privacycommand.app` onto its own window. It ships zero +analytics SDKs, so the [telemetry callout](https://docs.privacycommand.privacykey.org/telemetry) should come back empty — +if it doesn't, something is wrong and we'd like to hear about it via +[Security](https://docs.privacycommand.privacykey.org/security). + +# Your first audit +Source: https://docs.privacycommand.privacykey.org/first-audit + +Drop a bundle in, and what each part of the report is telling you. + +Drag a `.app` bundle onto the privacycommand window. A `.dmg` works too — it's +mounted, the app inside is analysed, and it's unmounted afterwards. + +Nothing is executed. The whole first pass is [static](https://docs.privacycommand.privacykey.org/static-analysis), so it's +safe on a bundle you have no reason to trust. + +## What you get + +The report opens on the **Dashboard**, which is the summary view. Four things +are worth reading first. + +**Step 1: The risk tier** + +A headline judgement with the findings behind it. Expand it — the tier on +its own is far less useful than the signals that produced it. + +**Step 2: The telemetry callout** + +How many analytics, advertising, and attribution SDKs the bundle ships, +heat-graded, with a per-category breakdown. See [Telemetry](https://docs.privacycommand.privacykey.org/telemetry). + +**Step 3: Privacy labels, if it came from the App Store** + +The developer's declared Privacy Nutrition Labels sitting next to what the +binary actually contains. See [Privacy labels](https://docs.privacycommand.privacykey.org/privacy-labels). + +**Step 4: Background Task Management** + +Everything the app registered to start on its own — login items, launch +agents, daemons, helpers. See [Background tasks](https://docs.privacycommand.privacykey.org/background-tasks). + +The **Static** tab walks every signal in detail, and every finding carries a +plain-English explanation of what it means. Expand the risk tier and there's a +layer under that again: fourteen of the contributor rows behind the score carry +a **show source** button pointing at the evidence itself — see +[Showing your work](https://docs.privacycommand.privacykey.org/static-analysis#showing-your-work). + +## A worked example + +Point it at something you already have opinions about — a video-conferencing +app, or anything free that's clearly monetised somehow. + +Useful questions to ask of the report: + +- **Does the SDK count match the business model?** A paid utility shipping four + attribution SDKs is worth a second look. +- **Do the hard-coded domains match the vendor?** Third-party hosts in a + first-party app tell you who else is in the loop. +- **Does the Privacy Manifest match the binary?** privacycommand cross-checks + these; a gap is more interesting than either side alone. +- **What starts without you?** Login items and launch agents are how an app + keeps running when you think it isn't. + +## Going further + +Static analysis has a ceiling: it shows what an app *can* do. To see what it +*does*, launch it under observation with a [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs) — +and if you don't trust it, do that inside [VM mode](https://docs.privacycommand.privacykey.org/vm-mode). + +# Static analysis +Source: https://docs.privacycommand.privacykey.org/static-analysis + +What privacycommand extracts from a bundle without running it — the bulk of the report. + +Static analysis reads the bundle on disk. Nothing executes, so this is the part +you can safely run against something you don't trust at all. + +It is also where most of the report comes from. Roughly forty detectors run over +a bundle; this page groups them by the question they answer. + +## Identity — is this what it says it is? + +**Code signing** + +Full chain validation: Developer ID, the signing Team ID, hardened-runtime +flags, and the `spctl` assessment. The 10-character Team ID is expanded to +the developer's registered name, so `A1B2C3D4E5` becomes a company you can +recognise. + +**Notarization** + +A deep dive rather than a yes/no: whether the ticket is stapled, what +`spctl` says about it, and the SHA-256 of the bundle. Every relaxation is +reported with the entitlement or flag responsible. + +**Provenance** + +Where the bundle came from — quarantine attributes, Mac App Store receipt +if present, and the update mechanism the app carries (Sparkle, its own +updater, or none). + +## Capability — what is it allowed to do? + +- **Entitlements** — the full list, with the sandbox and hardened-runtime + exceptions called out rather than buried. +- **TCC** — which privacy-protected resources the app declares an interest in, + and the usage-description strings it will show you when it asks. +- **Privacy Manifest** — Apple's declared manifest, **cross-checked against what + the binary actually links**. A manifest that omits an API the binary calls is + more interesting than either fact alone. +- **App extensions and helpers** — every embedded extension, XPC service, and + helper binary, each analysed rather than counted. + +### Requested, granted, used + +Declaring an interest is only the first of three questions, and the Static tab +crosses all three in one table — *Permissions: requested vs granted vs used*. + +| Axis | Where it comes from | +|---|---| +| **Requested** | Info.plist usage keys and the entitlements that declare them — the bullet above | +| **Granted** | What macOS has actually recorded, read from the system's TCC databases | +| **Used** | What a [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs) observed. Camera, microphone and screen recording only; everything else reads `—` | + +The gaps are the point. A capability requested and granted but never touched +across a run you drove properly is worth a question; one macOS has granted that +the app never declared a reason for is worth a larger one. + +> **Note** +> +> **The granted column needs Full Disk Access — privacycommand's own, not the +> inspected app's.** macOS protects the TCC databases, so a copy of +> privacycommand without FDA can't read them. It doesn't guess: the column +> reads `unknown` and the table shows a card with a button into the right +> System Settings pane. Grant it and reopen the app. +> +> This is a TCC grant, not root — the [helper](https://docs.privacycommand.privacykey.org/privileged-helper) is a separate +> thing and won't help here. Four things go missing without FDA, all of them in +> this table: the granted column; any row that exists *only* because macOS +> recorded a grant — including the granted-but-never-declared case above, which +> has no row at all when the grant can't be read; the *System access granted* +> list below the table, which is where the inspected app's own Full Disk Access, +> Accessibility, Screen Recording or Input Monitoring grants would appear; and +> the **Verdict** column, which collapses to *Grant unknown* on every row. +> +> That last one costs more than it looks. *Used in binary, not declared* — a +> warning that needs no TCC data at all, since it comes from the binary — is +> computed after the unknown-grant check and never reached, so the warning and +> the row ordering that surfaces it both disappear. The rest of the static +> report is unaffected. + +## Content — what does it ship? + +- **SDK fingerprints** — analytics, advertising, and attribution SDKs matched + against a fingerprint database. See [Telemetry](https://docs.privacycommand.privacykey.org/telemetry). +- **Hard-coded domains and URLs** — hosts sitting in the binary and in bundled + resources. +- **URL schemes and document types** — what the app registers to handle, which + is also what other apps can use to poke it. +- **Frameworks and rpaths** — what it links, where it looks, and whether the + search paths widen the [dylib hijacking](#dylib-hijacking-surface) surface. +- **Embedded assets and resources** — including scripts and databases shipped + inside the bundle. +- **Secrets and licence keys** — names of credential-shaped strings. It reports + that a key-shaped thing exists and what it appears to be for; it does not + print the value. +- **Feature flags and trial state** — `isPro`, `isTrial`, `subscription_status`, + `experiment_id`, and the platform-specific switches behind them (LaunchDarkly, + Optimizely, Firebase Remote Config, PostHog, Statsig, Unleash). + +## Risk — what should worry you? + +**Anti-analysis signals.** Checks a binary can make to notice it's being +inspected — debugger detection, VM detection, integrity checks. Present in +plenty of legitimate DRM, so this is a signal rather than a verdict. + + + +**Dylib hijacking surface.** Where a writable or under-specified library search +path would let an attacker land code inside the app's process. + +**Behaviour analysis.** The detectors above produce signals; a behaviour pass +turns combinations of them into findings with a risk tier, which is what drives +the summary and the [batch scan](https://docs.privacycommand.privacykey.org/batch-scan) triage table. + +## Every finding is explained + +privacycommand ships a Knowledge Base entry alongside each detector. When a +finding says the app has `com.apple.security.cs.disable-library-validation`, +you get a plain-English explanation of what that entitlement relaxes and why an +app might legitimately want it — not just the raw key. + +This is a deliberate contract: **a new detector is not complete without its +Knowledge Base entry.** If you're adding one, see +[Contributing](https://docs.privacycommand.privacykey.org/develop/contributing). + +## Showing your work + +Fourteen of the risk contributors carry a **show source** button — the +magnifying glass next to the row on the risk-score breakdown. It opens a +popover naming where that evidence actually came from. + +For the signing-related ones that's a command you can re-run yourself: + +```bash +codesign -dvv /Applications/SomeApp.app +codesign -d --entitlements :- /Applications/SomeApp.app +spctl --assess -vvv /Applications/SomeApp.app +``` + +For the rest it's a location rather than a command — `Contents/Info.plist`, +`Contents/MacOS/`, or the Files or Network tab for anything a +monitored run produced. The popover text is selectable, and where there's a +file behind it, **Reveal in Finder** takes you there. Contributors outside those +fourteen have no button — for those, the Knowledge Base entry above is the +explanation. + +The Executive Summary rows have a one-click copy, but what it copies is a +Markdown summary of the finding — severity, message, evidence, Knowledge Base +id — not a command. + +## Next + +**[The binary summary](https://docs.privacycommand.privacykey.org/binary-and-network)** + +Network call sites, and decompiling them. + +**[Privacy labels](https://docs.privacycommand.privacykey.org/privacy-labels)** + +Developer claims against binary contents. + +# The binary summary +Source: https://docs.privacycommand.privacykey.org/binary-and-network + +Which functions can open a connection, what they reach for, and how to decompile any of them. + +Knowing an app contains `api.example.com` tells you a string exists. It doesn't +tell you which code path uses it, or whether it's reachable at all. + +The forensic binary summary is a plain-English read of what the main binary +links and calls, built around an **outbound network call-sites map**. + +## The call-sites map + +For each function that can open a connection, the map records: + +- **The networking symbols it reaches for** — BSD sockets, `getaddrinfo`, + CFNetwork, or Network.framework's `nw_*` family. +- **Any host or URL literal sitting next to it.** Proximity is a hint, not + proof — a literal near a call site is often but not always the destination. + +That gives you a shortlist of "these are the places this app talks to the +network from", which is a far better starting point than a flat list of every +string in the binary. + +## Decompiling a call site + +Any call site can be decompiled in place, provided you have +[Ghidra](https://ghidra-sre.org) installed. privacycommand shells out to it, +caches the result, and shows the decompiled function next to the signals that +made it interesting. + +> **Note** +> +> Ghidra is not bundled and not required. Without it you still get the call-site +> map, the symbols, and the literals — you just can't expand a function into +> source-like output. + +## Live call stacks + +Static analysis says a function *can* open a connection. A live call stack +proves one *did*. + +For apps that can be relaunched under observation and are **not** using the +hardened runtime, privacycommand can capture the stack at the moment an outbound +connection is opened and tie it back to the function responsible. + +> **Warning** +> +> The hardened runtime is the limiting factor. Most notarized apps enable it, +> and it blocks the instrumentation this needs — so for a typical App Store or +> Developer ID app, expect the call-site map but not the live stack. + +Where it does work, this is the strongest evidence privacycommand produces: +not "this binary contains a URL" but "this function opened a connection to this +address, and here is the path that got there". + +## Reading it honestly + +A few things worth keeping in mind: + +- **Absence isn't proof.** A binary can construct a hostname at runtime, so an + empty literal list doesn't mean an app has no destinations. +- **Presence isn't intent.** Frameworks bring their own networking code. A call + site inside a vendored SDK belongs to that SDK, not necessarily to the app's + own logic. +- **Stripped binaries give less.** Symbol names carry a lot of the readability + here; a stripped release build degrades the summary considerably. + +For evidence about what an app actually did rather than what it could do, pair +this with a [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs). + +# Privacy labels +Source: https://docs.privacycommand.privacykey.org/privacy-labels + +The developer's declared App Store labels, set against what the binary actually contains. + +When a bundle was installed from the Mac App Store, privacycommand fetches the +developer's declared **Privacy Nutrition Labels** from Apple and shows them +beside its own static-analysis findings. + +The point is the comparison. A label is a *claim*; the binary is *evidence*. +Putting them side by side lets you see whether they agree. + +## How the lookup works + +privacycommand resolves the bundle identifier against Apple's public endpoints +and reads the same privacy disclosures shown on the app's App Store page. + +> **Note** +> +> The request is keyed on the **inspected app's bundle ID** and nothing else. +> No information about you, your machine, or your other apps is included. See +> [Security](https://docs.privacycommand.privacykey.org/security) for the full list of outbound calls. + +## What to look for + +- **A declared category with no supporting evidence.** Not suspicious on its + own — developers over-declare to be safe. +- **Binary evidence with no matching declaration.** More interesting. If the + bundle fingerprints an advertising SDK and the labels claim no data is + collected for advertising, that gap is worth understanding. +- **Third-party SDKs the labels don't account for.** Labels cover the + developer's own collection *and* what their SDKs do. An unaccounted-for + analytics SDK is a real discrepancy. + +## Limits + +**Only Mac App Store apps have labels.** Direct downloads and Homebrew casks +have nothing to fetch, and the card simply won't appear. + +**Labels are self-reported.** Apple does not verify them. That is precisely why +this comparison is worth making — but it also means a mismatch points at a +disclosure problem, not proof of misbehaviour. + +**A match isn't a clean bill of health.** Labels describe categories, not +volumes, destinations, or retention. + +## Related + +privacycommand's sibling [privacytracker](https://github.com/privacykey/privacytracker) +does the same job for **iOS** apps, and tracks label changes over time so you're +told when an app quietly starts collecting more. + +# Telemetry callout +Source: https://docs.privacycommand.privacykey.org/telemetry + +How many analytics, advertising, and attribution SDKs a bundle ships — and what fingerprinted each one. + +The Dashboard carries a telemetry card: a heat-graded count of analytics, +advertising, and attribution SDKs found in the bundle, broken down by category. + +## How detection works + +privacycommand matches the bundle against an SDK fingerprint database. A +fingerprint is a set of strings, symbols, framework names, and bundled resources +characteristic of a particular SDK. + +Every detection is **traceable**: hover any SDK and you see the matched strings +that produced it. There is no opaque score — if privacycommand claims Firebase +is present, it will show you what it found. + +The families that reach this card are the analytics, advertising and attribution +ones — Firebase Analytics, Mixpanel, Amplitude, Segment, PostHog, AdMob and +AppsFlyer among them. + +The database is wider than the card. Crash reporters (Sentry, Crashlytics) and +A/B platforms (LaunchDarkly, Optimizely, Firebase Remote Config) are +fingerprinted too and appear in the SDK list on the Static tab, but they aren't +counted here — the heat grade is telemetry only. + +## Reading the count + +The number matters less than the mix, and the mix matters less than whether it +matches the business model. + +| Pattern | Reading | +|---|---| +| Crash reporting only | Ordinary. Almost every app ships something here. | +| Analytics + crash | Normal for commercial software. | +| Attribution SDKs present | The app wants to know which ad you came from. Expected in a free consumer app, odd in a paid utility. | +| Advertising SDKs present | The app is, or has been, ad-supported. | +| Several overlapping in one category | Often an artefact — a vendored dependency dragging its own analytics in. Worth understanding rather than alarming. | + +> **Note** +> +> **Presence is not proof of transmission.** An SDK can be linked and never +> initialised, or gated behind a consent flow that never fires. The callout +> tells you what shipped. To find out whether it phones home, do a +> [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs) and watch the destinations. + +## Feature flags and trial state + +Alongside the SDK count, privacycommand extracts the flag names the binary +checks at runtime — `isPro`, `isTrial`, `subscription_status`, `experiment_id`, +and the vendor-specific equivalents for LaunchDarkly, Optimizely, Firebase +Remote Config, PostHog, Statsig and Unleash. + +This is a separate scanner from the SDK fingerprints, and it lands in a separate +section. Statsig and Unleash in particular are *only* recognised here — they +have no fingerprint entry, so they will never appear in the telemetry count +above. + +This is useful for a reason that isn't really about privacy: it shows you which +behaviours the vendor can change remotely, without shipping an update. + +## A note on our own numbers + +privacycommand ships **zero** analytics SDKs. Drop `privacycommand.app` onto its +own window and the callout should come back empty. If it ever doesn't, that is +a bug worth reporting — see [Security](https://docs.privacycommand.privacykey.org/security). + +# Background tasks +Source: https://docs.privacycommand.privacykey.org/background-tasks + +Every login item, launch agent, daemon, and helper an app registered to start without you. + +An app you opened once can keep running forever. The Background Task Management +audit lists everything a bundle registered to start on its own: + +- **Login items** — launched when you log in. +- **Launch agents** — run in your user session, on a schedule or a trigger. +- **Launch daemons** — run as root, before anybody logs in. +- **Helper tools and XPC services** — embedded binaries the app can start. + +## How it's collected + +privacycommand reads this through the [privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper), +which runs `sfltool dumpBTM` on your behalf. + +> **Note** +> +> Doing this through the helper means **no admin prompt mid-audit**. Without the +> helper installed, privacycommand asks before triggering the prompt itself, +> so the audit is never the thing that surprises you with an authentication +> dialog. + +## What to look for + +**Daemons over agents.** A launch *daemon* runs as root and starts before login. +The bar for one should be high — a bundled updater or a VPN helper is +reasonable; a note-taking app is not. + +**Items with no visible feature behind them.** If an app registered three helpers +and you can only account for one, that's the question to chase. + +**Persistence that outlives the app.** Login items and launch agents commonly +survive dragging the app to the Trash. This audit is often the fastest way to +find leftovers from software you removed months ago. + +**Helpers signed by someone else.** Every embedded binary is analysed too, so +check the signing identity matches the app's. + +## Acting on it + +privacycommand reports; it doesn't remove things. It shows the identifier and +the command behind each entry, so you can inspect or unload it yourself: + +```bash +# Inspect what is registered +sfltool dumpBTM + +# Look at a specific agent +launchctl print gui/$(id -u)/com.example.helper +``` + +Removing persistence properly means the vendor's own uninstaller, or unloading +the job and deleting its plist — which is deliberately outside what a read-only +forensic tool will do for you. + +# Monitored runs +Source: https://docs.privacycommand.privacykey.org/monitored-runs + +Launch the inspected app under observation and watch what it actually touches. + +Static analysis shows capability. A monitored run shows behaviour. + +privacycommand launches the inspected app and streams what it does in real time: + +| Stream | What you see | +|---|---| +| **File events** | Reads and writes, via `fs_usage` through the [helper](https://docs.privacycommand.privacykey.org/privileged-helper) | +| **Network** | Destinations with reverse-DNS labels, click-through IP lookups, row highlighting, and a highlighted-only filter | +| **Processes** | Child processes the app spawns | +| **Device access** | Pasteboard, camera, microphone, and screen-recording activity | +| **USB** | Device interactions | +| **Resources** | CPU, memory, and disk over the life of the run | + +A heat-graded callout fires when something spikes — a burst of disk writes, a +fresh outbound host, a CPU climb that wasn't there a moment ago. + +## What you need + +File-event monitoring requires the [privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper), +because `fs_usage` needs root. The other streams do not. + +> **Warning** +> +> **A monitored run executes the app.** If you have real doubts about a bundle, +> do this inside [VM mode](https://docs.privacycommand.privacykey.org/vm-mode) instead, where the blast radius is a +> disposable guest rather than your Mac. + +## Getting useful coverage + +Dynamic analysis only sees the code paths you exercise. An app sitting on its +launch screen will barely touch anything. + +- **Drive the features you care about.** If you want to know whether the export + function uploads anything, run an export. +- **Watch the first thirty seconds.** Analytics and attribution SDKs typically + fire their first beacon at startup. +- **Let it idle afterwards.** Some telemetry batches on a timer and won't appear + until minutes in. +- **Then check what changed at rest.** Some apps only phone home on quit. + +## Watch mode + +Some behaviour won't show up in the ten minutes you're prepared to sit there: +licence-server pings, telemetry that batches on a timer, a scheduled update +check, a phone-home the day a trial expires. Watch mode is a monitored run you +leave running. + +Start it from **Run → Start Watching…** (⇧⌘W) or the eye button in the header — +either one starts a run first if there isn't one already. Then: + +- **A menu-bar icon appears** with an unread badge. A change detector diffs each + tick of the run against the previous one and posts a single entry per + genuinely new thing: a destination not contacted before, a new behavioural + anomaly, an event the risk classifier called surprising, a live-probe event, a + CPU spike. Clicking the icon opens the list and marks it read. +- **Closing the main window does not quit privacycommand.** This is the part + worth knowing in advance — while watching, the app deliberately stays alive + with the menu-bar icon as your only handle on it. **Stop Watching** — ⇧⌘W + again, or the header button — ends the watch and the run together. +- The icon style is yours to pick under **Settings → General**. + +Everything else is an ordinary monitored run, with the same requirements — file +events still need the helper. + +## Pausing the target + +You can freeze the inspected app and its child processes mid-run, then resume +them. That's ordinary process suspension — useful for reading a fast-scrolling +event list without losing the run. + +It stops the app from doing anything further, but it does **not** sever +connections already open. For that, see the +[network kill switch](https://docs.privacycommand.privacykey.org/kill-switch). + +## Reading the network tab + +Destinations are reverse-DNS labelled, so `142.250.80.14` shows up as something +recognisable rather than a bare address. Rows can be highlighted and then +filtered to highlighted-only, which is the practical way to work through a busy +run: mark the interesting destinations as you go, then review just those. + +Bear in mind that a reverse lookup shows the *hosting* party. A connection to a +cloud provider's address tells you the provider, not necessarily who rented it. + +# Network kill switch +Source: https://docs.privacycommand.privacykey.org/kill-switch + +Cut the inspected app's outbound traffic system-wide, and watch how it copes. + +During a [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs) you can block the destinations the +inspected app has been contacting, then watch what it does about it. + +This is a genuinely useful test. An app that degrades gracefully when its +analytics endpoint disappears behaves very differently from one that blocks the +UI, retries in a tight loop, or refuses to start. + +## How it works + +privacycommand collects every IP address the app has contacted so far in the +run, then asks the [privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper) to install a `pf` +anchor that drops outbound traffic to that set. + +The helper: + +1. Writes an anchor file under `/etc/pf.anchors/`. +2. Adds a reference to it in `/etc/pf.conf`, keeping a copy of the original. +3. Reloads the ruleset with `pfctl -f /etc/pf.conf` and enables `pf` if needed. +4. Populates the blocked-address table. + +Removing the kill switch flushes the anchor and rolls `/etc/pf.conf` back to +the copy it saved. + +> **Warning** +> +> This is a **system-wide** packet-filter rule, not a per-process one. It blocks +> those addresses for your whole Mac while it is armed. If the inspected app +> shares a CDN or a cloud host with something you care about, that something is +> blocked too. + +## Requirements and caveats + +**The helper must be installed.** Without it there's no privileged path to +`pfctl`, and privacycommand will tell you so. + +**You need captured traffic first.** The block list is built from destinations +observed during the run. Arm it before the app has contacted anything and there +is nothing to block — let the run gather traffic first. + +**It blocks addresses, not names.** An app that re-resolves a hostname to a +different IP can route around it. For a hard boundary, take the whole guest +offline in [VM mode](https://docs.privacycommand.privacykey.org/vm-mode). + +**Always lift it when you're done.** The anchor persists until removed. +privacycommand restores the original `pf.conf` on removal, but if the app is +force-quit mid-run, check the switch is disarmed before you wonder why something +else can't reach the internet. + +## Verifying by hand + +```bash +sudo pfctl -s info +sudo pfctl -s Anchors +``` + +If you ever need to clear state manually, flushing the anchor and reloading the +system ruleset is the same pair of commands the helper uses. + +# VM mode +Source: https://docs.privacycommand.privacykey.org/vm-mode + +Run the analysis inside a disposable macOS VM, and ship the observations back to your Mac. + +For a bundle you genuinely don't trust, running it on your own machine is the +wrong move — even under observation. VM mode moves the execution into a +disposable macOS guest and streams the results back. + +## The shape of it + +privacycommand ships a **guest agent** into a macOS VM. The agent runs the same +observation work inside the guest and sends what it sees back to the host across +the VM boundary, using a small zero-dependency wire protocol shared by both +sides. + +Your Mac ends up with the report. The guest is what actually ran the app, and +you can throw it away afterwards. + +Tested against [VirtualBuddy](https://github.com/insidegui/VirtualBuddy), +[UTM](https://mac.getutm.app), and Parallels. + +## When to use it + +- **Anything you'd hesitate to double-click.** Unsigned bundles, things from a + download aggregator, a `.dmg` a stranger sent you. +- **Software that fights inspection.** If static analysis flagged anti-analysis + signals, assume it may behave differently when it thinks it's being watched — + and note that some of those checks specifically look for VMs, so a guest can + change behaviour too. +- **Anything that installs persistence.** Login items, agents, and daemons land + in the guest rather than on your Mac, and vanish with the snapshot. + +## Setting it up + +You need a macOS VM you can install into. privacycommand packages the agent for +you, but not all of this is automated — it's worth knowing which parts aren't +before you start. + +**Step 1: Build a guest and snapshot it clean** + +Install macOS in your VM tool of choice and take a snapshot before anything +else touches it. That snapshot is what makes runs disposable. + +**Step 2: Build the installer disk image** + +**Settings → VM agent → Build installer disk image.** It packages +`privacycommand-guest` together with its LaunchAgent plist and an +`Install.command` into a small `.dmg` under +`~/Library/Application Support/privacycommand/`. About thirty seconds the +first time. No toolchain needed — a release build ships the agent binary +inside itself. + +The same panel detects VirtualBuddy, UTM, Parallels and VMware Fusion. For +the first three it lists the VMs each one knows about and can start one for +you. VMware Fusion exposes no automation surface privacycommand can rely on, +so it is detected and then left alone — start that VM yourself. + +**Step 3: Get the image into the guest — by hand** + +Drag the `.dmg` onto the running VM's window. Every supported front-end +accepts the drop and mounts it inside the guest. + +This is the step privacycommand can't do for you: none of the VM tools +exposes a public way for an outside app to attach a disk image to a running +guest. + +**Step 4: Install the agent inside the guest** + +In the guest, open the mounted volume and double-click `Install.command`. +It asks for the guest's admin password, installs the LaunchAgent, and +confirms the agent is listening on **TCP 49374** (override with `--port`). + +**Step 5: Connect from the host** + +There is no discovery — nothing on your Mac goes looking for the guest. +Read the guest's address from inside it with `ifconfig en0 | grep inet`, +then type that IP and the port into **Settings → VM agent** and click +**Test connection**. **Run in VM** stays disabled until the agent answers. + +**Step 6: Revert when you're done** + +Roll the guest back to the clean snapshot. Anything the app installed goes +with it. + +Getting the app you want to inspect into the guest is also yours to do — drag, +AirDrop or `scp` it across — and you give privacycommand its path *inside* the +VM, because the host can't enumerate the guest's filesystem. + +The protocol and the agent's internals are documented under +[Guest agent](https://docs.privacycommand.privacykey.org/develop/guest-agent). + +## Limits worth knowing + +**VM detection is common.** Plenty of software — DRM especially — behaves +differently in a guest. A clean run in a VM is not proof of a clean run on +bare metal. + +**Apple Silicon guests run Apple Silicon macOS.** You can't audit an Intel-only +build in an ARM guest without Rosetta in the picture, which changes what you're +observing. + +**Performance costs coverage.** Guests are slower, and a slow run tends to be a +shorter run, which means fewer exercised code paths. Give it longer than you +would on the host. + +# Batch scan +Source: https://docs.privacycommand.privacykey.org/batch-scan + +Triage a folder — or every app you have installed — in one pass. + +Auditing one app at a time is fine when you have a suspect. Batch scan is for +when you don't: point privacycommand at a folder, or at all of +`/Applications`, and get a sortable, filterable table of everything at once. + +Each row carries the app's risk tier, its warning and error counts, and the +headline signals behind them. The same analyzer runs on every bundle — this is +the full static pass, not a cheaper approximation. + +Click any row to open that app in the main window for the complete report. + +## What it's good for + +**Fleet triage.** New machine, inherited laptop, or an IT estate you've just +taken responsibility for — this is the fastest way to find the handful of apps +worth a closer look. + +**Finding the outliers.** Sorting by **Trackers** or risk surfaces the apps that +don't resemble their neighbours, which is usually more informative than any +absolute threshold. Note that the Trackers column counts only tracker-class SDK +hits, not every fingerprinted SDK — the full SDK count is in the CSV export and +in each app's own report. + +**Periodic review.** Re-running a scan after a few months of updates shows which +apps have drifted. Pair it with [Compare runs](https://docs.privacycommand.privacykey.org/compare-runs) to see exactly +what changed in any one of them. + +## Working through the results + +Resist reading top to bottom. A more useful order: + +**Step 1: Sort by risk tier, then scan the top** + +The tier is a heuristic, not a verdict — but it's a reasonable reading +order. + +**Step 2: Filter to what you don't recognise** + +Bundled helpers and vendor utilities you've never opened are frequently the +most interesting rows in the table. + +**Step 3: Compare like with like** + +Two apps in the same category with very different tracker counts is a +question worth asking. + +**Step 4: Open the interesting ones properly** + +The table is triage. The judgement happens in the full report. + +> **Note** +> +> A full `/Applications` scan takes a while — every bundle gets the real static +> pass, including the binary work. Start it and go and do something else. + +## Getting the results out + +The batch window has its own **Export** menu, separate from the single-app +[report exports](https://docs.privacycommand.privacykey.org/reports): + +| Format | Contents | +|---|---| +| **CSV** | One row per app, twenty-two columns — identity, risk tier and score, signing, sandbox, hardened runtime, App Store origin, architecture, minimum macOS, update mechanism, download source, tracker and SDK counts, secrets, anti-analysis, launch items, hard-coded domains, concern flags, path, and any analysis error | +| **JSON** | The same rows as structured objects, wrapped with the scan's scope and a generated-at timestamp | + +Both export the rows **currently shown** — filters and sort order are applied +first. Narrow the table to what you care about, then export. + +This is the only way to get fleet-level results out of the GUI; the single-app +JSON, HTML and PDF exports cover one bundle at a time. + +## From the command line + +The same triage is available headless through +[`auditctl preview --all-apps`](https://docs.privacycommand.privacykey.org/auditctl), which is the better route for CI or +a scheduled job. + +# Compare runs +Source: https://docs.privacycommand.privacykey.org/compare-runs + +Diff two saved reports, or an update against the version you already have. + +A single report tells you what an app is today. The interesting question is +usually what changed. + +privacycommand compares in two directions. + +## Compare an update before it lands + +Drop in a candidate update and diff it against the version currently on disk. +For Sparkle-distributed apps, privacycommand can read the appcast itself and +pull the next release straight into the inspector — so the audit happens before +the live app's own updater ever sees it. + +This is the highest-value comparison the tool does: an app you already trusted +is exactly the one whose new version deserves a look. + +## Compare any two saved reports + +The **History** tab keeps your previous runs. Pick any two and diff them +side by side. Added and removed items are colour-cued across: + +- entitlements +- hard-coded domains +- SDK fingerprints +- login items and launch agents +- findings + +A **show-only-changes** toggle collapses everything identical, which is what +makes a large diff readable. + +## Reading a diff well + +**New entitlements are the headline.** An app that has quietly gained Full Disk +Access or a hardened-runtime exception has changed what it can do, regardless of +what the release notes say. + +**New domains are the second thing.** Especially third-party ones — that is +often a new SDK arriving. + +**Removals matter too.** An SDK disappearing is usually good news, but a +*capability* disappearing can mean functionality moved somewhere less visible. + +**Version churn is noise.** Build identifiers, timestamps, and resource hashes +change on every release. Focus on the categories above. + +## From the command line + +```bash +swift run -c release auditctl preview --fetch +``` + +does the same before-and-after against an incoming Homebrew cask build — see +[auditctl](https://docs.privacycommand.privacykey.org/auditctl), including the note about Gatekeeper and freshly +downloaded artifacts. + +# auditctl (CLI) +Source: https://docs.privacycommand.privacykey.org/auditctl + +The same analyzer, on the command line — for scripting, CI, and previewing updates before you take them. + +`auditctl` is a small CLI over the same analyzer the app uses. Useful for +scripting, for CI, and for the one thing the GUI can't easily do: checking an +update *before* you install it. + +## Building it + +`auditctl` is not shipped in the DMG. Build it from a checkout: + +```bash +cd privacycommand +swift build -c release +swift run -c release auditctl --help +``` + +## Usage + +``` +auditctl interactive browser (on a terminal) +auditctl static audit of one app (name or path) +auditctl audit same, explicit +auditctl -i, interactive force the interactive browser +auditctl preview [options] preview apps before you update them +``` + +`` is either a path to a `.app` or a substring of an app name, so +`auditctl fire` will find Firefox. + +Run bare on a terminal, `auditctl` opens an interactive browser. With stdin or +stdout redirected it prints usage instead, which keeps it safe in CI. + +### Auditing one app + +```bash +swift run -c release auditctl /Applications/SomeApp.app +``` + +Pretty-printed by default, non-zero exit on parse failure. Useful options: + +| Flag | Effect | +|---|---| +| `--json` | Machine-readable output | +| `--short` | Condensed summary | +| `--tree` | Tree view of the bundle | +| `--warnings` | Findings only | +| `--warn-exit` | Non-zero exit when warnings are present — the one you want in CI | +| `--no-color` | Plain output. Setting `NO_COLOR` in the environment does the same | +| `--verbose`, `--exact` | As they sound | + +There is no risk-tier filter here — `--min-tier` belongs to `preview`, and +`auditctl` exits 2 on any flag it doesn't recognise. + +## Previewing updates + +`preview` is the interesting subcommand. With no arguments it looks at your +**outdated Homebrew casks** and audits what you're about to receive: + +```bash +swift run -c release auditctl preview +swift run -c release auditctl preview --all-apps --only-noteworthy --min-tier high +swift run -c release auditctl preview --json +``` + +`brew` is looked for at `$HOMEBREW_PREFIX/bin/brew` if that variable is set, +then `/opt/homebrew/bin/brew` and `/usr/local/bin/brew`. If none of them is +executable, `preview` exits 2 and says so — `--all-apps` skips Homebrew +entirely and reads `/Applications` and `~/Applications` directly. + +With `--fetch`, it downloads the incoming cask build and diffs it against the +version you have installed — so you can see what an update *adds* before taking +it: + +```bash +swift run -c release auditctl preview --fetch firefox +``` + +| Flag | Effect | +|---|---| +| `--all-apps` | Scan everything installed, not just outdated casks | +| `--apps-dir ` | Look somewhere other than `/Applications` | +| `--fetch` | Download the incoming build and diff it | +| `--only-noteworthy` | Suppress apps with nothing to say | +| `--min-tier ` | Only report at or above a risk tier — `low`, `medium`, `high` or `critical`. Anything else exits 2 | +| `--json` | Machine-readable output | + +> **Note** +> +> **`preview` will not upgrade anything.** It never runs `brew upgrade` and +> never blocks or delays an update — it will not get between you and your +> package manager. +> +> It does run `brew`, though. In cask mode — the default — every invocation +> shells out to `brew outdated --cask --json=v2`; only if that comes back with +> something outdated does a second call to `brew info --cask --json=v2` follow, +> so on an up-to-date machine you pay for `outdated` alone. Neither call is +> timed out. `--fetch` adds `brew fetch --cask `, which downloads the +> incoming artifact into Homebrew's cache. And it exits 0 on success but 2 on +> failure, including when Homebrew isn't installed at all. Account for both +> before you wire it into a shell prompt or a pre-upgrade hook. + +### The `--fetch` caveat + +With `--fetch`, an incoming build is analysed *before* Gatekeeper has assessed +it. A freshly downloaded artifact can therefore show a one-off "notarization" +difference that is an artefact of the download rather than a real change in the +app. The output flags this where it applies — read it before drawing +conclusions. + +`.dmg` and `.zip` cask artifacts are understood. `.pkg` is skipped. + +# Exporting reports +Source: https://docs.privacycommand.privacykey.org/reports + +JSON, HTML, and PDF — which format to reach for. + +Every audit exports three ways. + +| Format | Shape | Reach for it when | +|---|---|---| +| **JSON** | Every detector's raw output | You're feeding another tool, diffing programmatically, or keeping an archive that outlives the UI | +| **HTML** | Reads like an IT vendor-review one-pager | You're sending it to somebody who won't install privacycommand | +| **PDF** | Same layout, fixed | It's going into a ticket, a procurement pack, or a compliance file | + +## JSON + +The complete output — nothing is summarised away. This is the format to keep if +you're building a record over time, because a future version of privacycommand +can read an old export even if the UI has moved on. + +It's also what you want for scripting — but [`auditctl --json`](https://docs.privacycommand.privacykey.org/auditctl) is +**not** this format. The CLI emits its own flat, summarised, static-only object: +identity and signing, risk score and tier, privacy keys, capabilities, signals +and findings, with components reduced to four counts. No events, no bundle +model, no fidelity notes. Its keys are emitted sorted alphabetically rather than +grouped, so read them by name and don't rely on position. The two share no +top-level keys, so a scheduled `auditctl --json` job and a saved GUI export +can't be diffed against each other. Pick one and stay on it. + +## HTML and PDF + +Both are laid out as a vendor-review one-pager, in this order: fidelity notes, +then the static analysis — code signing, declared privacy keys, inferred +capabilities, findings, hard-coded domains and paths — then the run summary +where the risk score and tier appear, then the event log. The tier comes *after* +the findings, not before them. PDF is a render of the same HTML. + +The audience is someone deciding whether to approve an app, not someone +debugging it. If your reader is technical and wants everything, send the JSON. + +## What's in an export + +Exports carry the findings — severity and message — and, in the JSON, the +evidence strings behind each one. + +They do **not** carry the Knowledge Base explanations. HTML and PDF omit the +reference entirely; JSON carries a `kbArticleID` per finding, which is a +pointer, not the article. If you're sending a report to somebody who won't +install privacycommand and the reasoning matters, paste the explanation in +yourself. + +> **Note** +> +> A report describes **one bundle at one version at one moment**. Include the +> app version and the audit date whenever you file one; a report about +> "SomeApp" with no version attached ages badly and misleads later. + +## A caution on sharing + +A report can contain hard-coded hostnames, internal domains, secret *names* (not +values), and file paths from your machine. Before attaching one to a public +issue or a vendor email, read it — particularly the strings and paths sections — +the same way you would any diagnostic bundle. + +# The privileged helper +Source: https://docs.privacycommand.privacykey.org/privileged-helper + +What it does, what it deliberately can't do, and how to remove it. + +Some of what privacycommand does needs root. Rather than asking you to run the +whole app as root, it ships a small privileged helper and keeps its API surface +deliberately tiny. + +## What needs it + +| Capability | Why root | +|---|---| +| File-event monitoring | `fs_usage` requires elevated privileges | +| [Background Task Management audit](https://docs.privacycommand.privacykey.org/background-tasks) | `sfltool dumpBTM` — through the helper this avoids an admin prompt mid-audit | +| [Network kill switch](https://docs.privacycommand.privacykey.org/kill-switch) | Installing a `pf` anchor needs `pfctl` | + +Everything else — the whole static report, App Store privacy labels, the +telemetry callout, batch scan, exports — runs unprivileged. **The helper is +opt-in and privacycommand is useful without it.** + +## How it's installed + +The helper is embedded in the app bundle and registered as a daemon through +`SMAppService`. You're asked once; after that it stays out of your way. + +## How it protects itself + +The helper **validates its clients by code signature on connect**. It reads its +own Team ID at startup and then requires every connecting process to satisfy an +Apple anchor plus that same Team ID, so a binary signed by anyone else cannot +talk to it — even running as your user. + +> **Warning** +> +> **That check only runs when the helper has a Team ID to compare against.** +> If the helper binary is unsigned, ad-hoc signed, or signed with Xcode's +> "Sign to Run Locally" (no team), the validator has nothing to match on. It +> logs `No Team ID — accepting connection (dev mode)` and returns true for +> every caller — no anchor check either. In that state any unprivileged process +> running as you can drive the whole root API: start the file monitor, run the +> BTM dump, install and remove the kill switch. +> +> Release builds are Developer ID-signed and do carry a Team ID, so this is a +> build-from-source condition rather than a shipped one. If you build locally +> and care about the boundary, set a signing team on the helper target — see +> [Build from source](https://docs.privacycommand.privacykey.org/develop/build-from-source). + +## What it will not do + +The helper exposes a handful of operations and nothing else: report its version, +start and stop the file monitor, run the BTM dump, install and remove the kill +switch, and uninstall itself. + +There is no general "run this command as root" path. That's the point of a +narrow helper rather than a privileged app. + +## Removing it + +privacycommand can uninstall the helper itself — it exposes an uninstall +operation for exactly this. Use that in preference to deleting files by hand. + +To confirm it's gone: + +```bash +sudo launchctl print system/org.privacykey.privacycommand.HelperTool +``` + +A "could not find service" response is what you want. If you removed +privacycommand without uninstalling the helper first, reinstall the app, remove +the helper properly, then remove the app. + +## If it misbehaves + +Symptoms and where to look are collected in +[Troubleshooting](https://docs.privacycommand.privacykey.org/troubleshooting#the-helper). + +# Updating +Source: https://docs.privacycommand.privacykey.org/updating + +Two channels sharing one DMG, and why they never fight. + +privacycommand updates through two channels that ship the same DMG. + +## Homebrew + +```bash +brew upgrade --cask privacycommand +``` + +When privacycommand detects it's running from a Homebrew Caskroom, it +**disables its own installer**. `brew` stays authoritative for the on-disk +version, and you never get two updaters disagreeing about what's installed. + +## Sparkle + +Direct downloads update in-app via [Sparkle 2](https://sparkle-project.org). + +**Automatic checks are off by default.** Opt in under **Settings → Updates**. +A privacy tool that phones a server on a timer without asking would be a poor +advertisement for itself, so it asks. + +The appcast is published at +`https://privacykey.github.io/privacycommand/appcast.xml` and **signed with +EdDSA**. Sparkle verifies the signature before applying anything, so a tampered +or substituted response can't push an update. + +## What an update can change + +privacycommand is pre-1.0 and says so in its own README. Between releases, +expect: + +- **New and changed detectors.** Findings can appear on an app that previously + showed none — usually because detection improved, not because the app changed. +- **Report-format shifts.** The JSON export shape is not yet stable. + +> **Note** +> +> If you keep audits as a record over time, export [JSON](https://docs.privacycommand.privacykey.org/reports) and store it +> with the app version and audit date. A finding that appears after an update is +> otherwise indistinguishable from a change in the app itself — and +> [Compare runs](https://docs.privacycommand.privacykey.org/compare-runs) is only meaningful when you know which +> privacycommand version produced each side. + +## Checking your version + +The version is in **privacycommand → About**. When filing an issue, include it +along with your macOS version and whether the helper is installed. + +# Troubleshooting +Source: https://docs.privacycommand.privacykey.org/troubleshooting + +The failures people actually hit, and what to do about each. + +## Analysis + +**A .dmg won't analyse** + +privacycommand mounts the image, analyses the app inside, and unmounts it. +That fails if the image needs a licence agreement accepted, is encrypted, or +contains a `.pkg` installer rather than an app bundle. + +Mount it yourself, drag the `.app` out, and analyse that directly. + +**The binary summary is nearly empty** + +Most likely a stripped release build — symbol names carry much of the +readability in the [call-sites map](https://docs.privacycommand.privacykey.org/binary-and-network). You'll still get +entitlements, signing, SDK fingerprints and strings. + +Electron and other runtime-hosted apps are also thin here: the interesting +code is JavaScript inside an asar archive, not the Mach-O. + +**Decompilation is unavailable** + +Ghidra isn't installed, or privacycommand can't find it. It is optional and +not bundled — everything else works without it. + +**No privacy labels appear** + +Expected unless the app came from the Mac App Store. Direct downloads and +Homebrew casks have no labels to fetch. See [Privacy labels](https://docs.privacycommand.privacykey.org/privacy-labels). + +**Every permission grant reads unknown** + +privacycommand couldn't read the TCC databases, which is where the +*granted* axis of the permission matrix comes from. macOS protects them, so +privacycommand itself needs **Full Disk Access** to open them. + +The matrix shows a card with a button straight into the right System +Settings pane. Grant it and reopen privacycommand. + +Four things go missing, all of them in the permission matrix: the granted +column, any row that exists only because macOS recorded a grant, the +*System access granted* list that flags the inspected app's own Full Disk +Access, Accessibility, Screen Recording or Input Monitoring, and the +**Verdict** column — every row reads *Grant unknown*, which also suppresses +the *Used in binary, not declared* warning even though that one needs no TCC +data. The rest of the static report is unaffected. See +[Requested, granted, used](https://docs.privacycommand.privacykey.org/static-analysis#requested-granted-used). + + + +## The helper + +**Installation is refused or immediately fails** + +If you built from source, the helper's signing team must match the app's — +`CodeSignValidator` rejects the XPC connection otherwise. Check both targets +in **Signing & Capabilities**. + +For a release build, this usually means a damaged or partially quarantined +install. Reinstall from a fresh download. + +**A source build connects, but the team check never fires** + +Expected, and worth knowing about. A helper with no Team Identifier — +unsigned, ad-hoc signed, or Xcode's "Sign to Run Locally" — accepts every +connecting process rather than refusing them. It logs +`No Team ID — accepting connection (dev mode)`; check with +`log show --predicate 'process == "privacycommandHelper"' --last 5m`. + +Set a signing team on the helper target to get the real check back. See +[How it protects itself](https://docs.privacycommand.privacykey.org/privileged-helper#how-it-protects-itself). + +**Connected, but no file events** + +`fs_usage` produces nothing for a process it can't observe. Confirm the run +actually launched the app and that the target isn't a launcher stub that +exits after spawning the real binary elsewhere. + +**The helper survived uninstalling the app** + +Reinstall privacycommand, uninstall the helper from within it, then remove +the app. Verify with: + +```bash +sudo launchctl print system/org.privacykey.privacycommand.HelperTool +``` + +## Network + +**The kill switch won't arm** + +It needs the helper installed, and it needs destinations to block — the list +is built from traffic already captured in this run. Let the app talk to +something first. + +**Something unrelated lost connectivity** + +The kill switch is a **system-wide** `pf` rule, not per-process. If the +inspected app shares a host or CDN with something else, that is blocked too. +Disarm it. See [Network kill switch](https://docs.privacycommand.privacykey.org/kill-switch). + +**The app routed around the block** + +Blocking is by address. An app that re-resolves a hostname to a different IP +can escape it. For a hard boundary, take the guest offline in +[VM mode](https://docs.privacycommand.privacykey.org/vm-mode). + +## auditctl + +**preview says Homebrew was not found** + +`auditctl preview` looks for `brew` at `$HOMEBREW_PREFIX/bin/brew` first, if +that variable is set, then `/opt/homebrew/bin/brew` and +`/usr/local/bin/brew`. A Homebrew installed somewhere else is invisible to +it unless you point the variable at the prefix: + +```bash +HOMEBREW_PREFIX="$(brew --prefix)" swift run -c release auditctl preview +``` + +Or skip brew entirely with `preview --all-apps`, which reads +`/Applications` and `~/Applications` directly. + +**Colour codes in a log file or CI transcript** + +Pass `--no-color`, or set `NO_COLOR` in the environment — `auditctl` honours +both. See [auditctl](https://docs.privacycommand.privacykey.org/auditctl). + +## Filing a useful issue + +Include the privacycommand version, your macOS version and architecture, +whether the helper is installed, and — if you can share it — the +[JSON export](https://docs.privacycommand.privacykey.org/reports). Open it at +[github.com/privacykey/privacycommand/issues](https://github.com/privacykey/privacycommand/issues). + +For anything security-sensitive, don't use a public issue — see +[Security](https://docs.privacycommand.privacykey.org/security). + +# FAQ +Source: https://docs.privacycommand.privacykey.org/faq + +Short answers to the questions that come up first. + +**Do I need to give it admin access?** + +Not for the core report. Static analysis, the App Store privacy-label +cross-check, batch scan and exports all run unprivileged. + +The optional [helper](https://docs.privacycommand.privacykey.org/privileged-helper) unlocks live file-event +monitoring, the Background Task Management audit without a mid-audit +prompt, and the network kill switch. Installing it asks for admin once. + +One separate thing, not admin and not the helper: reading *granted* +permissions out of the macOS TCC databases needs privacycommand itself to +hold Full Disk Access. Without it you lose the granted column, any matrix +row that exists only because macOS recorded a grant, the *System access +granted* list that flags the inspected app's own Full Disk Access, +Accessibility, Screen Recording or Input Monitoring, and the Verdict column +— every row reads *Grant unknown* — see +[Requested, granted, used](https://docs.privacycommand.privacykey.org/static-analysis#requested-granted-used). + +**Does it run the app I'm inspecting?** + +Not unless you ask it to. The first pass is entirely static. Executing the +app only happens in a [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs), which you start +deliberately — and for anything you don't trust, do it in +[VM mode](https://docs.privacycommand.privacykey.org/vm-mode). + +**Does it detect malware?** + +No, and it doesn't claim to. It reports what a bundle is entitled to, what +it ships, and what it does when observed. A determined adversary can hide +from static analysis. Use it as evidence, not as a verdict. + +**Is anything sent anywhere?** + +Nothing about you, and no part of the inspected bundle. Three calls happen +on privacycommand's own account: DNS reverse lookups for addresses the +inspected app contacted, Mac App Store privacy-label lookups keyed on the +inspected app's bundle ID, and the Sparkle appcast when you check for +updates — which is off by default. + +Two more only happen when you ask for a feature that fetches something: +**Preview next version** reads the *inspected* app's own update feed and +downloads the release it points at, and `auditctl preview --fetch` has +Homebrew download the incoming cask. Full detail in [Security](https://docs.privacycommand.privacykey.org/security). + +**Can I audit an app I haven't installed?** + +Yes. Drop a `.dmg` on it, or use +[`auditctl preview --fetch`](https://docs.privacycommand.privacykey.org/auditctl) to pull an incoming Homebrew cask +build and diff it against what you have. + +**Why does an app show findings after an update to privacycommand?** + +Usually because detection improved, not because the app changed. Detectors +are added between releases. If you're tracking an app over time, note which +privacycommand version produced each report — see [Updating](https://docs.privacycommand.privacykey.org/updating). + +**Is it free? Is there a paid tier?** + +Free, MIT-licensed, no paid tier and no upsell. + +**Does it work on Intel Macs?** + +Yes — macOS 13 or newer, Apple Silicon or Intel. + +**Can I run it in CI?** + +Yes, via [`auditctl`](https://docs.privacycommand.privacykey.org/auditctl). Use `--json` for machine-readable output +and `--warn-exit` to fail a build on findings. Run bare with stdout +redirected it prints usage rather than opening the interactive browser, so +it's safe in a pipeline. + +**What about iOS apps?** + +Different tool — [privacytracker](https://github.com/privacykey/privacytracker) +watches iOS App Store privacy labels and tells you when they change. + +# Security & privacy posture +Source: https://docs.privacycommand.privacykey.org/security + +Every outbound call privacycommand makes, and how to report a vulnerability. + +privacycommand is a privacy tool, so it should be able to survive its own audit. + +## No analytics + +privacycommand ships **no analytics SDKs**. There is no telemetry endpoint, no +install counter, and no crash-report bucket. Nobody is told that you installed +it or what you pointed it at. + +You can check this rather than take our word for it: drop `privacycommand.app` +onto its own window and read the [telemetry callout](https://docs.privacycommand.privacykey.org/telemetry). It should be +empty. + +## Every outbound call + +Five, all narrow. The first three happen on privacycommand's own account; the +last two only when you ask for a feature that fetches something: + +| Call | When | What is sent | +|---|---|---| +| **DNS reverse lookups** | During a [monitored run](https://docs.privacycommand.privacykey.org/monitored-runs) | The IP addresses the *inspected* app contacted, so the Network tab can label them | +| **App Store privacy labels** | Analysing a Mac App Store app | The inspected app's bundle ID, to Apple's lookup endpoint and then that app's App Store product page. Nothing about you | +| **Sparkle appcast** | Checking for updates — **off by default** | A request to `privacykey.github.io` | +| **The inspected app's own appcast** | You click **Preview next version** on a Sparkle-distributed app — see [Compare runs](https://docs.privacycommand.privacykey.org/compare-runs) | A request to the *vendor's* feed URL, then a download of the release it points at. HTTPS only, capped at 600 MB, analysed but never executed | +| **`brew fetch`** | [`auditctl preview --fetch`](https://docs.privacycommand.privacykey.org/auditctl) | Homebrew downloads the incoming cask artifact into its own cache, from wherever the cask points | + +**All analysis runs locally.** The inspected bundle's contents never leave your +machine, and privacycommand never uploads a report anywhere. The last two rows +are downloads *into* your machine, not uploads out of it. + +## The privileged helper + +Opt-in, narrow, and there is no general run-as-root path. See +[The privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper). + +It validates callers by code signature on connect — an Apple anchor plus a Team +ID matching its own — **but only when the helper binary itself carries a Team +Identifier.** That is the case for any Developer ID build, including every +release. A helper that is unsigned, ad-hoc signed, or built with Xcode's +"Sign to Run Locally" has no Team ID to compare against, and in that state it +accepts every connecting process. Treat a locally built helper as reachable by +anything running as you. + +## Things worth knowing + +**Reports can contain sensitive strings.** Hard-coded hostnames, internal +domains, secret *names*, and file paths from your machine. Read an export before +sharing it — see [Exporting reports](https://docs.privacycommand.privacykey.org/reports). + +**The kill switch changes system state.** It writes a `pf` anchor and edits +`/etc/pf.conf`, restoring the original on removal. It is system-wide while +armed. See [Network kill switch](https://docs.privacycommand.privacykey.org/kill-switch). + +**A monitored run executes untrusted code** on your machine unless you use +[VM mode](https://docs.privacycommand.privacykey.org/vm-mode). + +**Ghidra is third-party.** If you enable decompilation, that's Ghidra's code +running on your machine under its own terms. + +## What a clean report does not mean + +privacycommand makes **no claim to detect malware**. Static analysis can be +defeated by an adversary who wants to hide, and dynamic analysis only observes +the code paths a run happens to exercise. + +A clean report means the signals privacycommand extracts didn't look +noteworthy. Treat it as evidence, not as a verdict. + +## Reporting a vulnerability + +Please **don't** open a public issue. + +Email **security@privacykey.org**, or use +[GitHub's private advisory form](https://github.com/privacykey/privacycommand/security/advisories/new). +We aim to respond within 72 hours. + +Especially interested in: anything that lets an unauthorised process reach the +helper, any path from analysing a malicious bundle to code execution on the +host, and anything that causes privacycommand to transmit data beyond the calls +listed above. + +# About these docs +Source: https://docs.privacycommand.privacykey.org/about-these-docs + +How this site is written, where it can be wrong, and how to fix it. + +## Where this comes from + +These pages were written against the privacycommand source at `main` — the +detectors under `Sources/privacycommandCore/Analysis/`, the helper's XPC +interface, the `auditctl` command surface, and the project's in-repo +`ARCHITECTURE.md`, `HELPER.md`, `BUILDING.md` and `GUEST_AGENT.md`. + +Where a page describes behaviour, it was checked in the code rather than +inferred from the README. + +## Where it can be wrong + +privacycommand is **pre-1.0 and moving**. Detectors get added, the report format +shifts, and UI labels change. These docs will drift between releases. + +Two specific cautions: + +- **Screenshots and exact labels** age fastest. Trust the described behaviour + over the wording of any button. +- **The JSON export shape is not stable.** Don't build anything long-lived on it + without pinning a version. + +If a page contradicts the app, the app is right and the page is a bug. + +## Fixing something + +Small corrections: use **Suggest edits** at the bottom of any page. + +Anything larger, or if you're unsure: open an issue at +[github.com/privacykey/privacycommand/issues](https://github.com/privacykey/privacycommand/issues) +and say which page and what's wrong. + +If a page makes a claim about the **security posture** that isn't true — the +outbound calls, what the helper can do, what the kill switch touches — that's +not a docs typo, it's a security issue. Please follow +[Security](https://docs.privacycommand.privacykey.org/security) and don't open a public issue. + +## Reading these docs with an AI assistant + +This site follows the [llms.txt](https://llmstxt.org) convention, so you can hand +it to whichever assistant you use and ask it to walk you through an install or a +report: + +- `/llms.txt` is an index of every page with a one-line summary. +- `/llms-full.txt` is the whole site in one Markdown file — the thing to paste in + when you want the assistant to have everything. +- Appending `.md` to any page's URL returns that page as plain Markdown. + +Both files are generated from these pages, so they carry exactly the same +caveats as the pages themselves. + +## Conventions + +- **privacycommand** is one word, all lowercase, including at the start of a + sentence. So is **privacykey**. +- Commands are shown as you'd type them. +- Where a feature is optional, the page says so and says what you lose without + it. Nothing here should imply you need the helper, Ghidra, or a VM to get + value from the tool. + +# Developing privacycommand +Source: https://docs.privacycommand.privacykey.org/develop/overview + +Where the code lives, and the shape of a contribution. + +privacycommand is Swift: a SwiftUI app over a pure-Swift analyzer library, plus +a privileged helper, a CLI, and an agent that runs inside a VM. + +## The layout + +| Target | Path | Role | +|---|---|---| +| `privacycommandCore` | `Sources/privacycommandCore/` | The analyzer. No SwiftUI and no window, so it runs from the CLI, the tests, the GUI and the helper alike | +| `privacycommand` | `Sources/privacycommand/` | The SwiftUI app — views and view-models only | +| `privacycommandHelper` | the `privacycommandHelper` directory | Privileged XPC service, installed via `SMAppService.daemon` | +| `privacycommandGuestProtocol` | `Sources/privacycommandGuestProtocol/` | Wire format shared by host and guest. Zero dependencies, so the agent builds without compiling Core | +| `privacycommandGuestAgent` | `Sources/privacycommandGuestAgent/` | The `privacycommand-guest` daemon that runs inside a VM | +| `auditctl` / `auditctlKit` | `Sources/auditctl*/` | The [CLI](https://docs.privacycommand.privacykey.org/auditctl), and the smallest end-to-end exercise of the analyzer | + +The split is deliberate: a change to the analyzer can be tested with +`swift test` in seconds rather than by launching a GUI. + +The whole *analysis* half of Core has no UI dependency at all. Three files under +`Monitoring/` do import AppKit behind `#if canImport(AppKit)` — `DynamicMonitor` +launches the inspected bundle via `NSWorkspace`, `LiveProbeMonitor` polls +`NSPasteboard.general.changeCount`, and `VMHostDetection` drives the VM +front-ends. Those are on the dynamic path and can't do their jobs without it. +If you're adding a detector under `Analysis/`, keep it free of both. + +> **Note** +> +> There is a second, stale copy of the helper sources under +> `Sources/privacycommandHelper/`. The Xcode project builds the top-level +> `privacycommandHelper` directory — that's the one that ships. Check which you +> are editing. + +## Where to start + +- **A new detector** — [Contributing](https://docs.privacycommand.privacykey.org/develop/contributing) has the contract, + including the Knowledge Base entry that has to come with it. +- **How the pieces fit** — [Architecture](https://docs.privacycommand.privacykey.org/develop/architecture). +- **VM mode internals** — [Guest agent](https://docs.privacycommand.privacykey.org/develop/guest-agent). +- **Getting it building** — [Build from source](https://docs.privacycommand.privacykey.org/develop/build-from-source). + +## Tests + +```bash +cd privacycommand +swift test +``` + +Run this before opening a PR. The Xcode project also carries an XCTest bundle +for the app target (⌘U). + +# Build from source +Source: https://docs.privacycommand.privacykey.org/develop/build-from-source + +Two build paths, and the one signing step you can't skip. + +Two parallel paths over the same sources. Xcode builds the GUI; SwiftPM gives +you a fast loop on the analyzer. + +## Xcode — the app + +```bash +git clone https://github.com/privacykey/privacycommand.git +cd privacycommand/privacycommand +open privacycommand.xcodeproj +``` + +Then, before the first build: + +**Step 1: Add the Sparkle dependency** + +**File → Add Package Dependencies…** → +`https://github.com/sparkle-project/Sparkle`, *Up to Next Major* from +`2.9.1` — that's what the committed project pins. Tick the `Sparkle` +product on the **privacycommand** target. + +**Step 2: Set the app's signing team** + +**privacycommand → Signing & Capabilities → Team.** A personal team is fine +for development; distribution needs a Developer ID. + +**Step 3: Match the helper's team to the app's** + +**privacycommandHelper → Signing & Capabilities → Team**, set to the *same* +team. + +Two different failures hang off getting this wrong, and only one of them is +loud. If both targets are team-signed but with *different* teams, the +helper's `CodeSignValidator` refuses the XPC connection at runtime and every +privileged feature stops working — obvious, and easy to diagnose. If the +helper ends up with **no** team at all — unsigned, ad-hoc, or +"Sign to Run Locally" — the validator has nothing to compare against and +accepts *every* caller instead, silently. Your build works, and the root +helper is reachable by any process running as you. See +[How it protects itself](https://docs.privacycommand.privacykey.org/privileged-helper#how-it-protects-itself). + +**Step 4: Build** + +⌘B to build, ⌘R to run, ⌘U for the app test bundle. + +The app target depends on the helper, so building the app builds the helper +first and embeds it, along with its LaunchDaemon plist. + +App Sandbox is off, Hardened Runtime is on, deployment target is macOS 13.0, +and distribution is Developer ID plus notarization rather than the App Store. + +## SwiftPM — the analyzer and CLI + +```bash +cd privacycommand +swift build +swift test +.build/debug/auditctl /System/Applications/Calculator.app +``` + +This builds `privacycommandCore` and [`auditctl`](https://docs.privacycommand.privacykey.org/auditctl). **The SwiftUI app +is not built this way** — it exists only in the Xcode project. + +Use this loop when you're working on detectors. It's much faster than a GUI +build, and `auditctl` against a known-good system app is the quickest way to +see whether a change did what you meant. + +## How one source tree compiles both ways + +App sources use a conditional import: + +```swift +import SwiftUI +#if SWIFT_PACKAGE +import privacycommandCore +#endif +``` + +Under SwiftPM, `SWIFT_PACKAGE` is defined and Core is a separate module. Under +Xcode, everything is one app module and the import is skipped. Test files do the +same with `@testable import`. + +If you add a file that references Core, keep this pattern or the other build +path breaks. + +## Verifying the build + +```bash +APP=$(find ~/Library/Developer/Xcode/DerivedData -path '*Build/Products*/privacycommand.app' -maxdepth 7 -print -quit) +ls -1 "$APP/Contents/MacOS" +# privacycommand +# privacycommandHelper <- the helper is embedded +ls -1 "$APP/Contents/Library/LaunchDaemons" +``` + +If `privacycommandHelper` is missing from `Contents/MacOS`, the embed phase +didn't run and nothing privileged will work. + +# Contributing +Source: https://docs.privacycommand.privacykey.org/develop/contributing + +The one contract that matters, plus what to run before opening a PR. + +Issues and pull requests are welcome. + +## Before you open a PR + +- **Run the tests.** `swift test` from `privacycommand/`, and confirm they pass. +- **For UI changes, attach a before/after screenshot.** +- **For a new analysis signal, add its Knowledge Base entry.** See below. + +## The Knowledge Base contract + +This is the one convention that isn't negotiable. + +privacycommand explains what every finding means in plain English. A raw +entitlement key or a matched string is not a finding — it becomes one when a +reader who isn't a macOS internals specialist can tell what it implies. + +So **a new detector is not complete without a Knowledge Base entry alongside +it.** The entry should say what the signal is, why an app might legitimately +have it, and what would make it concerning. + +That last part matters. Most signals are dual-use: anti-analysis checks are +common in DRM, a launch daemon is reasonable for a VPN client. An entry that +only says "this is bad" makes the report worse, not better. + +## Writing a detector + +Detectors live in `Sources/privacycommandCore/Analysis/`. Keep them there, and +keep them free of AppKit — nothing under `Analysis/` imports it today — so they +stay testable from `swift test` and usable from [`auditctl`](https://docs.privacycommand.privacykey.org/auditctl). + +Useful habits: + +- **Prefer evidence over inference.** Report what was found and where. Let the + behaviour pass combine signals into findings. +- **Make it traceable.** Every detection should be able to show what matched. + The [telemetry callout](https://docs.privacycommand.privacykey.org/telemetry) shows the matched strings behind every + SDK, and new detectors should meet the same bar. +- **Test against a real bundle.** `auditctl` against a system app is the fastest + check that a detector behaves on real input. + +## Docs + +These docs live in +[privacykey/docs-privacycommand](https://github.com/privacykey/docs-privacycommand). +If your change alters user-visible behaviour, the docs change belongs with it. + +## Security + +Don't open a public issue for a vulnerability — see [Security](https://docs.privacycommand.privacykey.org/security). + +# Architecture +Source: https://docs.privacycommand.privacykey.org/develop/architecture + +The targets, why each is separate, and how data moves between them. + +One line: **a SwiftUI app drops a bundle onto a pure-Swift analyzer, 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.** + +## The picture + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ privacycommand.app │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ privacycommand │ │ privacycommandCore │ pure Swift, │ +│ │ (SwiftUI + AppKit)│───▶│ (analyzer) │ no views │ +│ └─────────┬──────────┘ └────────────────────┘ │ +│ │ XPC ▲ analyze(bundleAt:) │ +│ ▼ │ │ +│ ┌──────────────────────┐ │ │ +│ │ privacycommandHelper │ root: fs_usage, sfltool, pfctl. │ +│ │ (SMAppService daemon)│ Validates clients by Team ID. │ +│ └──────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + + ┌──────────────────────┐ ┌──────────────────────────┐ + │ auditctl (CLI) │ │ privacycommandGuestAgent │ + │ smallest end-to-end │ │ runs in a macOS VM, │ + │ run of the analyzer │ │ ships observations back │ + └──────────┬───────────┘ └────────────┬─────────────┘ + │ │ + └── privacycommandGuestProtocol ──┘ + (zero-dep wire format) +``` + +## Why each target is separate + +**`privacycommandCore` carries no UI on purpose.** It runs from the CLI, from +tests, from the GUI, and from the helper. Keeping the views out means a detector +change is testable in seconds with `swift test`, and that the same analysis code +runs everywhere rather than being reimplemented per surface. The `Analysis/` +half imports nothing from AppKit; three files under `Monitoring/` do, because +launching a bundle, reading the pasteboard, and driving a VM front-end have no +Foundation equivalent. + +**The helper is tiny on purpose.** It exposes only what genuinely needs root: +version, start/stop file monitor, the BTM dump, install/remove kill switch, +uninstall. No general run-as-root path. It validates callers by code signature — +Apple anchor plus matching Team ID — so a binary signed by anyone else can't +talk to it. That check depends on the helper itself being team-signed; see +[How it protects itself](https://docs.privacycommand.privacykey.org/privileged-helper#how-it-protects-itself) for what a +no-team build does instead. + +**The guest protocol has zero dependencies on purpose.** The agent has to build +and run inside a VM without compiling Core, so the wire format lives in its own +target that both sides import. + +**`auditctl` exists partly as a test.** It's the smallest path that exercises +the analyzer end to end, which makes it the fastest way to notice you broke +something. + +## How an audit flows + +**Step 1: Ingest** + +A `.app` — or a `.dmg`, mounted and unmounted around the analysis — becomes +a bundle path. + +**Step 2: Static pass** + +Roughly forty detectors under `Analysis/` run over the bundle: signing, +entitlements, Mach-O inspection, string scanning, SDK fingerprinting, +Privacy Manifest reading, and the rest. Each emits signals. + +**Step 3: Classification** + +A behaviour pass turns combinations of signals into findings with a risk +tier. This is what drives the summary and the [batch scan](https://docs.privacycommand.privacykey.org/batch-scan) +table. + +**Step 4: Enrichment** + +Optional and network-bound: App Store privacy labels for Mac App Store +bundles. + +**Step 5: Dynamic pass (optional)** + +The app is launched under observation. `Monitoring/` collects file events +(via the helper), network destinations, processes, device access, USB, and +resource usage — or receives the same from a VM guest. + +**Step 6: Report** + +Persisted to History, and exportable as JSON, HTML or PDF. + +## Reading the source + +- `Sources/privacycommandCore/Analysis/` — the detectors, roughly forty files +- `Sources/privacycommandCore/Monitoring/` — dynamic observation +- `Sources/privacycommandCore/Classification/` — signals to findings +- `Sources/privacycommandCore/KnowledgeBase/` — the plain-English explanations +- `Sources/privacycommandCore/IPC/` — the helper's XPC protocol +- `Sources/privacycommandCore/Batch/` — fleet scanning +- `Sources/privacycommandCore/Reporting/` — exports + +The repo's own `ARCHITECTURE.md` carries the longer version. + +# Guest agent +Source: https://docs.privacycommand.privacykey.org/develop/guest-agent + +The daemon behind VM mode — two binaries on two machines. + +[VM mode](https://docs.privacycommand.privacykey.org/vm-mode) is **two binaries on two machines**, not two copies of the +app. + +| Machine | Binary | UI | Role | +|---|---|---|---| +| Host | `privacycommand` | Yes | The app you're using. Where results appear | +| Guest | `privacycommand-guest` | No | A small TCP daemon. Takes commands, ships observations | + +You install `privacycommand-guest` inside the VM once. After that you keep +using privacycommand on your real Mac exactly as before — same window, same +tabs. Observations from the guest stream into those tabs with a **VM** badge so +you can tell them apart from anything running on the host. + +There is no second copy of the GUI inside the VM. + +## The protocol + +The host connects to the agent over TCP — port 49374 unless the agent was +started with `--port` — and exchanges two message types defined in +`privacycommandGuestProtocol`: + +- **`GuestCommand`** — host to guest: start a run, decompile, stop. +- **`GuestObservation`** — guest to host: the events a monitored run produces. + +That target has **zero dependencies** deliberately, so the agent compiles +without pulling in Core. It's the contract between the two machines, and it's +the thing to look at first if host and guest disagree. + +## Decompiling in the guest + +Beyond monitored runs, the agent can decompile an entire app inside the guest +and stream the reconstructed classes back +(**Settings → VM agent → Decompile in VM**). + +This offloads Ghidra's CPU-heavy analysis onto the VM, so it never runs on your +real Mac. It needs Ghidra installed **in the guest**, not on the host. + +For a large binary this is the difference between your Mac being unusable for +ten minutes and not noticing at all. + +## Practical notes + +**The guest needs to be reachable.** The host connects over TCP, so the VM's +networking has to allow it. Bridged or shared networking generally works; +fully isolated networking by definition does not — and if you've taken the +guest offline deliberately to contain an app, you've also cut the agent's link. + +**Snapshot before you run anything.** The value of VM mode is that the guest is +disposable. A clean snapshot taken before the first audit is what makes that +true. + +**VM detection is real.** Software that looks for a hypervisor may behave +differently, and privacycommand's own +[anti-analysis detector](https://docs.privacycommand.privacykey.org/static-analysis) will often tell you in advance that +a bundle contains those checks. + +The repo's `docs/GUEST_AGENT.md` carries the full protocol detail and setup +steps. diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..9e8297b --- /dev/null +++ b/llms.txt @@ -0,0 +1,62 @@ +# privacycommand documentation + +> Forensic permission audits for macOS apps — what a bundle is entitled to, what it ships, and what it actually does when you run it. + +This file indexes the privacycommand documentation site at https://docs.privacycommand.privacykey.org. Every page listed below is also served as plain Markdown at the same URL with `.md` appended, and the whole site is concatenated into one Markdown file at https://docs.privacycommand.privacykey.org/llms-full.txt. + +Site notice: privacycommand is pre-1.0 and under active development — the report format may still shift between releases. [Release notes →](https://github.com/privacykey/privacycommand/releases) + +## Guide / Get started + +- [Introduction](https://docs.privacycommand.privacykey.org/introduction.md): What privacycommand tells you about a macOS app, and what it deliberately doesn't. +- [Installation](https://docs.privacycommand.privacykey.org/installation.md): Homebrew, a signed DMG, or a build from source — and which update channel each one uses. +- [Your first audit](https://docs.privacycommand.privacykey.org/first-audit.md): Drop a bundle in, and what each part of the report is telling you. + +## Guide / Reading the report + +- [Static analysis](https://docs.privacycommand.privacykey.org/static-analysis.md): What privacycommand extracts from a bundle without running it — the bulk of the report. +- [The binary summary](https://docs.privacycommand.privacykey.org/binary-and-network.md): Which functions can open a connection, what they reach for, and how to decompile any of them. +- [Privacy labels](https://docs.privacycommand.privacykey.org/privacy-labels.md): The developer's declared App Store labels, set against what the binary actually contains. +- [Telemetry callout](https://docs.privacycommand.privacykey.org/telemetry.md): How many analytics, advertising, and attribution SDKs a bundle ships — and what fingerprinted each one. +- [Background tasks](https://docs.privacycommand.privacykey.org/background-tasks.md): Every login item, launch agent, daemon, and helper an app registered to start without you. + +## Guide / Watching it run + +- [Monitored runs](https://docs.privacycommand.privacykey.org/monitored-runs.md): Launch the inspected app under observation and watch what it actually touches. +- [Network kill switch](https://docs.privacycommand.privacykey.org/kill-switch.md): Cut the inspected app's outbound traffic system-wide, and watch how it copes. +- [VM mode](https://docs.privacycommand.privacykey.org/vm-mode.md): Run the analysis inside a disposable macOS VM, and ship the observations back to your Mac. + +## Guide / At scale + +- [Batch scan](https://docs.privacycommand.privacykey.org/batch-scan.md): Triage a folder — or every app you have installed — in one pass. +- [Compare runs](https://docs.privacycommand.privacykey.org/compare-runs.md): Diff two saved reports, or an update against the version you already have. +- [auditctl (CLI)](https://docs.privacycommand.privacykey.org/auditctl.md): The same analyzer, on the command line — for scripting, CI, and previewing updates before you take them. +- [Exporting reports](https://docs.privacycommand.privacykey.org/reports.md): JSON, HTML, and PDF — which format to reach for. + +## Guide / Operate + +- [The privileged helper](https://docs.privacycommand.privacykey.org/privileged-helper.md): What it does, what it deliberately can't do, and how to remove it. +- [Updating](https://docs.privacycommand.privacykey.org/updating.md): Two channels sharing one DMG, and why they never fight. +- [Troubleshooting](https://docs.privacycommand.privacykey.org/troubleshooting.md): The failures people actually hit, and what to do about each. + +## Guide / Help + +- [FAQ](https://docs.privacycommand.privacykey.org/faq.md): Short answers to the questions that come up first. +- [Security & privacy posture](https://docs.privacycommand.privacykey.org/security.md): Every outbound call privacycommand makes, and how to report a vulnerability. +- [About these docs](https://docs.privacycommand.privacykey.org/about-these-docs.md): How this site is written, where it can be wrong, and how to fix it. + +## Develop / Build privacycommand + +- [Developing privacycommand](https://docs.privacycommand.privacykey.org/develop/overview.md): Where the code lives, and the shape of a contribution. +- [Build from source](https://docs.privacycommand.privacykey.org/develop/build-from-source.md): Two build paths, and the one signing step you can't skip. +- [Contributing](https://docs.privacycommand.privacykey.org/develop/contributing.md): The one contract that matters, plus what to run before opening a PR. + +## Develop / How it works + +- [Architecture](https://docs.privacycommand.privacykey.org/develop/architecture.md): The targets, why each is separate, and how data moves between them. +- [Guest agent](https://docs.privacycommand.privacykey.org/develop/guest-agent.md): The daemon behind VM mode — two binaries on two machines. + +## Optional + +- [privacycommand source code](https://github.com/privacykey/privacycommand): the product these docs describe +- [Documentation source](https://github.com/privacykey/docs-privacycommand): the repository this site is built from diff --git a/package.json b/package.json index 90ef7db..ed6e98e 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,13 @@ "private": true, "version": "0.0.0", "type": "module", + "homepage": "https://docs.privacycommand.privacykey.org", + "repository": "https://github.com/privacykey/docs-privacycommand", "scripts": { "dev": "npx --yes mint dev", - "build": "npx --yes mint@latest export && rm -rf dist && mkdir -p dist && (unzip -q -o export.zip -d dist || python3 -m zipfile -e export.zip dist) && cp .assetsignore dist/.assetsignore", - "check": "node scripts/check-docs.mjs", + "build": "rm -rf dist export.zip && npx --yes mint@latest export && mkdir -p dist && (unzip -q -o export.zip -d dist || python3 -m zipfile -e export.zip dist) && cp .assetsignore dist/.assetsignore && node scripts/build-llms.mjs --dist dist", + "check": "node scripts/check-docs.mjs && node scripts/build-llms.mjs --check", + "llms": "node scripts/build-llms.mjs", "linkcheck": "lychee --no-progress --accept-timeouts --accept '100..=103,200..=299,429' --root-dir \"$PWD\" --scheme https --scheme http --exclude-loopback --exclude 'localhost' --exclude 'dash\\.cloudflare\\.com' --exclude 'docs\\.([a-z0-9-]+\\.)?privacykey\\.org' --exclude-path images --exclude-path .github './**/*.mdx' './**/*.md'" }, "engines": { diff --git a/scripts/build-llms.mjs b/scripts/build-llms.mjs new file mode 100644 index 0000000..1fbac86 --- /dev/null +++ b/scripts/build-llms.mjs @@ -0,0 +1,619 @@ +#!/usr/bin/env node + +/** + * Generates the llms.txt family for this docs site, following https://llmstxt.org: + * + * llms.txt an index of every page in navigation order, one line each + * llms-full.txt the full text of every page, converted to plain Markdown + * .md the same Markdown, one file per page (build output only) + * + * The first two are committed at the repository root so they can be read on GitHub as + * well as on the published site. `mint export` does not generate any of these, so the + * build step calls this script to drop them into the export directory alongside the + * HTML, and `npm run check` calls it with --check so the committed copies cannot drift + * from the pages they summarise. + * + * Usage: + * node scripts/build-llms.mjs rewrite llms.txt and llms-full.txt + * node scripts/build-llms.mjs --check exit 1 if either committed file is stale + * node scripts/build-llms.mjs --dist DIR also write per-page .md files, the two + * index files, and any OpenAPI spec into DIR + * + * Inputs: docs.json (navigation, site name and description, banner, repo links), the + * frontmatter of each page, and package.json's "homepage" for the site's public URL. + * Only Node built-ins are used, so this runs with no install. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const CALLOUTS = new Set(["Note", "Warning", "Tip", "Info", "Check", "Danger"]); +const PAGE_EXTENSIONS = [".mdx", ".md"]; + +// --------------------------------------------------------------------------- +// Inputs +// --------------------------------------------------------------------------- + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8")); +} + +function fail(message) { + console.error(`build-llms: ${message}`); + process.exit(1); +} + +const docs = readJson(path.join(root, "docs.json")); +const pkg = readJson(path.join(root, "package.json")); + +if (typeof pkg.homepage !== "string" || !/^https?:\/\//.test(pkg.homepage)) { + fail('package.json needs a "homepage" with the site\'s public URL, e.g. "https://docs.example.org"'); +} + +const site = pkg.homepage.replace(/\/+$/, ""); +const siteName = docs.name ?? pkg.name; + +const docsRepo = + typeof pkg.repository === "string" + ? pkg.repository + : typeof pkg.repository?.url === "string" + ? pkg.repository.url.replace(/^git\+/, "").replace(/\.git$/, "") + : null; + +const productRepo = + docs.navbar?.primary?.type === "github" + ? docs.navbar.primary.href + : (docs.footer?.socials?.github ?? + docs.navigation?.global?.anchors?.find((anchor) => /github\.com/.test(anchor.href ?? ""))?.href ?? + null); + +// --------------------------------------------------------------------------- +// Navigation walk +// --------------------------------------------------------------------------- + +/** + * Flattens docs.json navigation into ordered entries of either + * { kind: "page", slug, section } or { kind: "openapi", file, section } + * where `section` is the human label path, e.g. ["Guide", "Get started"]. + * Handles tabs, anchors, dropdowns, versions, languages, nested groups, `root`, and + * `openapi` groups — the shapes Mintlify accepts, whether or not this site uses them. + */ +function walkNavigation(node, section, entries) { + const containers = [ + ["tabs", "tab"], + ["anchors", "anchor"], + ["dropdowns", "dropdown"], + ["versions", "version"], + ["languages", "language"], + ["groups", "group"], + ]; + + for (const [listKey, labelKey] of containers) { + for (const child of node[listKey] ?? []) { + const label = child[labelKey]; + walkNavigation(child, label ? [...section, String(label)] : section, entries); + } + } + + if (typeof node.root === "string") { + entries.push({ kind: "page", slug: node.root, section }); + } + + for (const page of node.pages ?? []) { + if (typeof page === "string") { + entries.push({ kind: "page", slug: page, section }); + } else if (page && typeof page === "object") { + walkNavigation(page, page.group ? [...section, String(page.group)] : section, entries); + } + } + + if (typeof node.openapi === "string") { + entries.push({ kind: "openapi", file: node.openapi, section }); + } +} + +function resolvePageFile(slug) { + for (const extension of PAGE_EXTENSIONS) { + const candidate = path.join(root, `${slug}${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } + fail(`docs.json lists "${slug}" but no ${PAGE_EXTENSIONS.join(" or ")} file exists for it`); +} + +/** `index` and `foo/index` are served at `/` and `/foo`, matching Mintlify's routing. */ +function pageUrl(slug) { + if (slug === "index") { + return `${site}/`; + } + return `${site}/${slug.replace(/\/index$/, "")}`; +} + +/** Relative path of the page's Markdown twin inside the export, and its public URL. */ +function markdownPath(slug) { + return slug === "index" ? "index.md" : `${slug.replace(/\/index$/, "")}.md`; +} + +function markdownUrl(slug) { + return `${site}/${markdownPath(slug)}`; +} + +// --------------------------------------------------------------------------- +// Frontmatter +// --------------------------------------------------------------------------- + +function unquote(value) { + const trimmed = value.trim(); + const quoted = trimmed.match(/^"(.*)"$/s) ?? trimmed.match(/^'(.*)'$/s); + if (!quoted) { + return trimmed; + } + return trimmed.startsWith('"') ? quoted[1].replace(/\\"/g, '"').replace(/\\\\/g, "\\") : quoted[1]; +} + +function parseFrontmatter(source, filePath) { + const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!match) { + fail(`${path.relative(root, filePath)} has no frontmatter block`); + } + + const fields = {}; + for (const line of match[1].split("\n")) { + const keyMatch = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/); + if (keyMatch) { + fields[keyMatch[1]] = unquote(keyMatch[2]); + } + } + + if (!fields.title) { + fail(`${path.relative(root, filePath)} frontmatter has no title`); + } + + return { fields, body: source.slice(match[0].length) }; +} + +// --------------------------------------------------------------------------- +// MDX → Markdown +// --------------------------------------------------------------------------- + +function parseAttributes(attributeSource) { + const attributes = {}; + const pattern = /([A-Za-z][A-Za-z0-9-]*)=(?:"([^"]*)"|'([^']*)'|\{([^}]*)\})/g; + for (const match of attributeSource.matchAll(pattern)) { + attributes[match[1]] = match[2] ?? match[3] ?? match[4]; + } + return attributes; +} + +function absoluteUrl(target) { + if (target.startsWith("/") && !target.startsWith("//")) { + return `${site}${target}`; + } + return target; +} + +/** Applies `transform` to the parts of a line that are not inline code. */ +function outsideInlineCode(line, transform) { + return line + .split(/(`+[^`]*`+)/) + .map((part, index) => (index % 2 === 1 ? part : transform(part))) + .join(""); +} + +function transformInline(line) { + return outsideInlineCode(line, (text) => + text + // ... → ![...](https://site/images/x.png) + .replace(/]*?)\/?>/g, (_, attrs) => { + const { src = "", alt = "" } = parseAttributes(attrs); + return src ? `![${alt}](${absoluteUrl(src)})` : ""; + }) + // Markdown links and images with root-relative targets → absolute. Only the + // `](/…` tail is matched, because the link text may itself hold inline code. + .replace(/(\]\()(\/[^)\s]*)/g, (_, prefix, target) => `${prefix}${absoluteUrl(target)}`) + // href="/page" in any remaining HTML → absolute + .replace(/\bhref="(\/[^"]*)"/g, (_, target) => `href="${absoluteUrl(target)}"`), + ); +} + +function stripIndent(line, count) { + let removed = 0; + while (removed < count && line[removed] === " ") { + removed += 1; + } + return line.slice(removed); +} + +/** + * Line-oriented conversion. Mintlify components become plain Markdown: + * blockquote with a bold label (all callouts) + * "**Step n: title**" paragraphs + * , "**title**" paragraphs + * "**[title](href)**" paragraphs + * the content, then the caption in italics + * any other component removed; its content is kept + * Component bodies are indented two spaces per level in the source, so each open tag + * records how much to strip from the lines inside it. Fenced code is passed through + * untouched apart from that dedent, and inline code is never rewritten. + */ +function mdxToMarkdown(body) { + const out = []; + const stack = []; + let fence = null; + + const totalDedent = () => stack.reduce((sum, frame) => sum + frame.dedent, 0); + const quotePrefix = () => "> ".repeat(stack.filter((frame) => frame.quote).length); + + // Prose lines: trailing whitespace dropped, and never two blank lines in a row. + // Code lines (emitCode) are passed through exactly, apart from the dedent. + const emit = (text) => { + const prefix = quotePrefix(); + const trimmed = text.trimEnd(); + const rendered = trimmed === "" ? prefix.trimEnd() : `${prefix}${trimmed}`; + if (trimmed === "" && (out.length === 0 || out[out.length - 1] === rendered)) { + return; + } + out.push(rendered); + }; + + const emitCode = (text) => { + const prefix = quotePrefix(); + out.push(text === "" ? prefix.trimEnd() : `${prefix}${text}`); + }; + + const emitBlank = () => emit(""); + + for (const raw of body.replace(/\r\n/g, "\n").split("\n")) { + const line = stripIndent(raw, totalDedent()); + + if (fence) { + emitCode(line); + if (line.trimStart().startsWith(fence)) { + fence = null; + } + continue; + } + + const fenceMatch = line.match(/^\s*(```+|~~~+)/); + if (fenceMatch) { + fence = fenceMatch[1]; + emit(line); + continue; + } + + if (/^\s*\{\/\*.*\*\/\}\s*$/.test(line)) { + continue; + } + + const closing = line.match(/^\s*<\/([A-Z][A-Za-z0-9]*)>\s*$/); + if (closing) { + const frame = stack.pop(); + if (frame?.caption) { + emitBlank(); + emit(`*${frame.caption}*`); + } + emitBlank(); + continue; + } + + const opening = line.match(/^(\s*)<([A-Z][A-Za-z0-9]*)(\s[^>]*?)?\s*(\/?)>\s*$/); + if (opening) { + const [, indent, tag, attributeSource = "", selfClosing] = opening; + const attributes = parseAttributes(attributeSource); + const frame = { tag, dedent: indent.length + 2, quote: false, caption: null }; + + if (CALLOUTS.has(tag)) { + emitBlank(); + frame.quote = true; + stack.push(frame); + emit(`**${tag}**`); + emit(""); + } else if (tag === "Steps") { + frame.steps = 0; + stack.push(frame); + } else if (tag === "Step") { + const steps = [...stack].reverse().find((entry) => entry.tag === "Steps"); + const number = steps ? (steps.steps += 1) : null; + const title = attributes.title ?? ""; + stack.push(frame); + emitBlank(); + emit(number ? `**Step ${number}: ${title}**` : `**${title}**`); + emit(""); + } else if (tag === "Card") { + const title = attributes.title ?? ""; + const href = attributes.href ? absoluteUrl(attributes.href) : null; + stack.push(frame); + emitBlank(); + if (title) { + emit(href ? `**[${title}](${href})**` : `**${title}**`); + emit(""); + } + } else if (attributes.title) { + // Tab, Accordion, and anything else that carries a title + stack.push(frame); + emitBlank(); + emit(`**${attributes.title}**`); + emit(""); + } else { + frame.caption = attributes.caption ?? null; + stack.push(frame); + } + + if (selfClosing) { + stack.pop(); + emitBlank(); + } + continue; + } + + emit(transformInline(line)); + } + + return out.join("\n").trim(); +} + +// --------------------------------------------------------------------------- +// OpenAPI (a deliberately small reader — enough for a method/path/summary table) +// --------------------------------------------------------------------------- + +function readOpenApi(file) { + const filePath = path.join(root, file); + if (!fs.existsSync(filePath)) { + fail(`docs.json references missing OpenAPI file ${file}`); + } + + const lines = fs.readFileSync(filePath, "utf8").split("\n"); + const info = { title: "API", version: "", description: "" }; + const operations = []; + let currentPath = null; + let current = null; + let inInfo = false; + let descriptionIndent = null; + + for (const line of lines) { + if (descriptionIndent !== null) { + if (line.trim() === "" || line.startsWith(" ".repeat(descriptionIndent))) { + info.description += `${line.slice(descriptionIndent)}\n`; + continue; + } + descriptionIndent = null; + } + + if (/^info:\s*$/.test(line)) { + inInfo = true; + continue; + } + if (/^[A-Za-z]/.test(line)) { + inInfo = false; + } + + if (inInfo) { + const titleMatch = line.match(/^ title:\s*(.+)$/); + const versionMatch = line.match(/^ version:\s*(.+)$/); + if (titleMatch) info.title = unquote(titleMatch[1]); + if (versionMatch) info.version = unquote(versionMatch[1]); + if (/^ description:\s*[|>]-?\s*$/.test(line)) { + descriptionIndent = 4; + } + continue; + } + + const pathMatch = line.match(/^ (\/\S*):\s*$/); + if (pathMatch) { + currentPath = pathMatch[1]; + current = null; + continue; + } + + const methodMatch = line.match(/^ (get|put|post|delete|options|head|patch|trace):\s*$/); + if (methodMatch && currentPath) { + current = { method: methodMatch[1].toUpperCase(), path: currentPath, summary: "" }; + operations.push(current); + continue; + } + + const summaryMatch = line.match(/^ summary:\s*(.+)$/); + if (summaryMatch && current && !current.summary) { + current.summary = unquote(summaryMatch[1]); + } + } + + info.description = info.description.trim(); + return { file, info, operations }; +} + +// --------------------------------------------------------------------------- +// Assemble +// --------------------------------------------------------------------------- + +const entries = []; +walkNavigation(docs.navigation ?? {}, [], entries); + +if (entries.length === 0) { + fail("docs.json navigation produced no pages"); +} + +const pages = []; +for (const entry of entries) { + if (entry.kind === "page") { + const file = resolvePageFile(entry.slug); + const { fields, body } = parseFrontmatter(fs.readFileSync(file, "utf8"), file); + pages.push({ + ...entry, + file, + title: fields.title, + description: fields.description ?? "", + markdown: mdxToMarkdown(body), + }); + } else { + pages.push({ ...entry, ...readOpenApi(entry.file) }); + } +} + +function sectionLabel(section) { + return section.length > 0 ? section.join(" / ") : "Pages"; +} + +function pageDocument(page) { + const description = page.description ? `\n${page.description}\n` : ""; + return `# ${page.title}\nSource: ${pageUrl(page.slug)}\n${description}\n${page.markdown}\n`; +} + +function openApiDocument(page) { + const { info, operations, file } = page; + const heading = info.version ? `${info.title} (version ${info.version})` : info.title; + const table = + operations.length > 0 + ? [ + "| Method | Path | Summary |", + "|---|---|---|", + ...operations.map((op) => `| ${op.method} | \`${op.path}\` | ${op.summary} |`), + ].join("\n") + : ""; + const description = info.description ? `\n${transformInline(info.description)}\n` : ""; + return `# ${heading}\nSource: ${site}/${file}\n${description}\nThe complete OpenAPI document is at ${site}/${file}. Operations it defines:\n\n${table}\n`; +} + +function buildLlmsTxt() { + const lines = [`# ${siteName} documentation`, ""]; + + if (docs.description) { + lines.push(`> ${docs.description}`, ""); + } + + lines.push( + `This file indexes the ${siteName} documentation site at ${site}. Every page listed below is ` + + "also served as plain Markdown at the same URL with `.md` appended, and the whole site is " + + `concatenated into one Markdown file at ${site}/llms-full.txt.`, + "", + ); + + if (docs.banner?.content) { + lines.push(`Site notice: ${transformInline(docs.banner.content)}`, ""); + } + + let currentSection = null; + for (const page of pages) { + const label = sectionLabel(page.section); + if (label !== currentSection) { + if (currentSection !== null) { + lines.push(""); + } + lines.push(`## ${label}`, ""); + currentSection = label; + } + + if (page.kind === "page") { + const description = page.description ? `: ${page.description}` : ""; + lines.push(`- [${page.title}](${markdownUrl(page.slug)})${description}`); + } else { + const count = page.operations.length; + const summary = count > 0 ? `${count} operations, ` : ""; + lines.push( + `- [${page.info.title} OpenAPI specification](${site}/${page.file}): ${summary}the HTTP API as an OpenAPI document`, + ); + } + } + + const optional = []; + if (productRepo) { + optional.push(`- [${siteName} source code](${productRepo}): the product these docs describe`); + } + if (docsRepo) { + optional.push(`- [Documentation source](${docsRepo}): the repository this site is built from`); + } + if (optional.length > 0) { + lines.push("", "## Optional", "", ...optional); + } + + return `${lines.join("\n")}\n`; +} + +function buildLlmsFullTxt() { + const header = [`# ${siteName} documentation`, ""]; + if (docs.description) { + header.push(`> ${docs.description}`, ""); + } + header.push( + `The full text of every page on ${site}, in navigation order. Each page starts with a ` + + "level-one heading and a Source line giving its canonical URL.", + "", + ); + if (docs.banner?.content) { + header.push(`Site notice: ${transformInline(docs.banner.content)}`, ""); + } + + const sections = pages.map((page) => (page.kind === "page" ? pageDocument(page) : openApiDocument(page))); + return `${header.join("\n")}\n${sections.join("\n")}`; +} + +// --------------------------------------------------------------------------- +// Write / check +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const checkOnly = args.includes("--check"); +const distIndex = args.indexOf("--dist"); +const dist = distIndex === -1 ? null : args[distIndex + 1]; + +if (distIndex !== -1 && !dist) { + fail("--dist needs a directory argument"); +} + +const outputs = new Map([ + ["llms.txt", buildLlmsTxt()], + ["llms-full.txt", buildLlmsFullTxt()], +]); + +if (checkOnly) { + const stale = []; + for (const [name, content] of outputs) { + const filePath = path.join(root, name); + if (!fs.existsSync(filePath) || fs.readFileSync(filePath, "utf8") !== content) { + stale.push(name); + } + } + if (stale.length > 0) { + console.error(`llms check failed: ${stale.join(" and ")} out of date — run \`npm run llms\` and commit the result.`); + process.exit(1); + } + console.log(`llms check passed (${pages.length} entries).`); + process.exit(0); +} + +for (const [name, content] of outputs) { + fs.writeFileSync(path.join(root, name), content); +} +console.log(`Wrote llms.txt and llms-full.txt (${pages.length} entries).`); + +if (dist) { + const distRoot = path.resolve(root, dist); + if (!fs.existsSync(distRoot)) { + fail(`${dist} does not exist — run the export first`); + } + + let written = 0; + for (const [name, content] of outputs) { + fs.writeFileSync(path.join(distRoot, name), content); + written += 1; + } + + for (const page of pages) { + if (page.kind === "page") { + const target = path.join(distRoot, markdownPath(page.slug)); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, pageDocument(page)); + } else { + const target = path.join(distRoot, page.file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.copyFileSync(path.join(root, page.file), target); + } + written += 1; + } + + console.log(`Wrote ${written} files into ${path.relative(root, distRoot) || "."}/.`); +}