.app bundle by scripts/build-app.sh. It has one dependency — sentry-cocoa, pinned in Package.resolved and linked statically — and no .xcodeproj.
Two requests can leave the app, and both are yours to switch off. The update check in Engine/UpdateCheck.swift is a GET for a static file. Crash reporting in Engine/CrashReports.swift is off by default and not merely quiet: the SDK is never started until you turn it on.
How a scan works
AppModel.scan goes through the rule list and evaluates each rule off the main actor, so the progress bar stays accurate. Then it measures the posture numbers once and adds a Scan record.
A shell rule’s check is appended to DefaultRules.prelude and run with /bin/bash -c, with a 25-second timeout. The prelude provides helper functions, which keeps each probe short enough to read and edit in the app.
Probe helpers
Exit codes
mb_na is the helper for the third one. A control for data you do not have is not a failure, and marking it one teaches people to ignore the failures that are real.
The probe’s stdout and stderr become the evidence shown in the app.
Detecting blank cheques
mb_subsuming exists because matching the exact strings the doctrine names is not enough. Bash(sudo sqlite3:*), Bash(timeout 30 python3:*) and Bash(/usr/bin/python3 -c ' *) are all blank cheques, and none of them match ^Bash\(sqlite3:\*\)$.
The helper splits each entry into tokens and finds the first token whose basename is an interpreter. Then it checks what follows. A bare *, a trailing :*, -c, -e or stdin all count as blank cheques. A spelled-out command does not, because N5 explicitly tells you to keep a few of those.
Fixes for these rules use the same detector instead of a regex of their own. An earlier version used a separate pattern, and its sh alternative matched the sh in ./build.sh. That removed a grant that was never a blank cheque. Matching the whole basename keeps build.sh, du -sh and node --version out. Sharing one detector also means a probe can’t report a finding its own fix can’t fix.
Scoring
A rule’s severity is its weight: critical 10, high 6, medium 3, low 1. For each group of rules,possible is the total weight of every enabled rule. earned is the sum of credit × weight, where credit is 1.0 for holding, 0.5 for partial and 0 otherwise. Disabled rules are left out of the total, so turning a rule off lowers the maximum score instead of inflating your score.
The same function scores the whole pack, a single invariant or a single phase.
Drift
Each scan records:- SHA-256 prefixes of
settings.json,settings.local.json, the guard hook andCLAUDE.md - The size and modification time of the T0 SQLite file triplet (the database plus its
-waland-shmfiles) - The number of allow, deny and ask entries
- A rule that was holding stopped holding.
- A config file’s fingerprint changed.
- A file disappeared.
- The T0 store changed.
p2.t0-movement-attributable rule goes further. It uses lsof to check whether any process has the original open. That separates growth while the recorder is running from growth with no process attached.
Persistence
Mockingbyrd stores plain JSON under~/Library/Application Support/Mockingbird/:
Three files live under
~/.config/mockingbird/ instead, because the guard reads them on every call and must not depend on the app running:
Keeping the pack current
rules.json is a copy of the built-in pack, and a copy can outlive the build that wrote it: a new probe ships, the old probe keeps running, and the app reports a posture measured by rules it no longer ships.
The pack carries a fingerprint — every built-in rule’s id, probe and fix script — recorded in pack-fingerprint.json alongside a per-rule digest. When the fingerprint moves, the pack merges:
- a built-in whose digest still matches is replaced by the shipped version;
- a built-in whose digest differs was edited by hand, so it is kept and named in the activity log;
- custom rules carry across untouched;
- the previous file is copied to
rules.json.premigration-<stamp>.bakfirst.
rules.json.unreadable-<stamp>.bak first and the log says where it went. Losing rules somebody wrote, quietly, is worse than any posture number being wrong.
Settings → Restore built-in pack still exists, for putting everything back to the doctrine as written. You no longer need it after an update.
The projection scope
The P2 projection is the read-only copy of screen-capture data an Analyst session may open. It is the only control in the doctrine that reduces what actually leaves your Mac, which is the reason to take it seriously. What it carries is a setting, not a default, because the right answer depends on what you want to ask it.Projection.script(_:) generates ~/agent/bin/projection-narrow.py from your choice, with a # mockingbird-scope: marker and one policy line per table. The p2.projection-narrowed rule compares that marker against your setting and checks the script still drops audio and secrets and still refuses unknown tables.
Why a script rather than a list of SQL statements
The capture schema moves. The doctrine was written against tables that a current screenpipe no longer has, and a fixed list aimed at tables that are gone either fails outright or narrows nothing. The generated script reads the schema in front of it and:1
Drops every full-text index first
An fts5 index keeps its own copy of what it indexes. Narrowing the base tables and leaving the indexes behind would ship the text twice.
2
Applies one policy per table
keep, frame-text (keep the row, blank the text columns), by-app, by-frame-app, unattributed (text with no app behind it, so it cannot satisfy an allow-list), or drop.3
Clears browser_url everywhere, in every scope
A URL carries paths, query strings and tokens.
4
Refuses to build when it meets a table it has no rule for
Exit 3, naming the table. A screenpipe upgrade cannot widen your projection silently.
Audio is always dropped. Transcriptions, meetings, speakers and diarization never reach a projection in any scope, because what was said near your Mac includes people who never agreed to be recorded. Secrets, feedback and pipe output are dropped for the same reason.
scripts/check-projection.sh runs all three scopes against a fixture on the current schema and asserts what survives: 46 checks, taking the narrowing from --projection-script, so the file the app installs and the file under test are the same file. scripts/projection-narrow.example.py is that output for the widest scope, kept in the repo so you can read the policy without running anything.
The two dashboards
Both read what is already on disk, and neither makes a network request.Agent Roster
Who can reach what. Rows come from config, entitlements, TCC and what is actually running.
Token Ledger
Token use and API-equivalent spend, by model, project and week.
Colour
Every hex in the app lives inDesign/Theme.swift and carries the token it implements. The rest of the app asks for a name, never a value. Three rules from the colour guide are structural rather than cosmetic:
- Warm neutrals, never cool. The system has no blue, so there is no
infocolour. “Declared, not confirmed” in the roster reads as drift, which is what it is. - Status colours encode state only. Holding green, partial amber and open coral are never borrowed for decoration. The charts, the token mix and the tier diagram run on a separate warm series ramp added for exactly that purpose.
- Depth comes from value and a hairline, not shadow. Semantic surfaces follow a 12% fill, 45% border, solid text recipe.
One deliberate deviation
The guide’s--text-muted is 3.68:1 on canvas — AA for large text only — and the app spends that token entirely on 8–12pt meta text across roughly 99 sites.
Raising all of them to --text-secondary would have deleted the third type level, so the token moved instead. The new value keeps a visible step under secondary and clears 4.5:1 on canvas, surface, raised and hover. The guide’s literal value survives as inkMutedLarge, for text at 18pt and above and for marks, which need 3:1 rather than 4.5:1.
The lowest text pair in the app is 4.67:1. Every word clears AA.
Build
scripts/make-icon.swift finds the accent-colored glyph in the brand logo and removes its white background using the blue channel. A plain crop would include the “D” from the wordmark. The script then renders the iconset and a transparent mark for the sidebar.