#!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. # omarchy:args=[MINUTES] # 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 DEFAULT_MINUTES=15 readonly MAX_MINUTES=1440 readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless 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 INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 usage() { echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&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_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 names and the legacy filename both have metacharacters. Omarchy # accounts use this portable 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" } prepare_root_state() { omarchy_security_assert_root_directory /var 755 || return 1 [[ -d /var/lib && ! -L /var/lib ]] || return 1 [[ $(/usr/bin/stat -Lc '%u' /var/lib) == 0 ]] || return 1 ! ((8#$(/usr/bin/stat -Lc '%a' /var/lib) & 022)) || return 1 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_assert_root_directory /var/lib/omarchy 755 || return 1 omarchy_security_prepare_private_root_directory "$STATE_DIR" /var/lib/omarchy || return 1 omarchy_security_assert_root_directory /run 755 || return 1 if [[ ! -e /run/omarchy && ! -L /run/omarchy ]]; then /usr/bin/install -d -o root -g root -m 0755 /run/omarchy || return 1 fi omarchy_security_assert_root_directory /run/omarchy 755 || return 1 omarchy_security_prepare_private_root_directory "$RUNTIME_DIR" /run/omarchy } 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 /usr/bin/flock -x "$fd" || return 1 "$@" || rc=$? /usr/bin/flock -u "$fd" || rc=1 exec {fd}>&- return "$rc" } rule_file() { printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" } state_file() { printf '%s/%s.state' "$STATE_DIR" "$1" } read_state_record() { local uid="$1" file state_uid name expires timer canonical_uid local -a lines=() valid_uid "$uid" || return 1 canonical_uid=$((10#$uid)) file=$(state_file "$uid") [[ -f $file && ! -L $file ]] || return 1 mapfile -t lines <"$file" || return 1 (( ${#lines[@]} == 4 )) || return 1 [[ ${lines[0]} == UID=* && ${lines[1]} == USER=* && ${lines[2]} == EXPIRES=* && ${lines[3]} == TIMER=* ]] || return 1 state_uid=${lines[0]#UID=} name=${lines[1]#USER=} expires=${lines[2]#EXPIRES=} timer=${lines[3]#TIMER=} [[ $state_uid == "$canonical_uid" ]] || return 1 valid_account_name "$name" || return 1 [[ $expires =~ ^[1-9][0-9]{0,10}$ ]] || return 1 [[ $timer =~ ^omarchy-nopasswd-expire-${canonical_uid}-[0-9a-f]{32}$ ]] || return 1 printf '%s\t%s\t%s' "$name" "$expires" "$timer" } read_state_timer() { local record record=$(read_state_record "$1") || return 1 printf '%s' "${record##*$'\t'}" } current_epoch() { local now now=$(/usr/bin/date +%s) || return 1 [[ $now =~ ^[1-9][0-9]{0,10}$ ]] || return 1 printf '%s' "$now" } valid_expiry() { [[ $1 =~ ^[1-9][0-9]{0,10}$ ]] } valid_timer_for_uid() { local uid="$1" timer="$2" valid_uid "$uid" || return 1 uid=$((10#$uid)) [[ $timer =~ ^omarchy-nopasswd-expire-${uid}-[0-9a-f]{32}$ ]] } stop_timer() { local timer="$1" [[ $timer =~ ^omarchy-nopasswd-expire-[0-9]+-[0-9a-f]{32}$ ]] || return 0 /usr/bin/systemctl stop "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true /usr/bin/systemctl reset-failed "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true } classify_generated_rule() { local file=$1 suffix contents name GENERATED_RULE_LEGACY_TIMER="" [[ -f $file && ! -L $file ]] || return 1 contents=$(/usr/bin/cat -- "$file") || return 2 suffix=${file##*/99-omarchy-nopasswd-} if [[ $suffix =~ ^[0-9]+$ ]]; then 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$ ]] elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" else return 1 fi } remove_known_legacy_rules() { local file classification failed=0 shopt -s nullglob for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do if classify_generated_rule "$file"; then # A crash after publishing the numeric rule but before its state rename # must not survive the next boot. Do not require the account to still # exist: a deleted account could otherwise make the rule immortal and a # later username reuse could activate it again. if /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]; then [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true else failed=1 fi else classification=$? # An unreadable candidate cannot be proven inert. A symlink, non-file, # or administrator-authored body is unrelated and remains untouched. (( classification == 1 )) || failed=1 fi done shopt -u nullglob return "$failed" } cleanup_uid_locked() { local uid="$1" timer="" valid_uid "$uid" || return 1 timer=$(read_state_timer "$uid" 2>/dev/null || true) # Remove policy first. A failed timer stop can only leave an inert cleanup # job behind, never extend passwordless access. /usr/bin/rm -f -- "$(rule_file "$uid")" || return 1 [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 /usr/bin/rm -f -- "$(state_file "$uid")" || return 1 [[ -z $timer ]] || stop_timer "$timer" } cleanup_all_locked() { local state uid failed=0 file classification shopt -s nullglob for state in "$STATE_DIR"/*.state; do uid=${state##*/} uid=${uid%.state} if valid_uid "$uid" && ! cleanup_uid_locked "$uid"; then failed=1; fi done shopt -u nullglob remove_known_legacy_rules || failed=1 # Never report a successful boot cleanup while an exact rule emitted by any # Omarchy implementation is still active. Administrator-extended files do # not match these complete bodies and remain untouched. shopt -s nullglob for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do if classify_generated_rule "$file"; then failed=1 else classification=$? (( classification == 1 )) || failed=1 fi done shopt -u nullglob return "$failed" } verify_root_policy_file() { local file=$1 owner mode canonical current [[ -f $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_policy_file "$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_policy_file "$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 } prepare_state_file() { local uid="$1" name="$2" expires="$3" timer="$4" tmp tmp=$(/usr/bin/mktemp "$STATE_DIR/.state.XXXXXX") || return 1 if ! /usr/bin/printf 'UID=%s\nUSER=%s\nEXPIRES=%s\nTIMER=%s\n' \ "$uid" "$name" "$expires" "$timer" >"$tmp" || ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0600 "$tmp"; then /usr/bin/rm -f -- "$tmp" return 1 fi printf '%s' "$tmp" } start_expiry_timer() { local uid="$1" expires="$2" timer="$3" valid_uid "$uid" && valid_expiry "$expires" && valid_timer_for_uid "$uid" "$timer" || return 1 # Calendar timers use CLOCK_REALTIME and catch up immediately after resume; # a monotonic OnActiveSec timer pauses while the machine is suspended. /usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \ --timer-property=AccuracySec=1s --unit="$timer" \ -- "$INSTALLED_SELF" __expire "$uid" "$timer" || return 1 /usr/bin/systemctl is-active --quiet "${timer}.timer" } publish_rule() { local uid="$1" name="$2" expires="$3" destination tmp deadline valid_expiry "$expires" || return 1 deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 [[ $deadline =~ ^[0-9]{14}Z$ ]] || return 1 destination=$(rule_file "$uid") tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 if ! /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$name" "$deadline" >"$tmp" || ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || ! /usr/sbin/visudo -cf "$tmp" >/dev/null || ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then /usr/bin/rm -f -- "$tmp" return 1 fi /usr/bin/rm -f -- "$tmp" } abort_enable_locked() { local uid=$1 timer=$2 old_timer=$3 pending_state=$4 # Publication can install policy and then fail while cleaning its temporary # file. Never disarm either expiry job until policy revocation is confirmed. if cleanup_uid_locked "$uid"; then stop_timer "$timer" [[ -z $old_timer ]] || stop_timer "$old_timer" else echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2 fi /usr/bin/rm -f -- "$pending_state" || true return 1 } enable_locked() { local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now resolve_account "$uid" || return 1 valid_minutes "$minutes" || return 1 prepare_root_state || return 1 verify_boot_cleanup || { echo "omarchy-sudo-passwordless: package-owned boot cleanup or transaction hook is missing or unsafe" >&2 return 1 } old_timer=$(read_state_timer "$uid" 2>/dev/null || true) token=$(/usr/bin/tr -d '-' = 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer" || ! verify_boot_cleanup; then # The timer may have expired or failed between its initial verification and # rule publication. Revoke synchronously so a suspended or heavily loaded # machine cannot turn a short grant into a reboot-long one. abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" } status_locked() { local uid="$1" record state_name expires timer now remainder resolve_account "$uid" || return 2 if [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]]; then return "$STATUS_INACTIVE" fi record=$(read_state_record "$uid") || { revoke_inactive_grant "$uid" return $? } state_name=${record%%$'\t'*} remainder=${record#*$'\t'} expires=${remainder%%$'\t'*} timer=${record##*$'\t'} [[ $state_name == "$ACCOUNT_NAME" ]] || { revoke_inactive_grant "$uid" return $? } now=$(current_epoch) || { revoke_inactive_grant "$uid" return $? } ((10#$now < 10#$expires)) || { revoke_inactive_grant "$uid" return $? } /usr/bin/systemctl is-active --quiet "${timer}.timer" || { revoke_inactive_grant "$uid" return $? } } revoke_inactive_grant() { if cleanup_uid_locked "$1"; then return "$STATUS_INACTIVE" else echo "Could not revoke invalid or expired passwordless sudo. Administrator cleanup is required." >&2 return 2 fi } expire_locked() { local uid=$1 timer=${2:-} current_timer status if [[ -n $timer ]]; then current_timer=$(read_state_timer "$uid" 2>/dev/null || true) # A delayed predecessor must not revoke a newer, independently timed grant. [[ -z $current_timer || $current_timer == "$timer" ]] || return 0 cleanup_uid_locked "$uid" elif status_locked "$uid"; then # Compatibility with already scheduled UID-only jobs: enforce the current # grant's expiry instead of letting an old timer shorten its replacement. return 0 else status=$? (( status == STATUS_INACTIVE )) fi } root_dispatch() { local action="$1" shift case "$action" in __status) (($# == 1)) && verify_sudo_caller "$1" || return 2 with_root_lock status_locked "$1" ;; __enable) (($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1 with_root_lock enable_locked "$1" "$2" ;; __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:-} ]] || valid_timer_for_uid "$1" "$2" || return 1 with_root_lock expire_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 } case "${1:-}" in __status|__enable|__disable|__expire|__cleanup-all|__package-removing) action=$1 shift root_dispatch "$action" "$@" exit ;; esac (($# <= 1)) || usage minutes=${1:-$DEFAULT_MINUTES} valid_minutes "$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 } 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 timer updated. It will automatically disable in ${minutes} minutes." fi else status=$? if (( 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 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 "" 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 timer fires." echo "Run this command again to disable it early." echo "" if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? 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." else echo "Aborted. No changes made." fi fi