Files
omarchy/bin/omarchy-sudo-passwordless
T

483 lines
16 KiB
Bash
Executable File

#!/bin/bash -p
# omarchy:summary=Toggle passwordless sudo for the current user.
# omarchy:args=[MINUTES]
# omarchy:requires-sudo=true
source "${BASH_SOURCE[0]%/*}/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
}
unset BASH_ENV ENV
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 INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless
usage() {
echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2
echo "MINUTES must be between 1 and $MAX_MINUTES." >&2
exit 1
}
valid_minutes() {
[[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= MAX_MINUTES))
}
valid_uid() {
[[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= 4294967294))
}
valid_account_name() {
[[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]
}
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.
/usr/bin/rm -f -- "$file" || failed=1
[[ -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
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
/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
[[ -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" || 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"
}
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
/usr/bin/rm -f -- "$pending_state"
# Preserve the predecessor fix's fail-closed extension semantics: a caller
# must never mistake a failed replacement for a safely extended grant.
# The old timer is still armed here, but revoking the old rule as well is
# the unambiguous failure state.
cleanup_uid_locked "$uid" || true
return 1
fi
if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then
stop_timer "$timer"
/usr/bin/rm -f -- "$pending_state"
cleanup_uid_locked "$uid" || true
return 1
fi
if ! publish_rule "$uid" "$ACCOUNT_NAME"; then
stop_timer "$timer"
/usr/bin/rm -f -- "$(state_file "$uid")" "$(rule_file "$uid")"
return 1
fi
now=$(current_epoch) || {
cleanup_uid_locked "$uid" || true
return 1
}
if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer"; 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.
cleanup_uid_locked "$uid" || true
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 1
[[ -f $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1
record=$(read_state_record "$uid") || {
cleanup_uid_locked "$uid"
return 1
}
state_name=${record%%$'\t'*}
remainder=${record#*$'\t'}
expires=${remainder%%$'\t'*}
timer=${record##*$'\t'}
[[ $state_name == "$ACCOUNT_NAME" ]] || {
cleanup_uid_locked "$uid"
return 1
}
now=$(current_epoch) || {
cleanup_uid_locked "$uid"
return 1
}
((10#$now < 10#$expires)) || {
cleanup_uid_locked "$uid"
return 1
}
/usr/bin/systemctl is-active --quiet "${timer}.timer" || {
cleanup_uid_locked "$uid"
return 1
}
}
root_dispatch() {
local action="$1"
shift
case "$action" in
__status)
(($# == 1)) && verify_sudo_caller "$1" || return 1
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)) && ((EUID == 0)) && valid_uid "$1" || return 1
with_root_lock cleanup_uid_locked "$1"
;;
__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
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