“Temporary admin” is one of those features that sounds simple until you write it. A standard user asks for admin, gets it for twenty minutes, and loses it. How hard can that be?
The honest answer is that a granted user is root for the length of the window. Every control I build around that is about bounding and recording it, not preventing it. That framing shaped everything in LocalAdmin, and it’s the reason I’m writing this post: the first version worked on the happy path, and a security review showed me how many ways the unhappy path could go wrong.
What It Does
LocalAdmin gives a standard user time-boxed membership in the admin group plus sudo, with automatic revocation when the window expires. There are two front ends over one shared core:
localadmin, a CLI for people already in Terminal:localadmin status,localadmin grant --reason "...",localadmin revoke- A swiftDialog flow (
PPLocalAdminMgmt.sh) with a required reason dropdown, for everyone else
Both call the same grant and revoke functions in a shared library (localAdminCore.sh), so behavior doesn’t drift between interfaces. The window length, cooldown, per-day cap, and reason list all come from a managed configuration profile, so none of it is hardcoded.
The Shape of the System
A standard user can’t add themselves to admin, so something running as root has to do it. The CLI hands off to a root LaunchDaemon that watches a requests directory. The daemon authenticates the caller, applies the change through the shared core, writes a response, and the CLI polls for it.
localadmin grant -> requests/<user>-<uuid>.json
root daemon (WatchPaths) claims, validates, applies
responses/<same-name>.json -> CLI prints the message
That is the classic shape of a local privilege escalation bug, so the directory layout matters more than the code:
| Dir | Mode | Why |
|---|---|---|
requests/ | 1733 | users can create files but not list or read them |
responses/ | 0711 | the daemon writes; a user can read their own file by exact name but can’t plant anything |
processing/ | 0700 | each request is moved here before it’s read, then re-checked for symlinks |
state/ | 0700 | the authoritative grant record, root only |
The daemon only honors a request if the file’s owning UID matches the console user and the requested_by field in the JSON matches too. Identity comes from the filesystem, not from what the JSON claims.
What the Review Found
I did a security pass on the original version, and then a second one with fresh eyes. The headline findings, at the level I’m comfortable describing:
- Two paths where the root daemon wrote into world-writable locations, which meant a standard user could turn a root write into something they controlled, with no grant required. Both were fixed by tightening the directory modes above and moving root-side staging into root-only directories.
- The expiry timer deleted itself. The per-grant LaunchDaemon had
RunAtLoadset, so it ran its revoke sweep immediately, before the grant was recorded, and the sweep cleaned up the timer it had just been loaded from. Sessions were only ending because of the login and Jamf backstops. - Revocation followed the console user. Under Fast User Switching or after a logout, the code revoked the wrong person, or nobody.
- The grant record was user-writable. Expiry keyed off a plist in the user’s own home, which they could delete.
The common thread is that every one of these trusted something the user could influence: a directory they could write to, a file they could delete, or whoever happened to be at the console.
Records, Not Timers
The fix that changed the architecture was moving from a per-grant timer to a permanent sweeper and a root-owned record.
The sweeper is a package-owned LaunchDaemon that runs every 60 seconds whether or not anyone has admin:
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>60</integer>
It runs revokeAdmin.sh, which iterates state/*.json for every user instead of looking at /dev/console. Each record carries a grant ID and a generation counter, and every writer takes a per-user lock:
{"schema_version":2,"status":"active","grant_id":"<uuid>","generation":3,
"active_expiry_epoch":1788000000}
The generation check means a stale expiry snapshot can never revoke a newer grant. And the grant itself is a small transaction: before touching group membership or sudoers, apply_grant writes a granting record, so a crash halfway through leaves enough information for the sweeper to clean up at the right time. If the group change fails, it rolls back and verifies the rollback; if it can’t verify, the record goes to revoke_failed instead of pretending it worked.
The Jamf enforcer script (adminSessionEnforcer.sh) is now just a safety net. It runs the same sweep and confirms the sweeper is loaded, checking that the plist is root-owned, not writable by group or others, and still points at the right script with the right interval.
Proof of Presence
A CLI grant used to work for any process running as the user, which means malware in a user session could silently self-elevate. Now a grant requires Touch ID or the login password.
The prompt comes from a small signed Swift helper using LocalAuthentication’s deviceOwnerAuthentication policy, with Touch ID reuse disabled:
context.touchIDAuthenticationAllowableReuseDuration = 0
context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: reason) { success, _ in
authenticated = success
semaphore.signal()
}
The part I like is where the check lives. Early on it sat in the CLI, which meant anything talking to the daemon directly could skip it. It now runs inside apply_grant at the privileged boundary: the root side verifies the helper is root-owned and passes its code requirement with codesign --verify, then launches it in the target user’s GUI session. A missing helper, a cancelled prompt, or an SSH-only session all fail closed.
The XPC Path
The file-drop IPC is hardened, but it’s still a directory and a poll loop. The longer-term plan is an XPC helper, where launchd owns the socket and the caller’s identity comes from the connection’s audit token instead of a file owner.
The listener rejects a connection unless the peer satisfies a code requirement (checked against the audit token, not the PID), and then derives the real user from the token’s effective UID:
let uid = euid(from: token)
guard uid != 0, let user = username(forUID: uid) else { return false }
newConnection.exportedObject = LocalAdminService(user: user)
The exported object is bound to that user, so there is no user parameter a caller could lie about. It then shells to the same bash core with the validated username as an argument.
This one is a scaffold. It builds and is signed, it’s gated behind a UseXPCHelper key that defaults to off, and the CLI falls back to file-drop when the key is off or the client isn’t installed. It is not registered or tested end to end yet.
What’s Honest About the Limits
The README says this plainly and I’ll repeat it: a user who is root during the window can defeat any local control, including the cooldown and cap. Those two are convenience limits, not security boundaries. What bounds the risk is the short window, the root-owned record, an optional EnforceUnmanagedAdmins setting that revokes any admin with no grant record, and EDR as the authoritative detection layer. The sync to the directory service is a best-effort mirror, not an authorization gate; a grant proceeds locally either way.
EnforceUnmanagedAdmins ships off by default for a reason. Turn it on before populating the allowlist of break-glass accounts and the first sweep strips them fleet-wide. The order of operations is in the deployment notes in bold for that reason.
What I’d Do Differently
I built the timer first and the record second, and it should have been the other way around. Any time a security property depends on “this scheduled thing will run later,” I now ask what happens when it doesn’t, and keep the source of truth somewhere the user can’t reach.
The other lesson is about bash. The shared core is large for a shell library, and the transaction and locking logic is exactly the sort of thing that would be easier to reason about in Swift. I kept the bash core so the CLI, GUI, and XPC bridge share one implementation, but finishing the XPC migration is a good opportunity to move more of that logic into the compiled helper.
The lifecycle logic does have tests (state transitions, transactions, and the exclusion policy), but the end-to-end checks that matter most, like granting as one user, switching to another, and confirming the first is revoked, are still manual.