Skip to content

Declarative macOS defaults with drift detection - #57

Draft
nonrational wants to merge 24 commits into
mainfrom
macos-defaults-declarative
Draft

Declarative macOS defaults with drift detection#57
nonrational wants to merge 24 commits into
mainfrom
macos-defaults-declarative

Conversation

@nonrational

Copy link
Copy Markdown
Owner

Resolves #8

Problem

.macos was 833 lines of one-way imperative script: 218 defaults write statements with no record of desired state. Nothing detected when macOS or an application rewrote a setting after an OS update, a click in System Settings, or an app's own housekeeping. A read-only probe of every one of those statements against a converged machine found six settings already untrue, including mouse acceleration switched back on and Safari password autofill enabled where .macos explicitly disables it.

Motivation

Two of those six were the kind of thing you only find by looking. .macos disables mouse acceleration; the machine had it on, and had for an unknown length of time. Safari's homepage had reset to Apple's start page. Neither would ever have surfaced, because a write-only script has nothing to compare against.

Worse, one of them could not have been fixed by re-running .macos: defaults write .GlobalPreferences com.apple.mouse.scaling -1 writes the string -1, which macOS ignores, while the live key is a float. The script had been reporting success and changing nothing.

Proposed Solution

macos-defaults is a tab-delimited table, one row per setting: domain, key, type, value, optional status. scripts/macos-defaults.sh drives it with five modes — doctor, check, audit, apply, accept. .macos keeps only what has no domain/key/value shape (nvram, systemsetup, PlistBuddy, chflags, lsregister, tmutil, the killall loop) and drops from 833 lines to 70.

A noaudit= status records why a row might not be readable rather than a standing decision to ignore it. complex always skips, because a container value has no comparable scalar form. tcc and unset skip only when the key will not read, so they are audited whenever they can be.

Full Disk Access is now a setup prerequisite. Without it a shell cannot read Safari's or Mail's preferences, and 39 rows skip instead of being checked. make macos-doctor verifies it and make macos depends on it, so a fresh Mac fails fast with the remediation path instead of applying settings and silently skipping a fifth of the table.

scripts/migrate-macos-defaults.sh generated the table and is committed so the transcription is reviewable rather than trusted. 51 tests sandbox defaults against absolute plist paths under mktemp, so nothing touches a real preference; parser tests run on both CI legs, defaults-backed tests skip off Darwin. check-macos-defaults joins preflight; macos-doctor deliberately does not, since preflight runs on ubuntu.

Design, probe numbers, and parked decisions: docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md.

Feedback

  • The two eval paths. write_row evals container rows because an array/dict value has no type/value form; the generator evals each defaults write line out of .macos with defaults and sudo shimmed. The argument is that both sit at the same trust level as sh .macos. Worth a second opinion.
  • The accept-versus-reapply calls. Six drifts were resolved individually, not in bulk: AppleLocale and ShowCategory accepted, mouse.scaling reapplied, two trackpad rows accepted as type-only changes, and HomePage / AutoFillPasswords / OpenMainWindow restored to what .macos declares. That last group reverses an earlier call — accepting them made three rows contradict the comment directly above them. Applying it turns Safari password autofill back off.
  • The os=/host= vocabulary now works, and is about to matter. These dotfiles run on a second Mac and are upstream for agent config on a remote Linux box, so per-host and per-OS rows are coming. Two defects in that path were fixed before merge: the duplicate-key validator used to reject two rows sharing a domain and key under different conditions, which is the entire point of the vocabulary, and the audit summary did not count condition-mismatched skips, so its numbers did not sum to the row count. Same-condition duplicates are still rejected, because both would apply on one machine and whichever lost would drift forever.
  • Not covered, by design: the 12 container rows, the ~24 imperative lines left in .macos, and anything the machine has that the table does not declare. A green audit means everything declared is true, not that the machine is fully described.

