From 8b341b28a0c4a1af8bb1cad88fbcbdccba0cacd5 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Tue, 6 Oct 2026 16:42:52 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat:=20=F0=9F=8D=8E=20.macos?= =?UTF-8?q?=20enables=20Touch=20ID=20for=20sudo=20and=20a=20global=20sudo?= =?UTF-8?q?=20timestamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Touch ID goes through /etc/pam.d/sudo_local, Apple's update-proof hook, created from the template beside it or appended to. On its own it made things worse for anything that runs sudo once per step from fresh processes: sudo caches a tty-less authorisation per parent process, so an Ansible run raised ten Touch ID prompts. A global timestamp in /etc/sudoers.d/timestamp makes one touch cover every sudo on the machine; the file is validated by visudo before it is installed at 0440, and the 60-minute timeout is a preference behind SUDO_TIMESTAMP_TIMEOUT. Co-Authored-By: Claude Fable 5.1 --- .macos | 34 ++++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- tests/macos-apply.bats | 18 ++++++++++++++++++ tests/macos.bats | 17 +++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.macos b/.macos index 227589cd828..f622e9769dc 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 ! 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 19f35e63157..ce2306a3b87 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 ca7ca983686..4b21acc8367 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 ] + [ "$(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 bdd5bb184db..8efe2c705f8 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 ] +} From c1054c2b016652f637a2ce77bff2724061d81aa8 Mon Sep 17 00:00:00 2001 From: Evan Alter Date: Wed, 7 Oct 2026 00:48:23 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=90=9B=20fix:=20=F0=9F=94=92=20read?= =?UTF-8?q?=20the=200440=20sudoers=20file=20through=20sudo,=20so=20the=20c?= =?UTF-8?q?heck=20can=20see=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /etc/sudoers.d/timestamp is installed root:wheel 0440, so an unprivileged grep on it dies with "Permission denied". In .macos the -s flag hid that: the check always came back false and the file was reinstalled on every run, the opposite of the "left alone otherwise" the comment promises. In tests/macos-apply.bats the same grep failed loudly, which is how the macOS CI job caught it. Both reads now go through sudo — already live in .macos from the upfront sudo -v, and -n in the test like the visudo check beside it. Co-Authored-By: Claude Fable 5.1 --- .macos | 2 +- tests/macos-apply.bats | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.macos b/.macos index f622e9769dc..6e44bb761d0 100755 --- a/.macos +++ b/.macos @@ -55,7 +55,7 @@ fi # 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 ! grep -qs 'timestamp_type=global' /etc/sudoers.d/timestamp \ +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" diff --git a/tests/macos-apply.bats b/tests/macos-apply.bats index 4b21acc8367..eb17b40dc27 100644 --- a/tests/macos-apply.bats +++ b/tests/macos-apply.bats @@ -45,6 +45,6 @@ setup() { ./.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 ] - [ "$(grep -c 'timestamp_type' /etc/sudoers.d/timestamp)" = 1 ] + [ "$(sudo -n grep -c 'timestamp_type' /etc/sudoers.d/timestamp)" = 1 ] [ "$(stat -f %Lp /etc/sudoers.d/timestamp)" = 440 ] }