Declarative macOS defaults with drift detection - #57
Draft
nonrational wants to merge 24 commits into
Draft
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #8
Problem
.macoswas 833 lines of one-way imperative script: 218defaults writestatements 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.macosexplicitly disables it.Motivation
Two of those six were the kind of thing you only find by looking.
.macosdisables 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 -1writes 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-defaultsis a tab-delimited table, one row per setting: domain, key, type, value, optional status.scripts/macos-defaults.shdrives it with five modes —doctor,check,audit,apply,accept..macoskeeps only what has no domain/key/value shape (nvram,systemsetup,PlistBuddy,chflags,lsregister,tmutil, thekillallloop) 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.complexalways skips, because a container value has no comparable scalar form.tccandunsetskip 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-doctorverifies it andmake macosdepends 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.shgenerated the table and is committed so the transcription is reviewable rather than trusted. 51 tests sandboxdefaultsagainst absolute plist paths undermktemp, so nothing touches a real preference; parser tests run on both CI legs,defaults-backed tests skip off Darwin.check-macos-defaultsjoinspreflight;macos-doctordeliberately does not, sincepreflightruns on ubuntu.Design, probe numbers, and parked decisions:
docs/superpowers/specs/2026-08-25-macos-defaults-declarative-design.md.Feedback
evalpaths.write_rowevals container rows because anarray/dictvalue has no type/value form; the generator evals eachdefaults writeline out of.macoswithdefaultsandsudoshimmed. The argument is that both sit at the same trust level assh .macos. Worth a second opinion.AppleLocaleandShowCategoryaccepted,mouse.scalingreapplied, two trackpad rows accepted as type-only changes, andHomePage/AutoFillPasswords/OpenMainWindowrestored to what.macosdeclares. 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.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..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.