nonrational and others added 24 commits August 25, 2026 10:02
Records the design for issue #8. A read-only probe of all 217 `defaults
write` lines in .macos against this machine found four settings already
untrue, including mouse acceleration being on when .macos disables it.

Key constraints the probe surfaced:

- 40 rows (Safari, Mail) live in TCC-protected containers and cannot be
  audited from a shell without Full Disk Access, which CI can never have.
- Two keys contain spaces, so the table must be tab-delimited rather than
  whitespace-columned like `manifest`.
- Bash treats tab as IFS whitespace, so `IFS=$'\t' read` collapses empty
  columns and shifts every field left. The probe hit this and reported 215
  of 217 keys missing before the bug was found.
- Root-owned /Library/Preferences plists are world-readable, so audit
  never needs sudo. Only apply does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Seven tasks, each with its own tests and commit. Tasks 1-4 build the
parser and the check/audit/apply/accept modes against a sandbox that
points `defaults` at absolute plist paths under mktemp, so nothing touches
a real preference. Task 5 generates the 217-row table from .macos, Task 6
seeds it from this machine, Task 7 strips .macos and wires the Makefile.

Two deliberate deviations from the spec, both noted in the tasks that make
them: os=/host= conditions are implemented rather than merely parsed, since
a status the parser accepts and then ignores would apply an os=Linux row on
Darwin; and the accept filter is [domain [key]] rather than a list of pairs,
because expanding an empty array under set -u is an error in bash 3.2.

All 47 bash blocks pass bash -n under 3.2, and Task 1's script passes its
own 13 tests verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parses the tab-delimited table and validates it, with no machine access
yet. Two departures from deploy.sh's parser, both tested: a # only starts
a comment at the start of a line, and rows are split by parameter
expansion rather than IFS=$'\t' read, which collapses tab runs.
Compares each row against the live machine. Four rules the naive string
compare gets wrong, each with a test: booleans normalize (defaults stores
0/1, the table reads true/false), a changed storage type is drift, an
absent key is missing rather than drift, and noaudit rows never reach the
exit code.

os= and host= conditions are implemented rather than merely parsed. The
spec reserved them as unused, but a status the parser accepts and then
ignores would apply an os=Linux row on Darwin.
Writes only rows whose live value differs, except noaudit rows, which are
always written since audit cannot tell whether they need it.

sudo is chosen from the plist's writability, not the path prefix, so the
sandboxed tests never prompt. Container rows carry a literal argument tail
and are the only ones eval'd; an array value has no type/value form.
Rewrites a row's value and type from what is live, and clears a
noaudit=unset marker once the key reads. Rewriting the file wholesale means
comments and blank lines have to survive intact, which is tested: those
comments are the why carried over from .macos.

Also strengthens test_apply_writes_noaudit_rows: it previously wrote an
absent key, which an ordinary row would write too, so it did not prove
apply writes noaudit rows unconditionally. It now pre-sets a live value
that matches the table, so only the unconditional write path reports
write: instead of ok:.
run_accept had no branch excluding tcc/complex rows from the rewrite
decision. A tcc key never reads, so that path was inert, but a complex row's
value column is an eval argument tail that defaults_read can never match,
so the comparison always differed and accept rewrote it with a multi-line
plist dump, splicing newlines into a one-row-per-line file and breaking
every row after it. Adds accept_candidate to exclude both statuses before
the compare; noaudit=unset promotion is unaffected since it does not carry
either marker.

Also points the run_accept temp file at $TABLE.XXXXXX instead of $TMPDIR,
so the final mv is a same-filesystem rename rather than a copy-then-unlink
that could leave a partial table on a mid-copy crash.

Covers both regressions with new tests. The wc -l check in the complex-row
test uses -eq rather than =, since BSD wc pads its count with leading
spaces even through a redirect, which would fail a string comparison
against a bare 1 regardless of correctness.
The design probe grepped only matching lines into a scratch file before
sourcing it, so the trailing backslash on .macos:342 (FXInfoPanesExpanded
-dict) joined it to the next matching statement rather than to its own
continuation lines. Two statements merged into one and the probe
undercounted by one.

The swallowed row is com.apple.dock mouse-over-hilite-stack, which reads
back as a boolean 1 and matches what .macos declares, so it lands in the
healthy bucket: auditable-and-matching goes 153 -> 154 and rows carrying no
status go 157 -> 158. The marker counts are unchanged at tcc 40, unset 9,
complex 11.

The real generator walks the file itself and joins true continuations, so
it was always going to emit 218; only the plan's expected numbers were
wrong, and they would have halted the migration task on a false alarm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 rows transcribed mechanically, with each setting's comment carried
across. Container types get noaudit=complex; keys that will not read get
noaudit=tcc when the whole domain is unreadable and noaudit=unset when only
the key is absent.

.macos is untouched here, so the two files declare the same settings until
the next commit strips it.
The generator classifies a row by whether its DOMAIN reads, which is the
test that actually separates the two cases. The design probe instead
checked `defaults domains`, which lists com.apple.TextEdit even though
`defaults read com.apple.TextEdit` fails.

TextEdit (3 rows) and addressbook (1) own TCC container directories and
fail the domain read, so they belong with Safari and Mail rather than in
the unset bucket. Verified per row: all five remaining unset rows sit in
domains that read fine and are missing only the key.

Totals are unchanged — 60 rows carry a marker either way — so no audit
expectation moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ive path

The generator's eval-based parser expanded ${HOME} while transcribing
.macos, baking this machine's home directory into two rows of a table
meant to describe desired state. The generator now rewrites the
machine's home directory back to the literal ${HOME} token, and the
applier expands it at comparison and write time (audit, apply, accept)
so the table stays portable across machines.

Adds three tests covering audit, apply, and accept against a
${HOME}-tokenized row.
The probe compared values only, never storage types, so it missed that the
trackpad pane rewrote FirstClickThreshold and SecondClickThreshold as
booleans where .macos writes -int 1. Same effective value, different
storage, and the type check added with audit reports it.

Both are accepted rather than reapplied: the effective setting is already
what .macos asked for, and writing -int back invites the pane to rewrite it
again, turning the row into recurring noise in an audit whose worth depends
on not crying wolf.

Drift is 6 rather than 4 and the healthy core 152 rather than 154. The
post-seed total is unchanged at 158 ok.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run_accept compared the live value against the table's raw ${t_value},
which still holds the literal ${HOME} token for the two tokenized rows.
That made accept report a spurious change on every unfiltered run even
when the row already matched the machine, undermining the "accept
leaves matching rows alone" contract. Expand the table value with
expand_value in that comparison, matching audit_row and apply_row.

Adds a test asserting accept prints no accept: line and leaves the
table byte-identical when a tokenized row already matches.
audit_row checks the stored type via defaults_read_type and reports a
mismatch as drift, but apply_row only ever compared the normalized
value. A row whose value matched but whose storage type had drifted
left apply reporting ok while audit kept reporting the same drift, an
unresolvable loop short of running accept the wrong way round.

Adds type_matches, exempting raw rows (no type claim) and unreadable
types (not a mismatch) the same way audit_row already does, and wires
it into apply_row's match check alongside the existing value
comparison.

Adds a test where the value already matches and only the type differs,
confirmed to fail against the prior code before the fix landed.
Three drifting rows take the machine's value: AppleLocale (macOS
canonicalized the currency code) and two Activity Monitor keys the app
rewrites itself. Two Trackpad threshold rows take the machine's type,
int -> bool, since that is what defaults actually stores there.

The sixth, .GlobalPreferences com.apple.mouse.scaling, goes the other
way -- the table disables mouse acceleration and the machine had turned
it back on, which is the drift this change exists to catch. Retyped
that row from raw to float first: a raw write stores the string "-1"
where the live key is a float, so the write would have been silently
inert and audit would have gone green on an unchanged machine.

audit now exits 0: 158 ok, 60 skipped. TCC probe (Step 6) and the spec
write-up (Step 7) are deliberately withheld pending owner approval.
TCC visibility is a property of which terminal is running, not of the
row: with Full Disk Access granted, 40 of the table's 44 tcc rows read
fine and were being silently skipped, hiding real drift (e.g. Safari
password autofill). noaudit=complex still skips unconditionally since
a container value has no comparable scalar form; tcc and unset now
skip only when the key fails to read.

Add a doctor mode that reports whether this terminal has Full Disk
Access, and have audit print a hint when tcc rows were skipped for
lack of it.
Four rows (addressbook ABShowDebugMenu, TextEdit RichText/PlainTextEncoding/PlainTextEncodingForWrite) still fail to read with Full Disk Access granted, so tcc was never the blocker; their container directories hold no preference domain yet, so unset is accurate.

Safari ProxiesInBookmarksBar moves from tcc to complex: defaults read returns its empty array as a multi-line rendering that can never match the scalar () in the value column, so audit would report permanent drift. Keeping type=raw is deliberate — it reproduces .macos's untyped write of this key exactly; typing it as array would pass the literal string () as an array element instead of creating an empty array.

Two Safari rows (HomePage, AutoFillPasswords) also newly show real drift under the tcc/unset audit change, but accept refuses to touch any noaudit=tcc row by design (ae36e04, still true per the passing test "accept leaves a noaudit=tcc row untouched even when the key reads") - not resolved here.
accept_candidate excluded noaudit=tcc rows unconditionally, on the same
guard as noaudit=complex. That guard is only valid for complex: a
container value's argument tail has no scalar form defaults read could
ever match, so accepting one would splice a multi-line plist dump into
the table. A tcc row is an ordinary scalar whenever it reads at all —
the exclusion was only ever correct back when tcc rows were assumed
unreadable. Since audit now checks readable tcc rows, leaving accept
unable to resolve the drift it reports was a dead end: audit flags it,
accept refuses it, audit flags it again.

Narrow the guard to noaudit=complex. accept keeps the noaudit=tcc
marker when it writes a new value, since it still records why the row
may be unreadable on a different Mac; run_accept already only clears
noaudit=unset.

Resolve the two real Safari rows this unblocked: HomePage had drifted
to Apple's start page, and AutoFillPasswords was on where the table
asks for it off.
make macos now depends on macos-doctor so a fresh Mac fails fast with the
remediation path instead of applying settings and silently skipping 39 rows
it could not read. macos-doctor deliberately stays out of preflight: that
target runs on Linux in CI and must not call defaults.

Also drops two steps that assumed --remainder needed hand-tidying. Reading
all 65 lines of its output showed it already preserves the shebang, keeps
each comment with its own statement, and drops exactly the banners whose
settings moved to the table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four rows held 0/1 from a prior accept and one held YES from the .macos
generator, breaking the table's stated convention that booleans read
true/false. Add canonical_value() so accept normalizes the live value
before writing it back, and canonicalize bool values in the migration
script's emit() so future generation stays consistent. Fix the five
existing rows by hand.
.macos drops to its imperative remainder: nvram, systemsetup, PlistBuddy,
chflags, lsregister, tmutil and the app restarts. Everything with a
domain/key/value shape now lives in macos-defaults.

make macos-audit is the one meant for casual use and needs no sudo; root
owned plists under /Library/Preferences are world-readable, so only apply
ever reaches for it. check-macos-defaults joins preflight, so CI picks it
up on both legs without an ci.yml edit.

Co-Authored-By: Claude Sonnet 5 (1M context) <noreply@anthropic.com>
Restore three rows that drifted opposite their own comments (Safari
AutoFillPasswords, Safari HomePage, ActivityMonitor OpenMainWindow) and
reapply them to the machine. Fix accept's frozen type column on marked
rows, tighten the container-row marker validator to noaudit=complex only,
and make accept refuse live values it cannot represent (tab, newline, or
empty) instead of writing an unparseable table. Add an audit summary line
with ok/drift/missing/skipped counts. Correct the design spec's stale
probe numbers, FDA paragraph, and open-risk section. Add duplicate
domain+key detection (dict-add exempt), which caught and fixed a real
duplicate dock row; align accept --dry-run's would: prefix with apply;
note the discard risk in migrate-macos-defaults.sh's header; and
re-break a 1033-character collapsed comment line back into readable form.
…audit summary

Two rows sharing a domain+key are only a real collision when they'd both
apply on the same machine: same os= or host= condition, or both bare/noaudit
(which carry no condition). Different os=/host= conditions on the same key
is the whole point of the condition vocabulary and must keep parsing.

The audit summary was also missing a counter for rows skipped by condition
mismatch, so its skipped total silently under-reported once condition rows
existed.
…ative

* origin/main:
  Author PRs as atomic commit series and preserve them through review (#56)
  don't commit gemini config json
  Add code-comment-register skill
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Declarative macOS defaults (desired-state + drift) replacing imperative .macos [find-inspiration:macos-defaults-declarative]

1 participant