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-install-dev-env b/bin/omarchy-install-dev-env index 263fd755637..302516eae0a 100755 --- a/bin/omarchy-install-dev-env +++ b/bin/omarchy-install-dev-env @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install a supported development environment # omarchy:name=dev-env @@ -6,43 +6,82 @@ # omarchy:examples=omarchy install dev-env ruby | omarchy install dev-env node # omarchy:requires-sudo=true -if [[ -z $1 ]]; then +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for development-environment installation." >&2 + exit 126 +fi + +entrypoint_source=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${entrypoint_source%/*}/omarchy-install-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for development-environment installation." >&2 + exit 126 +} + +set -euo pipefail + +environment=${1:-} + +usage() { echo "Usage: omarchy-install-dev-env " >&2 +} + +case "$environment" in +ruby | node | bun | deno | go | laravel | symfony | php | python | elixir | phoenix | rust | java | zig | ocaml | dotnet | clojure | scala) ;; +*) + usage exit 1 -fi + ;; +esac + +# These mixed installers intentionally invalidate the calling terminal's sudo +# timestamp, including one that predated this command. sudo cannot selectively +# distinguish a timestamp refreshed by this workflow from an older one. The +# explicit contract prevents any home-owned tool manager, downloaded backend, +# language hook, or remote script below from inheriting silent root authority. +finish_privileged_phase() { + omarchy_install_security_finish_privileged_phase \ + "Could not invalidate cached sudo authorization; refusing to run user-owned installer code." +} + +install_packages() { + OMARCHY_SUDO_NO_UPDATE=1 /usr/bin/omarchy-pkg-add "$@" +} + +omarchy_security_install_sudo_cleanup_traps + +finish_privileged_phase +case "$environment" in + ruby|php|laravel|symfony|clojure) + omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing a mixed-trust installer." >&2 + exit 1 + } + ;; +esac +echo "Security note: this installer clears cached sudo authorization before running user-level tooling." install_php() { - omarchy-pkg-add php composer php-sqlite xdebug + local system_environment=php + [[ $environment != "symfony" ]] || system_environment=symfony - # Install Path for Composer + # One fixed packaged system phase covers both packages and configuration. + # No user tools, paths or scripts are accepted by the privileged helper. + /usr/bin/sudo -N -- /usr/bin/env -i PATH=/usr/bin:/usr/sbin:/bin:/sbin \ + /usr/bin/bash -p -- /usr/bin/omarchy-install-dev-env-system "$system_environment" +} + +configure_composer_path() { + # This touches user-owned shell configuration only after the caller has + # finished and revoked the privileged phase. Never source the file here. if [[ :$PATH: != *:$HOME/.config/composer/vendor/bin:* ]]; then echo 'export PATH="$HOME/.config/composer/vendor/bin:$PATH"' >>"$HOME/.bashrc" - source "$HOME/.bashrc" + export PATH="$HOME/.config/composer/vendor/bin:$PATH" echo "Added Composer global bin directory to PATH." else echo "Composer global bin directory already in PATH." fi - - # Enable some extensions - local php_ini_path="/etc/php/php.ini" - local extensions_to_enable=( - "bcmath" - "intl" - "iconv" - "openssl" - "pdo_sqlite" - "pdo_mysql" - ) - - # Enable Xdebug - sudo sed -i \ - -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ - -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ - /etc/php/conf.d/xdebug.ini - - for ext in "${extensions_to_enable[@]}"; do - sudo sed -i "s/^;extension=${ext}/extension=${ext}/" "$php_ini_path" - done } install_node() { @@ -50,10 +89,11 @@ install_node() { mise use --global node } -case "$1" in +case "$environment" in ruby) echo -e "Installing Ruby on Rails...\n" - omarchy-pkg-add libyaml + install_packages libyaml + finish_privileged_phase mise settings add ruby.compile false mise settings add idiomatic_version_file_enable_tools ruby mise use --global ruby@latest @@ -79,10 +119,14 @@ go) php) echo -e "Installing PHP...\n" install_php + finish_privileged_phase + configure_composer_path ;; laravel) echo -e "Installing PHP and Laravel...\n" install_php + finish_privileged_phase + configure_composer_path install_node composer global require laravel/installer echo -e "\nYou can now run: laravel new myproject" @@ -90,7 +134,8 @@ laravel) symfony) echo -e "Installing PHP and Symfony...\n" install_php - omarchy-pkg-add symfony-cli + finish_privileged_phase + configure_composer_path echo -e "\nYou can now run: symfony new --webapp myproject" ;; python) @@ -143,7 +188,8 @@ dotnet) ;; clojure) echo -e "Installing Clojure...\n" - omarchy-pkg-add rlwrap + install_packages rlwrap + finish_privileged_phase mise use --global clojure@latest ;; scala) diff --git a/bin/omarchy-install-dev-env-system b/bin/omarchy-install-dev-env-system new file mode 100755 index 00000000000..5325aa7c13f --- /dev/null +++ b/bin/omarchy-install-dev-env-system @@ -0,0 +1,42 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Install fixed PHP system prerequisites and configuration + +set -euo pipefail + +if (( EUID != 0 )) || [[ $- != *p* ]]; then + echo "Run this internal helper through the development-environment installer." >&2 + exit 126 +fi + +if (( $# != 1 )); then + echo "Expected one supported system environment." >&2 + exit 2 +fi + +packages=(php composer php-sqlite xdebug) +case "$1" in + php) ;; + symfony) packages+=(symfony-cli) ;; + *) + echo "Unsupported system environment." >&2 + exit 2 + ;; +esac + +readonly PATH=/usr/bin:/usr/sbin:/bin:/sbin +export PATH + +/usr/bin/omarchy-pkg-add "${packages[@]}" + +/usr/bin/sed -i \ + -e 's/^;zend_extension=xdebug.so/zend_extension=xdebug.so/' \ + -e 's/^;xdebug.mode=debug/xdebug.mode=debug/' \ + -e 's/^;extension=bcmath/extension=bcmath/' \ + -e 's/^;extension=intl/extension=intl/' \ + -e 's/^;extension=iconv/extension=iconv/' \ + -e 's/^;extension=openssl/extension=openssl/' \ + -e 's/^;extension=pdo_sqlite/extension=pdo_sqlite/' \ + -e 's/^;extension=pdo_mysql/extension=pdo_mysql/' \ + /etc/php/conf.d/xdebug.ini /etc/php/php.ini diff --git a/bin/omarchy-install-font b/bin/omarchy-install-font index 8811ba4852d..b335ea8229b 100755 --- a/bin/omarchy-install-font +++ b/bin/omarchy-install-font @@ -1,9 +1,26 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install a Nerd Font package and switch the system to it # omarchy:args= # omarchy:examples=omarchy install font 'Cascadia Mono' ttf-cascadia-mono-nerd 'CaskaydiaMono Nerd Font' +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for font installation." >&2 + exit 126 +fi + +entrypoint_source=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${entrypoint_source%/*}/omarchy-install-security-functions" || exit 126 + +if ! omarchy_security_require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for font installation." >&2 + exit 126 +fi + +set -e + +omarchy_install_security_sanitize_bash_startup_environment "$0" "$@" + name="${1-}" package="${2-}" family="${3-}" @@ -17,5 +34,9 @@ printf -v install_message '%q' "Installing ${name}..." printf -v package_arg '%q' "$package" printf -v family_arg '%q' "$family" -exec omarchy-launch-floating-terminal-with-presentation \ - "echo ${install_message}; omarchy-pkg-add ${package_arg} && sleep 2 && omarchy-font-set ${family_arg}" +omarchy_install_security_prepare_cold_command_scoped_sudo \ + "Could not invalidate cached sudo authorization." \ + "This sudo does not support --no-update; refusing a mixed-trust font install." + +exec /usr/bin/omarchy-launch-floating-terminal-with-presentation \ + "echo ${install_message}; trap '/usr/bin/sudo -k >/dev/null 2>&1 || true' EXIT; /usr/bin/sudo -k && OMARCHY_SUDO_NO_UPDATE=1 /usr/bin/omarchy-pkg-add ${package_arg} && /usr/bin/sudo -k && /usr/bin/sleep 2 && /usr/bin/omarchy-font-set ${family_arg}" diff --git a/bin/omarchy-install-gaming-battlenet b/bin/omarchy-install-gaming-battlenet index 1bb4ddcdc27..8823c9060fd 100755 --- a/bin/omarchy-install-gaming-battlenet +++ b/bin/omarchy-install-gaming-battlenet @@ -1,9 +1,40 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install Battle.net standalone via umu-launcher + GE-Proton (no Steam, no Lutris, no Heroic). # omarchy:requires-sudo=true -set -e +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for Battle.net installation." >&2 + exit 126 +fi + +entrypoint_source=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${entrypoint_source%/*}/omarchy-install-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for Battle.net installation." >&2 + exit 126 +} + +set -euo pipefail + +# This mixed installer intentionally invalidates the calling terminal's sudo +# timestamp, including one that predates this command. sudo cannot identify +# which package helper refreshed a shared timestamp, and downloaded vendor code +# must never inherit silent root access. +finish_privileged_phase() { + omarchy_install_security_finish_privileged_phase \ + "Could not invalidate cached sudo authorization; refusing to run Battle.net installer code." +} + +omarchy_security_install_sudo_cleanup_traps + +finish_privileged_phase +omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing a mixed-trust installer." >&2 + exit 1 +} +echo "Security note: this installer clears cached sudo authorization before running downloaded vendor code." PREFIX="$HOME/Games/battlenet" LAUNCHER="$PREFIX/drive_c/Program Files (x86)/Battle.net/Battle.net Launcher.exe" @@ -11,8 +42,9 @@ INSTALLER_URL="https://downloader.battle.net/download/getInstallerForGame?os=win echo "Installing Battle.net..." -omarchy-pkg-add umu-launcher -omarchy-install-gaming-gpu-lib32 +OMARCHY_SUDO_NO_UPDATE=1 /usr/bin/omarchy-pkg-add umu-launcher +OMARCHY_SUDO_NO_UPDATE=1 /usr/bin/omarchy-install-gaming-gpu-lib32 +finish_privileged_phase # Detect a half-finished prefix from a closed/crashed previous run and offer # to wipe it before trying again. Battle.net's installer isn't idempotent. diff --git a/bin/omarchy-install-gaming-geforce-now b/bin/omarchy-install-gaming-geforce-now index f740c344ca9..7130822472a 100755 --- a/bin/omarchy-install-gaming-geforce-now +++ b/bin/omarchy-install-gaming-geforce-now @@ -1,19 +1,66 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install and launch Geforce Now. # omarchy:group=install # omarchy:name=gaming geforce-now -set -e +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for GeForce NOW installation." >&2 + exit 126 +fi + +entrypoint_source=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${entrypoint_source%/*}/omarchy-install-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for GeForce NOW installation." >&2 + exit 126 +} + +set -euo pipefail + +installer="" + +cleanup() { + local status=$? + if [[ -n $installer ]] && ! /usr/bin/rm -f -- "$installer"; then + (( status != 0 )) || status=1 + fi + omarchy_security_exit_with_revoked_sudo "$status" +} + +trap cleanup EXIT +omarchy_security_install_signal_exit_traps + +# This command deliberately invalidates even a sudo timestamp that predated +# the workflow. sudo cannot identify which command refreshed a shared terminal +# timestamp, and downloaded vendor code must never inherit silent root access. +omarchy_install_security_prepare_cold_command_scoped_sudo \ + "Could not invalidate cached sudo authorization; refusing to continue." \ + "This sudo does not support --no-update; refusing a mixed-trust installer." +echo "Security note: this installer clears cached sudo authorization before running downloaded vendor code." echo "Installing GeForce NOW..." -omarchy-pkg-add flatpak -cd /tmp +OMARCHY_SUDO_NO_UPDATE=1 /usr/bin/omarchy-pkg-add flatpak + +if ! omarchy_security_revoke_sudo_timestamp; then + echo "Could not invalidate cached sudo authorization; refusing to run the downloaded installer." >&2 + exit 1 +fi + +installer=$(/usr/bin/mktemp -- /tmp/omarchy-geforce-now.XXXXXXXX.bin) +owner=$(/usr/bin/stat -c '%u' -- "$installer") +mode=$(/usr/bin/stat -c '%a' -- "$installer") +links=$(/usr/bin/stat -c '%h' -- "$installer") +if [[ -L $installer || ! -f $installer || $owner != "$(/usr/bin/id -u)" || $mode != 600 || $links != 1 ]]; then + echo "Could not create a private GeForce NOW installer file." >&2 + exit 1 +fi -# Download and run GeForce NOW -curl -LO https://international.download.nvidia.com/GFNLinux/GeForceNOWSetup.bin -chmod +x GeForceNOWSetup.bin -./GeForceNOWSetup.bin +/usr/bin/curl --fail --location --output "$installer" \ + https://international.download.nvidia.com/GFNLinux/GeForceNOWSetup.bin +/usr/bin/chmod 0700 -- "$installer" +(cd /tmp && exec "$installer") # Ensure a separate browser process not started by GFN is available. # If not, it seems like GFN has a tendency to hang on login. diff --git a/bin/omarchy-install-gaming-gpu-lib32 b/bin/omarchy-install-gaming-gpu-lib32 index 2e4e3533fe7..9143e942373 100755 --- a/bin/omarchy-install-gaming-gpu-lib32 +++ b/bin/omarchy-install-gaming-gpu-lib32 @@ -1,11 +1,24 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install lib32 graphics drivers (Vulkan + NVIDIA) for any detected GPUs. # omarchy:group=install # omarchy:name=gaming gpu-lib32 # omarchy:requires-sudo=true -set -e +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for graphics-driver installation." >&2 + exit 126 +fi + +entrypoint_source=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${entrypoint_source%/*}/omarchy-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for graphics-driver installation." >&2 + exit 126 +} + +set -euo pipefail echo "Installing lib32 graphics drivers..." @@ -16,15 +29,15 @@ declare -A VULKAN_DRIVERS=( [AMD]=lib32-vulkan-radeon ) for vendor in "${!VULKAN_DRIVERS[@]}"; do - if lspci | grep -iE "(VGA|Display).*$vendor" >/dev/null; then + if /usr/bin/lspci | /usr/bin/grep -iE "(VGA|Display).*$vendor" >/dev/null; then PACKAGES+=("${VULKAN_DRIVERS[$vendor]}") fi done -if omarchy-hw-nvidia-gsp; then +if /usr/bin/omarchy-hw-nvidia-gsp; then PACKAGES+=(lib32-nvidia-utils) -elif omarchy-hw-nvidia-without-gsp; then +elif /usr/bin/omarchy-hw-nvidia-without-gsp; then PACKAGES+=(lib32-nvidia-580xx-utils) fi -(( ${#PACKAGES[@]} > 0 )) && omarchy-pkg-add "${PACKAGES[@]}" +(( ${#PACKAGES[@]} == 0 )) || /usr/bin/omarchy-pkg-add "${PACKAGES[@]}" diff --git a/bin/omarchy-install-security-functions b/bin/omarchy-install-security-functions new file mode 100755 index 00000000000..40b41b72201 --- /dev/null +++ b/bin/omarchy-install-security-functions @@ -0,0 +1,37 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal security helpers for mixed-trust installers + +# Install-specific extensions to the shared security primitives. This file is +# sourced from the same package-owned bin directory as its consumers. + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-install-security-functions is an internal function library." >&2 + exit 64 +fi + +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || return 126 + +omarchy_install_security_sanitize_bash_startup_environment() { + omarchy_security_sanitize_bash_environment "$@" +} + +omarchy_install_security_finish_privileged_phase() { + local message=${1:-Could not invalidate cached sudo authorization.} + + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + return 1 + fi +} + +omarchy_install_security_prepare_cold_command_scoped_sudo() { + local cold_message=$1 no_update_message=$2 + + omarchy_install_security_finish_privileged_phase "$cold_message" || return 1 + if ! omarchy_security_sudo_supports_no_update; then + echo "$no_update_message" >&2 + return 1 + fi +} diff --git a/bin/omarchy-pkg-add b/bin/omarchy-pkg-add index 977d7efdbc9..5a07c3c2a62 100755 --- a/bin/omarchy-pkg-add +++ b/bin/omarchy-pkg-add @@ -1,21 +1,55 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Install Arch packages if they are missing # omarchy:args= # omarchy:examples=omarchy pkg add jq ripgrep # omarchy:requires-sudo=true -if omarchy-pkg-missing "$@"; then +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for package installation." >&2 + exit 126 +fi + +entrypoint_source=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${entrypoint_source%/*}/omarchy-install-security-functions" || exit 126 + +omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for package installation." >&2 + exit 126 +} + +omarchy_install_security_sanitize_bash_startup_environment "$0" "$@" +unset BASH_ENV ENV + +set -euo pipefail +PATH=/usr/bin:/usr/sbin:/bin:/sbin +export PATH + +case "${OMARCHY_SUDO_NO_UPDATE:-0}" in + 0|"") sudo_args=() ;; + 1) sudo_args=(-N) ;; + *) + echo "Invalid OMARCHY_SUDO_NO_UPDATE value." >&2 + exit 2 + ;; +esac + +(($# > 0)) || { + echo "Usage: omarchy-pkg-add " >&2 + exit 2 +} + +if /usr/bin/omarchy-pkg-missing "$@"; then if (( EUID == 0 )); then - pacman -S --noconfirm --needed "$@" || exit 1 + /usr/bin/pacman -S --noconfirm --needed -- "$@" || exit 1 else - sudo pacman -S --noconfirm --needed "$@" || exit 1 + /usr/bin/sudo "${sudo_args[@]}" -- /usr/bin/pacman -S --noconfirm --needed -- "$@" || exit 1 fi fi for pkg in "$@"; do # Secondary check to handle states where pacman doesn't actually register an error - if ! pacman -Q "$pkg" &>/dev/null; then + if ! /usr/bin/pacman -Q -- "$pkg" &>/dev/null; then echo -e "\033[31mError: Package '$pkg' did not install\033[0m" >&2 exit 1 fi 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..92d7ae168a7 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,70 +1,572 @@ -#!/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 -MINUTES=${1:-15} -if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then +set -euo pipefail + +readonly DEFAULT_MINUTES=15 +readonly MAX_MINUTES=1440 +readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless +readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless +readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock +readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook +readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing +readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless +readonly STATUS_INACTIVE=3 + +usage() { echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 + echo "MINUTES must be between 1 and $MAX_MINUTES." >&2 exit 1 -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 +valid_minutes() { + [[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES)) +} + +valid_uid() { + [[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294)) +} + +valid_account_name() { + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 )) +} + +resolve_account() { + local uid="$1" entry + valid_uid "$uid" || return 1 + entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1 + IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry" + [[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1 + # Sudoers names and the legacy filename both have metacharacters. Omarchy + # accounts use this portable subset; refusing anything else is safer than + # attempting to quote privileged policy syntax. + valid_account_name "$ACCOUNT_NAME" || return 1 + ACCOUNT_UID=$((10#$uid)) +} + +verify_sudo_caller() { + local requested_uid="$1" + ((EUID == 0)) || return 1 + valid_uid "$requested_uid" || return 1 + [[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1 + ((10#$SUDO_UID == 10#$requested_uid)) || return 1 + resolve_account "$requested_uid" +} + +prepare_root_state() { + omarchy_security_assert_root_directory /var 755 || return 1 + [[ -d /var/lib && ! -L /var/lib ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /var/lib) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /var/lib) & 022)) || return 1 + + if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /var/lib/omarchy || return 1 + fi + omarchy_security_assert_root_directory /var/lib/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$STATE_DIR" /var/lib/omarchy || return 1 + + omarchy_security_assert_root_directory /run 755 || return 1 + if [[ ! -e /run/omarchy && ! -L /run/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /run/omarchy || return 1 + fi + omarchy_security_assert_root_directory /run/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$RUNTIME_DIR" /run/omarchy +} + +with_root_lock() { + local fd rc=0 + # The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy: + # those are exactly the kinds of partial-install state it must fail closed + # through. /run/lock is established by the OS before sysinit services run. + omarchy_security_assert_root_directory /run 755 || return 1 + [[ -d /run/lock && ! -L /run/lock ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1 + exec {fd}>"$LOCK_FILE" || return 1 + /usr/bin/chown root:root "$LOCK_FILE" || return 1 + /usr/bin/chmod 0600 "$LOCK_FILE" || return 1 + /usr/bin/flock -x "$fd" || return 1 + "$@" || rc=$? + /usr/bin/flock -u "$fd" || rc=1 + exec {fd}>&- + return "$rc" +} + +rule_file() { + printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" +} + +state_file() { + printf '%s/%s.state' "$STATE_DIR" "$1" +} + +read_state_record() { + local uid="$1" file state_uid name expires timer canonical_uid + local -a lines=() + valid_uid "$uid" || return 1 + canonical_uid=$((10#$uid)) + file=$(state_file "$uid") + [[ -f $file && ! -L $file ]] || return 1 + mapfile -t lines <"$file" || return 1 + (( ${#lines[@]} == 4 )) || return 1 + [[ ${lines[0]} == UID=* && ${lines[1]} == USER=* && + ${lines[2]} == EXPIRES=* && ${lines[3]} == TIMER=* ]] || return 1 + state_uid=${lines[0]#UID=} + name=${lines[1]#USER=} + expires=${lines[2]#EXPIRES=} + timer=${lines[3]#TIMER=} + [[ $state_uid == "$canonical_uid" ]] || return 1 + valid_account_name "$name" || return 1 + [[ $expires =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + [[ $timer =~ ^omarchy-nopasswd-expire-${canonical_uid}-[0-9a-f]{32}$ ]] || return 1 + printf '%s\t%s\t%s' "$name" "$expires" "$timer" +} + +read_state_timer() { + local record + record=$(read_state_record "$1") || return 1 + printf '%s' "${record##*$'\t'}" +} + +current_epoch() { + local now + now=$(/usr/bin/date +%s) || return 1 + [[ $now =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + printf '%s' "$now" +} + +valid_expiry() { + [[ $1 =~ ^[1-9][0-9]{0,10}$ ]] +} + +valid_timer_for_uid() { + local uid="$1" timer="$2" + valid_uid "$uid" || return 1 + uid=$((10#$uid)) + [[ $timer =~ ^omarchy-nopasswd-expire-${uid}-[0-9a-f]{32}$ ]] +} + +stop_timer() { + local timer="$1" + [[ $timer =~ ^omarchy-nopasswd-expire-[0-9]+-[0-9a-f]{32}$ ]] || return 0 + /usr/bin/systemctl stop "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true + /usr/bin/systemctl reset-failed "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true +} + +classify_generated_rule() { + local file=$1 suffix contents name + + GENERATED_RULE_LEGACY_TIMER="" + [[ -f $file && ! -L $file ]] || return 1 + contents=$(/usr/bin/cat -- "$file") || return 2 + suffix=${file##*/99-omarchy-nopasswd-} + + if [[ $suffix =~ ^[0-9]+$ ]]; then + name=${contents%' ALL=(ALL) NOPASSWD: ALL'} + 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 + GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" + else + return 1 + fi +} + +remove_known_legacy_rules() { + local file classification failed=0 + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + # A crash after publishing the numeric rule but before its state rename + # must not survive the next boot. Do not require the account to still + # exist: a deleted account could otherwise make the rule immortal and a + # later username reuse could activate it again. + if /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]]; then + [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || + /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ + "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true + else + failed=1 + fi + else + classification=$? + # An unreadable candidate cannot be proven inert. A symlink, non-file, + # or administrator-authored body is unrelated and remains untouched. + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +cleanup_uid_locked() { + local uid="$1" timer="" + valid_uid "$uid" || return 1 + timer=$(read_state_timer "$uid" 2>/dev/null || true) + # Remove policy first. A failed timer stop can only leave an inert cleanup + # job behind, never extend passwordless access. + /usr/bin/rm -f -- "$(rule_file "$uid")" || return 1 + [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 + /usr/bin/rm -f -- "$(state_file "$uid")" || return 1 + [[ -z $timer ]] || stop_timer "$timer" +} + +cleanup_all_locked() { + local state uid failed=0 file classification + shopt -s nullglob + for state in "$STATE_DIR"/*.state; do + uid=${state##*/} + uid=${uid%.state} + if valid_uid "$uid" && ! cleanup_uid_locked "$uid"; then failed=1; fi + done + shopt -u nullglob + remove_known_legacy_rules || failed=1 + + # Never report a successful boot cleanup while an exact rule emitted by any + # Omarchy implementation is still active. Administrator-extended files do + # not match these complete bodies and remain untouched. + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + failed=1 + else + classification=$? + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +verify_root_policy_file() { + local file=$1 owner mode canonical current + [[ -f $file && ! -L $file ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$file") || return 1 + [[ $canonical == "$file" ]] || return 1 + owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1 + mode=$(/usr/bin/stat -Lc '%a' -- "$file") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + + current=${file%/*} + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + [[ $current == / ]] && break + current=${current%/*} + [[ -n $current ]] || current=/ + done +} + +verify_boot_cleanup() { + local active_rules hook + [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 + verify_root_policy_file "$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_policy_file "$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 +} + +prepare_state_file() { + local uid="$1" name="$2" expires="$3" timer="$4" tmp + tmp=$(/usr/bin/mktemp "$STATE_DIR/.state.XXXXXX") || return 1 + if ! /usr/bin/printf 'UID=%s\nUSER=%s\nEXPIRES=%s\nTIMER=%s\n' \ + "$uid" "$name" "$expires" "$timer" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0600 "$tmp"; then + /usr/bin/rm -f -- "$tmp" + return 1 fi + printf '%s' "$tmp" +} + +start_expiry_timer() { + local uid="$1" expires="$2" timer="$3" + valid_uid "$uid" && valid_expiry "$expires" && valid_timer_for_uid "$uid" "$timer" || return 1 + # Calendar timers use CLOCK_REALTIME and catch up immediately after resume; + # a monotonic OnActiveSec timer pauses while the machine is suspended. + /usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \ + --timer-property=AccuracySec=1s --unit="$timer" \ + -- "$INSTALLED_SELF" __expire "$uid" "$timer" || return 1 + /usr/bin/systemctl is-active --quiet "${timer}.timer" +} - 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 +publish_rule() { + local uid="$1" name="$2" expires="$3" destination tmp deadline + valid_expiry "$expires" || return 1 + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + [[ $deadline =~ ^[0-9]{14}Z$ ]] || return 1 + destination=$(rule_file "$uid") + tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 + if ! /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$name" "$deadline" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || + ! /usr/sbin/visudo -cf "$tmp" >/dev/null || + ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then + /usr/bin/rm -f -- "$tmp" + return 1 fi + /usr/bin/rm -f -- "$tmp" +} + +abort_enable_locked() { + local uid=$1 timer=$2 old_timer=$3 pending_state=$4 + # Publication can install policy and then fail while cleaning its temporary + # file. Never disarm either expiry job until policy revocation is confirmed. + if cleanup_uid_locked "$uid"; then + stop_timer "$timer" + [[ -z $old_timer ]] || stop_timer "$old_timer" + else + echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2 + fi + /usr/bin/rm -f -- "$pending_state" || true return 1 } -echo "Toggle passwordless sudo..." +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 or transaction hook is missing or unsafe" >&2 + return 1 + } -# 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 + old_timer=$(read_state_timer "$uid" 2>/dev/null || true) + token=$(/usr/bin/tr -d '-' /dev/null - arm_expiry || exit 1 - echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." + # State and a verified timer exist before the policy becomes reachable. If + # publication fails, cleanup removes both. During an update the old timer is + # deliberately kept until the replacement is active, so failure shortens the + # grant rather than extending it. + pending_state=$(prepare_state_file "$uid" "$ACCOUNT_NAME" "$expires" "$timer") || return 1 + if ! start_expiry_timer "$uid" "$expires" "$timer"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME" "$expires"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + now=$(current_epoch) || { + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + } + if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer" || ! verify_boot_cleanup; then + # The timer may have expired or failed between its initial verification and + # rule publication. Revoke synchronously so a suspended or heavily loaded + # machine cannot turn a short grant into a reboot-long one. + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" + return 1 + fi + [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" +} + +status_locked() { + local uid="$1" record state_name expires timer now remainder + resolve_account "$uid" || return 2 + if [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]]; then + return "$STATUS_INACTIVE" + fi + record=$(read_state_record "$uid") || { + revoke_inactive_grant "$uid" + return $? + } + state_name=${record%%$'\t'*} + remainder=${record#*$'\t'} + expires=${remainder%%$'\t'*} + timer=${record##*$'\t'} + [[ $state_name == "$ACCOUNT_NAME" ]] || { + revoke_inactive_grant "$uid" + return $? + } + now=$(current_epoch) || { + revoke_inactive_grant "$uid" + return $? + } + ((10#$now < 10#$expires)) || { + revoke_inactive_grant "$uid" + return $? + } + /usr/bin/systemctl is-active --quiet "${timer}.timer" || { + revoke_inactive_grant "$uid" + return $? + } +} + +revoke_inactive_grant() { + if cleanup_uid_locked "$1"; then + return "$STATUS_INACTIVE" + else + echo "Could not revoke invalid or expired passwordless sudo. Administrator cleanup is required." >&2 + return 2 + fi +} + +expire_locked() { + local uid=$1 timer=${2:-} current_timer status + if [[ -n $timer ]]; then + current_timer=$(read_state_timer "$uid" 2>/dev/null || true) + # A delayed predecessor must not revoke a newer, independently timed grant. + [[ -z $current_timer || $current_timer == "$timer" ]] || return 0 + cleanup_uid_locked "$uid" + elif status_locked "$uid"; then + # Compatibility with already scheduled UID-only jobs: enforce the current + # grant's expiry instead of letting an old timer shorten its replacement. + return 0 else - sudo rm "$NOPASSWD_FILE" - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null + status=$? + (( status == STATUS_INACTIVE )) + fi +} + +root_dispatch() { + local action="$1" + shift + case "$action" in + __status) + (($# == 1)) && verify_sudo_caller "$1" || return 2 + with_root_lock status_locked "$1" + ;; + __enable) + (($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1 + with_root_lock enable_locked "$1" "$2" + ;; + __disable) + (($# == 1)) && verify_sudo_caller "$1" || return 1 + with_root_lock cleanup_uid_locked "$1" + ;; + __expire) + (($# == 1 || $# == 2)) && ((EUID == 0)) && valid_uid "$1" || return 1 + [[ -z ${2:-} ]] || valid_timer_for_uid "$1" "$2" || return 1 + with_root_lock expire_locked "$@" + ;; + __cleanup-all) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock cleanup_all_locked + ;; + __package-removing) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock package_removing_locked + ;; + *) return 1 ;; + esac +} + +case "${1:-}" in + __status|__enable|__disable|__expire|__cleanup-all|__package-removing) + action=$1 + shift + root_dispatch "$action" "$@" + exit + ;; +esac + +(($# <= 1)) || usage +minutes=${1:-$DEFAULT_MINUTES} +valid_minutes "$minutes" || usage +uid=$(/usr/bin/id -u) +valid_uid "$uid" || { + echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2 + exit 1 +} + +omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2 + exit 1 +} + +omarchy_security_install_sudo_cleanup_traps +/usr/bin/sudo -k >/dev/null 2>&1 || { + echo "Could not start from a cold sudo credential state." >&2 + exit 1 +} + +echo "Toggle passwordless sudo..." +if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then + if (($# == 0)); then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" echo "Passwordless sudo has been DISABLED. Sudo will require a password again." + else + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + echo "Passwordless sudo timer updated. It will automatically disable in ${minutes} minutes." fi else + status=$? + if (( status != STATUS_INACTIVE )); then + echo "Could not safely inspect passwordless sudo; no grant will be enabled. Resolve the reported authorization or cleanup error first." >&2 + exit 1 + fi echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" - echo "execute ANY command as root WITHOUT a password for ${MINUTES} minutes." + echo "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/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..0e805e6a2ee --- /dev/null +++ b/docs/passwordless-sudo.md @@ -0,0 +1,25 @@ +# 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 + +Root state records the resolved account name, absolute expiry epoch and unique timer name. A calendar timer is armed and verified before the generated policy becomes active. The sudoers rule also embeds the same UTC deadline with `NOTAFTER`, so sudo independently rejects it after expiry even if timer cleanup is delayed. Publication rechecks the package-owned boot cleanup before and after installing policy. Policy revocation must succeed before expiry jobs are stopped; a deletion error leaves those jobs armed and reports that administrator cleanup is required. + +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. + +Each new expiry callback carries its timer identity. A delayed predecessor cannot revoke a newer grant. Already scheduled UID-only callbacks remain compatible by checking the current grant's expiry. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; it does not run during routine non-boot tmpfiles maintenance. + +## 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 + +`test/shell.d/nopasswd-sudo-expiry-test.sh` covers the public interface, cold authentication, timer setup, boot cleanup, package transitions and lock contention. `test/shell.d/passwordless-grant-lifecycle-test.sh` covers publication/cleanup failures, error status, supported account syntax, predecessor callbacks and the shared package-removal lock. Supply `OMARCHY_PKGS_PATH` as either a repository root or its `pkgbuilds` directory. + +These tests use private filesystem fixtures and mapped privileged commands. Package archive ownership, actual install/upgrade/removal, real calendar expiry, suspend/resume and boot cleanup must also be validated in a disposable VM before claiming release readiness. Changes to the common library require integration checks on the downstream update, migration, installer, package-picker and diagnostic 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..e2fe22725d2 --- /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/desktop-entry-launch-test.sh b/test/shell.d/desktop-entry-launch-test.sh index c6014b103d6..7c08841b439 100644 --- a/test/shell.d/desktop-entry-launch-test.sh +++ b/test/shell.d/desktop-entry-launch-test.sh @@ -13,6 +13,10 @@ mkdir -p "$mock_bin" "$test_home" cat >"$mock_bin/omarchy-pkg-add" <<'SH' #!/bin/bash +if [[ ${OMARCHY_TEST_FONT_BOUNDARY:-0} == 1 ]]; then + [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 99 + printf 'no-update\n' >>"$OMARCHY_TEST_LOG" +fi printf 'pkg:%s\n' "$*" >>"$OMARCHY_TEST_LOG" exit "${OMARCHY_TEST_PKG_STATUS:-0}" SH @@ -39,8 +43,38 @@ cat >"$mock_bin/omarchy-launch-floating-terminal-with-presentation" <<'SH' printf '%s\n' "$1" >"$OMARCHY_TEST_PRESENTATION" SH +cat >"$mock_bin/sudo" <<'SH' +#!/bin/bash +if [[ ${1:-} == -h ]]; then + printf 'usage: sudo [-ABbEHkNnPS] command\n' + exit 0 +fi +if [[ ${1:-} == -k ]]; then + printf 'revoke\n' >>"$OMARCHY_TEST_LOG" + exit 0 +fi +exit 90 +SH + chmod +x "$mock_bin"/* +font_script="$test_tmp/omarchy-install-font" +for helper in omarchy-security-functions omarchy-install-security-functions; do + sed "s#/usr/bin/sudo#$mock_bin/sudo#g" "$ROOT/bin/$helper" >"$test_tmp/$helper" +done +sed \ + -e "s#/usr/bin/omarchy-launch-floating-terminal-with-presentation#$mock_bin/omarchy-launch-floating-terminal-with-presentation#g" \ + -e "s#/usr/bin/omarchy-pkg-add#$mock_bin/omarchy-pkg-add#g" \ + -e "s#/usr/bin/omarchy-font-set#$mock_bin/omarchy-font-set#g" \ + -e "s#/usr/bin/sudo#$mock_bin/sudo#g" \ + -e "s#/usr/bin/sleep#/usr/bin/true#g" \ + "$ROOT/bin/omarchy-install-font" >"$font_script" +chmod 0755 "$font_script" +if grep -Fq '/usr/bin/sudo' "$test_tmp/omarchy-security-functions" "$test_tmp/omarchy-install-security-functions"; then + fail "desktop-entry test helpers still reach host sudo" +fi +pass "desktop-entry test routes installer helpers through its sudo mock" + export HOME="$test_home" export OMARCHY_TEST_LOG="$test_tmp/launch.log" export OMARCHY_TEST_PRESENTATION="$test_tmp/presentation" @@ -104,7 +138,11 @@ fi pass "generic installer does not launch after package installation failure" run_presentation() { - bash -c "sleep() { :; }; $(<"$OMARCHY_TEST_PRESENTATION")" + if [[ $(<"$OMARCHY_TEST_PRESENTATION") == *omarchy-font-set* ]]; then + OMARCHY_TEST_FONT_BOUNDARY=1 bash -c "sleep() { :; }; $(<"$OMARCHY_TEST_PRESENTATION")" + else + bash -c "sleep() { :; }; $(<"$OMARCHY_TEST_PRESENTATION")" + fi } bash "$ROOT/bin/omarchy-install-app" "LM Studio" "lmstudio-bin" @@ -146,7 +184,9 @@ grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" || fail "install-app still installs after quoting a hostile display name" pass "install-app does not run extra commands from a quote in the display name" -bash "$ROOT/bin/omarchy-install-font" "Cascadia Mono" "ttf-cascadia-mono-nerd" "CaskaydiaMono Nerd Font" +: >"$OMARCHY_TEST_LOG" +"$font_script" "Cascadia Mono" "ttf-cascadia-mono-nerd" "CaskaydiaMono Nerd Font" +[[ $(<"$OMARCHY_TEST_LOG") == "revoke" ]] || fail "font installer starts with cold authorization" presentation_command=$(<"$OMARCHY_TEST_PRESENTATION") [[ $presentation_command == *'echo Installing\ Cascadia\ Mono...;'* ]] || fail "install-font shell-quotes the display name" "$presentation_command" @@ -159,8 +199,17 @@ grep -Fxq 'pkg:ttf-cascadia-mono-nerd' "$OMARCHY_TEST_LOG" || grep -Fxq 'font:CaskaydiaMono Nerd Font' "$OMARCHY_TEST_LOG" || fail "install-font passes the family name through as one argument" pass "install-font shell-quotes the display name and family" +[[ $(<"$OMARCHY_TEST_LOG") == $'revoke\nno-update\npkg:ttf-cascadia-mono-nerd\nrevoke\nfont:CaskaydiaMono Nerd Font\nrevoke' ]] || + fail "font installation revokes around its no-update package operation and user font selection" +: >"$OMARCHY_TEST_LOG" +if OMARCHY_TEST_PKG_STATUS=42 run_presentation; then + fail "font installer reports package failure" +fi +[[ $(<"$OMARCHY_TEST_LOG") == $'revoke\nno-update\npkg:ttf-cascadia-mono-nerd\nrevoke' ]] || + fail "font package failure skips font selection and retains exit revocation" +pass "font installation enforces authorization ordering and failure cleanup" -bash "$ROOT/bin/omarchy-install-font" "Foo's App" "alpha" "Foo's Font" +"$font_script" "Foo's App" "alpha" "Foo's Font" : >"$OMARCHY_TEST_LOG" run_presentation >"$test_tmp/font-apostrophe.out" grep -Fxq 'pkg:alpha' "$OMARCHY_TEST_LOG" || @@ -169,7 +218,7 @@ grep -Fxq "font:Foo's Font" "$OMARCHY_TEST_LOG" || fail "install-font still sets the family when it has an apostrophe" pass "install-font still installs when the name or family has an apostrophe" -bash "$ROOT/bin/omarchy-install-font" "a'; echo PWNED; echo '" "alpha" "a'; echo PWNED; echo '" +"$font_script" "a'; echo PWNED; echo '" "alpha" "a'; echo PWNED; echo '" : >"$OMARCHY_TEST_LOG" run_presentation >"$test_tmp/font-inject.out" if grep -Fxq 'PWNED' "$test_tmp/font-inject.out"; then @@ -206,7 +255,7 @@ grep -Fq 'omarchy-pkg-add alpha beta' "$OMARCHY_TEST_PRESENTATION" || fail "install-and-launch keeps its package list under an inherited errexit" "$(<"$OMARCHY_TEST_PRESENTATION")" pass "the installers build their command under an inherited errexit" -bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha; echo PWNED" "Example Family" +"$font_script" "Example Font" "alpha; echo PWNED" "Example Family" : >"$OMARCHY_TEST_LOG" run_presentation >"$test_tmp/font-pkg-inject.out" if grep -Fxq 'PWNED' "$test_tmp/font-pkg-inject.out"; then @@ -216,7 +265,7 @@ grep -Fxq 'pkg:alpha; echo PWNED' "$OMARCHY_TEST_LOG" || fail "install-font hands a hostile package to the package helper as one argument" "$(<"$OMARCHY_TEST_LOG")" pass "install-font does not run extra commands from its package" -bash "$ROOT/bin/omarchy-install-font" "Example Font" "alpha" "Example Family" +"$font_script" "Example Font" "alpha" "Example Family" : >"$OMARCHY_TEST_LOG" if OMARCHY_TEST_PKG_STATUS=1 run_presentation; then fail "install-font propagates package installation failure" diff --git a/test/shell.d/install-chain-sudo-security-test.sh b/test/shell.d/install-chain-sudo-security-test.sh new file mode 100644 index 00000000000..4fd895f1ab6 --- /dev/null +++ b/test/shell.d/install-chain-sudo-security-test.sh @@ -0,0 +1,638 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +# Prove the privilege boundary in a disposable namespace. The setuid helper +# models sudo's documented per-terminal timestamp: omarchy-pkg-add authenticates +# it, `sudo -k` invalidates it, and the following user-owned mise executable +# only gets root while that credential is still live. +if [[ ${OMARCHY_INSTALL_CHAIN_SECURITY_NS:-} != 1 ]]; then + outer_uid=$(id -u) + outer_gid=$(id -g) + subuid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid) + subgid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid) + + if [[ -z $subuid || -z $subgid ]]; then + pass "no subordinate uid/gid range; skipping install-chain namespace proof" + exit 0 + fi + + namespace=(unshare --user --mount + --map-users "0:$outer_uid:1" --map-users "1:$subuid:65536" + --map-groups "0:$outer_gid:1" --map-groups "1:$subgid:65536") + if "${namespace[@]}" true 2>/dev/null; then + exec "${namespace[@]}" env OMARCHY_INSTALL_CHAIN_SECURITY_NS=1 bash "$0" + else + pass "requested user/mount namespace unavailable; skipping install-chain proof" + exit 0 + fi +fi + +[[ $(id -u) == 0 ]] || fail "install-chain proof entered its root namespace" + +test_tmp=$(mktemp -d) +mount -t tmpfs -o mode=0755 tmpfs "$test_tmp" +chmod 0755 "$test_tmp" +cleanup() { + umount /usr/bin/omarchy-install-gaming-gpu-lib32 2>/dev/null || true + umount /usr/bin/omarchy-pkg-add 2>/dev/null || true + umount /usr/bin/omarchy-pkg-missing 2>/dev/null || true + umount /usr/bin/pacman 2>/dev/null || true + umount /usr/bin/curl 2>/dev/null || true + umount /usr/bin/sudo 2>/dev/null || true + rm -rf "$test_tmp"/* + umount "$test_tmp" + rmdir "$test_tmp" +} +trap cleanup EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +root_dir="$test_tmp/root" +token="$test_tmp/sudo-token" +victim="$root_dir/90-dev-tool.rules" +mkdir -p "$stub_bin" "$test_home" "$root_dir" +chown 1000:1000 "$test_home" +chmod 0700 "$test_home" +chmod 0755 "$stub_bin" "$root_dir" + +cat >"$test_tmp/sudo.c" <<'C' +#include +#include +#include +#include +#include +#include +#include + +static const char *token(void) { + const char *value = getenv("TEST_SUDO_TOKEN"); + if (!value || !*value) exit(125); + return value; +} + +static void log_event(const char *event) { + const char *path = getenv("TEST_EVENT_LOG"); + int fd; + if (!path || !*path) return; + fd = open(path, O_WRONLY | O_CREAT | O_APPEND, 0600); + if (fd < 0) return; + dprintf(fd, "%s\n", event); + close(fd); +} + +int main(int argc, char **argv) { + struct stat st; + int index = 1, no_update = 0; + if (argc == 2 && strcmp(argv[1], "-h") == 0) { + puts("usage: sudo [-ABbEHkNnPS] command"); + return 0; + } + if (argc == 2 && strcmp(argv[1], "--authenticate-for-test") == 0) { + int fd = open(token(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 125; + close(fd); + log_event("AUTH"); + return 0; + } + if (argc == 2 && strcmp(argv[1], "-k") == 0) { + if (unlink(token()) < 0 && errno != ENOENT) return 125; + log_event("REVOKE"); + return 0; + } + if (index < argc && strcmp(argv[index], "-N") == 0) { + no_update = 1; + index++; + } + if (index < argc && strcmp(argv[index], "--") == 0) index++; + if (no_update) { + log_event("AUTH_NO_UPDATE"); + if (index < argc && strcmp(argv[index], "/usr/bin/sed") == 0) { + log_event("PRIVILEGED_CONFIG_NO_UPDATE"); + return 0; + } + if (index < argc && strcmp(argv[index], "/usr/bin/pacman") == 0) { + log_event("PACKAGE_NO_UPDATE"); + return 0; + } + if (index < argc && strcmp(argv[index], "/usr/bin/true") == 0) return 0; + return 125; + } + if (index < argc && strcmp(argv[index], "/usr/bin/pacman") == 0 && stat(token(), &st) < 0) { + int fd = open(token(), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 125; + close(fd); + log_event("PACKAGE_REFRESHED"); + return 0; + } + if (stat(token(), &st) < 0 || st.st_uid != 0) { + log_event("DENIED"); + fputs("sudo: a password is required\n", stderr); + return 1; + } + if (argc >= 2 && strcmp(argv[1], "/usr/bin/sed") == 0) { + log_event("PRIVILEGED_CONFIG"); + return 0; + } + log_event("PRIVILEGED_EXEC"); + if (argc < 2 || setuid(0) < 0) return 125; + execvp(argv[1], &argv[1]); + return 125; +} +C +gcc -O2 -Wall -Wextra -o "$stub_bin/sudo" "$test_tmp/sudo.c" +chown 0:0 "$stub_bin/sudo" +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/sudo" /usr/bin/sudo + +for installer_source in \ + "$ROOT/bin/omarchy-install-dev-env" \ + "$ROOT/bin/omarchy-install-gaming-geforce-now" \ + "$ROOT/bin/omarchy-install-gaming-battlenet"; do + grep -Eq 'omarchy_(security|install_security)_(revoke_sudo_timestamp|finish_privileged_phase|prepare_cold_command_scoped_sudo)' "$installer_source" || + fail "${installer_source##*/} no longer uses the shared trusted invalidation boundary" + grep -Eq 'omarchy_security_install_(sudo_cleanup|signal_exit)_traps|trap cleanup' "$installer_source" || + fail "${installer_source##*/} no longer invalidates on exit and signals" +done +grep -qF '/usr/bin/sudo -k' "$ROOT/bin/omarchy-security-functions" || + fail "shared security functions no longer pin sudo invalidation to /usr/bin" +grep -qF 'omarchy_security_sudo_supports_no_update' "$ROOT/bin/omarchy-install-security-functions" || + fail "install security functions no longer enforce command-scoped sudo support" +! grep -Eq '^[[:space:]]*(source|\.)[[:space:]]+.*\.bashrc' "$ROOT/bin/omarchy-install-dev-env" || + fail "development installer reintroduced execution of user-owned shell configuration" +pass "installer sources retain fixed-path invalidation and cleanup boundaries" + +cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +[[ ${1:-} == -Q ]] +STUB +chmod 0755 "$stub_bin/omarchy-pkg-missing" "$stub_bin/pacman" +mount --bind "$stub_bin/omarchy-pkg-missing" /usr/bin/omarchy-pkg-missing +mount --bind "$stub_bin/pacman" /usr/bin/pacman + +pkg_event_log="$test_tmp/pkg-add-events" +: >"$pkg_event_log" +chown 1000:1000 "$pkg_event_log" +setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" TEST_EVENT_LOG="$pkg_event_log" TEST_SUDO_TOKEN="$token" \ + OMARCHY_SUDO_NO_UPDATE=1 "$ROOT/bin/omarchy-pkg-add" audit-package +grep -qxF PACKAGE_NO_UPDATE "$pkg_event_log" || + fail "real package helper did not use sudo --no-update" +[[ ! -e $token ]] || fail "real no-update package helper published a timestamp" + +mutation_dir="$test_tmp/pkg-add-without-no-update" +mkdir -p "$mutation_dir" +cp "$ROOT/bin/omarchy-security-functions" "$ROOT/bin/omarchy-install-security-functions" "$mutation_dir/" +sed 's|/usr/bin/sudo "${sudo_args\[@\]}" --|/usr/bin/sudo --|' \ + "$ROOT/bin/omarchy-pkg-add" >"$mutation_dir/omarchy-pkg-add" +chmod 0755 "$mutation_dir/omarchy-pkg-add" +: >"$pkg_event_log" +setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" TEST_EVENT_LOG="$pkg_event_log" TEST_SUDO_TOKEN="$token" \ + OMARCHY_SUDO_NO_UPDATE=1 "$mutation_dir/omarchy-pkg-add" audit-package +grep -qxF PACKAGE_REFRESHED "$pkg_event_log" || + fail "removing command-scoped sudo did not reproduce credential publication" +[[ -e $token ]] || fail "mutated package helper did not leave reusable authorization" +TEST_EVENT_LOG="$pkg_event_log" TEST_SUDO_TOKEN="$token" /usr/bin/sudo -k +pass "removing command-scoped sudo reproduces the install-chain credential leak" + +pkg_bash_env="$test_home/pkg-add-bash-env" +pkg_bash_env_marker="$test_home/pkg-add-bash-env-ran" +cat >"$pkg_bash_env" <<'STUB' +: >"$TEST_PKG_BASH_ENV_RAN" +unset BASH_ENV +set -o privileged +set -- audit-package +STUB +chown 1000:1000 "$pkg_bash_env" +: >"$pkg_event_log" +if setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" BASH_ENV="$pkg_bash_env" TEST_PKG_BASH_ENV_RAN="$pkg_bash_env_marker" \ + TEST_EVENT_LOG="$pkg_event_log" TEST_SUDO_TOKEN="$token" \ + /usr/bin/bash "$ROOT/bin/omarchy-pkg-add" -p >/dev/null 2>&1; then + fail "package helper accepted a decoy post-script -p" +fi +[[ -e $pkg_bash_env_marker && ! -s $pkg_event_log && ! -e $token ]] || + fail "unsafe package-helper startup reached sudo" +pass "real package helper binds privileged startup and command-scoped sudo" + +cat >"$stub_bin/trusted-pkg-add" <<'STUB' +#!/bin/bash +printf 'PACKAGE:%s\n' "$*" >>"$TEST_EVENT_LOG" +[[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 98 +if [[ ${PKG_NO_AUTH:-0} != 1 ]]; then + /usr/bin/sudo -N -- /usr/bin/true +fi +[[ ${PKG_FAIL:-0} != 1 ]] || exit 42 +STUB + +cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +#!/bin/bash +printf 'UNTRUSTED:path-package-wrapper\n' >>"$TEST_EVENT_LOG" +/usr/bin/sudo --authenticate-for-test +/usr/bin/sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" +STUB + +cat >"$stub_bin/attempt-root" <<'STUB' +#!/bin/bash +tool=$1 +printf 'UNTRUSTED:%s\n' "$tool" >>"$TEST_EVENT_LOG" +if [[ ! -e $TEST_ATTACK_DONE ]]; then + if sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM"; then + : >"$TEST_ATTACK_DONE" + fi +fi +if [[ ${TOOL_BLOCK:-} == "$tool" ]]; then + : >"$TOOL_READY" + trap 'exit 143' TERM + trap 'exit 130' INT + while :; do sleep 0.05; done +fi +[[ ${TOOL_FAIL:-} != "$tool" ]] || exit 43 +exit 0 +STUB + +for tool in mise composer opam; do + cat >"$stub_bin/$tool" <"$stub_bin/curl" <<'STUB' +#!/bin/bash +cat <<'REMOTE' +printf 'UNTRUSTED:remote-script\n' >>"$TEST_EVENT_LOG" +sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" >/dev/null 2>&1 || : +[[ ${TOOL_FAIL:-} != remote-script ]] || exit 43 +REMOTE +STUB + +cat >"$stub_bin/omarchy-launch-browser" <<'STUB' +#!/bin/bash +printf 'BROWSER\n' >>"$TEST_EVENT_LOG" +STUB + +chmod 0755 "$stub_bin/"* +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/trusted-pkg-add" /usr/bin/omarchy-pkg-add + +cat >"$test_home/payload" <<'PAYLOAD' +RUN+="/tmp/dev-tool-payload" +PAYLOAD +chown 1000:1000 "$test_home/payload" +chmod 0600 "$test_home/payload" + +cat >"$test_home/.bashrc" <<'BASHRC' +printf 'UNTRUSTED:bashrc\n' >>"$TEST_EVENT_LOG" +sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" >/dev/null 2>&1 || : +BASHRC +chown 1000:1000 "$test_home/.bashrc" + +run_dev_env() { + local branch="$1" expected_status="${2:-0}" + shift 2 || true + local event_log="$test_tmp/events-$branch" output="$test_tmp/out-$branch" error="$test_tmp/err-$branch" + victim="$root_dir/90-$branch.rules" + : >"$event_log" + chown 1000:1000 "$event_log" + rm -f "$victim" "$test_home/attack-done" "$token" + if [[ ${PRECREATE_TOKEN:-0} == 1 ]]; then + : >"$token" + chown 0:0 "$token" + fi + + set +e + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" \ + TEST_ATTACK_DONE="$test_home/attack-done" TEST_EVENT_LOG="$event_log" \ + TEST_ROOT_VICTIM="$victim" TEST_SUDO_TOKEN="$token" "$@" \ + "$ROOT/bin/omarchy-install-dev-env" "$branch" >"$output" 2>"$error" + status=$? + set -e + + if ((status != expected_status)); then + sed -n '1,160p' "$output" >&2 + sed -n '1,160p' "$error" >&2 + fail "$branch development environment returned $status instead of $expected_status" + fi + [[ ! -e $victim && ! -e $test_home/attack-done ]] || + fail "$branch user-level tooling reused cached root authority" + [[ ! -e $token ]] || fail "$branch left the modeled sudo timestamp live" + + awk ' + /^REVOKE$/ { live=0; next } + /^(AUTH|PRIVILEGED_CONFIG|PRIVILEGED_EXEC)$/ { live=1; next } + /^UNTRUSTED:/ && live { exit 1 } + ' "$event_log" || fail "$branch crossed from privileged work into untrusted code before revocation" +} + +# Invert the original exploit: the package helper authenticates, but the real +# Ruby flow must revoke that credential before the user-owned mise executable. +run_dev_env ruby 0 +grep -q '^AUTH_NO_UPDATE$' "$test_tmp/events-ruby" || fail "Ruby package setup did not use command-scoped sudo authentication" +grep -q '^UNTRUSTED:mise$' "$test_tmp/events-ruby" || fail "Ruby legitimate user tooling did not run" +grep -q '^DENIED$' "$test_tmp/events-ruby" || fail "Ruby tool did not receive an authentication-required result" +pass "user-owned Ruby tooling cannot reuse the package install credential" + +# PHP/Laravel/Symfony now have a separate unprivileged system-phase fixture +# in install-dev-env-system-test.sh, including total authentication counts. +branches=(node bun deno go python elixir phoenix rust java zig ocaml dotnet clojure scala) +for branch in "${branches[@]}"; do + run_dev_env "$branch" 0 +done +if grep -q '^UNTRUSTED:bashrc$' "$test_tmp"/events-*; then + fail "PHP setup executes the user-owned bashrc inside its privileged phase" +fi +grep -q '^AUTH_NO_UPDATE$' "$test_tmp/events-clojure" || fail "Clojure prerequisite did not use command-scoped authentication" +pass "all development branches keep user and downloaded code beyond the sudo boundary" + +# A pre-existing timestamp is intentionally revoked by the documented command +# contract, even when the prerequisite helper does not refresh it. +PRECREATE_TOKEN=1 run_dev_env ruby 0 PKG_NO_AUTH=1 +grep -q '^UNTRUSTED:mise$' "$test_tmp/events-ruby" || fail "already-installed prerequisite skipped legitimate Ruby flow" +pass "already-installed prerequisites cannot hand a pre-existing timestamp to user tooling" + +# Package and tool failures must both run the EXIT revocation path. +run_dev_env ruby 42 PKG_FAIL=1 +! grep -q '^UNTRUSTED:' "$test_tmp/events-ruby" || fail "package failure continued into user tooling" +run_dev_env ruby 43 TOOL_FAIL=mise +pass "development package and user-tool failures invalidate cached authorization" + +dev_signal_events="$test_tmp/events-dev-signal" +dev_signal_ready="$test_home/dev-signal-ready" +dev_session_pid_file="$test_home/dev-session-pid" +: >"$dev_signal_events" +chown 1000:1000 "$dev_signal_events" +rm -f "$dev_signal_ready" "$dev_session_pid_file" "$token" "$root_dir/90-dev-signal.rules" +( + exec setsid --fork --wait setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TEST_EVENT_LOG="$dev_signal_events" \ + TEST_ATTACK_DONE="$test_home/attack-done" TEST_ROOT_VICTIM="$root_dir/90-dev-signal.rules" \ + TEST_SUDO_TOKEN="$token" TOOL_BLOCK=mise TOOL_READY="$dev_signal_ready" \ + SESSION_PID_FILE="$dev_session_pid_file" \ + bash -c 'printf "%s\n" "$$" >"$SESSION_PID_FILE"; exec "$1" ruby' bash \ + "$ROOT/bin/omarchy-install-dev-env" +) >"$test_tmp/out-dev-signal" 2>"$test_tmp/err-dev-signal" & +dev_signal_runner=$! +for _ in {1..300}; do [[ -e $dev_signal_ready && -s $dev_session_pid_file ]] && break; sleep 0.01; done +[[ -e $dev_signal_ready && -s $dev_session_pid_file ]] || fail "development HUP probe never reached mise" +dev_session_pid=$(<"$dev_session_pid_file") +[[ $dev_session_pid =~ ^[0-9]+$ ]] || fail "development HUP probe did not report its session leader" +kill -HUP -- "-$dev_session_pid" +if wait "$dev_signal_runner"; then fail "HUP-interrupted development installer reports success"; fi +[[ ! -e $token && ! -e $root_dir/90-dev-signal.rules ]] || fail "development HUP path retained or reused root authority" +pass "development HUP cancellation invalidates cached authorization" + +cat >"$test_tmp/curl-absolute" <<'STUB' +#!/bin/bash +output= +while (($#)); do + case "$1" in + --output) + output=$2 + shift 2 + ;; + *) shift ;; + esac +done +[[ -n $output ]] +printf 'INSTALLER_PATH:%s\n' "$output" >>"$TEST_EVENT_LOG" +printf 'DOWNLOAD_MODE:%s:%s:%s\n' "$(stat -c %u "$output")" "$(stat -c %a "$output")" "$(stat -c %h "$output")" >>"$TEST_EVENT_LOG" +[[ ${GFN_CURL_FAIL:-0} != 1 ]] || exit 45 +cat >"$output" <<'INSTALLER' +#!/bin/bash +printf 'UNTRUSTED:geforce-installer\n' >>"$TEST_EVENT_LOG" +printf 'INSTALLER_CWD:%s\n' "$PWD" >>"$TEST_EVENT_LOG" +sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" >/dev/null 2>&1 || : +printf 'INSTALLER_RAN\n' >>"$TEST_EVENT_LOG" +if [[ ${GFN_BLOCK:-0} == 1 ]]; then + : >"$GFN_READY" + trap 'exit 143' TERM + trap 'exit 130' INT + while :; do sleep 0.05; done +fi +[[ ${GFN_INSTALLER_FAIL:-0} != 1 ]] || exit 44 +INSTALLER +STUB +chmod 0755 "$test_tmp/curl-absolute" +mount --bind "$test_tmp/curl-absolute" /usr/bin/curl + +run_gfn() { + local label="$1" expected_status="$2" + shift 2 + local event_log="$test_tmp/events-gfn-$label" output="$test_tmp/out-gfn-$label" error="$test_tmp/err-gfn-$label" + victim="$root_dir/90-gfn-$label.rules" + : >"$event_log" + chown 1000:1000 "$event_log" + rm -f "$victim" "$test_home/attack-done" "$token" + if [[ ${PRECREATE_TOKEN:-0} == 1 ]]; then + : >"$token" + chown 0:0 "$token" + fi + + set +e + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" \ + TEST_ATTACK_DONE="$test_home/attack-done" TEST_EVENT_LOG="$event_log" \ + TEST_ROOT_VICTIM="$victim" TEST_SUDO_TOKEN="$token" "$@" \ + "$ROOT/bin/omarchy-install-gaming-geforce-now" >"$output" 2>"$error" + status=$? + set -e + + if ((status != expected_status)); then + sed -n '1,160p' "$output" >&2 + sed -n '1,160p' "$error" >&2 + fail "GeForce $label returned $status instead of $expected_status" + fi + [[ ! -e $victim && ! -e $test_home/attack-done && ! -e $token ]] || + fail "GeForce $label retained or reused cached root authority" + installer_path=$(awk -F: '/^INSTALLER_PATH:/ { sub(/^INSTALLER_PATH:/, ""); print; exit }' "$event_log") + [[ -z $installer_path || ! -e $installer_path ]] || fail "GeForce $label left its downloaded executable behind" +} + +run_gfn success 0 +grep -q '^AUTH_NO_UPDATE$' "$test_tmp/events-gfn-success" || fail "GeForce package setup did not use command-scoped authentication" +grep -q '^UNTRUSTED:geforce-installer$' "$test_tmp/events-gfn-success" || fail "GeForce downloaded installer did not run" +grep -q '^DENIED$' "$test_tmp/events-gfn-success" || fail "GeForce installer did not receive an authentication-required result" +grep -q '^DOWNLOAD_MODE:1000:600:1$' "$test_tmp/events-gfn-success" || fail "GeForce download was not private from first creation" +grep -q '^INSTALLER_CWD:/tmp$' "$test_tmp/events-gfn-success" || fail "GeForce installer no longer runs from /tmp" +grep -q '^BROWSER$' "$test_tmp/events-gfn-success" || fail "GeForce success no longer launches the browser" +pass "downloaded GeForce installer cannot reuse the Flatpak package credential" + +PRECREATE_TOKEN=1 run_gfn installed-prerequisite 0 PKG_NO_AUTH=1 +run_gfn package-failure 42 PKG_FAIL=1 +run_gfn download-failure 45 GFN_CURL_FAIL=1 +run_gfn installer-failure 44 GFN_INSTALLER_FAIL=1 +pass "GeForce prerequisite, package, download, and installer outcomes revoke and clean up" + +# Signal the whole terminal-style process group so the foreground downloaded +# child and its waiting shell are interrupted together. +signal_events="$test_tmp/events-gfn-signal" +signal_ready="$test_home/gfn-signal-ready" +session_pid_file="$test_home/gfn-session-pid" +: >"$signal_events" +chown 1000:1000 "$signal_events" +rm -f "$signal_ready" "$session_pid_file" "$token" "$root_dir/90-gfn-signal.rules" +( + exec setsid --fork --wait setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" TEST_EVENT_LOG="$signal_events" \ + TEST_ATTACK_DONE="$test_home/attack-done" TEST_ROOT_VICTIM="$root_dir/90-gfn-signal.rules" \ + TEST_SUDO_TOKEN="$token" GFN_BLOCK=1 GFN_READY="$signal_ready" SESSION_PID_FILE="$session_pid_file" \ + bash -c 'printf "%s\n" "$$" >"$SESSION_PID_FILE"; exec "$1"' bash \ + "$ROOT/bin/omarchy-install-gaming-geforce-now" +) >"$test_tmp/out-gfn-signal" 2>"$test_tmp/err-gfn-signal" & +signal_runner=$! +for _ in {1..300}; do [[ -e $signal_ready && -s $session_pid_file ]] && break; sleep 0.01; done +[[ -e $signal_ready && -s $session_pid_file ]] || fail "GeForce signal probe never reached the downloaded installer" +session_pid=$(<"$session_pid_file") +[[ $session_pid =~ ^[0-9]+$ ]] || fail "GeForce signal probe did not report its session leader" +kill -TERM -- "-$session_pid" +if wait "$signal_runner"; then fail "TERM-interrupted GeForce installer reports success"; fi +[[ ! -e $token && ! -e $root_dir/90-gfn-signal.rules ]] || fail "GeForce signal path retained or reused root authority" +signal_installer=$(awk -F: '/^INSTALLER_PATH:/ { sub(/^INSTALLER_PATH:/, ""); print; exit }' "$signal_events") +[[ -n $signal_installer && ! -e $signal_installer ]] || fail "GeForce signal path left its executable behind" +pass "GeForce TERM cancellation invalidates sudo and removes the downloaded executable" + +cat >"$stub_bin/battlenet-package-helper" <<'STUB' +#!/bin/bash +printf 'PACKAGE:%s:%s\n' "${0##*/}" "$*" >>"$TEST_EVENT_LOG" +[[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 98 +if [[ ${PKG_NO_AUTH:-0} != 1 ]]; then + /usr/bin/sudo -N -- /usr/bin/true +fi +if [[ ${BATTLENET_PKG_BLOCK:-0} == 1 ]]; then + : >"$BATTLENET_READY" + trap 'exit 143' TERM + trap 'exit 129' HUP + while :; do sleep 0.05; done +fi +[[ ${PKG_FAIL:-0} != 1 ]] || exit 42 +STUB +chmod 0755 "$stub_bin/battlenet-package-helper" +cat >"$stub_bin/battlenet-path-wrapper" <<'STUB' +#!/bin/bash +printf 'UNTRUSTED:path-package-wrapper\n' >>"$TEST_EVENT_LOG" +/usr/bin/sudo --authenticate-for-test +/usr/bin/sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" +STUB +chmod 0755 "$stub_bin/battlenet-path-wrapper" +ln -sf battlenet-path-wrapper "$stub_bin/omarchy-pkg-add" +ln -sf battlenet-path-wrapper "$stub_bin/omarchy-install-gaming-gpu-lib32" +umount /usr/bin/omarchy-pkg-add +mount --bind "$stub_bin/battlenet-package-helper" /usr/bin/omarchy-pkg-add +mount --bind "$stub_bin/battlenet-package-helper" /usr/bin/omarchy-install-gaming-gpu-lib32 + +cat >"$stub_bin/curl" <<'STUB' +#!/bin/bash +output= +while (($#)); do + case "$1" in + --output) + output=$2 + shift 2 + ;; + *) shift ;; + esac +done +printf 'UNTRUSTED:battlenet-download\n' >>"$TEST_EVENT_LOG" +[[ ${BATTLENET_CURL_FAIL:-0} != 1 ]] || exit 45 +[[ -n $output ]] +printf 'harmless Battle.net fixture\n' >"$output" +STUB + +cat >"$stub_bin/umu-run" <<'STUB' +#!/bin/bash +printf 'UNTRUSTED:umu-run\n' >>"$TEST_EVENT_LOG" +if sudo /usr/bin/install -o root -g root -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM"; then + : >"$TEST_ATTACK_DONE" +fi +: >"$BATTLENET_UMU_RAN" +STUB +chmod 0755 "$stub_bin/curl" "$stub_bin/umu-run" + +run_battlenet() { + local label="$1" expected_status="$2" + shift 2 + local event_log="$test_tmp/events-battlenet-$label" output="$test_tmp/out-battlenet-$label" error="$test_tmp/err-battlenet-$label" + local umu_ran="$test_home/battlenet-umu-$label" + victim="$root_dir/90-battlenet-$label.rules" + : >"$event_log" + chown 1000:1000 "$event_log" + rm -rf "$test_home/Games/battlenet" + rm -f "$victim" "$test_home/attack-done" "$token" "$umu_ran" + if [[ ${PRECREATE_TOKEN:-0} == 1 ]]; then + : >"$token" + chown 0:0 "$token" + fi + + set +e + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$ROOT" \ + TEST_ATTACK_DONE="$test_home/attack-done" TEST_EVENT_LOG="$event_log" \ + TEST_ROOT_VICTIM="$victim" TEST_SUDO_TOKEN="$token" BATTLENET_UMU_RAN="$umu_ran" "$@" \ + "$ROOT/bin/omarchy-install-gaming-battlenet" >"$output" 2>"$error" + status=$? + set -e + + if ((status != expected_status)); then + sed -n '1,160p' "$output" >&2 + sed -n '1,160p' "$error" >&2 + fail "Battle.net $label returned $status instead of $expected_status" + fi + if ((expected_status == 0)); then + for _ in {1..300}; do [[ -e $umu_ran ]] && break; sleep 0.01; done + [[ -e $umu_ran ]] || fail "Battle.net $label detached umu-run did not execute" + fi + [[ ! -e $victim && ! -e $test_home/attack-done && ! -e $token ]] || + fail "Battle.net $label retained or reused cached root authority" + + awk ' + /^REVOKE$/ { live=0; next } + /^(AUTH|PRIVILEGED_CONFIG|PRIVILEGED_EXEC)$/ { live=1; next } + /^UNTRUSTED:/ && live { exit 1 } + ' "$event_log" || fail "Battle.net $label crossed into downloaded code before revocation" +} + +run_battlenet success 0 +grep -q '^AUTH_NO_UPDATE$' "$test_tmp/events-battlenet-success" || fail "Battle.net package setup did not use command-scoped authentication" +grep -q '^UNTRUSTED:umu-run$' "$test_tmp/events-battlenet-success" || fail "Battle.net detached umu-run did not run" +grep -q '^DENIED$' "$test_tmp/events-battlenet-success" || fail "Battle.net umu-run did not receive an authentication-required result" +pass "detached Battle.net vendor execution cannot reuse package-helper authorization" + +PRECREATE_TOKEN=1 run_battlenet installed-prerequisite 0 PKG_NO_AUTH=1 +run_battlenet package-failure 42 PKG_FAIL=1 +run_battlenet download-failure 45 BATTLENET_CURL_FAIL=1 +pass "Battle.net prerequisite, package, and download outcomes revoke sudo" + +battlenet_signal_events="$test_tmp/events-battlenet-signal" +battlenet_signal_ready="$test_home/battlenet-signal-ready" +battlenet_session_pid_file="$test_home/battlenet-session-pid" +: >"$battlenet_signal_events" +chown 1000:1000 "$battlenet_signal_events" +rm -f "$battlenet_signal_ready" "$battlenet_session_pid_file" "$token" "$root_dir/90-battlenet-signal.rules" +( + exec setsid --fork --wait setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$ROOT" \ + TEST_EVENT_LOG="$battlenet_signal_events" TEST_ATTACK_DONE="$test_home/attack-done" \ + TEST_ROOT_VICTIM="$root_dir/90-battlenet-signal.rules" TEST_SUDO_TOKEN="$token" \ + BATTLENET_PKG_BLOCK=1 BATTLENET_READY="$battlenet_signal_ready" \ + SESSION_PID_FILE="$battlenet_session_pid_file" \ + bash -c 'printf "%s\n" "$$" >"$SESSION_PID_FILE"; exec "$1"' bash \ + "$ROOT/bin/omarchy-install-gaming-battlenet" +) >"$test_tmp/out-battlenet-signal" 2>"$test_tmp/err-battlenet-signal" & +battlenet_signal_runner=$! +for _ in {1..300}; do [[ -e $battlenet_signal_ready && -s $battlenet_session_pid_file ]] && break; sleep 0.01; done +[[ -e $battlenet_signal_ready && -s $battlenet_session_pid_file ]] || fail "Battle.net signal probe never reached its package helper" +battlenet_session_pid=$(<"$battlenet_session_pid_file") +[[ $battlenet_session_pid =~ ^[0-9]+$ ]] || fail "Battle.net signal probe did not report its session leader" +kill -TERM -- "-$battlenet_session_pid" +if wait "$battlenet_signal_runner"; then fail "TERM-interrupted Battle.net installer reports success"; fi +[[ ! -e $token && ! -e $root_dir/90-battlenet-signal.rules ]] || fail "Battle.net signal path retained or reused root authority" +pass "Battle.net TERM cancellation invalidates cached authorization" diff --git a/test/shell.d/install-dev-env-system-test.sh b/test/shell.d/install-dev-env-system-test.sh new file mode 100755 index 00000000000..d20db65462f --- /dev/null +++ b/test/shell.d/install-dev-env-system-test.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +export TEST_EVENT_LOG="$test_tmp/events" +export TEST_SYSTEM_SCRIPT="$test_tmp/omarchy-install-dev-env-system" +export HOME="$test_tmp/home" +mkdir -p "$test_tmp/bin" "$HOME" "$test_tmp/php/conf.d" + +cat >"$test_tmp/bin/sudo" <<'SH' +#!/bin/bash +set -euo pipefail +case "${1:-}" in + -k) echo REVOKE >>"$TEST_EVENT_LOG" ;; + -h) echo 'usage: sudo [-ABbEHkNnPS] command' ;; + -N) + [[ $# == 10 && $2 == -- && $3 == /usr/bin/env && $4 == -i && + $5 == PATH=/usr/bin:/usr/sbin:/bin:/sbin && $6 == /usr/bin/bash && + $7 == -p && $8 == -- && $9 == /usr/bin/omarchy-install-dev-env-system ]] + [[ ${10} == php || ${10} == symfony ]] + echo "AUTH:${10}" >>"$TEST_EVENT_LOG" + /usr/bin/bash -p -- "$TEST_SYSTEM_SCRIPT" "${10}" + ;; + *) exit 90 ;; +esac +SH +cat >"$test_tmp/bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +echo "PACKAGES:$*" >>"$TEST_EVENT_LOG" +exit "${TEST_PACKAGE_STATUS:-0}" +SH +for tool in mise composer; do + cat >"$test_tmp/bin/$tool" <<'SH' +#!/bin/bash +echo "USER:${0##*/}:$*" >>"$TEST_EVENT_LOG" +exit "${TEST_USER_STATUS:-0}" +SH +done + +for name in omarchy-security-functions omarchy-install-security-functions omarchy-install-dev-env; do + sed -e "s#/usr/bin/sudo#$test_tmp/bin/sudo#g" \ + -e "s#/usr/bin/omarchy-pkg-add#$test_tmp/bin/omarchy-pkg-add#g" \ + "$ROOT/bin/$name" >"$test_tmp/$name" +done +# Only the fixture bypasses root identity; all effects target ordinary temporary files. +sed -e 's/if (( EUID != 0 )) ||/if/' \ + -e "s#/usr/bin/omarchy-pkg-add#$test_tmp/bin/omarchy-pkg-add#g" \ + -e "s#/etc/php/#$test_tmp/php/#g" \ + "$ROOT/bin/omarchy-install-dev-env-system" >"$TEST_SYSTEM_SCRIPT" +chmod 755 "$test_tmp/bin/"* "$test_tmp/omarchy-install-dev-env" +export PATH="$test_tmp/bin:/usr/bin:/bin" + +reset_config() { + printf ';zend_extension=xdebug.so\n;xdebug.mode=debug\n' >"$test_tmp/php/conf.d/xdebug.ini" + printf ';extension=%s\n' bcmath intl iconv openssl pdo_sqlite pdo_mysql >"$test_tmp/php/php.ini" + : >"$TEST_EVENT_LOG" +} + +for environment in php laravel symfony; do + reset_config + "$test_tmp/omarchy-install-dev-env" "$environment" >"$test_tmp/output" 2>&1 || + fail "$environment completes its fixed system phase" + [[ $(grep -c '^AUTH:' "$TEST_EVENT_LOG") == 1 ]] || fail "$environment authenticates once in total" + [[ $(head -n 1 "$TEST_EVENT_LOG") == REVOKE && $(tail -n 1 "$TEST_EVENT_LOG") == REVOKE ]] || + fail "$environment revokes on entry and exit" + expected='PACKAGES:php composer php-sqlite xdebug' + [[ $environment != symfony ]] || expected+=' symfony-cli' + grep -Fxq "$expected" "$TEST_EVENT_LOG" || fail "$environment installs its exact complete package set" + if grep -q '^;' "$test_tmp/php/conf.d/xdebug.ini" "$test_tmp/php/php.ini"; then + fail "$environment enables every required PHP setting" + fi + awk '/^AUTH:/ { cold=0 } /^REVOKE$/ { cold=1 } /^USER:/ && !cold { exit 1 }' "$TEST_EVENT_LOG" || + fail "$environment revokes before user tools" +done +pass "PHP, Laravel and Symfony use one fixed authentication for packages and configuration" + +reset_config +if TEST_PACKAGE_STATUS=42 "$test_tmp/omarchy-install-dev-env" laravel >"$test_tmp/output" 2>&1; then + fail "PHP package failure propagates" +else + [[ $? == 42 ]] || fail "PHP preserves the package failure status" +fi +[[ $(tail -n 1 "$TEST_EVENT_LOG") == REVOKE ]] || fail "PHP package failure revokes" +if grep -q '^USER:' "$TEST_EVENT_LOG" || grep -q '^extension=' "$test_tmp/php/php.ini"; then + fail "PHP package failure precedes configuration and user work" +fi +pass "PHP package failure prevents configuration and user work and still revokes" + +reset_config +if /usr/bin/bash -p -- "$TEST_SYSTEM_SCRIPT" unsupported >"$test_tmp/output" 2>&1; then + fail "system phase rejects an unsupported selector" +fi +[[ ! -s $TEST_EVENT_LOG ]] || fail "invalid system selector reaches no package operation" +if /usr/bin/bash -p -- "$TEST_SYSTEM_SCRIPT" php extra >"$test_tmp/output" 2>&1; then + fail "system phase rejects extra arguments" +fi +pass "system phase has a closed argument vocabulary" + +cat >"$test_tmp/startup" <<'SH' +: >"$TEST_STARTUP_MARKER" +unset BASH_ENV +set -o privileged +SH +export TEST_STARTUP_MARKER="$test_tmp/startup-ran" +for name in omarchy-pkg-add omarchy-install-dev-env omarchy-install-font omarchy-install-gaming-battlenet omarchy-install-gaming-geforce-now omarchy-install-gaming-gpu-lib32; do + sed -e "s#/usr/bin/sudo#$test_tmp/bin/sudo#g" \ + -e "s#/usr/bin/omarchy-pkg-add#$test_tmp/bin/omarchy-pkg-add#g" \ + "$ROOT/bin/$name" >"$test_tmp/$name" + rm -f "$TEST_STARTUP_MARKER" + : >"$TEST_EVENT_LOG" + if BASH_ENV="$test_tmp/startup" /usr/bin/bash "$test_tmp/$name" -p >"$test_tmp/output" 2>&1; then + fail "$name rejects ordinary Bash with a post-script -p" + else + [[ $? == 126 ]] || fail "$name rejects unsafe startup before operational work" + fi + [[ -f $TEST_STARTUP_MARKER && ! -s $TEST_EVENT_LOG ]] || fail "$name validates actual interpreter startup" +done +pass "all five installers and the package helper reject a decoy privileged startup" 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..06965215f0a --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -2,122 +2,457 @@ 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" +function_prefix() { + printf 'source %q\n' "$security_library_path" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path" +} -cat >"$mock_bin/gum" <<'SH' -#!/bin/bash -exit 0 -SH +# 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' '' 18446744073709551617; do + ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" + done +) +pass "passwordless sudo validates a bounded positive duration" -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash +# 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" -printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" -[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] -SH +# 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" -cat >"$mock_bin/sudo" <<'SH' +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 - -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) +if [[ ${1:-} == -h ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' exit 0 - ;; -*) - echo "unexpected sudo command: $*" >&2 - exit 90 - ;; +fi +if [[ ${1:-} == -k ]]; then + rm -f -- "$TEST_PUBLIC_TOKEN" + exit 0 +fi +no_update=0 +if [[ ${1:-} == -N ]]; then no_update=1; shift; fi +[[ ${1:-} != -- ]] || shift +((no_update)) || : >"$TEST_PUBLIC_TOKEN" +case "${2:-}" in + __status) exit "${TEST_PUBLIC_STATUS:-3}" ;; + __enable|__disable) exit 0 ;; + *) exit 2 ;; esac -SH +STUB +cat >"$public_gum_stub" <<'STUB' +#!/bin/bash +[[ -z ${TEST_PUBLIC_GUM_LOG:-} ]] || : >"$TEST_PUBLIC_GUM_LOG" +[[ ! -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/sed "s#/usr/bin/sudo#$public_sudo_stub#g" "$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" +for status in 1 2; do + if TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ + TEST_PUBLIC_STATUS="$status" TEST_PUBLIC_GUM_LOG="$test_tmp/unsafe-status-confirmation" \ + /usr/bin/bash -p "$public_flow" 15 >"$test_tmp/status-error.output" 2>&1; then + fail "passwordless sudo treats status/authorization failure $status as inactive" + fi + [[ ! -e $test_tmp/unsafe-status-confirmation ]] || fail "failed status inspection opens the enable prompt" + grep -q 'Could not safely inspect passwordless sudo' "$test_tmp/status-error.output" || + fail "failed status inspection lacks recovery guidance" +done -chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" +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" -run_command() { - TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ - "$script" "$@" -} +# 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" -: >"$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" +# 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" -mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") -(( ${#tmpfiles_rules[@]} == 1 )) || - fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" +# 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" -fake_root="$test_tmp/root" +# 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 +: >"$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" + +# 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/pkgbuilds + break + elif [[ -n $candidate && -d $candidate/omarchy-settings ]]; then + pkgs_root=$candidate + break + fi done -touch "$sudoers_dir/omarchy-dns" +[[ -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/$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_root/run/lock" "$removal_root/etc/tmpfiles.d" + : >"$removal_sudoers/99-omarchy-nopasswd-1000" + : >"$removal_sudoers/99-omarchy-nopasswd-legacy-user" + : >"$removal_sudoers/omarchy-dns" + ln -s ../administrator/os-release "$removal_root/etc/os-release" + package_stat="$test_tmp/package-stat" + cat >"$package_stat" <<'STUB' +#!/bin/bash +if [[ $2 == '%u' ]]; then printf '0\n'; else /usr/bin/stat "$@"; fi +STUB + chmod +x "$package_stat" + sed -e "s#/etc/#$removal_root/etc/#g" \ + -e "s#/run#$removal_root/run#g" \ + -e "s#/usr/bin/stat#$package_stat#g" "$install_script" >"$transformed_install" + ( + source "$transformed_install" + pre_remove + [[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + 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" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + fail "$package_name removal changes unrelated OS metadata" + : >"$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" -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" + ( + source "$transformed_install" + _etc_overrides_apply() { :; } + if post_install; then exit 1; fi + [[ -f $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + : >"$removal_root/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" + post_install + [[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + : >"$removal_sudoers/99-omarchy-nopasswd-1002" + pre_upgrade + [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1002 ]] + post_upgrade + [[ ! -e $removal_root/run/omarchy-sudo-passwordless-package-removing ]] + ) || fail "$package_name restores grant availability only after boot cleanup is installed" +done +pass "settings package transitions revoke grants and preserve unrelated configuration" -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" +# 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 -[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" -pass "systemd-tmpfiles removes generated grants only during boot" +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" "$timer"' "$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" 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..b3198405642 --- /dev/null +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -0,0 +1,235 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +children=() +cleanup() { + 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 EXIT + +# All policy, state, locks and command mutations stay in this private fixture. +# Native visudo validates inert fragments; no test installs host sudo policy. +mkdir -p "$test_tmp/bin" "$test_tmp/state" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/hooks" +export TEST_GRANT_ROOT="$test_tmp" +cat >"$test_tmp/bin/stat" <<'STUB' +#!/bin/bash +case $2 in + '%u') printf '0\n' ;; + '%a') if [[ -d ${@: -1} ]]; then printf '755\n'; else printf '644\n'; fi ;; + '%u %a') if [[ -d ${@: -1} ]]; then printf '0 755\n'; else printf '0 644\n'; fi ;; + *) exec /usr/bin/stat "$@" ;; +esac +STUB +cat >"$test_tmp/bin/install" <<'STUB' +#!/bin/bash +args=() +while (($#)); do + case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac +done +exec /usr/bin/install "${args[@]}" +STUB +cat >"$test_tmp/bin/rm" <<'STUB' +#!/bin/bash +for path in "$@"; do + if [[ ${TEST_FAIL_TEMP_CLEANUP:-0} == 1 && $path == "$TEST_GRANT_ROOT/state/".sudoers.* ]]; then exit 1; fi + if [[ ${TEST_FAIL_RULE_DELETE:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/"* ]]; then exit 1; fi +done +exec /usr/bin/rm "$@" +STUB +cat >"$test_tmp/bin/systemctl" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >>"$TEST_GRANT_ROOT/systemctl.log" +exit 0 +STUB +chmod +x "$test_tmp/bin/"* +library="$test_tmp/grant-functions.sh" +{ + printf 'source %q\n' "$ROOT/bin/omarchy-security-functions" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$ROOT/bin/omarchy-sudo-passwordless" +} | sed \ + -e "s|/var/lib/omarchy/sudo-passwordless|$test_tmp/state|g" \ + -e "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|g" \ + -e "s|/etc/tmpfiles.d|$test_tmp/etc/tmpfiles.d|g" \ + -e "s|/usr/share/libalpm/hooks|$test_tmp/hooks|g" \ + -e "s|/run/lock/omarchy-sudo-passwordless.lock|$test_tmp/run/lock/omarchy-sudo-passwordless.lock|g" \ + -e "s|/run/omarchy-sudo-passwordless-package-removing|$test_tmp/run/omarchy-sudo-passwordless-package-removing|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" \ + -e "s|/usr/bin/install|$test_tmp/bin/install|g" \ + -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \ + -e "s|/usr/bin/systemctl|$test_tmp/bin/systemctl|g" \ + -e 's|/usr/bin/chown|/usr/bin/true|g' >"$library" + +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" + +printf 'r! /etc/sudoers.d/99-omarchy-nopasswd-*\n' >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +# The expected policy text is mapped along with its filename in this fixture. +sed -i "s|/etc/sudoers.d|$test_tmp/etc/sudoers.d|" "$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" + +( + source "$library" + for name in 'buildbot$' audituser aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + valid_account_name "$name" || fail "supported account name rejected: $name" + printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$test_tmp/name-policy" + /usr/sbin/visudo -cf "$test_tmp/name-policy" >/dev/null + done + for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do + ! valid_account_name "$name" || fail "invalid account name accepted" + done + ! valid_uid 18446744073709551617 || fail "overflowed UID accepted" + printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" + remove_known_legacy_rules + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot\$ ]] +) || fail "supported account names and legacy cleanup disagree" +pass "provisioning-compatible names validate as sudoers and clean up correctly" + +transaction_setup() { + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + start_expiry_timer() { printf '%s\n' "$3" >>"$test_tmp/armed"; } + stop_timer() { printf '%s\n' "$1" >>"$test_tmp/stopped"; } +} + +( + source "$library" + transaction_setup + TEST_FAIL_TEMP_CLEANUP=1 enable_locked 1000 15 && exit 1 + [[ ! -e $(rule_file 1000) && ! -e $(state_file 1000) && -s $test_tmp/stopped ]] +) || fail "post-publication cleanup failure did not revoke before timer cleanup" +pass "failed temporary cleanup after publication revokes the live policy" + +rm -f "$test_tmp/stopped" +( + source "$library" + transaction_setup + TEST_FAIL_TEMP_CLEANUP=1 TEST_FAIL_RULE_DELETE=1 enable_locked 1000 15 && exit 1 + [[ -f $(rule_file 1000) && -f $(state_file 1000) && ! -e $test_tmp/stopped ]] + if TEST_FAIL_RULE_DELETE=1 revoke_inactive_grant 1000; then exit 1; else status=$?; fi + (( status == 2 )) +) || fail "failed policy revocation disarmed expiry or claimed inactive status" +pass "failed revocation preserves expiry jobs and returns a distinct error" + +( + source "$library" + transaction_setup + current_timer=$(read_state_timer 1000) + expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff + [[ -f $(rule_file 1000) ]] + expire_locked 1000 + [[ -f $(rule_file 1000) ]] + expire_locked 1000 "$current_timer" + [[ ! -e $(rule_file 1000) ]] +) || fail "a predecessor timer invalidates its replacement" +pass "old and legacy timer callbacks preserve a newer valid grant" + +( + source "$library" + transaction_setup + start_expiry_timer() { + : >"$REMOVAL_BLOCKER" + return 0 + } + enable_locked 1000 15 && exit 1 + [[ ! -e $(rule_file 1000) ]] +) || fail "publication ignores a lost package prerequisite" +rm "$test_tmp/run/omarchy-sudo-passwordless-package-removing" +pass "grant publication rechecks package availability after timer setup" + +pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs} +[[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds +package_script="$pkgs_path/omarchy-settings/omarchy-settings.install" +[[ -f $package_script ]] || fail "package checkout is required for shared lifecycle coverage" +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" "$package_script" >"$test_tmp/package.install" + +worker="$test_tmp/publisher.sh" +{ + printf '#!/bin/bash\nset -euo pipefail\nsource %q\n' "$library" + declare -f transaction_setup + printf 'test_tmp=%q\ntransaction_setup\n' "$test_tmp" + cat <<'WORKER' +publish_rule() { + : >"$test_tmp/publisher.entered" + while [[ ! -e $test_tmp/publisher.release ]]; do sleep 0.02; done + printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file "$1")" +} +with_root_lock enable_locked 1000 15 +WORKER +} >"$worker" +bash "$worker" >"$test_tmp/publisher.output" 2>&1 & +children+=("$!") +for ((attempt = 0; attempt < 250; attempt++)); do + [[ ! -e $test_tmp/publisher.entered ]] || break + sleep 0.02 +done +[[ -e $test_tmp/publisher.entered ]] || fail "grant publisher did not enter the shared lock" +bash -euo pipefail -c 'source "$1"; : >"$2"; pre_remove; post_remove' bash \ + "$test_tmp/package.install" "$test_tmp/removal.started" >"$test_tmp/removal.output" 2>&1 & +children+=("$!") +for ((attempt = 0; attempt < 250; attempt++)); do + [[ ! -e $test_tmp/removal.started ]] || break + sleep 0.02 +done +[[ -e $test_tmp/removal.started ]] || fail "package removal did not start" +touch "$test_tmp/publisher.release" +for child in "${children[@]}"; do wait "$child" || fail "shared lifecycle worker failed"; done +children=() +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "removal left a concurrently published grant" +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not block later publication" +( + source "$library" + transaction_setup + ! with_root_lock enable_locked 1000 15 +) || fail "a publisher can create a grant after package removal begins" +pass "package removal shares the grant lock and blocks later publication" + +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" +if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash "$test_tmp/package.install" >"$test_tmp/removal-failure.output" 2>&1; then + fail "package removal hid a failed policy deletion" +fi +grep -q 'Administrator cleanup is required' "$test_tmp/removal-failure.output" || fail "package deletion failure lacks recovery guidance" +pass "package removal reports cleanup failures instead of successful revocation" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + record=$(read_state_record 1000) + expiry=${record#*$'\t'} + expiry=${expiry%%$'\t'*} + deadline=$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ) + [[ $(cat "$(rule_file 1000)") == "audituser ALL=(ALL) NOTAFTER=$deadline NOPASSWD: ALL" ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + classify_generated_rule "$(rule_file 1000)" + rm -f "$(state_file 1000)" + remove_known_legacy_rules + [[ ! -e $(rule_file 1000) ]] +) || fail "native sudo deadline or state-independent bounded rule cleanup is incorrect" +pass "sudo policy contains the same deadline and bounded orphan rules are recognized" + +( + source "$library" + transaction_setup + rm -f "$REMOVAL_BLOCKER" + enable_locked 1000 5 + if TEST_FAIL_RULE_DELETE=1 package_removing_locked; then exit 1; fi + [[ -f $REMOVAL_BLOCKER && -f $(rule_file 1000) ]] + ! enable_locked 1000 5 + package_removing_locked + [[ ! -e $(rule_file 1000) ]] + rm -f "$REMOVAL_BLOCKER" "$PACKAGE_HOOK" + ! enable_locked 1000 5 +) || fail "pre-transaction revocation error or missing hook does not prevent new grants" +pass "package hook fails closed and grants require its installed policy"