From 87625c2ad8cf0e0bc08676127548a532d318036e Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 31 Aug 2026 21:27:48 +0100 Subject: [PATCH] OM-SEC-01: Make passwordless sudo expiry fail closed --- bin/omarchy-security-functions | 87 ++++ bin/omarchy-sudo-passwordless | 491 +++++++++++++++++++-- etc/tmpfiles.d/omarchy-nopasswd-sudo.conf | 8 +- manual/48-security.md | 2 +- migrations/1788163635.sh | 6 + test/shell.d/nopasswd-sudo-expiry-test.sh | 509 +++++++++++++++++----- 6 files changed, 955 insertions(+), 148 deletions(-) create mode 100755 bin/omarchy-security-functions create mode 100644 migrations/1788163635.sh mode change 100644 => 100755 test/shell.d/nopasswd-sudo-expiry-test.sh diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000..2f3d2242 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,87 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal fail-closed helpers for security-sensitive commands + +# Shared fail-closed primitives for security-sensitive Omarchy commands. This +# file is sourced from the same package-owned bin directory as its consumers. + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + local pid=${1:-$$} + + [[ $- == *p* && $pid =~ ^[1-9][0-9]*$ ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1 + executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1 + [[ $executable == /usr/bin/bash ]] + [[ ${argv[0]:-} == /bin/bash || ${argv[0]:-} == /usr/bin/bash ]] + [[ ${argv[1]:-} == -p ]] + ' omarchy-bash-startup "$pid" +} + +omarchy_security_sudo_supports_no_update() { + LC_ALL=C /usr/bin/sudo -h 2>&1 | + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k >/dev/null 2>&1 +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" +} + +omarchy_security_install_sudo_cleanup_traps() { + OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} + trap omarchy_security_run_sudo_cleanup_trap EXIT + omarchy_security_install_signal_exit_traps +} + +omarchy_security_assert_root_directory() { + local path=$1 expected_mode=$2 canonical owner actual_mode + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 719d881b..717e8aaf 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,70 +1,481 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. # omarchy:args=[MINUTES] # omarchy:requires-sudo=true -NOPASSWD_FILE="/etc/sudoers.d/99-omarchy-nopasswd-${USER}" -TIMER_NAME="omarchy-nopasswd-expire-${USER}" +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 -MINUTES=${1:-15} -if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then - echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 - exit 1 +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 -arm_expiry() { - if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm -f -- "$NOPASSWD_FILE"; then - return 0 - fi +set -euo pipefail - echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2 - if ! sudo rm -f -- "$NOPASSWD_FILE"; then - echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2 +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 - return 1 + 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 '-' = 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..." - -# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up -if sudo test -f "$NOPASSWD_FILE" && ! systemctl is-active "${TIMER_NAME}.timer" &>/dev/null; then - sudo rm "$NOPASSWD_FILE" -fi - -# Check for the file directly — sudo -n can stay cached or be granted by other rules -if sudo test -f "$NOPASSWD_FILE"; then - if [[ $1 ]]; then - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null - arm_expiry || exit 1 - echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." - else - sudo rm "$NOPASSWD_FILE" - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null +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 "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 "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 gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then - echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null - sudo chmod 440 "$NOPASSWD_FILE" - arm_expiry || exit 1 - + 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." - echo "A restart removes the passwordless sudo rule as well." + echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." else echo "Aborted. No changes made." fi diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf index 2c644ff1..be81137a 100644 --- a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -1,5 +1,5 @@ -# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd- and -# arms a transient systemd-run timer to remove it again. Transient units do not -# survive a reboot, so remove any remaining grant during early boot. Boot-only -# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short. +# omarchy-sudo-passwordless creates grants in this owned filename namespace. +# Transient expiry timers do not survive reboot, so early boot removes every +# remaining grant. The boot-only modifier prevents later tmpfiles runs from +# shortening a live, explicitly requested window. r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/manual/48-security.md b/manual/48-security.md index 45750398..86028a23 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,7 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough. Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk. diff --git a/migrations/1788163635.sh b/migrations/1788163635.sh new file mode 100644 index 00000000..e2fe2272 --- /dev/null +++ b/migrations/1788163635.sh @@ -0,0 +1,6 @@ +echo "Remove legacy temporary passwordless sudo grants" + +# This removes current numeric grants, exact legacy username grants, corrupt or +# orphaned state, and their known timers. Administrator-authored sudoers files +# whose contents do not exactly match Omarchy's generated grammar are preserved. +sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh old mode 100644 new mode 100755 index f332f80e..6d54633c --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -2,122 +2,425 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -script="$ROOT/bin/omarchy-sudo-passwordless" -tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +command_path="$ROOT/bin/omarchy-sudo-passwordless" +security_library_path="$ROOT/bin/omarchy-security-functions" +tmpfiles_path="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +migration_path="$ROOT/migrations/1788163635.sh" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT -mock_bin="$test_tmp/bin" -grant="$test_tmp/grant" -calls="$test_tmp/calls" -mkdir -p "$mock_bin" - -cat >"$mock_bin/gum" <<'SH' -#!/bin/bash -exit 0 -SH - -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash - -printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" -[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] -SH - -cat >"$mock_bin/sudo" <<'SH' -#!/bin/bash - -printf 'sudo %s\n' "$*" >>"$TEST_CALLS" - -case ${1:-} in -test) - [[ ${2:-} == "-f" && -f $TEST_GRANT ]] - ;; -tee) - /usr/bin/tee "$TEST_GRANT" - ;; -chmod) - /usr/bin/chmod "$2" "$TEST_GRANT" - ;; -systemd-run) - [[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]] - ;; -rm) - /usr/bin/rm -f -- "$TEST_GRANT" - ;; -systemctl) - exit 0 - ;; -*) - echo "unexpected sudo command: $*" >&2 - exit 90 - ;; -esac -SH - -chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" - -run_command() { - TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ - "$script" "$@" +function_prefix() { + printf 'source %q\n' "$security_library_path" + awk '/^source .*omarchy-security-functions/ { next } /^case "\$\{1:-\}" in$/ { exit } { print }' "$command_path" } -: >"$calls" -enable_output=$(run_command 15) -[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled" -[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] || - fail "the enabled grant belongs to the current user" "$(cat "$grant")" -grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" || - fail "enabling arms the expiry timer" "$(cat "$calls")" -[[ $enable_output == *"automatically disable in 15 minutes"* ]] || - fail "success is reported after the timer is armed" "$enable_output" -pass "enabling arms expiry before reporting success" +# Exercise the validation code itself. Leading zeroes remain numeric, but zero, +# negatives, oversized grants, and shell syntax are rejected. +( + source <(function_prefix) + for minutes in 1 15 1440 00015; do + valid_minutes "$minutes" || fail "passwordless sudo accepts bounded duration $minutes" + done + for minutes in 0 1441 -1 1m '1;id' ''; do + ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" + done +) +pass "passwordless sudo validates a bounded positive duration" -: >"$calls" -rm -f "$grant" -if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then - fail "enabling fails when the expiry timer cannot be armed" +# The public entry point uses the kernel-backed numeric identity; $USER is +# never interpolated into a privileged filename or sudoers rule. +grep -F 'uid=$(/usr/bin/id -u)' "$command_path" >/dev/null || + fail "passwordless sudo derives the caller from id -u" +! grep -Eq '\$\{?USER\}?' "$command_path" || + fail "passwordless sudo does not trust USER for privileged policy" +grep -F '[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]]' "$command_path" >/dev/null || + fail "passwordless sudo validates sudo provenance" +pass "passwordless sudo derives and validates trusted account identity" + +# Status inspection and the confirmation UI are mixed-trust: a normal sudo +# status call would publish a timestamp that a hostile prompt helper could use +# even when the user declines the grant. Exercise the public flow with a sudo +# model that publishes a token only when -N is missing. +grep -Fxq '#!/bin/bash -p' "$command_path" || + fail "passwordless sudo no longer suppresses Bash startup injection" +grep -F '[[ ${argv[1]:-} == -p ]]' "$security_library_path" >/dev/null || + fail "passwordless sudo accepts a decoy post-script -p" + +public_sudo_stub="$test_tmp/public-sudo" +public_gum_stub="$test_tmp/public-gum" +public_token="$test_tmp/public-token" +public_exploit="$test_tmp/public-exploit" +cat >"$public_sudo_stub" <<'STUB' +#!/bin/bash +if [[ ${1:-} == -h ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' + exit 0 fi -[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant" -[[ $failure_output == *"Revoking access now"* ]] || - fail "timer setup failure explains the fail-closed revocation" "$failure_output" -[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] || - fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output" -pass "timer setup failure revokes a new grant" - -: >"$calls" -printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant" -if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then - fail "updating fails when the replacement expiry timer cannot be armed" +if [[ ${1:-} == -k ]]; then + rm -f -- "$TEST_PUBLIC_TOKEN" + exit 0 fi -[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant" -[[ $update_output != *"timer updated"* ]] || - fail "timer update failure does not report success" "$update_output" -pass "timer update failure revokes the existing grant" +no_update=0 +if [[ ${1:-} == -N ]]; then no_update=1; shift; fi +[[ ${1:-} != -- ]] || shift +((no_update)) || : >"$TEST_PUBLIC_TOKEN" +case "${2:-}" in + __status) exit 1 ;; + __enable|__disable) exit 0 ;; + *) exit 2 ;; +esac +STUB +cat >"$public_gum_stub" <<'STUB' +#!/bin/bash +[[ ! -e $TEST_PUBLIC_TOKEN ]] || : >"$TEST_PUBLIC_EXPLOIT" +exit 1 +STUB +chmod 0755 "$public_sudo_stub" "$public_gum_stub" +public_flow="$test_tmp/passwordless-public-flow" +/usr/bin/cp "$security_library_path" "$test_tmp/omarchy-security-functions" +/usr/bin/sed \ + -e "s#/usr/bin/sudo#$public_sudo_stub#g" \ + -e "s#/usr/bin/gum#$public_gum_stub#g" \ + "$command_path" >"$public_flow" +chmod 0755 "$public_flow" +TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ + /usr/bin/bash -p "$public_flow" 15 >/dev/null +[[ ! -e $public_token && ! -e $public_exploit ]] || + fail "passwordless confirmation inherited a reusable status credential" -mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") -(( ${#tmpfiles_rules[@]} == 1 )) || - fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" +startup_env="$test_tmp/passwordless-bash-env" +startup_marker="$test_tmp/passwordless-bash-env-ran" +cat >"$startup_env" <<'STUB' +: >"$TEST_STARTUP_MARKER" +set -o privileged +unset BASH_ENV +STUB +if BASH_ENV="$startup_env" TEST_STARTUP_MARKER="$startup_marker" \ + /usr/bin/bash "$public_flow" -p >/dev/null 2>&1; then + fail "passwordless sudo accepted an unsafe interpreter with a decoy -p" +fi +[[ -e $startup_marker && ! -e $public_token && ! -e $public_exploit ]] || + fail "unsafe passwordless startup reached its sudo workflow" +pass "passwordless confirmation uses a cold command-scoped credential boundary" -fake_root="$test_tmp/root" +# Source a path-rewritten copy so the real cleanup implementation can be +# exercised without touching /etc. Exact generated numeric rules are removed +# even after account deletion or a crash before state publication. Anything an +# administrator changed, and every symlink, is preserved. +fake_sudoers="$test_tmp/sudoers.d" +mkdir "$fake_sudoers" +rewritten="$test_tmp/passwordless-lib.sh" +function_prefix | sed "s#/etc/sudoers.d#$fake_sudoers#g" >"$rewritten" +( + source "$rewritten" + printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$fake_sudoers/99-omarchy-nopasswd-424242" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' >"$fake_sudoers/99-omarchy-nopasswd-424243" + ln -s "$fake_sudoers/99-omarchy-nopasswd-424243" "$fake_sudoers/99-omarchy-nopasswd-424244" + remove_known_legacy_rules +) +[[ ! -e $fake_sudoers/99-omarchy-nopasswd-424242 ]] || + fail "boot cleanup removes a state-less numeric orphan" +[[ -f $fake_sudoers/99-omarchy-nopasswd-424243 ]] || + fail "boot cleanup preserves administrator-authored policy" +[[ -L $fake_sudoers/99-omarchy-nopasswd-424244 ]] || + fail "boot cleanup refuses sudoers symlinks" +pass "boot cleanup removes crash/deleted-account orphans conservatively" + +# A boot gate must not report success when deletion itself fails. Exercise the +# real cleanup and post-cleanup verification with a deterministic failing rm. +rm_failure_dir="$test_tmp/rm-failure-sudoers" +mkdir "$rm_failure_dir" +printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$rm_failure_dir/99-omarchy-nopasswd-424245" +failing_rm="$test_tmp/failing-rm" +cat >"$failing_rm" <<'FAILING_RM' +#!/bin/bash +exit 1 +FAILING_RM +chmod +x "$failing_rm" +rm_failure_lib="$test_tmp/rm-failure-lib.sh" +function_prefix | + sed -e "s#/etc/sudoers.d#$rm_failure_dir#g" \ + -e "s#/var/lib/omarchy/sudo-passwordless#$test_tmp/empty-state#g" \ + -e "s#/usr/bin/rm#$failing_rm#g" >"$rm_failure_lib" +mkdir "$test_tmp/empty-state" +( + source "$rm_failure_lib" + ! cleanup_all_locked +) || fail "boot cleanup fails when an Omarchy rule cannot be removed" +[[ -f $rm_failure_dir/99-omarchy-nopasswd-424245 ]] || + fail "rm-failure fixture remains available for verification" +pass "boot cleanup fails closed when policy deletion fails" + +# Reproduce the migration's real sudo provenance: sudo sets SUDO_UID. Rewrite +# only the read-only EUID probe so this unprivileged test can exercise the root +# dispatcher, then assert that cleanup (which can only revoke privilege) runs. +dispatch_lib="$test_tmp/dispatch-lib.sh" +function_prefix | sed 's/((EUID == 0))/((TEST_EUID == 0))/g' >"$dispatch_lib" +( + source "$dispatch_lib" + called="" + cleanup_all_locked() { called=cleanup; } + with_root_lock() { "$@"; } + TEST_EUID=0 SUDO_UID=1000 root_dispatch __cleanup-all + [[ $called == cleanup ]] +) || fail "migration cleanup dispatch accepts authenticated sudo provenance" +pass "migration can invoke fail-closed cleanup through sudo" + +# A grant cannot be published until the static unit is verified/enabled, and a +# timer setup failure removes its pending state without calling publish_rule. +transaction_dir="$test_tmp/transaction" +mkdir "$transaction_dir" +transaction_lib="$test_tmp/transaction-lib.sh" +function_prefix | sed "s#/var/lib/omarchy/sudo-passwordless#$transaction_dir#g" >"$transaction_lib" +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 1; } + publish_rule() { return 99; } + ! enable_locked 1000 15 +) +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + publish_rule() { printf published >"$transaction_dir/published"; } + cleanup_uid_locked() { : >"$transaction_dir/failed-timer-cleanup"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/pending && ! -e $transaction_dir/published && + -e $transaction_dir/failed-timer-cleanup ]] +) || fail "passwordless sudo fails closed on prerequisite/timer failure" +pass "passwordless sudo publishes no rule after partial setup failure" + +# Erik's predecessor fix revoked an already-active grant when an extension +# could not arm its replacement timer. Keep that fail-closed property while +# the new transaction deliberately leaves the old timer armed until the new +# one is verified. +replacement_state="$transaction_dir/1000.state" +replacement_rule="$transaction_dir/1000.rule" +replacement_stopped="$transaction_dir/old-timer-stopped" +old_timer=omarchy-nopasswd-expire-1000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +printf 'UID=1000\nUSER=audituser\nEXPIRES=2000000000\nTIMER=%s\n' "$old_timer" >"$replacement_state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$replacement_rule" +( + source "$transaction_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + state_file() { printf '%s' "$replacement_state"; } + rule_file() { printf '%s' "$replacement_rule"; } + prepare_state_file() { local pending="$transaction_dir/replacement-pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + stop_timer() { [[ $1 == "$old_timer" ]] && : >"$replacement_stopped"; } + ! enable_locked 1000 30 + [[ ! -e $replacement_state && ! -e $replacement_rule && -e $replacement_stopped ]] +) || fail "passwordless sudo leaves an existing grant live after replacement timer failure" +pass "replacement timer failure revokes the existing grant" + +# Expiry is a wall-clock promise, so the transient timer must carry the exact +# absolute epoch recorded in root state. A monotonic-only --on-active timer +# pauses during suspend and can otherwise extend a short grant by hours. +timer_args="$test_tmp/timer-args" +calendar_systemd_run="$test_tmp/calendar-systemd-run" +calendar_systemctl="$test_tmp/calendar-systemctl" +cat >"$calendar_systemd_run" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" >"$TEST_TIMER_ARGS" +STUB +cat >"$calendar_systemctl" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$calendar_systemd_run" "$calendar_systemctl" +calendar_lib="$test_tmp/calendar-lib.sh" +function_prefix | + sed -e "s#/usr/bin/systemd-run#$calendar_systemd_run#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$calendar_lib" +( + source "$calendar_lib" + TEST_TIMER_ARGS="$timer_args" start_expiry_timer 1000 2000000000 \ + omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +) || fail "passwordless sudo cannot arm its absolute expiry timer" +grep -Fx -- '--on-calendar=@2000000000' "$timer_args" >/dev/null || + fail "passwordless sudo timer does not advance across suspend" +pass "passwordless sudo arms the recorded absolute wall-clock expiry" + +# A resumed machine can briefly observe the timer as active before systemd +# dispatches its overdue service. Status must independently enforce EXPIRES and +# synchronously remove policy instead of trusting timer activity alone. +expired_state="$test_tmp/expired-state" +expired_sudoers="$test_tmp/expired-sudoers" +mkdir "$expired_state" "$expired_sudoers" +expired_timer=omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +printf 'UID=1000\nUSER=audituser\nEXPIRES=1\nTIMER=%s\n' "$expired_timer" >"$expired_state/1000.state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$expired_sudoers/99-omarchy-nopasswd-1000" +expired_lib="$test_tmp/expired-lib.sh" +function_prefix | + sed -e "s#/var/lib/omarchy/sudo-passwordless#$expired_state#g" \ + -e "s#/etc/sudoers.d#$expired_sudoers#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$expired_lib" +( + source "$expired_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + ! status_locked 1000 +) || fail "passwordless sudo accepts expired root state while its timer is active" +[[ ! -e $expired_state/1000.state && ! -e $expired_sudoers/99-omarchy-nopasswd-1000 ]] || + fail "passwordless sudo does not synchronously revoke expired state" +pass "passwordless sudo enforces wall-clock expiry independently of timer dispatch" + +# If the transient timer fires between its first active check and publication, +# the just-created rule must be synchronously revoked instead of surviving to +# reboot. Model that narrow transition with the real enable transaction. +inactive_systemctl="$test_tmp/inactive-systemctl" +cat >"$inactive_systemctl" <<'STUB' +#!/bin/bash +exit 1 +STUB +chmod 0755 "$inactive_systemctl" +post_publish_lib="$test_tmp/post-publish-lib.sh" +sed "s#/usr/bin/systemctl#$inactive_systemctl#g" "$transaction_lib" >"$post_publish_lib" +( + source "$post_publish_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending-after-arm"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 0; } + publish_rule() { : >"$transaction_dir/published-after-arm"; } + cleanup_uid_locked() { rm -f "$transaction_dir/published-after-arm"; : >"$transaction_dir/revoked-after-arm"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/published-after-arm && -e $transaction_dir/revoked-after-arm ]] +) || fail "passwordless sudo leaves a grant when its armed timer expires before publication completes" +pass "timer expiry during publication revokes the grant synchronously" + +# Follow the maintainer's package-owned tmpfiles design: one boot-only rule +# owns this filename namespace. A routine --remove leaves live grants alone; +# early boot removes them before a user can log in. The migration only revokes +# legacy runtime state and never writes static policy into /usr. +mapfile -t tmpfiles_rules < <(/usr/bin/grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_path") +(( ${#tmpfiles_rules[@]} == 1 )) || fail "passwordless sudo ships one boot cleanup rule" +[[ ${tmpfiles_rules[0]} == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || + fail "passwordless sudo boot cleanup does not own the exact generated namespace" +fake_root="$test_tmp/tmpfiles-root" sudoers_dir="$fake_root/etc/sudoers.d" mkdir -p "$sudoers_dir" -grant_names=(alice buildbot-2 user.123 'service$') -for grant_name in "${grant_names[@]}"; do - touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name" +for name in alice buildbot-2 424242; do + : >"$sudoers_dir/99-omarchy-nopasswd-$name" done -touch "$sudoers_dir/omarchy-dns" +: >"$sudoers_dir/omarchy-dns" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[0]}" +[[ -e $sudoers_dir/99-omarchy-nopasswd-alice ]] || fail "non-boot tmpfiles run shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[0]}" +! find "$sudoers_dir" -name '99-omarchy-nopasswd-*' -print -quit | /usr/bin/grep -q . || + fail "boot cleanup left a generated passwordless grant" +[[ -e $sudoers_dir/omarchy-dns ]] || fail "boot cleanup removed an unrelated sudoers rule" +/usr/bin/grep -Fx 'sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all' "$migration_path" >/dev/null +! /usr/bin/grep -q 'omarchy-sudo-passwordless-cleanup.service' "$migration_path" || + fail "migration retained a custom boot service instead of package-owned tmpfiles" +pass "package-owned boot cleanup is narrow, boot-only, and migration-safe" -systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}" -[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] || - fail "boot-only cleanup leaves a live grant alone outside boot" - -systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}" -for grant_name in "${grant_names[@]}"; do - stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name" - [[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant" +# Removing the settings package also removes the tmpfiles rule. Its package +# lifecycle must therefore revoke the same owned namespace synchronously, while +# preserving every unrelated sudoers file. +pkgs_candidates=( + "${OMARCHY_PKGS_PATH:-}" + "$ROOT/../omarchy-pkgs" + "$ROOT/../../omarchy-pkgs" + "$HOME/Work/omarchy/omarchy-pkgs" + "$HOME/Work/omacom/omarchy-pkgs" +) +pkgs_root="" +for candidate in "${pkgs_candidates[@]}"; do + if [[ -n $candidate && -d $candidate/pkgbuilds/omarchy-settings ]]; then + pkgs_root=$candidate + break + fi done -[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" -pass "systemd-tmpfiles removes generated grants only during boot" +[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" + +for package_name in omarchy-settings omarchy-settings-dev; do + install_script="$pkgs_root/pkgbuilds/$package_name/$package_name.install" + transformed_install="$test_tmp/$package_name.install" + removal_root="$test_tmp/$package_name-remove" + removal_sudoers="$removal_root/etc/sudoers.d" + mkdir -p "$removal_sudoers" + : >"$removal_sudoers/99-omarchy-nopasswd-1000" + : >"$removal_sudoers/99-omarchy-nopasswd-legacy-user" + : >"$removal_sudoers/omarchy-dns" + ln -s ../usr/share/omarchy/etc-overrides/os-release "$removal_root/etc/os-release" + grep -Fq 'ln -s ../usr/share/omarchy/etc-overrides/os-release /etc/os-release' "$install_script" || + fail "$package_name installation does not select package-owned OS metadata" + sed "s#/etc/#$removal_root/etc/#g" "$install_script" >"$transformed_install" + ( + source "$transformed_install" + post_remove + ) || fail "$package_name removal revokes active passwordless grants" + ! find "$removal_sudoers" -name '99-omarchy-nopasswd-*' -print -quit | grep -q . || + fail "$package_name removal leaves a passwordless grant behind" + [[ -e $removal_sudoers/omarchy-dns ]] || + fail "$package_name removal deletes an unrelated sudoers policy" + [[ -L $removal_root/etc/os-release ]] && + [[ $(readlink "$removal_root/etc/os-release") == ../usr/lib/os-release ]] || + fail "$package_name removal does not restore the standard OS selector" + + ln -sfn ../administrator/os-release "$removal_root/etc/os-release" + : >"$removal_sudoers/99-omarchy-nopasswd-1001" + ( + source "$transformed_install" + post_remove + ) || fail "$package_name removal handles administrator OS selector state" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + fail "$package_name removal overwrites an administrator OS selector" + [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1001 ]] || + fail "$package_name removal grant cleanup depends on OS selector state" +done +pass "settings package removal revokes grants and preserves package-selector ownership" + +# Exercise the production flock wrapper under contention. mkdir is an atomic +# overlap detector; all workers must enter and leave the protected region. +lock_dir="$test_tmp/lock-runtime" +mkdir "$lock_dir" +lock_lib="$test_tmp/lock-lib.sh" +function_prefix | + sed -e "s#/run/omarchy/sudo-passwordless#$lock_dir#g" \ + -e "s#/run/lock/omarchy-sudo-passwordless.lock#$test_tmp/passwordless.lock#g" \ + -e 's#/usr/bin/chown root:root "$LOCK_FILE"#/usr/bin/true#' >"$lock_lib" +worker="$test_tmp/worker.sh" +cat >"$worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$LOCK_LIB" +prepare_root_state() { :; } +critical() { + mkdir "$LOCK_SENTINEL" + sleep 0.03 + rmdir "$LOCK_SENTINEL" + printf x >>"$LOCK_RESULTS" +} +with_root_lock critical +WORKER +chmod +x "$worker" +for _ in {1..8}; do + LOCK_LIB="$lock_lib" LOCK_SENTINEL="$test_tmp/held" LOCK_RESULTS="$test_tmp/results" bash "$worker" & +done +wait +[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize" +pass "passwordless sudo serializes concurrent operations" + +# Same-boot expiry calls the fixed installed cleanup command, and cleanup +# removes policy before touching a timer so timer failures cannot extend it. +grep -F '"$INSTALLED_SELF" __expire "$uid"' "$command_path" >/dev/null +cleanup_body=$(awk '/^cleanup_uid_locked\(\) \{/ { in_body=1 } in_body { print } in_body && /^}/ { exit }' "$command_path") +rm_line=$(grep -n '/usr/bin/rm -f' <<<"$cleanup_body" | head -1 | cut -d: -f1) +stop_line=$(grep -n 'stop_timer' <<<"$cleanup_body" | tail -1 | cut -d: -f1) +((rm_line < stop_line)) || fail "expiry removes sudo policy before timer cleanup" +pass "same-boot expiration is fixed-target and fail closed"