* 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
597 lines
22 KiB
Bash
Executable File
597 lines
22 KiB
Bash
Executable File
#!/bin/bash -p
|
|
|
|
# omarchy:summary=Toggle passwordless sudo for the current user.
|
|
# omarchy:args=[MINUTES|permanent|--active|--disable]
|
|
# omarchy:requires-sudo=true
|
|
|
|
if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
|
|
exit 126
|
|
fi
|
|
|
|
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
|
|
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
|
|
|
|
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
|
omarchy_security_require_privileged_bash_startup || {
|
|
echo "Refusing an unsafe Bash startup for passwordless sudo." >&2
|
|
exit 126
|
|
}
|
|
omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126
|
|
fi
|
|
|
|
set -euo pipefail
|
|
|
|
readonly MAX_MINUTES=1440
|
|
readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock
|
|
readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf
|
|
readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook
|
|
readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing
|
|
readonly MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635
|
|
readonly QUARANTINE_DIR=/var/lib/omarchy/sudoers-quarantine
|
|
readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
|
|
readonly STATUS_INACTIVE=3
|
|
|
|
usage() {
|
|
echo "Usage: omarchy-sudo-passwordless [MINUTES|permanent|--active|--disable]" >&2
|
|
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
|
|
exit 1
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
valid_account_name() {
|
|
[[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 ))
|
|
}
|
|
|
|
resolve_account() {
|
|
local uid="$1" entry
|
|
valid_uid "$uid" || return 1
|
|
entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1
|
|
IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry"
|
|
[[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1
|
|
# Sudoers has metacharacters, and it reads an upper-case word such as ALICE
|
|
# as an alias reference rather than a user. Accounts use this portable
|
|
# lower-case subset; refusing anything else is safer than attempting to
|
|
# quote privileged policy syntax.
|
|
valid_account_name "$ACCOUNT_NAME" || return 1
|
|
ACCOUNT_UID=$((10#$uid))
|
|
}
|
|
|
|
verify_sudo_caller() {
|
|
local requested_uid="$1"
|
|
((EUID == 0)) || return 1
|
|
valid_uid "$requested_uid" || return 1
|
|
[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1
|
|
((10#$SUDO_UID == 10#$requested_uid)) || return 1
|
|
resolve_account "$requested_uid"
|
|
}
|
|
|
|
with_root_lock() {
|
|
local fd rc=0
|
|
# The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy:
|
|
# those are exactly the kinds of partial-install state it must fail closed
|
|
# through. /run/lock is established by the OS before sysinit services run.
|
|
omarchy_security_assert_root_directory /run 755 || return 1
|
|
[[ -d /run/lock && ! -L /run/lock ]] || return 1
|
|
[[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1
|
|
! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1
|
|
exec {fd}>"$LOCK_FILE" || return 1
|
|
/usr/bin/chown root:root "$LOCK_FILE" || return 1
|
|
/usr/bin/chmod 0600 "$LOCK_FILE" || return 1
|
|
# Grant operations are short. A stalled holder must not hang a caller
|
|
# indefinitely, least of all pacman's pre-transaction hook.
|
|
/usr/bin/flock -x -w 60 "$fd" || return 1
|
|
"$@" || rc=$?
|
|
/usr/bin/flock -u "$fd" || rc=1
|
|
exec {fd}>&-
|
|
return "$rc"
|
|
}
|
|
|
|
rule_file() {
|
|
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 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
|
|
[[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2
|
|
GRANT_NAME=${BASH_REMATCH[1]}
|
|
GRANT_DEADLINE=${BASH_REMATCH[2]}
|
|
valid_account_name "$GRANT_NAME" || return 2
|
|
}
|
|
|
|
# Returns 0 for a rule this command generated, 1 for anything else under the
|
|
# owned prefix (preserved as administrator policy), and 2 when unreadable.
|
|
classify_generated_rule() {
|
|
local file=$1 suffix contents name
|
|
|
|
[[ -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
|
|
# account the current policy would reject still has its old grant removed.
|
|
if [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
[[ $suffix =~ ^[0-9]+$ ]] || return 1
|
|
name=${contents%' ALL=(ALL) NOPASSWD: ALL'}
|
|
if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then
|
|
return 0
|
|
fi
|
|
name=${contents%%' ALL=(ALL) NOTAFTER='*}
|
|
valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]]
|
|
}
|
|
|
|
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
|
|
# its content is kept for the administrator instead of being deleted.
|
|
quarantine_foreign_rule() {
|
|
local file=$1 target
|
|
if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then
|
|
/usr/bin/install -d -o root -g root -m 0755 -- /var/lib/omarchy || return 1
|
|
fi
|
|
omarchy_security_prepare_private_root_directory "$QUARANTINE_DIR" /var/lib/omarchy || return 1
|
|
# A legacy filename can already be close to NAME_MAX, so the destination
|
|
# name is fixed and the original name travels beside it.
|
|
target=$(/usr/bin/mktemp -d "$QUARANTINE_DIR/XXXXXXXXXX") || return 1
|
|
/usr/bin/printf '%s\n' "${file##*/}" >"$target/name" || return 1
|
|
/usr/bin/mv -fT -- "$file" "$target/policy" && [[ ! -e $file && ! -L $file ]] || return 1
|
|
echo "Moved unrecognized sudoers policy $file to $target/policy" >&2
|
|
}
|
|
|
|
cleanup_all_locked() {
|
|
local file classification failed=0
|
|
verify_root_path /etc/sudoers.d || return 1
|
|
for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do
|
|
[[ -e $file || -L $file ]] || continue
|
|
if classify_generated_rule "$file"; then
|
|
if ! /usr/bin/rm -f -- "$file" || [[ -e $file || -L $file ]]; then
|
|
failed=1
|
|
fi
|
|
else
|
|
classification=$?
|
|
if (( classification != 1 )) || ! quarantine_foreign_rule "$file"; then
|
|
failed=1
|
|
fi
|
|
fi
|
|
done
|
|
return "$failed"
|
|
}
|
|
|
|
verify_root_path() {
|
|
local file=$1 owner mode canonical current
|
|
[[ ( -f $file || -d $file ) && ! -L $file ]] || return 1
|
|
canonical=$(/usr/bin/realpath -e -- "$file") || return 1
|
|
[[ $canonical == "$file" ]] || return 1
|
|
owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1
|
|
mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1
|
|
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
|
|
|
|
current=${file%/*}
|
|
while :; do
|
|
[[ -d $current && ! -L $current ]] || return 1
|
|
canonical=$(/usr/bin/realpath -e -- "$current") || return 1
|
|
[[ $canonical == "$current" ]] || return 1
|
|
read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1
|
|
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
|
|
[[ $current == / ]] && break
|
|
current=${current%/*}
|
|
[[ -n $current ]] || current=/
|
|
done
|
|
}
|
|
|
|
verify_boot_cleanup() {
|
|
local active_rules hook
|
|
[[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1
|
|
verify_root_path "$BOOT_CLEANUP_FILE" || return 1
|
|
active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1
|
|
[[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1
|
|
verify_root_path "$PACKAGE_HOOK" || return 1
|
|
hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1
|
|
[[ $hook == '[Trigger]
|
|
Operation = Upgrade
|
|
Operation = Remove
|
|
Type = Package
|
|
Target = omarchy-settings
|
|
Target = omarchy-settings-dev
|
|
|
|
[Action]
|
|
Description = Revoking temporary Omarchy sudo grants before settings changes...
|
|
When = PreTransaction
|
|
Exec = /usr/bin/omarchy-sudo-passwordless __package-removing
|
|
AbortOnFail' ]]
|
|
}
|
|
|
|
package_removing_locked() {
|
|
# ALPM must abort before removing the helper or boot cleanup if revocation
|
|
# fails. The marker also blocks publication after this lock is released.
|
|
(umask 077; : >"$REMOVAL_BLOCKER") || return 1
|
|
/usr/bin/rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-* || return 1
|
|
cleanup_all_locked
|
|
}
|
|
|
|
migration_complete() {
|
|
[[ -f $MIGRATION_MARKER && ! -s $MIGRATION_MARKER ]] && verify_root_path "$MIGRATION_MARKER"
|
|
}
|
|
|
|
migrate_locked() {
|
|
local directory
|
|
if migration_complete; then
|
|
return 0
|
|
fi
|
|
[[ ! -e $MIGRATION_MARKER && ! -L $MIGRATION_MARKER ]] || return 1
|
|
verify_root_path /var/lib || return 1
|
|
for directory in /var/lib/omarchy /var/lib/omarchy/migrations; do
|
|
if [[ ! -e $directory && ! -L $directory ]]; then
|
|
/usr/bin/install -d -o root -g root -m 0755 -- "$directory" || return 1
|
|
fi
|
|
verify_root_path "$directory" || return 1
|
|
done
|
|
cleanup_all_locked || return 1
|
|
# The empty marker is written only after cleanup succeeds, under the same
|
|
# machine lock. Later accounts need no sudo and cannot revoke newer grants.
|
|
/usr/bin/install -o root -g root -m 0644 /dev/null "$MIGRATION_MARKER"
|
|
}
|
|
|
|
# Old callbacks only remove an expired current rule. Renewing a grant never
|
|
# needs a second state file or a stored timer generation to identify it.
|
|
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"
|
|
else
|
|
status=$?
|
|
if (( status == STATUS_INACTIVE )); then
|
|
return 0
|
|
else
|
|
cleanup_uid_locked "$1"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
status_locked() {
|
|
local status now
|
|
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
|
|
fi
|
|
cleanup_uid_locked "$1" || return 2
|
|
return "$STATUS_INACTIVE"
|
|
else
|
|
status=$?
|
|
return "$status"
|
|
fi
|
|
}
|
|
|
|
finish_enable() {
|
|
local status=$?
|
|
trap - EXIT HUP INT TERM
|
|
if (( status != 0 )); then
|
|
if cleanup_uid_locked "$uid"; then
|
|
[[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true
|
|
else
|
|
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"
|
|
exit "$status"
|
|
}
|
|
|
|
enable_locked() (
|
|
local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status
|
|
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
|
|
status=$?
|
|
(( status == STATUS_INACTIVE )) || return 1
|
|
fi
|
|
trap finish_enable EXIT
|
|
omarchy_security_install_signal_exit_traps
|
|
pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || 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
|
|
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=""
|
|
# 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" status
|
|
shift
|
|
case "$action" in
|
|
__status)
|
|
(($# == 1)) && verify_sudo_caller "$1" || return 2
|
|
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_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
|
|
with_root_lock cleanup_uid_locked "$1"
|
|
;;
|
|
__expire)
|
|
(($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1
|
|
[[ -z ${2:-} || $2 =~ ^omarchy-nopasswd-expire-${1}-[0-9a-f]{32}$ ]] || return 1
|
|
with_root_lock expire_locked "$@"
|
|
;;
|
|
__migration-complete)
|
|
(($# == 0)) && migration_complete
|
|
;;
|
|
__migrate)
|
|
(($# == 0)) && ((EUID == 0)) || return 1
|
|
with_root_lock migrate_locked
|
|
;;
|
|
__cleanup-all)
|
|
(($# == 0)) && ((EUID == 0)) || return 1
|
|
with_root_lock cleanup_all_locked
|
|
;;
|
|
__package-removing)
|
|
(($# == 0)) && ((EUID == 0)) || return 1
|
|
with_root_lock package_removing_locked
|
|
;;
|
|
*) return 1 ;;
|
|
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
|
|
shift
|
|
root_dispatch "$action" "$@"
|
|
exit
|
|
;;
|
|
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:-}
|
|
(($# == 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
|
|
exit 1
|
|
}
|
|
|
|
omarchy_security_sudo_supports_no_update || {
|
|
echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2
|
|
exit 1
|
|
}
|
|
|
|
omarchy_security_install_sudo_cleanup_traps
|
|
/usr/bin/sudo -k >/dev/null 2>&1 || {
|
|
echo "Could not start from a cold sudo credential state." >&2
|
|
exit 1
|
|
}
|
|
|
|
# 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
|
|
# 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 $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 ""
|
|
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 $duration? This is a significant security risk!"; then
|
|
/usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes"
|
|
echo ""
|
|
show_enabled
|
|
else
|
|
echo "Aborted. No changes made."
|
|
fi
|
|
fi
|