diff --git a/.macos b/.macos index 227589cd82..6e44bb761d 100755 --- a/.macos +++ b/.macos @@ -33,6 +33,40 @@ else echo "Skipping computer name configuration (COMPUTER_NAME not set)." fi +# Touch ID for sudo. /etc/pam.d/sudo_local is Apple's own hook for this: it +# survives OS updates where /etc/pam.d/sudo does not, and the template beside +# it ships with the pam_tid line commented out. Created from the template when +# missing, appended to when present without the line, left alone otherwise. +if ! grep -Eqs '^auth[[:space:]]+sufficient[[:space:]]+pam_tid\.so' /etc/pam.d/sudo_local; then + if [[ -f /etc/pam.d/sudo_local ]]; then + printf 'auth sufficient pam_tid.so\n' | sudo tee -a /etc/pam.d/sudo_local >/dev/null + elif [[ -f /etc/pam.d/sudo_local.template ]]; then + sed 's/^#auth/auth/' /etc/pam.d/sudo_local.template | sudo tee /etc/pam.d/sudo_local >/dev/null + else + echo "Skipping Touch ID for sudo (no /etc/pam.d/sudo_local.template on this macOS)." + fi +fi + +# One touch for everything. sudo caches a tty-less authorisation per parent +# process, so a tool that runs sudo once per step from fresh processes +# (Ansible's local connection, say) raises a Touch ID prompt for every step — +# measured at ten in one run. A global timestamp makes one touch cover every +# sudo on the machine. The timeout is taste, not part of the fix: `global` +# alone cures the per-process prompts; export SUDO_TIMESTAMP_TIMEOUT (minutes) +# to override the 60 here. The file is validated by visudo BEFORE it is +# installed, at the 0440 sudo expects, so a bad file never reaches sudoers.d. +if ! sudo grep -qs 'timestamp_type=global' /etc/sudoers.d/timestamp \ + || [[ "$(stat -f %Lp /etc/sudoers.d/timestamp 2>/dev/null)" != 440 ]]; then + sudoers_tmp="$(mktemp)" + printf 'Defaults timestamp_type=global\nDefaults timestamp_timeout=%s\n' "${SUDO_TIMESTAMP_TIMEOUT:-60}" > "$sudoers_tmp" + if visudo -cf "$sudoers_tmp" >/dev/null 2>&1; then + sudo install -m 0440 -o root -g wheel "$sudoers_tmp" /etc/sudoers.d/timestamp + else + echo "Skipping the global sudo timestamp: visudo rejected the generated file." + fi + rm -f "$sudoers_tmp" +fi + # Disable the sound effects on boot (Apple Silicon) sudo nvram StartupMute=%01 diff --git a/CLAUDE.md b/CLAUDE.md index 19f35e6315..ce2306a3b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ VMs; the test skips everywhere else unless `MACOS_APPLY_OK=1` is set. - **`.chezmoiignore`** — target paths chezmoi must not manage (repo-level files everywhere, `CLAUDE.md` and `com.googlecode.iterm2.plist` among them; macOS GUI config off-mac; identity/secrets on `ephemeral`) - **`bootstrap.sh`** — deprecated wrapper around `chezmoi init --source . --apply` - **`docs/watchlist.md`** — tools evaluated and parked as "not yet", each with what would make it worth another look; check it before re-evaluating something, and add to it when an evaluation ends in "not yet" -- **`.macos`** — macOS `defaults write` settings; reads `$COMPUTER_NAME` env var for machine-specific naming +- **`.macos`** — macOS `defaults write` settings; reads `$COMPUTER_NAME` env var for machine-specific naming; also enables Touch ID for sudo (via `/etc/pam.d/sudo_local`, Apple's update-proof hook) and a global sudo timestamp (`/etc/sudoers.d/timestamp`, `visudo`-checked before install, mode 0440), because a per-process cache plus Touch ID means one prompt per step for anything that runs sudo from fresh processes; the 60-minute timeout is a preference, overridable with `$SUDO_TIMESTAMP_TIMEOUT` - **`Brewfile`** — Homebrew formulae, casks, and Mac App Store apps (macOS only; `.chezmoiscripts/darwin/` runs `brew bundle` when it changes) - **`packages-apt.txt`** — Debian/Ubuntu/WSL package list (`.chezmoiscripts/linux/` installs it when it changes; skips gracefully without apt or sudo) - **`packages-apt-wsl.txt`** — WSL-only additions, installed on top of the shared list when the machine class is `wsl` diff --git a/tests/macos-apply.bats b/tests/macos-apply.bats index ca7ca98368..eb17b40dc2 100644 --- a/tests/macos-apply.bats +++ b/tests/macos-apply.bats @@ -30,3 +30,21 @@ setup() { @test "highlight color is set" { [ "$(defaults read NSGlobalDomain AppleHighlightColor)" = "0.764700 0.976500 0.568600" ] } + +@test "Touch ID for sudo is enabled in sudo_local" { + grep -Eq '^auth[[:space:]]+sufficient[[:space:]]+pam_tid\.so' /etc/pam.d/sudo_local +} + +@test "the global sudo timestamp is in effect and its file parses" { + sudo -n visudo -cf /etc/sudoers.d/timestamp + sudo -n -l | grep -q 'timestamp_type=global' +} + +@test ".macos is idempotent for the sudo settings" { + cd "$BATS_TEST_DIRNAME/.." + ./.macos > "$BATS_TEST_TMPDIR/macos2.log" 2>&1 + # Anchored: the template's commented-out line also says pam_tid.so. + [ "$(grep -Ec '^auth[[:space:]]+sufficient[[:space:]]+pam_tid\.so' /etc/pam.d/sudo_local)" = 1 ] + [ "$(sudo -n grep -c 'timestamp_type' /etc/sudoers.d/timestamp)" = 1 ] + [ "$(stat -f %Lp /etc/sudoers.d/timestamp)" = 440 ] +} diff --git a/tests/macos.bats b/tests/macos.bats index bdd5bb184d..8efe2c705f 100644 --- a/tests/macos.bats +++ b/tests/macos.bats @@ -19,3 +19,20 @@ run grep -q 'AppleHighlightColor -string "0.764700 0.976500 0.568600"' "$BATS_TEST_DIRNAME/../.macos" [ "$status" -eq 0 ] } + +@test "macos enables Touch ID for sudo through sudo_local, never /etc/pam.d/sudo" { + run grep -q 'pam_tid.so' "$BATS_TEST_DIRNAME/../.macos" + [ "$status" -eq 0 ] + run grep -E 'tee.*/etc/pam\.d/sudo([^_]|$)' "$BATS_TEST_DIRNAME/../.macos" + [ "$status" -ne 0 ] +} + +@test "macos makes one sudo authorisation cover every process, and validates the sudoers file" { + run grep -q 'Defaults timestamp_type=global' "$BATS_TEST_DIRNAME/../.macos" + [ "$status" -eq 0 ] + # Validated before it is installed, and installed at the mode sudo wants. + run grep -q 'visudo -cf "$sudoers_tmp"' "$BATS_TEST_DIRNAME/../.macos" + [ "$status" -eq 0 ] + run grep -q 'install -m 0440 -o root -g wheel "$sudoers_tmp" /etc/sudoers.d/timestamp' "$BATS_TEST_DIRNAME/../.macos" + [ "$status" -eq 0 ] +}