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:
1 parent
982290fa42
commit
d9b970dd62
9 files changed
+552
-59
No files matched your search
+194
-47
@@ -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
|
||||
|
||||
Reference in new issue
Block a user