The legacy command wrote the caller's unvalidated name into both the sudoers filename and the rule. Cleanup applied the current lower-case account pattern to that suffix, so an exact legacy grant for an account such as Alice was classified as administrator policy, left active, and the machine-wide migration marker was written anyway. Match a legacy grant by its exact filename and rule relationship instead of the account policy, and cover it in the lifecycle suite through both the unit cleanup and the real migration runner. The account pattern itself stays lower-case: sudoers reads an upper-case word such as ALICE as a User_Alias reference, and ALL as every user, so such names must never reach the generated rule. Pin that with a test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
427 lines
15 KiB
Bash
Executable File
427 lines
15 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 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 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
|
|
/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"
|
|
}
|
|
|
|
# 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 ]]
|
|
}
|
|
|
|
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=$?
|
|
(( classification == 1 )) || failed=1
|
|
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 '-' </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
|
|
# 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=""
|
|
now=$(/usr/bin/date +%s) || return 1
|
|
(( now < expires )) && verify_boot_cleanup && /usr/bin/systemctl is-active --quiet "$timer.timer"
|
|
)
|
|
|
|
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:-} || $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
|
|
}
|
|
|
|
case "${1:-}" in
|
|
__status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete)
|
|
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 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
|