Skip to content

Harden macOS setup: package tiers, drift detection, safer stow - #1

Merged
jpmontez merged 2 commits into
mainfrom
macos-setup-hardening
Aug 8, 2026
Merged

Harden macOS setup: package tiers, drift detection, safer stow#1
jpmontez merged 2 commits into
mainfrom
macos-setup-hardening

Conversation

@jpmontez

@jpmontez jpmontez commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Context

The Brewfile had drifted badly from reality: brew bundle check failed on 7 of 13 entries, while ~20 formulae, 7 casks, and 15 App Store apps were installed and unlisted. Missing dockutil meant defaults.sh silently skipped its entire Dock section, and several configs referenced packages the Brewfile never installed.

Three latent failure modes also needed closing — details below.

Changes

Package tiers. Brewfile (core, every machine) + Brewfile.personal (media, games, creative). bootstrap.sh asks once whether this is a personal machine and records the answer in .machine (gitignored); --personal/--no-personal override it, unattended runs default to core-only. Two casks were renamed upstream: logi-options-pluslogi-options+, xcodesxcodes-app.

macos/defaults.sh rebuilt around a declarative table. One SETTINGS table walked by both apply and a new --check mode, so drift detection cannot fall out of sync with what gets written. Adds a Darwin guard, sudo keep-alive, and skips Dock entries whose app doesn't exist on this macOS version instead of aborting mid-run. New coverage: keyboard and text substitution (press-and-hold off so key repeat works in nvim), Finder, screenshots, and security — Touch ID for sudo via /etc/pam.d/sudo_local, and the application firewall. FileVault is reported only, never enabled, since it generates a recovery key a human has to record.

macos/login-items.sh. Registration extracted and reused for SizeUp, Mullvad VPN, Amphetamine, and Ice, retiring the manual System Settings step. A denied Automation prompt now warns instead of failing silently.

doctor.sh. Read-only drift report across packages (both directions, tier-aware), stow links, macOS defaults, submodules, and environment. Exits 1 on drift.

CI. zsh syntax checks, plist linting, and a macOS job validating both Brewfiles, plus permissions, concurrency, and a SHA-pinned checkout.

Failure modes fixed

  • bootstrap.sh could die silently. The brew-shellenv fallback loop's trailing && chain made set -e kill the script with no output when neither standard brew path existed. Added an ERR trap, an Xcode CLT check, nullglob around the prezto globs, and non-fatal brew bundle so a missing App Store sign-in no longer aborts before anything is stowed.
  • ~/.ssh was a symlink into this repo. Stow had folded the whole directory, so any key or known_hosts written there — including by the ssh-keygen step the README recommends — landed in the git working tree. Now stowed --no-folding as a real 700 directory containing only a symlinked config. ssh is stowed first and alone so an unrelated conflict can't leave ~/.ssh deleted between the unfold and the restow.
  • The claude package had never been stowed. ~/.claude/settings.json was a regular file that had diverged; adopted into the repo, which adds swift-lsp, "model": "opus", and an afplay notification hook in place of the cross-platform one.

Verification

shellcheck, zsh -n, plutil -lint, and brew bundle list all clean. Tier resolution verified across all three paths (flag / recorded / non-tty default), as was the ~/.ssh migration and the non-fatal brew bundle path. defaults.sh --check and doctor.sh both correctly report the machine's outstanding drift and exit 1.

Two settings are best-effort and may still show as drift after applying — the screensaver password keys (partly system-managed since Ventura) and socketfilterfw (deprecated by Apple). Both are flagged in comments.

No packages were installed and no system defaults applied on the local machine; that's left to run deliberately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01GtdF61oBMtp6qaMMWDTF73

jpmontez and others added 2 commits August 8, 2026 13:21
The Brewfile had drifted badly from reality — `brew bundle check` failed
on 7 of 13 entries, while ~20 formulae, 7 casks, and 15 App Store apps
were installed and unlisted. Missing dockutil meant defaults.sh silently
skipped its entire Dock section, and configs referenced packages the
Brewfile never installed (git >= 2.38 for .gitconfig, ripgrep/fd for
nvim, terminal-notifier for the alias).

Split the inventory into Brewfile (core, every machine) and
Brewfile.personal (media, games, creative). bootstrap.sh asks once
whether this is a personal machine and records the answer in .machine
(gitignored); --personal/--no-personal override it and unattended runs
default to core-only. Two casks were renamed upstream: logi-options-plus
-> logi-options+ and xcodes -> xcodes-app.

Rebuild macos/defaults.sh around a single SETTINGS table walked by both
apply and a new --check mode, so drift detection cannot fall out of sync
with what gets written. Adds a Darwin guard, sudo keep-alive, and skips
Dock entries whose app does not exist on this macOS version rather than
aborting mid-run. New settings cover keyboard and text substitution
(press-and-hold off so key repeat works in nvim), Finder, screenshots,
and security — Touch ID for sudo via /etc/pam.d/sudo_local and the
application firewall. FileVault is reported only, never enabled, since
it generates a recovery key a human has to record.

Extract login-item registration into macos/login-items.sh and use it for
SizeUp, Mullvad VPN, Amphetamine, and Ice, retiring the manual System
Settings step. A denied Automation prompt now warns instead of failing
silently.

Add doctor.sh: a read-only drift report across packages (both
directions, tier-aware), stow links, macOS defaults, submodules, and
environment. Exits 1 on drift.

bootstrap.sh also gains flag parsing, an ERR trap (a mid-script failure
previously exited with no output at all), an Xcode CLT check, nullglob
around the prezto runcoms globs, and a fix for the brew shellenv
fallback loop, whose trailing && chain made `set -e` kill the script
when neither standard brew path existed. `brew bundle` failures are now
non-fatal so a missing App Store sign-in no longer aborts before
anything is stowed.

Stow ssh with --no-folding, migrating ~/.ssh from a symlink into this
repo to a real 700 directory containing only a symlinked config. As a
folded symlink, any key or known_hosts written there — including by the
ssh-keygen step the README recommends — landed in the git working tree.
ssh is stowed first and alone so an unrelated conflict cannot leave
~/.ssh deleted between the unfold and the restow.

Adopt the live ~/.claude/settings.json into the claude package. That
file was a regular file, not a symlink, so the package had never
actually been stowed and had diverged: it adds swift-lsp, "model":
"opus", and an afplay notification hook in place of the cross-platform
one. README updated accordingly.

Expand CI with zsh syntax checks, plist linting, and a macOS job
validating both Brewfiles, plus permissions, concurrency, and a
SHA-pinned checkout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GtdF61oBMtp6qaMMWDTF73
CI's shellcheck flagged SC2015 on the stow check: `cd "$DOTFILES_DIR" &&
stow ... || true` swallows a failed cd as well as a failed stow, so an
unreadable repo directory would have reported every package as fine.
Pass --dir to stow instead, dropping the cd and the && chain entirely.

The rule never fired locally: ubuntu-latest ships an older shellcheck
whose SC2015 heuristic differs from 0.11.0, so a locally-clean script
failed in CI. Pin the CI version to match what the Brewfile installs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GtdF61oBMtp6qaMMWDTF73
@jpmontez
jpmontez merged commit ba50824 into main Aug 8, 2026
2 checks passed
@jpmontez
jpmontez deleted the macos-setup-hardening branch August 8, 2026 17:27
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.

1 participant