Files
omarchy/bin/omarchy-sudo-passwordless
T

536 lines
18 KiB
Bash
Executable File

#!/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 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'}
valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) 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_boot_cleanup() {
local owner mode canonical current active_rules
[[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1
[[ -f $BOOT_CLEANUP_FILE && ! -L $BOOT_CLEANUP_FILE ]] || return 1
canonical=$(/usr/bin/realpath -e -- "$BOOT_CLEANUP_FILE") || return 1
[[ $canonical == "$BOOT_CLEANUP_FILE" ]] || return 1
owner=$(/usr/bin/stat -Lc '%u' -- "$BOOT_CLEANUP_FILE") || return 1
mode=$(/usr/bin/stat -Lc '%a' -- "$BOOT_CLEANUP_FILE") || return 1
[[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1
current=${BOOT_CLEANUP_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
active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1
[[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]]
}
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" destination tmp
destination=$(rule_file "$uid")
tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1
if ! /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$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 rule is missing or unsafe" >&2
return 1
}
old_timer=$(read_state_timer "$uid" 2>/dev/null || true)
token=$(/usr/bin/tr -d '-' </proc/sys/kernel/random/uuid)
[[ $token =~ ^[0-9a-f]{32}$ ]] || return 1
timer="omarchy-nopasswd-expire-${uid}-${token}"
now=$(current_epoch) || return 1
expires=$((10#$now + 10#$minutes * 60))
# State and a verified timer exist before the policy becomes reachable. If
# publication fails, cleanup removes both. During an update the old timer is
# deliberately kept until the replacement is active, so failure shortens the
# grant rather than extending it.
pending_state=$(prepare_state_file "$uid" "$ACCOUNT_NAME" "$expires" "$timer") || return 1
if ! start_expiry_timer "$uid" "$expires" "$timer"; then
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
return 1
fi
if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
return 1
fi
if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME"; then
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
return 1
fi
now=$(current_epoch) || {
abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state"
return 1
}
if ((10#$now >= 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
;;
*) return 1 ;;
esac
}
case "${1:-}" in
__status|__enable|__disable|__expire|__cleanup-all)
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