Let users choose passwordless sudo duration (#14435)

* Let users choose passwordless sudo duration

* Warn in the menu bar when passwordless sudo is active

* Fix sudo indicator hover behavior and repeated authentication

* Disable passwordless sudo from the bar without a terminal

* Shorten passwordless sudo indicator tooltip

* Recover interrupted passwordless sudo duration switches

* Allow sudo grant changes when listings require authentication

* Start passwordless sudo setup with the duration question
This commit is contained in:
David Heinemeier Hansson authored and GitHub committed 2026-10-07 14:41:27 +02:00
1 parent 982290fa42
commit d9b970dd62
9 files changed
+552 -59

No files matched your search

+194 -47
View File
@@ -1,7 +1,7 @@
#!/bin/bash -p
# omarchy:summary=Toggle passwordless sudo for the current user.
# omarchy:args=[MINUTES]
# omarchy:args=[MINUTES|permanent|--active|--disable]
# omarchy:requires-sudo=true
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
@@ -22,7 +22,6 @@ fi
set -euo pipefail
readonly DEFAULT_MINUTES=15
readonly MAX_MINUTES=1440
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
@@ -34,7 +33,7 @@ readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
readonly STATUS_INACTIVE=3
usage() {
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
echo "Usage: omarchy-sudo-passwordless [MINUTES|permanent|--active|--disable]" >&2
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
exit 1
}
@@ -43,6 +42,10 @@ valid_minutes() {
[[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES))
}
valid_duration() {
[[ $1 == "permanent" ]] || valid_minutes "$1"
}
valid_uid() {
[[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294))
}
@@ -96,14 +99,39 @@ with_root_lock() {
}
rule_file() {
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1"
local uid=$((10#$1))
if [[ ${2:-} == "permanent" ]]; then
printf '/etc/sudoers.d/99-omarchy-permanent-nopasswd-%s' "$uid"
else
printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$uid"
fi
}
# The sudoers rule is the only grant record. A missing file is distinct from
# an unreadable, unsafe, or administrator-modified file.
read_grant() {
local file contents
local file permanent contents
file=$(rule_file "$1")
permanent=$(rule_file "$1" permanent)
if [[ -e $permanent || -L $permanent ]]; then
verify_root_path "$permanent" && [[ -f $permanent ]] || return 2
contents=$(/usr/bin/cat -- "$permanent") || return 2
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOPASSWD:\ ALL$ ]] || return 2
GRANT_NAME=${BASH_REMATCH[1]}
GRANT_DEADLINE=""
valid_account_name "$GRANT_NAME" || return 2
if [[ -e $file || -L $file ]]; then
# A SIGKILL between publication and removal can leave both policies.
# Only accept a matching generated timed rule. Permanent access already
# exists, so old expiry callbacks must not revoke it; disable removes
# both files, and the next duration change finishes their replacement.
verify_root_path "$file" && [[ -f $file ]] || return 2
contents=$(/usr/bin/cat -- "$file") || return 2
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
[[ ${BASH_REMATCH[1]} == "$GRANT_NAME" ]] || return 2
fi
return 0
fi
[[ -e $file || -L $file ]] || return "$STATUS_INACTIVE"
verify_root_path "$file" && [[ -f $file ]] || return 2
contents=$(/usr/bin/cat -- "$file") || return 2
@@ -121,6 +149,9 @@ classify_generated_rule() {
[[ -f $file && ! -L $file ]] || return 1
contents=$(/usr/bin/cat -- "$file") || return 2
suffix=${file##*/99-omarchy-nopasswd-}
if [[ $file == */99-omarchy-permanent-nopasswd-* ]]; then
suffix=${file##*/99-omarchy-permanent-nopasswd-}
fi
# The legacy command wrote the caller's unvalidated name into both the
# filename and the rule. That exact relationship is its fingerprint, so an
@@ -138,14 +169,21 @@ classify_generated_rule() {
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
}
cleanup_uid_locked() {
local file
file=$(rule_file "$1")
cleanup_rule_file() {
local file=$1
[[ -e $file || -L $file ]] || return 0
verify_root_path "$file" && classify_generated_rule "$file" || return 1
/usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]
}
cleanup_uid_locked() {
local file duration
for duration in temporary permanent; do
file=$(rule_file "$1" "$duration")
cleanup_rule_file "$file" || return 1
done
}
# The generated prefix is reserved: boot cleanup and the package hook already
# remove everything in it, and the legacy writer could produce a rule whose
# body differs from its filename. Nothing unrecognized may stay live there, but
@@ -263,6 +301,7 @@ migrate_locked() {
expire_locked() {
local status now
if read_grant "$1"; then
[[ -n $GRANT_DEADLINE ]] || return 0
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
[[ $now < $GRANT_DEADLINE ]] && return 0
cleanup_uid_locked "$1"
@@ -281,6 +320,7 @@ status_locked() {
resolve_account "$1" || return 2
if read_grant "$1"; then
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2
[[ -n $GRANT_DEADLINE ]] || return 0
now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2
if [[ $now < $GRANT_DEADLINE ]]; then
return 0
@@ -300,7 +340,11 @@ finish_enable() {
if cleanup_uid_locked "$uid"; then
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
else
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
if [[ -n $timer ]]; then
echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2
else
echo "Could not revoke passwordless sudo. Administrator cleanup is required." >&2
fi
fi
fi
[[ -z $pending ]] || /usr/bin/rm -f -- "$pending"
@@ -309,9 +353,15 @@ finish_enable() {
enable_locked() (
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
resolve_account "$uid" && valid_minutes "$minutes" || return 1
verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1
file=$(rule_file "$uid")
resolve_account "$uid" && valid_duration "$minutes" || return 1
verify_root_path /etc/sudoers.d || return 1
if [[ $minutes != "permanent" ]]; then
verify_boot_cleanup || {
echo "Temporary sudo cleanup is unavailable or a settings package transaction is incomplete. Check the omarchy-settings installation." >&2
return 1
}
fi
file=$(rule_file "$uid" "$minutes")
if read_grant "$uid"; then
[[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1
else
@@ -320,39 +370,69 @@ enable_locked() (
fi
trap finish_enable EXIT
omarchy_security_install_signal_exit_traps
now=$(/usr/bin/date +%s) || return 1
expires=$((now + 10#$minutes * 60))
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
if [[ $minutes == "permanent" ]]; then
/usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$ACCOUNT_NAME" >"$pending" || return 1
else
now=$(/usr/bin/date +%s) || return 1
expires=$((now + 10#$minutes * 60))
deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1
/usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1
fi
/usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1
/usr/sbin/visudo -cf "$pending" >/dev/null || return 1
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
timer="omarchy-nopasswd-expire-$uid-$token"
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
--timer-property=AccuracySec=1s --unit="$timer" \
-- "$INSTALLED_SELF" __expire "$uid" || return 1
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
if [[ $minutes != "permanent" ]]; then
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid) || return 1
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
timer="omarchy-nopasswd-expire-$uid-$token"
/usr/bin/systemd-run --quiet --collect --on-calendar="@$expires" \
--timer-property=AccuracySec=1s --unit="$timer" \
-- "$INSTALLED_SELF" __expire "$uid" || return 1
/usr/bin/systemctl is-active --quiet "$timer.timer" || return 1
fi
# The temporary filename contains a dot, so sudo ignores it. Rename within
# sudoers.d publishes the complete validated policy in one operation.
/usr/bin/mv -fT -- "$pending" "$file" || return 1
pending=""
now=$(/usr/bin/date +%s) || return 1
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
# Keep access uninterrupted during renewal. When changing duration type,
# remove the other rule only after its replacement is published. The lock
# keeps status and expiry callbacks from observing both rules together.
if [[ $minutes == "permanent" ]]; then
cleanup_rule_file "$(rule_file "$uid")" || return 1
else
cleanup_rule_file "$(rule_file "$uid" permanent)" || return 1
fi
if [[ $minutes != "permanent" ]]; then
now=$(/usr/bin/date +%s) || return 1
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
fi
)
root_dispatch() {
local action="$1"
local action="$1" status
shift
case "$action" in
__status)
(($# == 1)) && verify_sudo_caller "$1" || return 2
with_root_lock status_locked "$1"
if with_root_lock status_locked "$1"; then
return 0
else
status=$?
# Reserve status 1 for sudo refusing noninteractive authentication.
# Every internal inspection failure, including lock failures, is 2.
(( status == STATUS_INACTIVE )) || status=2
return "$status"
fi
;;
__enable)
(($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1
with_root_lock enable_locked "$1" "$2"
(($# == 2)) && verify_sudo_caller "$1" && valid_duration "$2" || return 1
if with_root_lock enable_locked "$1" "$2"; then
return 0
else
status=$?
echo "Could not enable passwordless sudo. Check existing sudoers policy and cleanup support." >&2
return "$status"
fi
;;
__disable)
(($# == 1)) && verify_sudo_caller "$1" || return 1
@@ -382,6 +462,14 @@ root_dispatch() {
esac
}
show_enabled() {
if [[ $minutes == "permanent" ]]; then
echo "Passwordless sudo has been ENABLED permanently. Run this command again to disable it."
else
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
fi
}
case "${1:-}" in
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
action=$1
@@ -391,9 +479,30 @@ case "${1:-}" in
;;
esac
# Prefer matched policy tags to executing a privileged command, avoiding
# authentication/session logs under the default listing policy. Restrictive
# listpw settings require the root-status fallback. Neither path prompts or
# trusts cached credentials as evidence of passwordless access.
if [[ ${1:-} == "--active" ]]; then
(($# == 1)) || usage
uid=$(/usr/bin/id -u)
if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then
/usr/bin/grep -q '!authenticate' <<<"$policy"
else
# listpw=always can require authentication to list a passwordless grant.
# With a command, -k ignores cached credentials without invalidating them.
status=0
/usr/bin/sudo -kn -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$?
# An internal inspection error still proves that sudo ran the helper
# without authentication. Keep the warning visible for unsafe policy.
(( status == 0 || status == 2 )) || exit 1
fi
exit
fi
(($# <= 1)) || usage
minutes=${1:-$DEFAULT_MINUTES}
valid_minutes "$minutes" || usage
minutes=${1:-}
(($# == 0)) || [[ $minutes == "--disable" ]] || valid_duration "$minutes" || usage
uid=$(/usr/bin/id -u)
valid_uid "$uid" || {
echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2
@@ -411,38 +520,76 @@ omarchy_security_install_sudo_cleanup_traps
exit 1
}
echo "Toggle passwordless sudo..."
if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then
if (($# == 0)); then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid"
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
else
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes."
# Inspect policy without authenticating. Only inspect the root-owned grant
# when policy already permits it without a password; otherwise the eventual
# enable action is the sole authentication boundary. enable_locked validates
# any existing policy before publishing, including expired or modified rules.
status=$STATUS_INACTIVE
if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then
if /usr/bin/grep -q '!authenticate' <<<"$policy"; then
status=0
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" || status=$?
fi
else
status=$?
if (( status != STATUS_INACTIVE )); then
# A listing password is independent of command authorization. An active
# grant can still be inspected/revoked without prompting under listpw=always.
status=0
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$?
if (( status == 1 )) && [[ $minutes != "--disable" ]]; then
# No noninteractive inspection is available. Offer the confirmed enable
# flow; its single authenticated action validates existing policy itself.
status=$STATUS_INACTIVE
fi
fi
if (( status == 0 )) && { (($# == 0)) || [[ $minutes == "--disable" ]]; }; then
/usr/bin/sudo -n -N -- "$INSTALLED_SELF" __disable "$uid"
echo "Passwordless sudo has been DISABLED. Sudo will require a password again."
elif (( status == STATUS_INACTIVE )) && [[ $minutes == "--disable" ]]; then
echo "Passwordless sudo is already DISABLED."
elif (( status == 0 )) && [[ $minutes != "permanent" ]]; then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
show_enabled
else
if (( status != 0 && status != STATUS_INACTIVE )); then
echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2
exit 1
fi
if [[ -z $minutes ]]; then
choice=$(/usr/bin/gum choose --header "How long should passwordless sudo stay enabled?" "15 minutes" "1 Hour" "1 Day" "Permanently") || exit 130
case "$choice" in
"15 minutes") minutes=15 ;;
"1 Hour") minutes=60 ;;
"1 Day") minutes=1440 ;;
"Permanently") minutes=permanent ;;
*) exit 130 ;;
esac
fi
if [[ $minutes == "permanent" ]]; then
duration="permanently"
else
duration="for ${minutes} minutes"
fi
echo ""
echo "⚠️ WARNING: This will allow ANY process running as your user to"
echo "execute ANY command as root WITHOUT a password for ${minutes} minutes."
echo "execute ANY command as root WITHOUT a password $duration."
echo ""
echo "This is useful for AI agents that need to run sudo commands,"
echo "but it significantly weakens the security of your system."
echo "Anyone or anything with access to your user account gets full root."
echo ""
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
echo "including if the machine reboots before the deadline."
echo "Run this command again to disable it early."
if [[ $minutes == "permanent" ]]; then
echo "Passwordless sudo will remain enabled across reboots until you disable it."
else
echo "Passwordless sudo will automatically disable after ${minutes} minutes,"
echo "including if the machine reboots before the deadline."
fi
echo "Run this command again to disable it."
echo ""
if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then
if /usr/bin/gum confirm "Enable passwordless sudo $duration? This is a significant security risk!"; then
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
echo ""
echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes."
show_enabled
else
echo "Aborted. No changes made."
fi
+10 -6
View File
@@ -1,24 +1,28 @@
# Temporary passwordless sudo
# Passwordless sudo
`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
`omarchy-sudo-passwordless` publishes a timed or permanent grant for the numeric UID authenticated by sudo. Its user interface inspects sudo policy without prompting and authenticates only the enable action when access is off, without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`.
The menu bar's `PasswordlessSudo` indicator polls `--active` every five seconds and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed `__status` action and checks its `!authenticate` tag. If listing requires authentication, it falls back to a noninteractive `__status` call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs `--disable` noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow.
## Grant lifecycle
The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
The sudoers rule is the only grant record. A timed grant contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed.
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists.
An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface offers a duration picker after result `3`. Result `0` toggles access off when no argument is given, or changes its duration when an argument is given. Permanent access always requires confirmation, including when replacing an active timed grant. It must not turn an inspection failure into a claim that no grant exists.
Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone.
Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content.
Permanent grants live under `/etc/sudoers.d/99-omarchy-permanent-nopasswd-<uid>`, outside the temporary cleanup namespace. They have no `NOTAFTER` deadline or timer. Status and disable handle both forms; switching duration publishes the replacement before removing the prior policy under the same lock. Old expiry callbacks leave permanent grants intact. If an untrappable interruption leaves both rules, a pair of trusted generated rules for the same account remains inspectable and revocable. Permanent policy continues to govern until an explicit disable or completed duration change; callbacks never remove it merely because the timed companion has expired. Mismatched accounts, symlinks, and administrator-modified companion rules remain errors. Permanent policy survives reboot and package upgrade or removal; disable it before uninstalling the command or downgrading to a version without permanent-grant support, or remove its sudoers file as an administrator afterward.
## Package ownership
The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files.
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant.
Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing temporary policy. The marker prevents a waiting publisher from creating a new timed grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New timed grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another timed grant.
The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
The runtime marker need not survive reboot: pre-removal revokes the old timed grants before package files disappear, and a new timed grant independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch.
## Validation
+3 -1
View File
@@ -20,7 +20,9 @@ It works by restoring the baseline snapshot the installer takes, so it's only av
## Passwordless sudo
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough.
Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ asks how long to allow access: **15 minutes**, **1 Hour**, **1 Day**, or **Permanently**. A red warning icon appears beside the other menu bar indicators while access is active. Click it or run the command again to turn access off. You can also pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30`, or use `omarchy-sudo-passwordless permanent`.
Timed access expires automatically, including immediately after resuming from a suspend that crossed the deadline. Restarting the computer ends it early. Permanent access survives reboots and stays enabled until you disable it.
Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive.
@@ -0,0 +1,71 @@
import QtQuick
import Quickshell.Io
import qs.Commons
import qs.Ui
BarIndicator {
id: root
property bool granted: false
property bool refreshPending: false
active: granted
activeText: "󰟵"
inactiveText: "󰟵"
activeTooltipText: "Disable Passwordless Sudo"
inactiveTooltipText: "Passwordless Sudo"
useActiveColor: true
activeColor: Color.urgent
function refresh() {
if (!root.bar) return
if (statusProc.running) {
root.refreshPending = true
return
}
root.refreshPending = false
statusProc.running = true
}
onBarChanged: refresh()
Component.onCompleted: refresh()
Connections {
target: root.indicatorHost
ignoreUnknownSignals: true
function onRefreshRequested() { root.refresh() }
}
Timer {
interval: 5000
repeat: true
running: !!root.bar
onTriggered: root.refresh()
}
Process {
id: statusProc
command: ["omarchy-sudo-passwordless", "--active"]
onExited: function(exitCode, exitStatus) {
root.granted = exitCode === 0 && exitStatus === 0
if (root.refreshPending) Qt.callLater(root.refresh)
}
}
Process {
id: disableProc
command: ["omarchy-sudo-passwordless", "--disable"]
onExited: function(exitCode, exitStatus) {
if ((exitCode !== 0 || exitStatus !== 0) && root.bar)
root.bar.run('omarchy-notification-send "Could not disable passwordless sudo" "Check the sudo configuration and try again."')
if (root.indicatorHost) root.indicatorHost.refresh()
else root.refresh()
}
}
onPressed: function() {
if (!root.bar || disableProc.running) return
if (root.granted) disableProc.running = true
else root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless")
}
}
@@ -26,6 +26,11 @@
"placeholderText": "Search indicators...",
"emptyText": "No indicators",
"options": [
{
"value": "PasswordlessSudo",
"label": "Passwordless sudo",
"description": "Warning while passwordless root access is enabled"
},
{
"value": "Dictation",
"label": "Dictation",
+1 -1
View File
@@ -8,7 +8,7 @@ BarWidget {
id: root
moduleName: "omarchy.indicators"
readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
property var activeIndicatorIds: []
property var indicatorActiveStates: ({})
@@ -52,7 +52,9 @@ case "$name" in
;;
mv)
[[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1
[[ ${TEST_REQUIRE_EXISTING_TARGET:-0} != 1 || -f ${@: -1} ]] || exit 1
/usr/bin/mv "$@"
[[ ${TEST_KILL_AFTER_PUBLISH:-0} != 1 ]] || /usr/bin/kill -KILL "$PPID"
[[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing"
;;
systemd-run)
@@ -69,7 +71,15 @@ case "$name" in
sudo)
if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi
if [[ ${1:-} == -k ]]; then exit 0; fi
if [[ ${1:-} == -N ]]; then shift; fi
if [[ ${1:-} == -n && ${3:-} == -l ]]; then
[[ ${TEST_POLICY_FAILURE:-0} != 1 ]] || exit 1
default_policy=' Options: authenticate'
[[ ${TEST_STATUS:-3} == 3 ]] || default_policy=' Options: !authenticate'
printf '%s\n' "${TEST_POLICY:-$default_policy}"
exit 0
fi
if [[ ${1:-} == -n ]]; then shift; fi
if [[ ${1:-} == -N || ${1:-} == -kn ]]; then shift; fi
if [[ ${1:-} == -- ]]; then shift; fi
if [[ ${TEST_MIGRATION:-0} == 1 ]]; then
[[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1
@@ -78,7 +88,14 @@ case "$name" in
[[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}"
fi
;;
gum) exit 1 ;;
gum)
if [[ $1 == choose ]]; then
[[ ${TEST_CHOICE_CANCEL:-0} != 1 ]] || exit 130
printf '%s\n' "${TEST_CHOICE:-15 minutes}"
else
exit "${TEST_CONFIRM_STATUS:-1}"
fi
;;
*) exit 99 ;;
esac
STUB
@@ -120,6 +137,6 @@ assert_status() {
(( actual == expected )) || fail "expected status $expected, got $actual from $*"
}
reset_grant() {
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
rm -f "$test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-1000" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing"
: >"$test_tmp/commands"
}
+212 -1
View File
@@ -32,7 +32,7 @@ for status in 1 2 3; do
else
(( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement"
fi
grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization"
grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" || fail "status must inspect policy without authentication"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization"
done
pass "public status distinguishes inactive from errors and revokes authorization on exit"
@@ -164,3 +164,214 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/
[[ ! -e $(rule_file 1000) ]]
)
pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish"
reset_grant
(
source "$library"
valid_duration permanent
! valid_duration forever || fail "invalid duration was accepted"
enable_locked 1000 permanent
read_grant 1000
[[ $GRANT_NAME == audituser && -z $GRANT_DEADLINE ]]
[[ $(stat -c '%a' "$(rule_file 1000 permanent)") == 440 ]]
/usr/sbin/visudo -cf "$(rule_file 1000 permanent)" >/dev/null
! grep -q '^systemd-run ' "$test_tmp/commands" || fail "permanent grant started a timer"
TEST_EXPIRED=1 status_locked 1000
TEST_EXPIRED=1 expire_locked 1000
cleanup_all_locked
/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline 'r! /etc/sudoers.d/99-omarchy-nopasswd-*'
[[ -f $(rule_file 1000 permanent) ]]
cleanup_uid_locked 1000
assert_status 3 status_locked 1000
)
pass "permanent access has no timer, survives expiry and temporary cleanup, and can be disabled"
reset_grant
(
source "$library"
enable_locked 1000 15
enable_locked 1000 permanent
[[ ! -e $(rule_file 1000) && -e $(rule_file 1000 permanent) ]]
TEST_EXPIRED=1 expire_locked 1000
[[ -e $(rule_file 1000 permanent) ]]
enable_locked 1000 60
[[ -e $(rule_file 1000) && ! -e $(rule_file 1000 permanent) ]]
TEST_EXPIRED=1 expire_locked 1000
assert_status 3 status_locked 1000
TEST_PUBLISH_FAIL=1 assert_status 1 enable_locked 1000 permanent
[[ ! -e $(rule_file 1000 permanent) ]]
)
pass "switching duration replaces the prior policy and old callbacks preserve permanent access"
reset_grant
(
source "$library"
permanent=$(rule_file 1000 permanent)
printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$permanent"
assert_status 2 status_locked 1000
assert_status 1 enable_locked 1000 permanent
assert_status 1 cleanup_uid_locked 1000
[[ -e $permanent ]]
rm "$permanent"
enable_locked 1000 permanent
TEST_BAD_PATH="$permanent" assert_status 2 status_locked 1000
)
pass "permanent policy rejects unsafe ownership and preserves administrator edits"
for choice in '15 minutes' '1 Hour' '1 Day' Permanently; do
case "$choice" in
'15 minutes') expected=15 ;;
'1 Hour') expected=60 ;;
'1 Day') expected=1440 ;;
Permanently) expected=permanent ;;
esac
: >"$test_tmp/commands"
TEST_CHOICE="$choice" TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
grep -q '^gum choose .*15 minutes 1 Hour 1 Day Permanently$' "$test_tmp/commands"
grep -q "^sudo -N -- .* __enable .* $expected$" "$test_tmp/commands"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
if [[ $expected == permanent ]]; then
grep -q 'remain enabled across reboots until you disable it' "$test_tmp/public.log"
! grep -q 'automatically disable' "$test_tmp/public.log" || fail "permanent grant claims automatic expiry"
fi
done
pass "each duration choice dispatches its exact grant duration with permanent-specific copy"
: >"$test_tmp/commands"
assert_status 130 env TEST_CHOICE_CANCEL=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
! grep -q '__enable\|^gum confirm ' "$test_tmp/commands" || fail "cancelled picker continued enablement"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
: >"$test_tmp/commands"
TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
! grep -q '^gum ' "$test_tmp/commands" || fail "disabling access offered a picker"
grep -q '__disable ' "$test_tmp/commands"
pass "cancelling the picker grants nothing and active access still toggles off"
for confirm_status in 0 1; do
: >"$test_tmp/commands"
TEST_STATUS=0 TEST_CONFIRM_STATUS=$confirm_status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" permanent >"$test_tmp/public.log" 2>&1
grep -q '^gum confirm Enable passwordless sudo permanently?' "$test_tmp/commands"
if (( confirm_status == 0 )); then
grep -q '__enable .* permanent$' "$test_tmp/commands"
else
! grep -q '__enable ' "$test_tmp/commands" || fail "declining permanent access still enabled it"
fi
done
pass "changing active access to permanent always requires confirmation"
reset_grant
(
source "$library"
enable_locked 1000 15
TEST_REQUIRE_EXISTING_TARGET=1 enable_locked 1000 60
cleanup_uid_locked 1000
enable_locked 01000 permanent
status_locked 1000
[[ -e $(rule_file 1000 permanent) ]]
[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-01000 ]]
cleanup_uid_locked 1000
assert_status 3 status_locked 01000
)
pass "renewal replaces the existing rule atomically and zero-padded UIDs share one policy"
for policy in ' Options: !authenticate' ' Options: authenticate'; do
: >"$test_tmp/commands"
expected=1
[[ $policy != *'!authenticate'* ]] || expected=0
assert_status "$expected" env TEST_POLICY="$policy" /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
[[ $(wc -l <"$test_tmp/commands") == 1 ]]
grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands"
done
assert_status 1 env TEST_POLICY_FAILURE=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
pass "menu probe reads policy tags without executing a privileged action or changing the timestamp"
for status in 0 3; do
: >"$test_tmp/commands"
TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "explicit disable offered enablement"
if (( status == 0 )); then
grep -q '__disable ' "$test_tmp/commands"
fi
done
pass "explicit disable never enables access even when a displayed grant has expired"
: >"$test_tmp/commands"
TEST_STATUS=3 TEST_CHOICE='15 minutes' TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1
[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "enable must authenticate exactly one sudo call"
grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands"
! grep -q '^sudo -n -N -- .* __status ' "$test_tmp/commands" || fail "inactive flow attempted root status"
[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]]
pass "enabling from inactive policy has exactly one password-capable sudo invocation"
: >"$test_tmp/commands"
TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" || fail "disable must refuse interactive authentication"
! grep -q '^sudo -N -- ' "$test_tmp/commands" || fail "disable attempted an interactive sudo command"
pass "disabling active access is fully noninteractive"
for duration in permanent 15; do
reset_grant
(
source "$library"
if [[ $duration == permanent ]]; then
enable_locked 1000 15
else
enable_locked 1000 permanent
fi
assert_status 137 env TEST_KILL_AFTER_PUBLISH=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 "$duration"
[[ -f $(rule_file 1000) && -f $(rule_file 1000 permanent) ]]
status_locked 1000
TEST_EXPIRED=1 expire_locked 1000
[[ -f $(rule_file 1000 permanent) ]]
cleanup_uid_locked 1000
assert_status 3 status_locked 1000
)
done
pass "SIGKILL during either duration switch leaves access inspectable and revocable without expiring permanent policy"
reset_grant
(
source "$library"
enable_locked 1000 permanent
timed=$(rule_file 1000)
printf 'otheruser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
assert_status 2 read_grant 1000
assert_status 1 enable_locked 1000 15
printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
TEST_BAD_PATH="$timed" assert_status 2 read_grant 1000
rm "$timed"
ln -s "$(rule_file 1000 permanent)" "$timed"
assert_status 2 read_grant 1000
rm "$timed"
printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed"
enable_locked 1000 15
[[ -f $timed && ! -e $(rule_file 1000 permanent) ]]
)
pass "paired policy recovery rejects mismatched or unsafe rules and allows a complete duration change"
: >"$test_tmp/commands"
TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1
grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands"
! grep -q '^gum\|^sudo -N -- ' "$test_tmp/commands" || fail "listpw=always disable prompted"
TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
: >"$test_tmp/commands"
TEST_POLICY_FAILURE=1 TEST_STATUS=1 TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1
[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "listing failure must leave one enable authentication"
grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands"
: >"$test_tmp/commands"
assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1
! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "unsafe grant was offered enablement"
pass "password-required listings do not block enabling, disabling, or active detection"
: >"$test_tmp/commands"
TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active
grep -q '^sudo -kn -- .* __status ' "$test_tmp/commands" || fail "fallback probe must ignore cached credentials"
(
source "$library"
with_root_lock() { return 1; }
verify_sudo_caller() { return 0; }
assert_status 2 root_dispatch __status 1000
)
pass "status lock failures remain inspection errors and an authenticated unsafe grant keeps its warning"
@@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS'
const fs = require('fs')
const read = name => fs.readFileSync(path.join(root, name), 'utf8')
const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml')
const widget = read('shell/plugins/bar/widgets/Indicators.qml')
const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json'))
assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray')
assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light')
assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Color.urgent'), 'active passwordless sudo uses the theme danger color')
assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states')
assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), 'sudo participates in the normal indicator blocks')
assert(!indicator.includes('visible:'), 'sudo uses the shared indicator visibility and hover behavior')
assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--active"]'), 'indicator uses the noninteractive grant probe')
assert(indicator.includes('root.granted = exitCode === 0 && exitStatus === 0'), 'failed or interrupted probes do not claim an active grant')
assert(indicator.includes('interval: 5000') && indicator.includes('onTriggered: root.refresh()'), 'indicator refreshes after activation, revocation, and expiry')
assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--disable"]'), 'active indicator revokes access through a background process')
assert(indicator.includes('if (root.granted) disableProc.running = true'), 'active click bypasses the terminal launcher')
assert(indicator.includes('root.indicatorHost.refresh()'), 'disabling access refreshes all indicator instances immediately')
const press = new Function('root', 'disableProc', indicator.match(/onPressed: function\(\) \{([\s\S]*?)\n \}/)[1])
const launched = []
const button = { granted: true, bar: { run: command => launched.push(command) } }
const revoke = { running: false }
press(button, revoke)
assert(revoke.running && launched.length === 0, 'clicking active sudo starts background revocation without a terminal')
button.granted = false
press(button, revoke)
assert(launched.length === 0, 'repeat clicks during revocation cannot open the enable flow')
revoke.running = false
press(button, revoke)
assertDeepEqual(launched, ['omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless'], 'inactive click still opens interactive setup')
JS