Skip to main content
Mockingbyrd is a single SwiftPM executable target, packaged into an .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 and CLAUDE.md
  • The size and modification time of the T0 SQLite file triplet (the database plus its -wal and -shm files)
  • The number of allow, deny and ask entries
Comparing two scans in a row can produce four kinds of event:
  1. A rule that was holding stopped holding.
  2. A config file’s fingerprint changed.
  3. A file disappeared.
  4. The T0 store changed.
The 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>.bak first.
A pack that will not decode is a separate case. The built-ins have to take over for the app to run at all, but the file is copied to 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.
An allow-list matters more than it looks. A deny-list only covers the apps you thought of, and browser-based banking and password pages are captured under the browser’s own name — so an app-name deny-list never sees them. 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 in Design/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 info colour. “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.
Intuitive mode is the one place decoration spends a status colour: navigation tiles take their tint from where they sit rather than from any state. A navigation surface has no status of its own, so it can carry it.

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.