diff --git a/AGENTS.md b/AGENTS.md index e4a0084eadf..6de19cb9a50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience: - Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`) - Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable - For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`) -- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`) +- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000000..890d4430d80 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,137 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal helpers for command-scoped sudo authentication + +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() { + [[ $- == *p* ]] || 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 "$$" +} + +omarchy_security_sanitize_bash_environment() { + local script=$1 + shift + local entry name environment_fd environment_pid + local -a unsets=() + + # Read the raw environment: privileged Bash ignores exported functions, but + # leaves their records for ordinary child interpreters to import later. + exec {environment_fd}< <(/usr/bin/env -0) + environment_pid=$! + while IFS= read -r -d '' entry <&"$environment_fd"; do + name=${entry%%=*} + case "$name" in + BASH_ENV|ENV|SHELLOPTS|BASHOPTS|PS4|CDPATH|GLOBIGNORE|BASH_FUNC_*%%) + unsets+=(-u "$name") + ;; + esac + done + exec {environment_fd}<&- + wait "$environment_pid" || return 1 + if (( ${#unsets[@]} > 0 )); then + exec /usr/bin/env "${unsets[@]}" /usr/bin/bash -p -- "$script" "$@" + fi +} + +omarchy_security_require_source_root() { + local command_source command_name=${1##*/} + command_source=$(/usr/bin/readlink -e -- "$1") || return 1 + + # A runtime root selects the code used by this invocation. Accept the + # canonical checkout containing the entrypoint or the package's bin links. + if [[ ${OMARCHY_PATH:-} != /* || $(/usr/bin/realpath -e -- "$OMARCHY_PATH") != "$OMARCHY_PATH" ]] || + ! { [[ $command_source == "$OMARCHY_PATH/bin/$command_name" ]] || + [[ $OMARCHY_PATH == "/usr/share/omarchy" && $command_source == "/usr/bin/$command_name" ]]; }; then + echo "OMARCHY_PATH does not match this Omarchy command." >&2 + return 1 + fi +} + +omarchy_security_sudo_supports_no_update() { + local help + help=$(LC_ALL=C /usr/bin/sudo -h 2>&1) || return 1 + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' <<< "$help" +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k +} + +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_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_enable_no_update_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + if ! omarchy_security_sudo_supports_no_update; then + echo "This sudo does not support --no-update; refusing mixed-trust work." >&2 + return 1 + fi + if [[ ! -f $wrapper_dir/sudo || ! -x $wrapper_dir/sudo ]]; then + echo "The command-scoped sudo wrapper is missing." >&2 + return 1 + fi + PATH="$wrapper_dir:$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" + OMARCHY_SUDO_NO_UPDATE=1 + export PATH OMARCHY_SUDO_NO_UPDATE +} + +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_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 719d881bfe7..91695c69e1f 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,70 +1,420 @@ -#!/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}" +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 -MINUTES=${1:-15} -if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then +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 -fi +} + +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" +} + +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 +} + +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-} + + if [[ $suffix =~ ^[0-9]+$ ]]; then + 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$ ]] + elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then + return 0 + else + return 1 + fi +} + +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 -arm_expiry() { - if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm -f -- "$NOPASSWD_FILE"; then + 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" +} - 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 +# 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 - return 1 } -echo "Toggle passwordless sudo..." +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 +} -# 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 +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" +} -# 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." +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 - sudo rm "$NOPASSWD_FILE" - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null + status=$? + (( status == STATUS_INACTIVE )) || return 1 + fi + trap finish_enable EXIT + omarchy_security_install_signal_exit_traps + now=$(/usr/bin/date +%s) || return 1 + expires=$((now + 10#$minutes * 60)) + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1 + /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1 + /usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1 + /usr/sbin/visudo -cf "$pending" >/dev/null || return 1 + token=$(/usr/bin/tr -d '-' &2 + exit 1 +} + +omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2 + exit 1 +} + +omarchy_security_install_sudo_cleanup_traps +/usr/bin/sudo -k >/dev/null 2>&1 || { + echo "Could not start from a cold sudo credential state." >&2 + exit 1 +} + +echo "Toggle passwordless sudo..." +if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then + if (($# == 0)); then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" echo "Passwordless sudo has been DISABLED. Sudo will require a password again." + else + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes." fi else + status=$? + if (( status != STATUS_INACTIVE )); then + echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2 + exit 1 + fi echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" - echo "execute ANY command as root WITHOUT a password for ${MINUTES} minutes." + echo "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 deadline." 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/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook new file mode 100644 index 00000000000..f0bce15d324 --- /dev/null +++ b/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook @@ -0,0 +1,12 @@ +[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 diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md new file mode 100644 index 00000000000..393555040af --- /dev/null +++ b/docs/passwordless-sudo.md @@ -0,0 +1,27 @@ +# Temporary passwordless sudo + +`omarchy-sudo-passwordless` publishes a bounded grant for the numeric UID authenticated by sudo. Its user interface runs without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`. + +## Grant lifecycle + +The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed. + +An internal status result is `0` for an active, validated grant and `3` for confirmed inactive access. All other results are errors, including failed authentication and failed revocation. The user interface only offers a new grant after result `3`. It must not turn an inspection failure into a claim that no grant exists. + +Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone. + +Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted; generated legacy policy is removed conservatively and administrator-modified policy is preserved by the migration. + +## Package ownership + +The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. + +Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant. + +The runtime marker need not survive reboot: pre-removal revokes the old grants before package files disappear, and a new invocation independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. + +## Validation + +The two passwordless-sudo test suites share a private filesystem and command fixture. They cover caller validation, the public prompt boundary, atomic publication, renewal failures, expiry, old callbacks, machine migration, and the source/package lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory. An optional `OMARCHY_TEST_SUDOERS` path to sudo's upstream `testsudoers` executable evaluates the generated policy before and after its deadline without root or changing host policy. + +These local tests do not establish release readiness. The simplified candidate needs fresh installed-package, suspend/resume, boot-cleanup, and package-removal validation in a disposable VM. The shared security library and its interface are unchanged for downstream PRs. diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf index 2c644ff1fc9..be81137ad05 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 45750398e30..6db0dff5f17 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,9 @@ 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. + +Updating or removing Omarchy's settings package ends any temporary grant before its expiry support changes. If the command reports an authorization or cleanup error, resolve it before trying to enable another grant; an error does not mean passwordless access is inactive. 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 00000000000..7ce941a5ad0 --- /dev/null +++ b/migrations/1788163635.sh @@ -0,0 +1,6 @@ +echo "Remove legacy temporary passwordless sudo grants" + +# Migration queues are per-user; the privileged repair is once per machine. +if ! /usr/bin/omarchy-sudo-passwordless __migration-complete; then + sudo /usr/bin/omarchy-sudo-passwordless __migrate +fi diff --git a/test/shell.d/fixtures/passwordless-sudo-test.sh b/test/shell.d/fixtures/passwordless-sudo-test.sh new file mode 100644 index 00000000000..3f4f00901b4 --- /dev/null +++ b/test/shell.d/fixtures/passwordless-sudo-test.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +# Exercise complete production functions with private paths and harmless +# command stand-ins. Never install sudo policy or start a host timer. +test_tmp=$(mktemp -d) +children=() +cleanup_grant_fixture() { + local status=$? + trap - EXIT + if (( ${#children[@]} )); then + kill "${children[@]}" 2>/dev/null || true + wait "${children[@]}" 2>/dev/null || true + fi + rm -rf "$test_tmp" + exit "$status" +} +trap cleanup_grant_fixture EXIT +export TEST_GRANT_ROOT=$test_tmp +mkdir -p "$test_tmp/bin" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/var/lib" "$test_tmp/hooks" +cat >"$test_tmp/bin/mock" <<'STUB' +#!/bin/bash +set -euo pipefail +name=${0##*/} +printf '%s %s\n' "$name" "$*" >>"$TEST_GRANT_ROOT/commands" +case "$name" in + stat) + path=${@: -1} + owner=0 + mode=$(/usr/bin/stat -Lc '%a' -- "$path") + [[ $path != /tmp ]] || mode=755 + [[ $path != "${TEST_BAD_PATH:-}" ]] || owner=1000 + case $2 in + '%u') echo "$owner" ;; + '%a') echo "$mode" ;; + '%u %a') echo "$owner $mode" ;; + *) exec /usr/bin/stat "$@" ;; + esac + ;; + chown) exit 0 ;; + install) + args=() + while (($#)); do + case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac + done + exec /usr/bin/install "${args[@]}" + ;; + rm) + for path in "$@"; do + if [[ ${TEST_DELETE_FAIL:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/99-omarchy-nopasswd-1000" ]]; then exit 1; fi + done + exec /usr/bin/rm "$@" + ;; + mv) + [[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1 + /usr/bin/mv "$@" + [[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing" + ;; + systemd-run) + [[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1 + if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi + ;; + systemctl) + [[ $1 != "is-active" || ${TEST_INACTIVE_TIMER:-0} != 1 ]] + ;; + date) + if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi + ;; + getent) printf '%s:x:1000:1000:Test:/nonexistent:/bin/bash\n' "${TEST_ACCOUNT:-audituser}" ;; + sudo) + if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi + if [[ ${1:-} == -k ]]; then exit 0; fi + if [[ ${1:-} == -N ]]; then shift; fi + if [[ ${1:-} == -- ]]; then shift; fi + if [[ ${TEST_MIGRATION:-0} == 1 ]]; then + [[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1 + TEST_EUID=0 /usr/bin/bash -p "$@" + else + [[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}" + fi + ;; + gum) exit 1 ;; + *) exit 99 ;; +esac +STUB +chmod +x "$test_tmp/bin/mock" +for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do + ln -s mock "$test_tmp/bin/$name" +done + +python3 - "$ROOT" "$test_tmp" <<'PY' +from pathlib import Path +import sys +root, temp = map(Path, sys.argv[1:]) +for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'): + text = (root/'bin'/name).read_text() + for path in ('/etc/', '/var/lib', '/run/', '/usr/share/libalpm/hooks'): + target = str(temp/'hooks') if path == '/usr/share/libalpm/hooks' else str(temp) + path + text = text.replace(path, target) + text = text.replace('((EUID == 0))', '((${TEST_EUID:-1} == 0))') + for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum'): + text = text.replace('/usr/bin/' + command, str(temp/'bin'/command)) + (temp/name).write_text(text) + (temp/name).chmod(0o755) +PY +library="$test_tmp/functions.sh" +{ + printf 'source %q\n' "$test_tmp/omarchy-security-functions" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$test_tmp/omarchy-sudo-passwordless" +} >"$library" +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" +sed "s|/etc/|$test_tmp/etc/|g" "$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +: >"$test_tmp/commands" + +# New subshell per case prevents one test's overrides and readonly constants +# from affecting the next. External commands log enough to verify ordering. +assert_status() { + local expected=$1 actual=0 + shift + "$@" || actual=$? + (( actual == expected )) || fail "expected status $expected, got $actual from $*" +} +reset_grant() { + rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing" + : >"$test_tmp/commands" +} 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 f332f80e414..173ea1b00c0 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -1,123 +1,163 @@ #!/bin/bash set -euo pipefail - -source "$(dirname "$0")/base-test.sh" - -script="$ROOT/bin/omarchy-sudo-passwordless" -tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" -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" "$@" -} - -: >"$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" - -: >"$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" -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" -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" - -mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") -(( ${#tmpfiles_rules[@]} == 1 )) || - fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" - -fake_root="$test_tmp/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" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" + +( + source "$library" + for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done + for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done + for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done + for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done + ! valid_uid 18446744073709551617 +) +pass "duration and account validation retains bounded inputs and trailing-dollar usernames" + +( + source "$library" + assert_status 2 root_dispatch __status 1000 + assert_status 2 env TEST_EUID=0 SUDO_UID=1001 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 + assert_status 3 env TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 +) +pass "internal actions reject missing root and mismatched sudo identity" + +for status in 1 2 3; do + : >"$test_tmp/commands" + result=0 + TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 || result=$? + if (( status == 3 )); then + (( result == 0 )) && grep -q '^gum confirm ' "$test_tmp/commands" || fail "inactive status must allow confirmation" + else + (( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement" + fi + grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization" + [[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization" done -touch "$sudoers_dir/omarchy-dns" +pass "public status distinguishes inactive from errors and revokes authorization on exit" -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" +printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\nunset BASH_ENV\n' >"$test_tmp/startup" +: >"$test_tmp/commands" +if TEST_STARTUP_MARKER="$test_tmp/startup-ran" BASH_ENV="$test_tmp/startup" bash "$test_tmp/omarchy-sudo-passwordless" -p >/dev/null 2>&1; then + fail "ordinary Bash with a decoy -p was accepted" +fi +[[ -f $test_tmp/startup-ran && ! -s $test_tmp/commands ]] || fail "startup rejection must precede sudo" +pass "startup validation rejects ordinary Bash before authorization" + +( + source "$library" + enable_locked 1000 15 + read_grant 1000 + [[ $GRANT_NAME == audituser && $(stat -c '%a' "$(rule_file 1000)") == 440 ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + expiry=$(sed -n 's/^systemd-run .*--on-calendar=@\([0-9]*\).*$/\1/p' "$test_tmp/commands" | tail -1) + [[ $GRANT_DEADLINE == "$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)" ]] + if [[ -n ${OMARCHY_TEST_SUDOERS:-} ]]; then + [[ -x $OMARCHY_TEST_SUDOERS ]] || fail "OMARCHY_TEST_SUDOERS must name an executable" + printf 'root:x:0:0:root:/root:/bin/bash\naudituser:x:1000:1000:Test:/nonexistent:/bin/bash\n' >"$test_tmp/passwd" + printf 'root:x:0:\naudituser:x:1000:\n' >"$test_tmp/group" + { printf 'audituser ALL=(ALL) ALL\n'; cat "$(rule_file 1000)"; } >"$test_tmp/policy" + for offset in -1 1; do + when=$(/usr/bin/date -u -d "@$((expiry + offset))" +%Y%m%d%H%M%SZ) + "$OMARCHY_TEST_SUDOERS" -p "$test_tmp/passwd" -P "$test_tmp/group" -T "$when" audituser /usr/bin/true <"$test_tmp/policy" >"$test_tmp/policy-result" + if (( offset < 0 )); then + ! grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy requires a password before expiry" + else + grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy remains passwordless after expiry" + fi + done + pass "native sudoers evaluation requires authentication after the generated deadline" + fi + assert_status 0 status_locked 1000 + TEST_INACTIVE_TIMER=1 assert_status 0 status_locked 1000 + [[ ! -e $test_tmp/var/lib/omarchy/sudo-passwordless ]] + ! compgen -G "$test_tmp/etc/sudoers.d/.omarchy-nopasswd.*" +) +pass "one complete mode-0440 sudoers rule holds the deadline with no separate grant state" + +( + source "$library" + before=$(cat "$(rule_file 1000)") + expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff + [[ $(cat "$(rule_file 1000)") == "$before" ]] + enable_locked 1000 30 + renewed=$(cat "$(rule_file 1000)") + [[ $renewed != "$before" ]] + expire_locked 1000 + [[ $(cat "$(rule_file 1000)") == "$renewed" ]] + TEST_EXPIRED=1 expire_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "legacy and current callbacks preserve renewed grants and remove expired ones" + +( + source "$library" + enable_locked 1000 1 + assert_status 2 env TEST_EXPIRED=1 TEST_DELETE_FAIL=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 + [[ -e $(rule_file 1000) ]] + TEST_EXPIRED=1 assert_status 3 status_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "expired status reports cleanup failure separately from confirmed inactivity" + +for failure in TEST_TIMER_FAIL TEST_INACTIVE_TIMER TEST_PUBLISH_FAIL TEST_POST_PUBLISH_FAIL TEST_CANCEL_ENABLE; do + reset_grant + expected=1 + if [[ $failure == "TEST_CANCEL_ENABLE" ]]; then expected=143; fi + ( + source "$library" + enable_locked 1000 15 + assert_status "$expected" env "$failure=1" TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 30 + [[ ! -e $(rule_file 1000) ]] + ) done -[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" -pass "systemd-tmpfiles removes generated grants only during boot" +pass "timer, publication, post-publication and cancellation failures revoke renewed access" + +reset_grant +( + source "$library" + TEST_POST_PUBLISH_FAIL=1 TEST_DELETE_FAIL=1 assert_status 1 enable_locked 1000 15 + [[ -e $(rule_file 1000) ]] + ! grep -q '^systemctl stop ' "$test_tmp/commands" +) +pass "failed policy deletion retains the timer and reports failure" + +reset_grant +( + source "$library" + printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$(rule_file 1000)" + cp "$(rule_file 1000)" "$test_tmp/admin-rule" + assert_status 2 status_locked 1000 + assert_status 1 enable_locked 1000 15 + assert_status 1 cleanup_uid_locked 1000 + cmp "$(rule_file 1000)" "$test_tmp/admin-rule" + rm "$(rule_file 1000)" + ln -s "$test_tmp/admin-rule" "$(rule_file 1000)" + assert_status 2 status_locked 1000 + assert_status 1 cleanup_uid_locked 1000 + [[ -L $(rule_file 1000) ]] +) +pass "grant operations preserve administrator policies and reject symlinks" + +reset_grant +( + source "$library" + TEST_BAD_PATH="$test_tmp/etc/sudoers.d" assert_status 1 enable_locked 1000 15 + [[ ! -e $(rule_file 1000) ]] + rm "$PACKAGE_HOOK" + assert_status 1 enable_locked 1000 15 +) +pass "publication requires trusted paths and the packaged cleanup hook" + +reset_grant +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" +( + source "$library" + TEST_ACCOUNT='buildbot$' enable_locked 1000 15 + read_grant 1000 + [[ $GRANT_NAME == 'buildbot$' ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + cleanup_uid_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "trailing-dollar accounts publish and revoke valid native policy" diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh new file mode 100644 index 00000000000..a0be9d4f0ce --- /dev/null +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" + +( + source "$library" + printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" + printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" + TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked + [[ -e $(rule_file 1000) ]] + cleanup_all_locked + [[ ! -e $(rule_file 1000) && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom ]] + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" +) +pass "legacy cleanup removes generated orphan rules and preserves custom policy" + +# Run the actual migration queue for separate temporary homes. Sudo only calls +# the mapped helper and can be refused without requesting host authorization. +mkdir -p "$test_tmp/source/migrations" +sed "s|/usr/bin/omarchy-sudo-passwordless|$test_tmp/omarchy-sudo-passwordless|g" \ + "$ROOT/migrations/1788163635.sh" >"$test_tmp/source/migrations/1788163635.sh" +printf 'echo "later migration ran"\n' >"$test_tmp/source/migrations/1788163636.sh" +run_migrations() { + TEST_MIGRATION=1 OMARCHY_PATH="$test_tmp/source" OMARCHY_MIGRATION_STATE="$test_tmp/$1" \ + PATH="$test_tmp/bin:$PATH" /usr/bin/bash "$ROOT/bin/omarchy-migrate" >"$test_tmp/migrations.log" 2>&1 +} +marker="$test_tmp/var/lib/omarchy/migrations/1788163635" +( + source "$library" + printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" + TEST_DELETE_FAIL=1 assert_status 1 run_migrations first + [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] + run_migrations first + [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] + enable_locked 1000 15 + cp "$(rule_file 1000)" "$test_tmp/renewed" + : >"$test_tmp/commands" + TEST_NO_SUDO=1 run_migrations second + [[ -f $test_tmp/second/1788163636.sh ]] + ! grep -q '^sudo ' "$test_tmp/commands" + cmp "$(rule_file 1000)" "$test_tmp/renewed" +) +pass "migration completion is machine-wide, retryable, and needs no sudo for later users" + +( + source "$library" + TEST_BAD_PATH="$marker" assert_status 1 migration_complete + rm "$marker" + ln -s "$test_tmp/renewed" "$marker" + assert_status 1 migration_complete + assert_status 1 migrate_locked + [[ -L $marker ]] + rm "$marker" +) +pass "migration checks marker ownership and rejects symlinks" + +# Keep real package scripts in the contract: source and packaging share the +# same lock and blocker, including the legacy scriptlet fallback. +pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs} +[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds +for name in omarchy-settings omarchy-settings-dev; do + script="$pkgs_path/$name/$name.install" + [[ -f $script ]] || fail "set OMARCHY_PKGS_PATH to the companion package checkout" + sed -e "s|/etc/|$test_tmp/etc/|g" -e "s|/run|$test_tmp/run|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \ + "$script" >"$test_tmp/$name.install" + reset_grant + ( + source "$library" + source "$test_tmp/$name.install" + _etc_overrides_apply() { :; } + enable_locked 1000 15 + TEST_DELETE_FAIL=1 assert_status 1 pre_remove + [[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]] + assert_status 1 enable_locked 1000 15 + pre_remove && post_remove + [[ ! -e $(rule_file 1000) ]] + post_install + [[ ! -e $REMOVAL_BLOCKER ]] + enable_locked 1000 15 + pre_upgrade && post_upgrade + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + ) +done +pass "both settings packages revoke grants, block publication, and recover on installation" + +reset_grant +# Hold the source lock, then start package removal. A native flock on the +# mapped file must serialize both implementations. +cat >"$test_tmp/worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$TEST_LIBRARY" +critical() { + touch "$TEST_GRANT_ROOT/entered" + for (( attempt=0; attempt<500; attempt++ )); do + [[ ! -e $TEST_GRANT_ROOT/release ]] || break + sleep 0.01 + done + [[ -e $TEST_GRANT_ROOT/release ]] || return 1 + enable_locked 1000 15 +} +with_root_lock critical +WORKER +TEST_LIBRARY="$library" /usr/bin/bash "$test_tmp/worker" >"$test_tmp/publisher.log" 2>&1 & +publisher=$! +children+=("$publisher") +for (( attempt=0; attempt<200; attempt++ )); do + [[ ! -e $test_tmp/entered ]] || break + sleep 0.01 +done +[[ -f $test_tmp/entered ]] || fail "publisher failed to acquire the lock" +/usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 & +removal=$! +children+=("$removal") +touch "$test_tmp/release" +wait "$publisher" || fail "publisher failed" "$(cat "$test_tmp/publisher.log")" +wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")" +children=() +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] +pass "native lock serializes grant publication with package removal" + +# systemd-tmpfiles operates on an explicit disposable root, never the host. +reset_grant +: >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" +: >"$test_tmp/etc/sudoers.d/unrelated" +rule='r! /etc/sudoers.d/99-omarchy-nopasswd-*' +/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --inline "$rule" +[[ -f $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "routine tmpfiles shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline "$rule" +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 && -f $test_tmp/etc/sudoers.d/unrelated ]] || fail "boot cleanup boundary" +pass "native boot cleanup removes grants while routine tmpfiles preserves them"