TL;DR Link to heading
My everyday macOS account isn’t an admin, and a separate local admin account is the only one that can use sudo. That keeps my login password from unlocking root, though a compromised everyday account can still work around it. Going through it tripped me up in a few ways: globs, ~ and $USER resolve as admin, and each command asks for the password twice. I gave admin passwordless sudo and wrapped su admin -c 'sudo …' in a zsh function called asudo, so I now type one password per command.
Why I think it matters Link to heading
On macOS, membership of the admin group carries more than sudo. /Applications is group-writable by admin, so any process running as an admin user can replace an app there without a prompt:
ls -ld /Applications
# drwxrwxr-x 38 root admin 1216 30 Oct 16:51 /Applications
sudo on an admin account also asks for the same password I use to log in, unlock the screen and approve installers. I type it many times a day, so a fake prompt or a stolen login password is enough for root. With the split, my login password doesn’t unlock sudo, and I type admin’s password only when I mean to do something as root.
The split has limits. A process running as me can still edit my ~/.zshrc and wrap su to capture admin’s password the next time I type it. I also own /opt/homebrew, so anything running as me can swap a Homebrew binary that I later run as root. I think of the split as raising the bar for a compromise of my everyday account, and it doesn’t close the gap on its own.
You can check which accounts can use sudo:
dscl . -read /Groups/admin GroupMembership
# GroupMembership: root admin
How I ran into it Link to heading
I hadn’t needed root for a while, until I moved my WireGuard tunnels from wg-quick into the WireGuard app. wg-quick needs root on macOS to create its utun interface and change routes. The app uses Apple’s Network Extension framework instead, so I can switch tunnels with scutil --nc start my-tunnel as myself. Getting the configs into the app meant copying root-owned files out of /opt/homebrew/etc/wireguard/, and a few things caught me out.
Pitfalls Link to heading
I can’t type a sudo password through Claude Code’s ! prefix. Claude Code’s ! prefix has no terminal for sudo to prompt on, so it fails with sudo: a terminal is required to read the password. I ran these commands in a normal terminal window.
~ and $USER resolve to admin. Inside su admin -c '...', ~ means /Users/admin, so I wrote absolute paths.
Globs expand as the wrong user. My first try looked like this:
su admin -c 'sudo chown me /Users/me/wg-import/*.conf'
# zsh:1: no matches found: /Users/me/wg-import/*.conf
admin’s shell expands the * before sudo runs, and admin can’t list a folder that’s mode 700 and owned by me. Writing out each file name worked.
Two password prompts. su admin asks for admin’s password, then sudo asks for it again. As I understand it, sudo caches the password per terminal session, and the shell that su -c starts counts as a fresh one with nothing cached.
Files I own in folders I don’t. I owned an old /etc/wireguard/wg0.conf, but rm still failed with Permission denied. Deleting a file needs write access to its folder, and root owned /etc/wireguard.
One prompt instead of two Link to heading
Anyone who knows admin’s password can get root by typing it into sudo, so I decided the second prompt added little protection. I gave admin passwordless sudo:
su admin -c 'echo "admin ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/admin-nopasswd >/dev/null \
&& sudo chmod 440 /etc/sudoers.d/admin-nopasswd && sudo visudo -c'
I check that visudo -c prints parsed OK, because a broken file in /etc/sudoers.d can leave sudo unusable. The trade-off I accepted is that a process running as admin can now get root without a prompt, and on my machine I only use admin when I su into it.
I considered adding my everyday account to /etc/sudoers.d instead, so sudo would work from it. That would give my everyday account the same root access admin has and undo the split.
asudo Link to heading
Then I wrapped it in a zsh function:
# Usage: asudo (root shell) | asudo -c '<shell code>' | asudo <command> [args...]
asudo() {
if (( $# == 0 )); then
su admin -c 'sudo -i'
elif [[ $1 == -c ]]; then
(( $# == 2 )) || { echo "usage: asudo -c '<shell code>'" >&2; return 2; }
su admin -c "sudo sh -c ${(q)2}"
else
su admin -c "sudo ${(j: :)${(q)@}}"
fi
}
I quote each argument with ${(q)@} so paths with spaces get through admin’s shell intact. The catch is that a pipe, redirect or glob in a plain asudo cmd runs in my own shell as me. For those I use asudo -c, which runs a string as shell code under root:
asudo rm -r '/some path/old' # plain command, args quoted
asudo -c 'echo hi > /etc/foo && ls /root/*' # redirect and glob run as root
asudo # interactive root shell
I made shell-code mode need an explicit -c, so a plain call treats each argument as one quoted word.