#!/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 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]" >&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 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() { printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" } # 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 file=$(rule_file "$1") [[ -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-} # 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_uid_locked() { local file file=$(rule_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 ]] } # 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 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 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 echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2 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_minutes "$minutes" || return 1 verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1 file=$(rule_file "$uid") 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 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 /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 '-' &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 expiry 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 deadline." 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