From 1136a715c08b73009d794ab6c2cc43c9406af720 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 31 Aug 2026 21:27:50 +0100 Subject: [PATCH 01/29] OM-SEC-14: Run update hooks without reusable sudo authority --- bin/omarchy-refresh-pacman | 243 +++- bin/omarchy-update | 243 +++- .../add-custom-repo.sample | 15 +- default/agents/skills/omarchy/hooks.md | 6 +- default/omarchy/sudo-no-update/sudo | 27 + docs/update-process.md | 35 +- test/shell.d/update-hook-security-test.sh | 1040 +++++++++++++++++ test/shell.d/update-sequence-test.sh | 60 +- 8 files changed, 1628 insertions(+), 41 deletions(-) create mode 100755 default/omarchy/sudo-no-update/sudo create mode 100644 test/shell.d/update-hook-security-test.sh diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 299d6c20..612a31de 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -1,26 +1,245 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Overwrite the package configuration for /etc/pacman with the Omarchy default of using its dedicated mirrors and repositories, then update all packages. # omarchy:requires-sudo=true -sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak -sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for pacman refresh." >&2 + exit 126 +fi -channel="${1:-stable}" +require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 + 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 "$$" +} +if ! require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for pacman refresh." >&2 + exit 126 +fi +unset -f require_privileged_bash_startup -if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then - echo "Error: Invalid channel '$channel'. Must be one of: stable, rc, edge" +set -e + +sanitize_bash_startup_environment() { + local environment_entry environment_name + local needs_reexec=0 + local -a environment_unsets=(-u BASH_ENV -u ENV) + + [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 + while IFS= read -r -d '' environment_entry; do + environment_name="${environment_entry%%=*}" + if [[ $environment_name == BASH_FUNC_*%% ]]; then + environment_unsets+=(-u "$environment_name") + needs_reexec=1 + fi + done < <(/usr/bin/env -0) + + if (( needs_reexec )); then + exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" + fi +} +sanitize_bash_startup_environment "$@" +unset -f sanitize_bash_startup_environment + +usage() { + echo "Usage: omarchy-refresh-pacman [stable|rc|edge]" >&2 +} + +# Composite commands can postpone the legacy user hook until their own final +# privilege boundary. The two internal modes are deliberately paired: a caller +# that defers must invoke --run-deferred-hook exactly once after all of its +# sudo-capable work has finished. +channel=stable +hook_mode=normal +case "$#:$1:${2:-}" in + 0::) + ;; + 1:stable: | 1:rc: | 1:edge:) + channel="$1" + ;; + 1:--run-deferred-hook:) + hook_mode=run-deferred + ;; + 2:stable:--defer-hook | 2:rc:--defer-hook | 2:edge:--defer-hook) + channel="$1" + hook_mode=defer + ;; + *) + usage + exit 2 + ;; +esac + +trusted_directory_chain() { + local current="$1" allow_current_user="$2" canonical owner mode current_uid + current_uid=$(/usr/bin/id -u) || return 1 + + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + [[ $current == / ]] && break + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then + return 1 + fi + (( (8#$mode & 0022) == 0 )) || return 1 + current=${current%/*} + [[ -n $current ]] || current=/ + done +} + +trusted_omarchy_source_root() { + local config=/etc/omarchy.conf default_root=/usr/share/omarchy configured_root="" canonical="" + local owner="" mode="" links="" size="" line="" encoded="" decoded="" character="" + local index=0 escaped=0 lines=() + + if [[ ! -e $config && ! -L $config ]]; then + configured_root="$default_root" + else + [[ -f $config && ! -L $config ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$config") || return 1 + [[ $canonical == "$config" ]] || return 1 + read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 + [[ $owner == "0" && $links == "1" ]] || return 1 + (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 + trusted_directory_chain /etc false || return 1 + mapfile -t lines <"$config" || return 1 + (( ${#lines[@]} == 1 )) || return 1 + line="${lines[0]}" + [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 + encoded="${line#'export OMARCHY_PATH="'}" + encoded="${encoded%'"'}" + for (( index = 0; index < ${#encoded}; index++ )); do + character="${encoded:index:1}" + if (( escaped )); then + case "$character" in + '\' | '"' | '$' | '`') decoded+="$character" ;; + *) return 1 ;; + esac + escaped=0 + elif [[ $character == '\' ]]; then + escaped=1 + elif [[ $character == '"' ]]; then + return 1 + else + decoded+="$character" + fi + done + (( escaped == 0 )) || return 1 + configured_root="$decoded" + fi + + [[ -d $configured_root && ! -L $configured_root ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 + [[ $canonical == "$configured_root" ]] || return 1 + if [[ $configured_root == "$default_root" ]]; then + trusted_directory_chain "$configured_root" false || return 1 + else + trusted_directory_chain "$configured_root" true || return 1 + fi + printf '%s\n' "$configured_root" +} + +trusted_omarchy_source_file() { + local relative="$1" source="$OMARCHY_PATH/$1" canonical owner mode links directory current_uid + current_uid=$(/usr/bin/id -u) || return 1 + [[ $relative != /* && $relative != ../* && $relative != */../* && $relative != */.. ]] || return 1 + [[ -f $source && ! -L $source ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$source") || return 1 + [[ $canonical == "$source" && $canonical == "$OMARCHY_PATH/"* ]] || return 1 + read -r owner mode links < <(/usr/bin/stat -Lc '%u %a %h' -- "$source") || return 1 + [[ $links == 1 ]] && (( (8#$mode & 0022) == 0 )) || return 1 + if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then + [[ $owner == 0 ]] || return 1 + else + [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 + fi + + directory=${source%/*} + while [[ $directory == "$OMARCHY_PATH" || $directory == "$OMARCHY_PATH/"* ]]; do + [[ -d $directory && ! -L $directory ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$directory") || return 1 + [[ $canonical == "$directory" ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$directory") || return 1 + (( (8#$mode & 0022) == 0 )) || return 1 + if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then + [[ $owner == 0 ]] || return 1 + else + [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 + fi + [[ $directory == "$OMARCHY_PATH" ]] && break + directory=${directory%/*} + done + printf '%s\n' "$source" +} + +if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then + echo "Refusing to refresh pacman from an untrusted Omarchy source root." >&2 exit 1 fi +export OMARCHY_PATH +user_path="${PATH:-/usr/bin:/bin}" +PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" +export PATH + +as_root() { + if ((EUID == 0)); then + "$@" + elif [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then + /usr/bin/sudo -N -- "$@" + else + /usr/bin/sudo -- "$@" + fi +} + +cleanup_sudo_credentials() { + /usr/bin/sudo -k || true +} + +trap cleanup_sudo_credentials EXIT + +if [[ $hook_mode == "run-deferred" ]]; then + /usr/bin/sudo -k || exit 1 + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman + exit +fi + +pacman_source=$(trusted_omarchy_source_file "default/pacman/pacman-$channel.conf") || { + echo "Refusing an untrusted pacman configuration source." >&2 + exit 1 +} +mirror_source=$(trusted_omarchy_source_file "default/pacman/mirrorlist-$channel") || { + echo "Refusing an untrusted pacman mirror source." >&2 + exit 1 +} + +as_root /usr/bin/cp -f -- /etc/pacman.conf /etc/pacman.conf.bak +as_root /usr/bin/cp -f -- /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak echo "Setting channel to $channel" echo -sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf -sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist +# The unprivileged shell opens the authorized source. Root consumes only the +# inherited descriptor, never a caller-writable development-checkout pathname. +as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.conf <"$pacman_source" +as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.d/mirrorlist <"$mirror_source" -# Allow user customization of /etc/pacman.conf before the upgrade runs -omarchy-hook pre-refresh-pacman +# Reset all package DBs and then update. +as_root /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -Syyuu --noconfirm -# Reset all package DBs and then update -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +# This legacy hook used to run before pacman. Executable user code cannot +# safely precede a later sudo authentication: a child can wait for the new +# timestamp even if the parent invalidates around the hook. Keep the hook, but +# run it only after every privileged refresh step and with a cold credential. +if [[ $hook_mode == "normal" ]]; then + /usr/bin/sudo -k || exit 1 + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman +fi diff --git a/bin/omarchy-update b/bin/omarchy-update index e71e8086..4b0b1cc8 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -1,12 +1,217 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Update Omarchy and system packages # omarchy:args=[-y] # omarchy:examples=omarchy update | omarchy update -y # omarchy:requires-sudo=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 + exit 126 +fi + +require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 + 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 "$$" +} +if ! require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 + exit 126 +fi +unset -f require_privileged_bash_startup + set -e +# Privileged mode prevents BASH_ENV and exported functions from running before +# this boundary. Re-exec once without their raw environment records so ordinary +# Bash helpers cannot import them again and bypass the trusted command paths. +sanitize_bash_startup_environment() { + local environment_entry environment_name + local needs_reexec=0 + local -a environment_unsets=(-u BASH_ENV -u ENV) + + [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 + while IFS= read -r -d '' environment_entry; do + environment_name="${environment_entry%%=*}" + if [[ $environment_name == BASH_FUNC_*%% ]]; then + environment_unsets+=(-u "$environment_name") + needs_reexec=1 + fi + done < <(/usr/bin/env -0) + + if (( needs_reexec )); then + exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" + fi +} +sanitize_bash_startup_environment "$@" +unset -f sanitize_bash_startup_environment + +trusted_directory_chain() { + local current="$1" allow_current_user="$2" canonical owner mode current_uid + current_uid=$(/usr/bin/id -u) || return 1 + + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + [[ $current == / ]] && break + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then + return 1 + fi + (( (8#$mode & 0022) == 0 )) || return 1 + current=${current%/*} + [[ -n $current ]] || current=/ + done +} + +trusted_omarchy_source_root() { + local config=/etc/omarchy.conf + local default_root=/usr/share/omarchy + local configured_root="" + local canonical="" + local owner="" + local mode="" + local links="" + local size="" + local line="" + local encoded="" + local decoded="" + local character="" + local index=0 + local escaped=0 + local lines=() + + if [[ ! -e $config && ! -L $config ]]; then + configured_root="$default_root" + else + [[ -f $config && ! -L $config ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$config") || return 1 + [[ $canonical == "$config" ]] || return 1 + read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 + [[ $owner == "0" && $links == "1" ]] || return 1 + (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 + trusted_directory_chain /etc false || return 1 + + mapfile -t lines <"$config" || return 1 + (( ${#lines[@]} == 1 )) || return 1 + line="${lines[0]}" + [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 + encoded="${line#'export OMARCHY_PATH="'}" + encoded="${encoded%'"'}" + + for (( index = 0; index < ${#encoded}; index++ )); do + character="${encoded:index:1}" + if (( escaped )); then + case "$character" in + '\' | '"' | '$' | '`') decoded+="$character" ;; + *) return 1 ;; + esac + escaped=0 + elif [[ $character == '\' ]]; then + escaped=1 + elif [[ $character == '"' ]]; then + return 1 + else + decoded+="$character" + fi + done + (( escaped == 0 )) || return 1 + configured_root="$decoded" + fi + + [[ -d $configured_root && ! -L $configured_root ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 + [[ $canonical == "$configured_root" ]] || return 1 + + if [[ $configured_root == "$default_root" ]]; then + trusted_directory_chain "$configured_root" false || return 1 + else + trusted_directory_chain "$configured_root" true || return 1 + fi + + printf '%s\n' "$configured_root" +} + +sudo_supports_no_update() { + LC_ALL=C /usr/bin/sudo -h 2>&1 | /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' +} + +validate_non_reusable_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + local wrapper="$wrapper_dir/sudo" canonical="" current="" owner="" mode="" + + sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing to run a mixed-trust update." >&2 + return 1 + } + [[ -f $wrapper && -x $wrapper && ! -L $wrapper ]] || { + echo "Trusted no-update sudo wrapper is missing; refusing to run a mixed-trust update." >&2 + return 1 + } + canonical=$(/usr/bin/realpath -e -- "$wrapper") || return 1 + [[ $canonical == "$wrapper" ]] || return 1 + if [[ $OMARCHY_PATH == "/usr/share/omarchy" ]]; then + current="$wrapper" + while :; do + [[ ! -L $current ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + [[ $owner == "0" ]] || return 1 + (( (8#$mode & 0022) == 0 )) || return 1 + [[ $current == "$OMARCHY_PATH" ]] && break + current=${current%/*} + done + fi +} + +enable_non_reusable_sudo() { + local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" + + validate_non_reusable_sudo + PATH="$wrapper_dir:$PATH" + export PATH +} + +if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then + echo "Refusing to update from an untrusted Omarchy source root." >&2 + exit 1 +fi +export OMARCHY_PATH +user_path="${PATH:-/usr/bin:/bin}" +PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" +export PATH +update_stay_awake_stopped=0 + +# Verify and enable the security primitive before any update-owned privileged +# work. Every authorization in this workflow is command-scoped (`sudo -N`): it +# may prompt for the command being run, but it never publishes a reusable +# timestamp to a dev hook, migration tool, AUR build, or detached child. +validate_non_reusable_sudo || exit 1 +if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then + enable_non_reusable_sudo +fi +/usr/bin/sudo -k || exit 1 +enable_non_reusable_sudo +export OMARCHY_SUDO_NO_UPDATE=1 + +cleanup_update() { + local status=$? + + trap - EXIT + if (( update_stay_awake_stopped == 0 )); then + omarchy-update-stay-awake stop || true + fi + /usr/bin/sudo -k || true + exit "$status" +} + if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then script_command=$(printf '%q ' "$0" "$@") exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log" @@ -17,7 +222,7 @@ if ! omarchy-update-lock held; then fi trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR -trap 'omarchy-update-stay-awake stop' EXIT +trap cleanup_update EXIT omarchy-update-requires-free-space @@ -38,6 +243,9 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start + # A dev link explicitly authorizes its checkout through root-owned system + # configuration (including sudo's secure_path), so preserve the established + # pull-before-packages/migrations ordering for that trusted mode. omarchy-update-dev omarchy-update-keyring @@ -45,20 +253,41 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # them, so everything below waits on this finishing. An upgrade that stopped # takes the update with it rather than migrating against what is still on disk. omarchy-update-system-pkgs + + # Historical migrations are strictly ordered and mix user hooks/downloaded + # tooling with privileged repairs. The no-update sudo wrapper has covered the + # whole update, so neither the package transaction nor a later repair can + # publish a timestamp to a detached migration child. + /usr/bin/sudo -k omarchy-migrate - omarchy-hook post-update - omarchy-update-aur-pkgs - omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status + # Service restart helpers can need sudo. Run them before any user-controlled + # update tooling; the reboot-only phase below performs no privileged work. + omarchy-update-restart --services-only + # Release update-owned inhibitors before offering a reboot. A confirmed # reboot can terminate this process before its EXIT trap gets a chance to # remove the persistent Stay Awake marker. omarchy-update-stay-awake stop - trap - EXIT + update_stay_awake_stopped=1 - omarchy-update-restart + # AUR installation can refresh sudo after running package build code. No + # privileged update stage may follow it: user-controlled code can outlive + # its parent and wait for a later timestamp even if we invalidate in between. + omarchy-update-aur-pkgs + /usr/bin/sudo -k + + # Hooks and mise execute user-controlled code. Give each a cold credential + # boundary and run mise last so it cannot wait for a legitimate hook sudo. + # Only the unprivileged reboot prompt follows them. + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update + /usr/bin/sudo -k + PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" + /usr/bin/sudo -k + + "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only fi diff --git a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample index cebec4fc..0c8ba7c4 100644 --- a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample +++ b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample @@ -1,17 +1,20 @@ #!/bin/bash -# This hook is called by `omarchy refresh pacman` AFTER the channel template -# is copied to /etc/pacman.conf and BEFORE `pacman -Syyuu` runs. Use it to -# layer customizations onto the freshly-written pacman.conf so they're -# respected by the upgrade — common cases are adding a custom repository +# This legacy-named hook is called by `omarchy refresh pacman` AFTER the +# channel template is copied and the privileged package transaction finishes. +# During `omarchy channel set`, it is deferred until the package switch and +# complete update have also finished. +# Use it to layer customizations onto the freshly-written pacman.conf for +# subsequent package operations — common cases are adding a custom repository # (e.g. CachyOS, Chaotic-AUR, an internal company repo) or extra IgnorePkg # lines. # -# The hook runs as the invoking user with a warm sudo cache. +# The hook runs as the invoking user after Omarchy invalidates its sudo cache. +# A sudo command here therefore requires its own explicit authorization. # # To put it into use, remove .sample from this file name. -# Example: add an Include line above [core] for a custom repo. +# Example: add an Include line above [core] for future package operations. # Maintain the repo entries in /etc/pacman.d/custom-repos.conf yourself. CONF=/etc/pacman.conf diff --git a/default/agents/skills/omarchy/hooks.md b/default/agents/skills/omarchy/hooks.md index 4f8236ac..4878c7e1 100644 --- a/default/agents/skills/omarchy/hooks.md +++ b/default/agents/skills/omarchy/hooks.md @@ -14,8 +14,8 @@ file first, if one exists. ├── battery-low.d/ # Low battery (percentage in $1) ├── font-set.d/ # After font change (font name in $1) ├── post-boot.d/ # After the desktop starts -├── post-update.d/ # During `omarchy update`, after system packages and migrations -├── pre-refresh-pacman.d/ # Before `omarchy refresh pacman` re-syncs packages +├── post-update.d/ # At the end of `omarchy update`, after privileged work +├── pre-refresh-pacman.d/ # After `omarchy refresh pacman` finishes (legacy name) └── theme-set.d/ # After theme change (theme slug in $1) ``` @@ -26,3 +26,5 @@ THEME_NAME=$1 echo "Theme changed to: $THEME_NAME" # Add custom actions here ``` + +Update-related hooks run only after the workflow's privileged work and after Omarchy invalidates its sudo timestamp. A hook that invokes `sudo` must therefore request its own explicit authorization. The legacy-named `pre-refresh-pacman` hook runs after the refresh transaction; during `omarchy channel set`, it is deferred further until the package switch and complete update finish. Executable user code cannot safely run before a later sudo authentication because a detached child could wait for the new timestamp. diff --git a/default/omarchy/sudo-no-update/sudo b/default/omarchy/sudo-no-update/sudo new file mode 100755 index 00000000..ca94c1e6 --- /dev/null +++ b/default/omarchy/sudo-no-update/sudo @@ -0,0 +1,27 @@ +#!/bin/bash -p + +# Internal update/migration sudo boundary. Authentication may authorize this +# command, but -N prevents it from publishing a timestamp that detached user +# code can silently reuse. + +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 + exit 126 +fi + +require_privileged_bash_startup() { + [[ $- == *p* ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 + 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 "$$" +} +if ! require_privileged_bash_startup; then + echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 + exit 126 +fi +exec /usr/bin/sudo -N -- "$@" diff --git a/docs/update-process.md b/docs/update-process.md index bec3350d..bb73d271 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -26,7 +26,7 @@ The design goal is: | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | | `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | -| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. The shell needs no marker: it is restarted unconditionally after every update. | +| `~/.local/state/omarchy/restart-*-required` | user | Optional allowlisted service/app restart markers checked by `omarchy-update-restart`. Marker names select fixed commands from the system-authorized Omarchy tree; they are not resolved through caller `PATH`. The shell needs no marker: it is restarted unconditionally after every update. | ## Migration layout @@ -56,6 +56,13 @@ privileged work should invoke the appropriate helper or privilege prompt. Migrations must be idempotent; if one user already applied a machine-wide repair, the migration should no-op for other users. +The runner derives the migration source from root-owned `/etc/omarchy.conf`, +starts from a cold sudo timestamp, and routes migration sudo calls through +`sudo --no-update`. Historical migrations are strictly ordered and can mix +user-controlled tools or theme hooks with later privileged repairs; no-update +authentication lets those repairs run without publishing a credential a +detached earlier process could reuse. + For watchers and diagnostics, `omarchy-migrate --pending` prints pending migration names and exits `0` when any are pending. When no migrations are pending, it prints nothing and exits non-zero. @@ -125,18 +132,28 @@ omarchy-update │ installed but unconfigured fails the snapshot loudly, pointing at │ install/config/snapper.sh, and the update continues without one) ├─ omarchy-update-stay-awake start - ├─ run package updates, migrations, hooks, and log analysis + ├─ run system-package updates + ├─ invalidate sudo, then run migrations and all later privileged work with + │ no-update authentication + ├─ run orphan review and log analysis ├─ omarchy-update-status │ └─ refresh or clear the shell update indicator + ├─ restart marked services and the shell ├─ omarchy-update-stay-awake stop │ └─ release the sleep inhibitor and restore shell idle state, if changed - └─ omarchy-update-restart + ├─ update AUR packages + ├─ invalidate sudo credentials + ├─ run the post-update hook, invalidate again, then update mise tools + └─ offer the unprivileged reboot prompt ``` Important behavior: -- In dev-link mode, `omarchy update` fast-forwards the active checkout from its - configured upstream before changing system packages or running migrations. +- `omarchy update` derives its source tree from root-owned `/etc/omarchy.conf`, or the root-owned `/usr/share/omarchy` default when that file is absent. It does not trust inherited `OMARCHY_PATH` or caller `PATH` for the privileged phase. +- Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary. +- In dev-link mode, the root-owned configuration is the explicit authorization for the user-writable checkout. `omarchy update` fast-forwards that authorized checkout from its configured upstream before changing system packages or running migrations. +- Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. +- User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything. Steps that would prompt (orphan removal, conflict handoff) report and skip instead of blocking. @@ -251,6 +268,9 @@ which pacman repo the mirrorlist points at (and swap between the `omarchy` and `omarchy-dev` packages through a guard-allowed pacman run), while `dev` links the runtime to a git checkout via the dev-link mechanism, after which `omarchy update` fast-forwards that checkout instead of upgrading a package. +Channel switching defers the legacy `pre-refresh-pacman` hook across the package +swap and the complete update. The hook runs exactly once at the final cold +credential boundary; it is skipped if the composite operation fails earlier. There is no version file at runtime. `omarchy-version` derives the version from `pacman -Q` on whichever package is installed, or reports `dev ()` for a @@ -272,8 +292,7 @@ scripts. | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | -| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | -| `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | +| `omarchy-update-system-pkgs` | Runs the ordinary guarded `pacman -Syu --noconfirm`. Package-vs-package conflicts may be retried interactively with `pacman -Su`; filesystem conflicts fail closed without moving, restoring, quarantining, or broadly overwriting live paths. The production Quattro transition performs the sole explicit settings-package takeover with `--overwrite='*'`; all later production and developer package transactions obey Pacman's ownership checks. | **Keep.** Small leaf command with no generic privileged conflict handler. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | | `omarchy-update-requires-free-space` | Aborts the update below a 10 GiB free-space threshold on `/`; silently skipped when free space cannot be determined; `OMARCHY_UPDATE_FORCE=1` bypasses. | **Keep internal/hidden.** | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | @@ -285,7 +304,7 @@ scripts. | `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | -| `omarchy-update-restart` | Prompts for reboot after kernel/Hyprland updates, restarts components with `restart-*-required` markers, and always restarts the shell. | **Keep.** Important final step; may eventually include service-restart checks. | +| `omarchy-update-restart` | Restarts allowlisted components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh new file mode 100644 index 00000000..6245205f --- /dev/null +++ b/test/shell.d/update-hook-security-test.sh @@ -0,0 +1,1040 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +# The setuid helper below models the strongest sudo timestamp mode: one token +# shared by every process for this uid. That makes a detached hook child a +# faithful regression for both global timestamps and the easier tty-sharing +# case. The helper is mounted over /usr/bin/sudo only in this private namespace +# so the scripts must use the fixed trusted invalidation path. +if [[ ${OMARCHY_UPDATE_HOOK_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 group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) + + if [[ -z $subuid || -z $subgid ]]; then + pass "no subordinate uid/gid range; skipping update-hook namespace proof" + exit 0 + fi + + exec 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" \ + env OMARCHY_UPDATE_HOOK_SECURITY_NS=1 bash "$0" +fi + +[[ $(id -u) == 0 ]] || fail "update-hook proof entered its root namespace" + +mount -t tmpfs -o mode=0755 tmpfs /run +run_bound=1 +test_tmp=$(mktemp -d -p /run omarchy-update-hook-security.XXXXXXXX) +mount -t tmpfs -o mode=0755 tmpfs "$test_tmp" +chmod 0755 "$test_tmp" +sudo_path_bound=0 +channel_paths_bound=0 +channel_wrapper_tree_bound=0 +aur_paths_bound=0 +font_paths_bound=0 +migration_pkg_paths_bound=0 +etc_bound=0 +persistent_pids=() +cleanup() { + local pid pid_file + for pid_file in "$test_home"/*.pid; do + if [[ -s $pid_file ]]; then + persistent_pids+=("$(<"$pid_file")") + fi + done + for pid in "${persistent_pids[@]}"; do + kill "$pid" 2>/dev/null || true + done + sleep 0.05 + for pid in "${persistent_pids[@]}"; do + kill -KILL "$pid" 2>/dev/null || true + done + if (( sudo_path_bound )); then + umount /usr/bin/sudo + fi + if (( channel_paths_bound )); then + umount /usr/bin/pacman + umount /usr/bin/omarchy-dev-unlink + umount /usr/bin/omarchy-state + umount /usr/bin/omarchy-refresh-pacman + umount /usr/bin/omarchy-update + fi + if (( channel_wrapper_tree_bound )); then + umount /usr/share/omarchy + fi + if (( aur_paths_bound )); then + umount /usr/bin/omarchy-pkg-aur-accessible + umount /usr/bin/yay + fi + if (( font_paths_bound )); then + umount /usr/bin/omarchy-launch-floating-terminal-with-presentation + umount /usr/bin/omarchy-pkg-add + umount /usr/bin/omarchy-font-set + fi + if (( migration_pkg_paths_bound )); then + umount /usr/bin/omarchy-pkg-missing + umount /usr/bin/omarchy-pkg-add + fi + if (( etc_bound )); then + umount /etc + fi + rm -rf "$test_tmp"/* + umount "$test_tmp" + rmdir "$test_tmp" + if (( run_bound )); then + umount /run + fi +} +trap cleanup EXIT + +stub_bin="$test_tmp/bin" +test_home="$test_tmp/home" +root_dir="$test_tmp/root" +token="$test_tmp/sudo-token" +event_log="$test_tmp/events" +hook_log="$test_home/hook-events" +mkdir -p "$stub_bin" "$test_home/.config/omarchy/hooks" "$root_dir" +mkdir -p "$test_tmp/default/omarchy/sudo-no-update" +cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$test_tmp/default/omarchy/sudo-no-update/sudo" +chmod 0755 "$test_tmp/default/omarchy/sudo-no-update/sudo" +mkdir -p "$test_tmp/default/pacman" +cp "$ROOT/default/pacman"/* "$test_tmp/default/pacman/" +chmod 0755 "$test_tmp/default" "$test_tmp/default/omarchy" \ + "$test_tmp/default/omarchy/sudo-no-update" "$test_tmp/default/pacman" +chmod 0644 "$test_tmp/default/pacman"/* +touch "$event_log" "$hook_log" +chown -R 1000:1000 "$test_home" +chown 1000:1000 "$event_log" +chmod 0700 "$test_home" +chmod 0600 "$event_log" "$hook_log" +chmod 0755 "$stub_bin" "$root_dir" + +cat >"$test_tmp/sudo.c" <<'C' +#include +#include +#include +#include +#include +#include +#include + +static const char *required_env(const char *name) { + const char *value = getenv(name); + if (!value || !*value) exit(125); + return value; +} + +static void log_event(const char *event) { + int fd = open(required_env("TEST_SUDO_EVENT_LOG"), O_WRONLY | O_APPEND); + if (fd < 0) exit(125); + if (dprintf(fd, "%s\n", event) < 0) exit(125); + close(fd); +} + +static int authenticate(void) { + int fd = open(required_env("TEST_SUDO_TOKEN"), O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) return 125; + close(fd); + return 0; +} + +static int token_valid(void) { + struct stat st; + return stat(required_env("TEST_SUDO_TOKEN"), &st) == 0 && st.st_uid == 0; +} + +static int is_pacman_command(int argc, char **argv) { + int index; + for (index = 1; index < argc; index++) { + const char *base = strrchr(argv[index], '/'); + base = base ? base + 1 : argv[index]; + if (strcmp(base, "pacman") == 0) return 1; + } + return 0; +} + +int main(int argc, char **argv) { + int no_update = 0; + if (argc == 2 && strcmp(argv[1], "-h") == 0) { + const char *disable = getenv("TEST_SUDO_NO_N"); + if (disable && strcmp(disable, "1") == 0) { + fputs("usage: sudo [-ABbEHknPS] command\n", stdout); + } else { + fputs("usage: sudo [-ABbEHkNnPS] command\n", stdout); + } + return 0; + } + if (argc > 1 && strcmp(argv[1], "-N") == 0) { + no_update = 1; + argc--; + argv++; + } + if (argc > 1 && strcmp(argv[1], "--") == 0) { + argc--; + argv++; + } + if (argc == 2 && strcmp(argv[1], "--authenticate-for-test") == 0) { + if (no_update) { + log_event("authenticate-no-update"); + return 0; + } else { + log_event("authenticate"); + return authenticate(); + } + } + if (argc == 2 && strcmp(argv[1], "-k") == 0) { + log_event("invalidate"); + if (unlink(required_env("TEST_SUDO_TOKEN")) < 0 && errno != ENOENT) return 125; + return 0; + } + if (!token_valid() && !no_update) { + if (is_pacman_command(argc, argv)) { + log_event("authenticate-command"); + if (authenticate() != 0) return 125; + } else { + log_event("deny"); + fputs("sudo: a password is required\n", stderr); + return 1; + } + } + log_event(no_update ? "grant-no-update" : "grant"); + 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 +sudo_path_bound=1 +mount --bind "$ROOT/bin/omarchy-refresh-pacman" /usr/bin/omarchy-refresh-pacman +mount --bind "$ROOT/bin/omarchy-update" /usr/bin/omarchy-update +mount -t tmpfs -o mode=0755 tmpfs /usr/share/omarchy +mkdir -p /usr/share/omarchy/default/omarchy/sudo-no-update +cp "$ROOT/default/omarchy/sudo-no-update/sudo" /usr/share/omarchy/default/omarchy/sudo-no-update/sudo +chmod 0755 /usr/share/omarchy/default /usr/share/omarchy/default/omarchy \ + /usr/share/omarchy/default/omarchy/sudo-no-update +chmod 0755 /usr/share/omarchy/default/omarchy/sudo-no-update/sudo +channel_wrapper_tree_bound=1 + +# Authorize this private tree exactly as omarchy-dev-link authorizes a +# development checkout. Hiding the host /etc keeps the proof self-contained. +mount -t tmpfs -o mode=0755 tmpfs /etc +etc_bound=1 +mkdir -p /etc/pacman.d +touch /etc/pacman.conf /etc/pacman.d/mirrorlist +chmod 0644 /etc/pacman.conf /etc/pacman.d/mirrorlist +printf 'root:x:0:0:root:/root:/bin/bash\n' >/etc/passwd +printf 'root:x:0:\n' >/etc/group +chmod 0644 /etc/passwd /etc/group +write_authorized_source_root() { + local source_root="$1" + local quoted="$source_root" + + quoted=${quoted//\\/\\\\} + quoted=${quoted//\"/\\\"} + quoted=${quoted//\$/\\\$} + quoted=${quoted//\`/\\\`} + printf 'export OMARCHY_PATH="%s"\n' "$quoted" >/etc/omarchy.conf + chown 0:0 /etc/omarchy.conf + chmod 0644 /etc/omarchy.conf +} +write_authorized_source_root "$test_tmp" + +cat >"$test_home/launch-persistent-attack" <<'ATTACK' +#!/bin/bash +/usr/bin/setsid --fork /bin/bash -c ' + printf "%s\n" "$$" >"$3" + for (( attempt = 0; attempt < 200; attempt++ )); do + if /usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 0600 "$1" "$2" 2>/dev/null; then + exit 0 + fi + /usr/bin/sleep 0.01 + done +' omarchy-hook-child "$HOME/payload" "$1" "$2" +ATTACK +cat >"$test_home/payload" <<'PAYLOAD' +RUN+="/tmp/update-hook-payload" +PAYLOAD +chown 1000:1000 "$test_home/launch-persistent-attack" "$test_home/payload" +chmod 0700 "$test_home/launch-persistent-attack" +chmod 0600 "$test_home/payload" + +cat >"$stub_bin/omarchy-update-lock" <<'STUB' +#!/bin/bash +[[ ${1:-} == held ]] +STUB +cat >"$stub_bin/omarchy-update-system-pkgs" <<'STUB' +#!/bin/bash +[[ ${TEST_SKIP_UPDATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test +STUB +cat >"$stub_bin/omarchy-update-orphan-pkgs" <<'STUB' +#!/bin/bash +[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test +STUB +cat >"$stub_bin/omarchy-update-aur-pkgs" <<'STUB' +#!/bin/bash +[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test +STUB +cat >"$stub_bin/omarchy-update-mise" <<'STUB' +#!/bin/bash +sudo /usr/bin/true 2>/dev/null && exit 97 +"$HOME/launch-persistent-attack" "$TEST_MISE_VICTIM" "$TEST_MISE_PID" +STUB +cat >"$stub_bin/omarchy-update-restart" <<'STUB' +#!/bin/bash +printf 'restart:%s\n' "$1" >>"$TEST_HOOK_LOG" +if [[ $1 == --services-only && ${TEST_SKIP_LATE_AUTH:-0} != 1 ]]; then + sudo --authenticate-for-test +fi +STUB +cat >"$stub_bin/omarchy-update-confirm" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-migrate" <<'STUB' +#!/bin/bash +if [[ ${TEST_REAL_MIGRATE:-0} == 1 ]]; then + exec "$TEST_ROOT/bin/omarchy-migrate" +fi +if [[ ${TEST_FAILING_STAGE:-} == signal ]]; then + kill -TERM "$PPID" + sleep 0.1 +fi +[[ ${TEST_FAILING_STAGE:-} != migration ]] +STUB +cat >"$stub_bin/omarchy-hook" <<'STUB' +#!/bin/bash +exec bash "$TEST_ROOT/bin/omarchy-hook" "$@" +STUB +cat >"$stub_bin/omarchy-dev-unlink" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/omarchy-state" <<'STUB' +#!/bin/bash +exit 0 +STUB +for command in \ + omarchy-update-requires-free-space omarchy-update-pkg-prune omarchy-snapshot \ + omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring \ + omarchy-update-analyze-logs omarchy-update-status; do + cat >"$stub_bin/$command" <<'STUB' +#!/bin/bash +exit 0 +STUB +done +cat >"$stub_bin/cp" <<'STUB' +#!/bin/bash +printf 'cp:%s\n' "$*" >>"$TEST_SUDO_EVENT_LOG" +exit 0 +STUB +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +[[ -z ${TEST_PACMAN_DELAY:-} ]] || /usr/bin/sleep "$TEST_PACMAN_DELAY" +exit "${TEST_PACMAN_STATUS:-0}" +STUB +chmod 0755 "$stub_bin"/* +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/omarchy-dev-unlink" /usr/bin/omarchy-dev-unlink +mount --bind "$stub_bin/omarchy-state" /usr/bin/omarchy-state +mount --bind "$stub_bin/pacman" /usr/bin/pacman +channel_paths_bound=1 + +evil_update_bin="$test_home/evil-update-bin" +evil_update_root="$test_home/evil-update-root" +evil_update_marker="$test_home/evil-update-ran" +mkdir -p "$evil_update_bin" "$evil_update_root/migrations" +for command in script omarchy-migrate omarchy-update-system-pkgs; do + cat >"$evil_update_bin/$command" <<'STUB' +#!/bin/bash +touch "$TEST_EVIL_UPDATE_MARKER" +exit 97 +STUB +done +cat >"$evil_update_bin/omarchy-dev-unlink" <<'STUB' +#!/bin/bash +touch "$TEST_EVIL_CHANNEL_HELPER_MARKER" +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_CHANNEL_VICTIM" +STUB +cat >"$evil_update_root/migrations/9999999999.sh" <<'STUB' +#!/bin/bash +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_MIGRATION_VICTIM" +touch "$TEST_EVIL_UPDATE_MARKER" +STUB +chown -R 1000:1000 "$evil_update_bin" "$evil_update_root" +chmod 0755 "$evil_update_bin"/* "$evil_update_root/migrations/9999999999.sh" + +write_attack_hook() { + local hook_name="$1" + + mkdir -p "$test_home/.config/omarchy/hooks/$hook_name.d" + cat >"$test_home/.config/omarchy/hooks/$hook_name" <<'HOOK' +#!/bin/bash +printf 'file:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" 2>/dev/null || true +"$HOME/launch-persistent-attack" "$TEST_PERSISTENT_VICTIM" "$TEST_PERSISTENT_PID" +HOOK + cat >"$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" <<'HOOK' +#!/bin/bash +printf 'directory:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" +sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_DIR_VICTIM" 2>/dev/null || true +HOOK + chown -R 1000:1000 "$test_home/.config/omarchy/hooks/$hook_name" \ + "$test_home/.config/omarchy/hooks/$hook_name.d" + chmod 0700 "$test_home/.config/omarchy/hooks/$hook_name" \ + "$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" +} + +reset_case() { + local pid_file + for pid_file in "$test_home"/*.pid; do + if [[ -s $pid_file ]]; then + persistent_pids+=("$(<"$pid_file")") + kill "$(<"$pid_file")" 2>/dev/null || true + fi + done + rm -f "$test_home"/*.pid "$token" "$root_dir"/* + : >"$event_log" + setpriv --reuid 1000 --regid 1000 --clear-groups /usr/bin/truncate -s 0 "$hook_log" +} + +run_as_user() { + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$ROOT" \ + TEST_ROOT="$ROOT" TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ + TEST_HOOK_LOG="$hook_log" "$@" +} + +authenticate_for_test() { + TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ + /usr/bin/sudo --authenticate-for-test +} + +wait_for_persistent_attempts() { + local pid_file="$1" + local pid="" + for (( attempt = 0; attempt < 100; attempt++ )); do + [[ -s $pid_file ]] && break + sleep 0.01 + done + [[ -s $pid_file ]] || fail "detached hook child did not start" + pid=$(<"$pid_file") + persistent_pids+=("$pid") + sleep 0.2 +} + +assert_hook_sandboxed() { + local direct_victim="$1" + local directory_victim="$2" + local persistent_victim="$3" + + [[ ! -e $direct_victim && ! -e $directory_victim && ! -e $persistent_victim ]] || + fail "hook code reused an Omarchy sudo credential" + grep -qxF 'file:1000' "$hook_log" || fail "the regular hook did not run as the desktop user" + grep -qxF 'directory:1000' "$hook_log" || fail "the hook-directory entry did not run as the desktop user" + [[ ! -e $token ]] || fail "the workflow left its modeled sudo credential live" +} + +write_attack_hook post-update +update_victim="$root_dir/80-update-hook.rules" +update_dir_victim="$root_dir/81-update-hook-dir.rules" +update_persistent_victim="$root_dir/82-update-hook-child.rules" +mise_victim="$root_dir/83-mise-child.rules" +reset_case +set +e +run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ + TEST_EVIL_UPDATE_MARKER="$evil_update_marker" \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/update.out" 2>"$test_tmp/update.err" +status=$? +set -e +(( status == 0 )) || fail "isolated unattended update failed" "$(<"$test_tmp/update.err")" +wait_for_persistent_attempts "$test_home/update.pid" +wait_for_persistent_attempts "$test_home/mise.pid" +assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" +[[ ! -e $mise_victim ]] || fail "detached mise code observed a later update authorization" +[[ ! -e $evil_update_marker ]] || fail "update trusted an inherited PATH or OMARCHY_PATH override" +grep -qxF 'restart:--services-only' "$hook_log" || fail "privileged restart phase did not run before user code" +grep -qxF 'restart:--reboot-only' "$hook_log" || fail "reboot-only phase did not run after user code" +pass "update leaves no later sudo authentication for mise or persistent hook children" + +# OM-SEC-14 ends at the final cold hook boundary. Later sections exercise +# separate migration, restart-marker, channel, and installer findings in their +# own PRs. +exit 0 + +# Exercise the real migration dispatcher separately from PATH spoofing. An old +# update inherited the evil root here and ran its migration with the live +# system-package credential; the fixed update exports the authorized root. +evil_migration_victim="$root_dir/90-evil-migration.rules" +reset_case +set +e +run_as_user env OMARCHY_PATH="$evil_update_root" TEST_REAL_MIGRATE=1 \ + TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_MIGRATION_STATE="$test_home/migration-state" OMARCHY_UPDATE_LOGGED=1 \ + "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-root-spoof.out" 2>"$test_tmp/update-root-spoof.err" +status=$? +set -e +(( status == 0 )) || fail "authorized-root update with real migration dispatcher failed" +[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || + fail "update dispatched a migration from inherited OMARCHY_PATH" +pass "update rejects inherited OMARCHY_PATH for real migration dispatch" + +# The login-notification workflow invokes omarchy-migrate directly, outside an +# update that already normalized OMARCHY_PATH. It must independently reject an +# inherited attacker tree rather than running the migration placed there. +reset_case +set +e +run_as_user env OMARCHY_PATH="$evil_update_root" \ + TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ + OMARCHY_MIGRATION_STATE="$test_home/direct-migration-state" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/direct-migrate.out" 2>"$test_tmp/direct-migrate.err" +status=$? +set -e +(( status == 0 )) || fail "direct authorized migration dispatch failed" +[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || + fail "direct migration dispatch trusted inherited OMARCHY_PATH" +pass "direct migration dispatch derives its source from root-owned configuration" + +# Reproduce the real historical ordering that exposed the remaining gap: the +# mise-wrapper migration can execute user tooling at 1784909971, while the +# 1784914435 migration invokes sudo later. A following migration also invokes +# the real absolute-path package helper, which cannot be protected by PATH +# alone. Every later authorization must inherit the exported no-update policy. +mkdir -p "$test_tmp/migrations" "$test_home/.local/bin" +chmod 0755 "$test_tmp/migrations" +cp "$ROOT/migrations/1784909971.sh" "$test_tmp/migrations/1784909971.sh" +cp "$ROOT/migrations/1784914435.sh" "$test_tmp/migrations/1784914435.sh" +cat >"$test_tmp/migrations/1784914436.sh" <<'STUB' +#!/bin/bash +/usr/bin/omarchy-pkg-add migration-security-fixture +STUB +chmod 0644 "$test_tmp/migrations/1784909971.sh" "$test_tmp/migrations/1784914435.sh" \ + "$test_tmp/migrations/1784914436.sh" +cat >"$test_home/.local/bin/legacy-mise-wrapper" <<'STUB' +#!/bin/bash +mise use -g "github:attacker/tool" +exec "attacker-tool" "$@" +STUB +cat >"$stub_bin/omarchy-mise-install" <<'STUB' +#!/bin/bash +"$HOME/launch-persistent-attack" "$TEST_MIGRATION_VICTIM" "$TEST_MIGRATION_PID" +STUB +cat >"$stub_bin/nmcli" <<'STUB' +#!/bin/bash +printf '%s\n' "$(id -u)" >"$TEST_PRIV_MIGRATION_MARKER" +STUB +cat >"$stub_bin/omarchy-notification-dismiss" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$stub_bin/omarchy-mise-install" "$stub_bin/nmcli" \ + "$stub_bin/omarchy-notification-dismiss" "$test_home/.local/bin/legacy-mise-wrapper" +chown -R 1000:1000 "$test_home/.local" +cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$stub_bin/omarchy-pkg-missing" +mount --bind "$ROOT/bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add +mount --bind "$stub_bin/omarchy-pkg-missing" /usr/bin/omarchy-pkg-missing +migration_pkg_paths_bound=1 +migration_victim="$root_dir/95-migration-child.rules" +migration_marker="$root_dir/96-privileged-migration-ran" +reset_case +set +e +run_as_user env OMARCHY_PATH="$evil_update_root" \ + TEST_MIGRATION_VICTIM="$migration_victim" TEST_MIGRATION_PID="$test_home/migration.pid" \ + TEST_PRIV_MIGRATION_MARKER="$migration_marker" TEST_PACMAN_DELAY=0.2 \ + OMARCHY_MIGRATION_STATE="$test_home/mixed-migration-state" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/mixed-migrate.out" 2>"$test_tmp/mixed-migrate.err" +status=$? +set -e +(( status == 0 )) || fail "real mixed-trust migration sequence failed" "$(<"$test_tmp/mixed-migrate.err")" +wait_for_persistent_attempts "$test_home/migration.pid" +[[ -f $migration_marker && $(<"$migration_marker") == 0 ]] || fail "later privileged migration did not run as root" +[[ ! -e $migration_victim && ! -e $token ]] || fail "mise migration child reused a later migration authorization" +(( $(grep -c '^grant-no-update$' "$event_log") >= 2 )) || + fail "direct package-helper migration did not inherit the no-update policy" +pass "real mise-before-sudo and absolute package-helper migrations publish no reusable timestamp" +umount /usr/bin/omarchy-pkg-missing +umount /usr/bin/omarchy-pkg-add +migration_pkg_paths_bound=0 + +# Bash can import both BASH_ENV startup code and exported functions before an +# ordinary script body. An exported sudo function used to bypass the PATH +# wrapper in a later helper, publishing a global token to a BASH_ENV child that +# had started before migration invalidation. Exercise both injection channels +# through a normal-shebang child of the privileged migration shell. +cat >"$test_tmp/migrations/9999999998.sh" <<'STUB' +#!/bin/bash +"$HOME/launch-persistent-attack" "$TEST_BASH_STARTUP_VICTIM" "$TEST_BASH_STARTUP_PID" +omarchy-exported-function-auth +STUB +cat >"$stub_bin/omarchy-exported-function-auth" <<'STUB' +#!/bin/bash +sudo --authenticate-for-test +STUB +cat >"$test_home/bash-env-attack" <<'STUB' +#!/bin/bash +if [[ ! -e $TEST_BASH_ENV_MARKER ]]; then + /usr/bin/touch "$TEST_BASH_ENV_MARKER" + "$HOME/launch-persistent-attack" "$TEST_BASH_ENV_VICTIM" "$TEST_BASH_ENV_PID" +fi +STUB +chmod 0755 "$test_tmp/migrations/9999999998.sh" "$stub_bin/omarchy-exported-function-auth" +chown 1000:1000 "$test_home/bash-env-attack" +chmod 0600 "$test_home/bash-env-attack" +bash_startup_victim="$root_dir/99-exported-function-child.rules" +bash_env_victim="$root_dir/100-bash-env-child.rules" +bash_env_marker="$test_home/bash-env-ran" +reset_case +set +e +run_as_user env \ + 'BASH_FUNC_sudo%%=() { /usr/bin/sudo "$@"; }' \ + TEST_MULTILINE_ENV=$'value\nBASH_FUNC_fake%%=not-an-environment-record' \ + BASH_ENV="$test_home/bash-env-attack" \ + TEST_BASH_ENV_MARKER="$bash_env_marker" \ + TEST_BASH_ENV_VICTIM="$bash_env_victim" TEST_BASH_ENV_PID="$test_home/bash-env.pid" \ + TEST_BASH_STARTUP_VICTIM="$bash_startup_victim" TEST_BASH_STARTUP_PID="$test_home/bash-startup.pid" \ + OMARCHY_MIGRATION_STATE="$test_home/bash-startup-migration-state" \ + "$ROOT/bin/omarchy-migrate" >"$test_tmp/bash-startup.out" 2>"$test_tmp/bash-startup.err" +status=$? +set -e +(( status == 0 )) || fail "migration rejected a sanitized Bash startup environment" "$(<"$test_tmp/bash-startup.err")" +wait_for_persistent_attempts "$test_home/bash-startup.pid" +sleep 0.2 +[[ ! -e $bash_env_marker && ! -e $bash_env_victim ]] || + fail "BASH_ENV ran before or beneath the migration security boundary" +[[ ! -e $bash_startup_victim && ! -e $token ]] || + fail "an exported sudo function published a reusable migration credential" +grep -q '^grant-no-update$' "$event_log" || fail "sanitized helper did not use sudo --no-update" +grep -qxF 'exec /usr/bin/sudo -N -- "$@"' "$ROOT/default/omarchy/sudo-no-update/sudo" || + fail "no-update sudo wrapper omitted the option terminator" +pass "Bash startup injection cannot bypass the no-update sudo boundary" + +# Invoking a mixed-trust entrypoint with an ordinary explicit Bash bypasses its +# shebang. A BASH_ENV can erase its own environment record and retain a DEBUG +# trap, so environment-record cleanup alone is not a sufficient startup gate. +# The exact interpreter argv/privileged-mode check must reject this process +# before update authentication, leaving even its already-detached child cold. +cat >"$test_home/self-erasing-bash-env" <<'STUB' +#!/bin/bash +unset BASH_ENV ENV +trap ' + if [[ ! -e $TEST_DEBUG_TRAP_MARKER ]]; then + /usr/bin/touch "$TEST_DEBUG_TRAP_MARKER" + "$HOME/launch-persistent-attack" "$TEST_DEBUG_TRAP_VICTIM" "$TEST_DEBUG_TRAP_PID" + fi +' DEBUG +STUB +chown 1000:1000 "$test_home/self-erasing-bash-env" +chmod 0600 "$test_home/self-erasing-bash-env" +debug_trap_marker="$test_home/debug-trap-ran" +debug_trap_victim="$root_dir/101-debug-trap-child.rules" +reset_case +set +e +run_as_user env BASH_ENV="$test_home/self-erasing-bash-env" \ + TEST_DEBUG_TRAP_MARKER="$debug_trap_marker" TEST_DEBUG_TRAP_VICTIM="$debug_trap_victim" \ + TEST_DEBUG_TRAP_PID="$test_home/debug-trap.pid" OMARCHY_UPDATE_LOGGED=1 \ + /usr/bin/bash "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/unsafe-bash.out" 2>"$test_tmp/unsafe-bash.err" +status=$? +set -e +(( status == 126 )) || fail "update did not reject an unsafe explicit Bash interpreter" +[[ -e $debug_trap_marker ]] || fail "self-erasing BASH_ENV regression did not install its DEBUG trap" +wait_for_persistent_attempts "$test_home/debug-trap.pid" +[[ ! -e $debug_trap_victim && ! -e $token ]] || + fail "unsafe Bash startup reached update authentication" +! grep -qE '^(authenticate|authenticate-command|grant|grant-no-update)$' "$event_log" || + fail "unsafe Bash startup reached privileged update work" +grep -q 'unsafe Bash startup' "$test_tmp/unsafe-bash.err" || + fail "unsafe Bash startup rejection lacked a diagnostic" +pass "self-erasing BASH_ENV and DEBUG traps cannot cross the interpreter gate" + +# Model a hostile yay configuration that selects absolute /usr/bin/sudo and a +# refresh loop. The real AUR helper must override both on its command line, so +# even a migration child already polling the global token sees no credential. +cat >"$stub_bin/omarchy-pkg-aur-accessible" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/yay" <<'STUB' +#!/bin/bash +sudo_command=/usr/bin/sudo +sudoflags="" +sudoloop=true +while (($#)); do + case "$1" in + --sudo) + sudo_command="$2" + shift 2 + ;; + --sudoloop=false) + sudoloop=false + shift + ;; + --sudoflags=-N) + sudoflags=-N + shift + ;; + *) + shift + ;; + esac +done +printf 'sudo=%s sudoflags=%s sudoloop=%s\n' "$sudo_command" "$sudoflags" "$sudoloop" >"$TEST_YAY_LOG" +"$sudo_command" $sudoflags --authenticate-for-test +STUB +chmod 0755 "$stub_bin/omarchy-pkg-aur-accessible" "$stub_bin/yay" +mount --bind "$stub_bin/omarchy-pkg-aur-accessible" /usr/bin/omarchy-pkg-aur-accessible +mount --bind "$stub_bin/yay" /usr/bin/yay +aur_paths_bound=1 +yay_victim="$root_dir/97-yay-override-child.rules" +reset_case +run_as_user env TEST_YAY_LOG="$test_home/yay.log" \ + "$test_home/launch-persistent-attack" "$yay_victim" "$test_home/yay-child.pid" +wait_for_persistent_attempts "$test_home/yay-child.pid" +run_as_user env OMARCHY_PATH="$test_tmp" OMARCHY_SUDO_NO_UPDATE=1 TEST_YAY_LOG="$test_home/yay.log" \ + "$ROOT/bin/omarchy-update-aur-pkgs" >"$test_tmp/yay.out" 2>"$test_tmp/yay.err" +sleep 0.2 +grep -qxF "sudo=/usr/bin/sudo sudoflags=-N sudoloop=false" "$test_home/yay.log" || + fail "AUR update did not override hostile yay sudo settings" +[[ ! -e $yay_victim && ! -e $token ]] || fail "hostile yay sudo configuration published a reusable timestamp" +pass "AUR updates force no-update sudo and disable yay's credential loop" + +# A pre-existing credential and skipped package paths exercise the interactive +# branch independently of authority acquired by update helpers. +reset_case +authenticate_for_test +set +e +run_as_user env TEST_SKIP_UPDATE_AUTH=1 TEST_SKIP_LATE_AUTH=1 \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" \ + >"$test_tmp/update-interactive.out" 2>"$test_tmp/update-interactive.err" +status=$? +set -e +(( status == 0 )) || fail "isolated interactive update failed" +wait_for_persistent_attempts "$test_home/update.pid" +assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" +pass "interactive update invalidates a pre-existing credential before user code" + +for failing_stage in migration signal; do + reset_case + set +e + run_as_user env TEST_FAILING_STAGE="$failing_stage" TEST_SKIP_LATE_AUTH=1 \ + TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ + TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ + TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/update-$failing_stage.out" 2>"$test_tmp/update-$failing_stage.err" + status=$? + set -e + (( status != 0 )) || fail "update $failing_stage case unexpectedly succeeded" + [[ ! -e $token ]] || fail "update $failing_stage exit left its credential live" + [[ ! -s $hook_log ]] || fail "update $failing_stage case reached user-controlled stages" +done +pass "failed and signaled updates invalidate credentials before user code" + +reset_case +set +e +run_as_user env TEST_SUDO_NO_N=1 OMARCHY_UPDATE_LOGGED=1 \ + "$ROOT/bin/omarchy-update" -y >"$test_tmp/no-update-unsupported.out" 2>"$test_tmp/no-update-unsupported.err" +status=$? +set -e +(( status != 0 )) || fail "update accepted sudo without --no-update support" +! grep -qE '^(authenticate|grant)' "$event_log" || fail "unsupported sudo reached privileged update work" +grep -q 'does not support --no-update' "$test_tmp/no-update-unsupported.err" || + fail "unsupported sudo failure did not explain the missing security primitive" +pass "update fails closed before privileged work when sudo lacks --no-update" + +# The config parser is the authority for all three scoped commands. Exercise a +# different unsafe shape through each copy before restoring the valid config. +chmod 0666 /etc/omarchy.conf +if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/untrusted-update.out" 2>"$test_tmp/untrusted-update.err"; then + fail "update accepted a writable source-root authorization" +fi + +/usr/bin/mv /etc/omarchy.conf /etc/omarchy.real +/usr/bin/ln -s /etc/omarchy.real /etc/omarchy.conf +if run_as_user "$ROOT/bin/omarchy-refresh-pacman" stable \ + >"$test_tmp/untrusted-refresh.out" 2>"$test_tmp/untrusted-refresh.err"; then + fail "pacman refresh accepted a symlinked source-root authorization" +fi +/usr/bin/rm /etc/omarchy.conf +/usr/bin/mv /etc/omarchy.real /etc/omarchy.conf + +chown 1000:1000 /etc/omarchy.conf +if run_as_user "$ROOT/bin/omarchy-update-restart" --services-only \ + >"$test_tmp/untrusted-restart.out" 2>"$test_tmp/untrusted-restart.err"; then + fail "update restart accepted a non-root source-root authorization" +fi +write_authorized_source_root "$test_tmp" +pass "update commands reject writable, symlinked, and non-root source-root authorization" + +# A root-owned config may authorize a development checkout, but not a tree +# another local account (or every account) can rewrite. Exercise both unsafe +# directory-chain shapes before restoring the valid authorized fixture. +writable_source_root="$test_tmp/writable-source-root" +foreign_source_root="$test_tmp/foreign-source-root" +mkdir -p "$writable_source_root" "$foreign_source_root" +chown 1000:1000 "$writable_source_root" +chmod 0777 "$writable_source_root" +chown 1001:1001 "$foreign_source_root" +chmod 0755 "$foreign_source_root" + +write_authorized_source_root "$writable_source_root" +if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ + >"$test_tmp/writable-source.out" 2>"$test_tmp/writable-source.err"; then + fail "update accepted a group/world-writable authorized source tree" +fi +grep -q 'untrusted Omarchy source root' "$test_tmp/writable-source.err" || + fail "writable source-root rejection happened after the trust parser" + +write_authorized_source_root "$foreign_source_root" +if run_as_user "$ROOT/bin/omarchy-migrate" --pending \ + >"$test_tmp/foreign-source.out" 2>"$test_tmp/foreign-source.err"; then + fail "migration runner accepted a foreign-owned authorized source tree" +fi +grep -q 'untrusted Omarchy source root' "$test_tmp/foreign-source.err" || + fail "foreign source-root rejection happened after the trust parser" +write_authorized_source_root "$test_tmp" +pass "authorized source roots reject foreign-owned and group/world-writable path components" + +# The restart marker is attacker-writable, but its value is now an allowlisted +# selector into the configured Omarchy tree rather than a command resolved by +# PATH. A dev-linked tree is honored only through root-owned /etc/omarchy.conf. +reset_case +restart_home="$test_tmp/restart-home" +restart_log="$test_tmp/restart.log" +mkdir -p "$restart_home/.local/state/omarchy" +touch "$restart_log" +chown -R 1000:1000 "$restart_home" +chown 1000:1000 "$restart_log" +touch "$restart_home/.local/state/omarchy/restart-btop-required" \ + "$restart_home/.local/state/omarchy/restart-evil-required" +chown 1000:1000 "$restart_home/.local/state/omarchy"/* +cat >"$stub_bin/omarchy-restart-btop" <<'STUB' +#!/bin/bash +echo trusted-btop >>"$TEST_RESTART_LOG" +STUB +cat >"$stub_bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash +echo trusted-shell >>"$TEST_RESTART_LOG" +STUB +evil_bin="$test_home/evil-bin" +mkdir -p "$evil_bin" +cat >"$evil_bin/omarchy-restart-btop" <<'STUB' +#!/bin/bash +echo path-btop >>"$TEST_RESTART_LOG" +STUB +cat >"$evil_bin/omarchy-restart-evil" <<'STUB' +#!/bin/bash +echo path-evil >>"$TEST_RESTART_LOG" +sudo /usr/bin/true +STUB +chown -R 1000:1000 "$evil_bin" +chmod 0755 "$stub_bin/omarchy-restart-btop" "$stub_bin/omarchy-restart-shell" "$evil_bin"/* +run_as_user env HOME="$restart_home" PATH="$evil_bin:$stub_bin:/usr/bin:/bin" \ + OMARCHY_PATH="$test_home/evil-root" TEST_RESTART_LOG="$restart_log" \ + "$ROOT/bin/omarchy-update-restart" --services-only >"$test_tmp/restart.out" 2>"$test_tmp/restart.err" +grep -qxF trusted-btop "$restart_log" || fail "allowed marker did not use the configured Omarchy command" +grep -qxF trusted-shell "$restart_log" || fail "shell restart did not use the configured Omarchy command" +! grep -q '^path-' "$restart_log" || fail "restart marker resolved an attacker PATH command" +[[ ! -e $restart_home/.local/state/omarchy/restart-evil-required ]] || fail "unsupported restart marker was retained" +pass "restart markers use an allowlist and fixed configured command paths" + +special_root="$test_tmp/dev root\\checkout\$cash" +special_home="$test_tmp/special-home" +special_log="$test_tmp/special.log" +mkdir -p "$special_root/bin" "$special_home/.local/state/omarchy" +touch "$special_home/.local/state/omarchy/restart-btop-required" "$special_log" +chown -R 1000:1000 "$special_home" "$special_log" +cat >"$special_root/bin/omarchy-restart-btop" <<'STUB' +#!/bin/bash +echo special-btop >>"$TEST_RESTART_LOG" +STUB +cat >"$special_root/bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$special_root/bin"/* +chown -R 1000:1000 "$special_root" +write_authorized_source_root "$special_root" +run_as_user env HOME="$special_home" TEST_RESTART_LOG="$special_log" \ + "$ROOT/bin/omarchy-update-restart" --services-only \ + >"$test_tmp/special-root.out" 2>"$test_tmp/special-root.err" +grep -qxF special-btop "$special_log" || fail "authorized quoted dev root did not dispatch its restart helper" +write_authorized_source_root "$test_tmp" +pass "source-root authorization decodes spaces, backslashes, and dollar signs" + +# The legacy pre-refresh hook now runs only after pacman; a detached child can +# no longer wait for a later authentication in either tty or global mode. +write_attack_hook pre-refresh-pacman +refresh_victim="$root_dir/84-refresh-hook.rules" +refresh_dir_victim="$root_dir/85-refresh-hook-dir.rules" +refresh_persistent_victim="$root_dir/86-refresh-hook-child.rules" +reset_case +authenticate_for_test +cat >"$evil_bin/cp" <<'STUB' +#!/bin/bash +touch "$TEST_EVIL_REFRESH_MARKER" +exit 97 +STUB +chmod 0755 "$evil_bin/cp" +evil_refresh_marker="$test_home/evil-refresh-ran" +set +e +run_as_user env PATH="$evil_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ + TEST_EVIL_REFRESH_MARKER="$evil_refresh_marker" \ + TEST_ROOT_VICTIM="$refresh_victim" TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" \ + TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" TEST_PERSISTENT_PID="$test_home/refresh.pid" \ + "$ROOT/bin/omarchy-refresh-pacman" stable >"$test_tmp/refresh.out" 2>"$test_tmp/refresh.err" +status=$? +set -e +(( status == 0 )) || fail "isolated pacman refresh failed" "$(<"$test_tmp/refresh.err")" +wait_for_persistent_attempts "$test_home/refresh.pid" +assert_hook_sandboxed "$refresh_victim" "$refresh_dir_victim" "$refresh_persistent_victim" +[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh resolved cp through caller PATH" +/usr/bin/cmp -s "$test_tmp/default/pacman/pacman-stable.conf" /etc/pacman.conf || + fail "pacman refresh did not copy config from the authorized source root" +[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh copied from inherited OMARCHY_PATH or PATH" +pass "pacman refresh runs its legacy hook only after all privileged work" + +reset_case +authenticate_for_test +set +e +run_as_user env TEST_PACMAN_STATUS=1 TEST_ROOT_VICTIM="$refresh_victim" \ + TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" \ + TEST_PERSISTENT_PID="$test_home/refresh.pid" "$ROOT/bin/omarchy-refresh-pacman" stable \ + >"$test_tmp/refresh-fail.out" 2>"$test_tmp/refresh-fail.err" +status=$? +set -e +(( status != 0 )) || fail "failing pacman refresh unexpectedly succeeded" +[[ ! -e $token ]] || fail "failed pacman refresh left its credential live" +[[ ! -s $hook_log ]] || fail "failed pacman transaction reached the refresh hook" +pass "failed pacman refresh invalidates and does not run its hook" + +# Channel switching is a composite refresh caller: after refreshing it +# authenticates for the package swap and runs the full update. Exercise the +# real channel, refresh, update, and hook commands against the global-token +# model. A detached legacy refresh-hook child must not start until that entire +# chain has finished. +cat >"$test_home/.config/omarchy/hooks/post-update" <<'HOOK' +#!/bin/bash +printf 'post-update:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" +HOOK +chown 1000:1000 "$test_home/.config/omarchy/hooks/post-update" +chmod 0700 "$test_home/.config/omarchy/hooks/post-update" +channel_victim="$root_dir/91-channel-refresh-hook.rules" +channel_dir_victim="$root_dir/92-channel-refresh-dir.rules" +channel_persistent_victim="$root_dir/93-channel-refresh-child.rules" +channel_mise_victim="$root_dir/94-channel-mise-child.rules" +reset_case +set +e +evil_channel_helper_marker="$test_home/evil-channel-helper-ran" +evil_channel_helper_victim="$root_dir/98-channel-path-helper.rules" +run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" \ + TEST_EVIL_CHANNEL_HELPER_MARKER="$evil_channel_helper_marker" \ + TEST_EVIL_CHANNEL_VICTIM="$evil_channel_helper_victim" \ + TEST_ROOT_VICTIM="$channel_victim" TEST_ROOT_DIR_VICTIM="$channel_dir_victim" \ + TEST_PERSISTENT_VICTIM="$channel_persistent_victim" TEST_PERSISTENT_PID="$test_home/channel.pid" \ + TEST_MISE_VICTIM="$channel_mise_victim" TEST_MISE_PID="$test_home/channel-mise.pid" \ + OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-channel-set" stable \ + >"$test_tmp/channel.out" 2>"$test_tmp/channel.err" +status=$? +set -e +(( status == 0 )) || fail "isolated channel switch failed" "$(<"$test_tmp/channel.err")" +wait_for_persistent_attempts "$test_home/channel.pid" +wait_for_persistent_attempts "$test_home/channel-mise.pid" +assert_hook_sandboxed "$channel_victim" "$channel_dir_victim" "$channel_persistent_victim" +[[ ! -e $channel_mise_victim ]] || fail "channel mise child reused a later refresh-hook credential" +[[ ! -e $evil_channel_helper_marker && ! -e $evil_channel_helper_victim ]] || + fail "channel switch resolved a post-pacman helper through caller PATH" +[[ $(grep -c '^file:1000$' "$hook_log") == 1 ]] || fail "channel switch did not run the deferred refresh hook exactly once" +pass "channel switching defers its refresh hook past every later authentication" + +cat >"$stub_bin/omarchy-launch-floating-terminal-with-presentation" <<'STUB' +#!/bin/bash +exec bash -c "$1" +STUB +cat >"$stub_bin/omarchy-pkg-add" <<'STUB' +#!/bin/bash +[[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 98 +/usr/bin/sudo -N -- /usr/bin/true +exit "${TEST_PKG_STATUS:-0}" +STUB +cat >"$stub_bin/omarchy-font-set" <<'STUB' +#!/bin/bash +exec bash "$TEST_ROOT/bin/omarchy-font-set" "$@" +STUB +cat >"$stub_bin/fc-list" <<'STUB' +#!/bin/bash +echo 'Example Family' +STUB +cat >"$stub_bin/omarchy-restart-shell" <<'STUB' +#!/bin/bash +exit 0 +STUB +cat >"$stub_bin/pgrep" <<'STUB' +#!/bin/bash +exit 1 +STUB +cat >"$stub_bin/sleep" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$stub_bin"/* +chmod 4755 "$stub_bin/sudo" +mount --bind "$stub_bin/omarchy-launch-floating-terminal-with-presentation" \ + /usr/bin/omarchy-launch-floating-terminal-with-presentation +mount --bind "$stub_bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add +mount --bind "$stub_bin/omarchy-font-set" /usr/bin/omarchy-font-set +font_paths_bound=1 + +write_attack_hook font-set +font_victim="$root_dir/87-font-hook.rules" +font_dir_victim="$root_dir/88-font-hook-dir.rules" +font_persistent_victim="$root_dir/89-font-hook-child.rules" +reset_case +set +e +run_as_user env TEST_ROOT_VICTIM="$font_victim" TEST_ROOT_DIR_VICTIM="$font_dir_victim" \ + TEST_PERSISTENT_VICTIM="$font_persistent_victim" TEST_PERSISTENT_PID="$test_home/font.pid" \ + "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ + >"$test_tmp/font.out" 2>"$test_tmp/font.err" +status=$? +set -e +(( status == 0 )) || fail "isolated font install failed" "$(<"$test_tmp/font.err")" +wait_for_persistent_attempts "$test_home/font.pid" +assert_hook_sandboxed "$font_victim" "$font_dir_victim" "$font_persistent_victim" +pass "font installation invalidates before file, directory, and persistent hooks" + +reset_case +set +e +run_as_user env TEST_PKG_STATUS=1 TEST_ROOT_VICTIM="$font_victim" \ + TEST_ROOT_DIR_VICTIM="$font_dir_victim" TEST_PERSISTENT_VICTIM="$font_persistent_victim" \ + TEST_PERSISTENT_PID="$test_home/font.pid" \ + "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ + >"$test_tmp/font-fail.out" 2>"$test_tmp/font-fail.err" +status=$? +set -e +(( status != 0 )) || fail "failing font package installation unexpectedly succeeded" +[[ ! -e $token ]] || fail "failed font package installation left its credential live" +[[ ! -s $hook_log ]] || fail "failed font package installation reached the hook" +pass "failed font installation invalidates without running its hook" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index 2dd62b6e..edac1209 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -4,8 +4,55 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT +if [[ -z ${OMARCHY_UPDATE_SEQUENCE_NS:-} ]]; 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 group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) + if [[ -z $subuid || -z $subgid ]]; then + pass "no subordinate uid/gid range; skipping authorized update-sequence test" + exit 0 + fi + exec 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" \ + env OMARCHY_UPDATE_SEQUENCE_NS=setup bash "$0" +elif [[ $OMARCHY_UPDATE_SEQUENCE_NS == setup ]]; then + mount -t tmpfs -o mode=0755 tmpfs /run + namespace_tmp=$(mktemp -d -p /run omarchy-update-sequence.XXXXXXXX) + chmod 0755 "$namespace_tmp" + mkdir -p "$namespace_tmp/default/omarchy/sudo-no-update" + cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$namespace_tmp/default/omarchy/sudo-no-update/sudo" + chmod 0755 "$namespace_tmp/default/omarchy/sudo-no-update/sudo" + cat >"$namespace_tmp/fixed-sudo" <<'STUB' +#!/bin/bash +if [[ ${1:-} == "-h" ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' +fi +exit 0 +STUB + chmod 0755 "$namespace_tmp/fixed-sudo" + mount --bind "$namespace_tmp/fixed-sudo" /usr/bin/sudo + mount -t tmpfs -o mode=0755 tmpfs /etc + printf 'export OMARCHY_PATH="%s"\n' "$namespace_tmp" >/etc/omarchy.conf + chmod 0644 /etc/omarchy.conf + chown -R 1000:1000 "$namespace_tmp" + + set +e + setpriv --reuid 1000 --regid 1000 --clear-groups \ + env OMARCHY_UPDATE_SEQUENCE_NS=run OMARCHY_AUTHORIZED_TEST_ROOT="$namespace_tmp" bash "$0" + status=$? + set -e + + umount /usr/bin/sudo + umount /etc + rm -rf "$namespace_tmp" + umount /run + exit "$status" +fi + +test_tmp="$OMARCHY_AUTHORIZED_TEST_ROOT" +trap 'rm -rf "$test_tmp"/*' EXIT stub_bin="$test_tmp/bin" mkdir -p "$stub_bin" @@ -49,7 +96,7 @@ run_update() { FAILING_STEP="${FAILING_STEP:-}" \ OMARCHY_UPDATE_LOGGED=1 \ PATH="$stub_bin:$PATH" \ - bash "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" + "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" } steps_run() { @@ -70,13 +117,14 @@ expected_steps() { omarchy-update-keyring \ omarchy-update-system-pkgs \ omarchy-migrate \ - omarchy-hook \ - omarchy-update-aur-pkgs \ - omarchy-update-mise \ omarchy-update-orphan-pkgs \ omarchy-update-analyze-logs \ omarchy-update-status \ + omarchy-update-restart \ omarchy-update-stay-awake \ + omarchy-update-aur-pkgs \ + omarchy-hook \ + omarchy-update-mise \ omarchy-update-restart } From 87625c2ad8cf0e0bc08676127548a532d318036e Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 31 Aug 2026 21:27:48 +0100 Subject: [PATCH 02/29] OM-SEC-01: Make passwordless sudo expiry fail closed --- bin/omarchy-security-functions | 87 ++++ bin/omarchy-sudo-passwordless | 491 +++++++++++++++++++-- etc/tmpfiles.d/omarchy-nopasswd-sudo.conf | 8 +- manual/48-security.md | 2 +- migrations/1788163635.sh | 6 + test/shell.d/nopasswd-sudo-expiry-test.sh | 509 +++++++++++++++++----- 6 files changed, 955 insertions(+), 148 deletions(-) create mode 100755 bin/omarchy-security-functions create mode 100644 migrations/1788163635.sh mode change 100644 => 100755 test/shell.d/nopasswd-sudo-expiry-test.sh diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100755 index 00000000..2f3d2242 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,87 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Provide internal fail-closed helpers for security-sensitive commands + +# Shared fail-closed primitives for security-sensitive Omarchy commands. This +# file is sourced from the same package-owned bin directory as its consumers. + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + echo "omarchy-security-functions is an internal function library." >&2 + exit 64 +fi + +omarchy_security_require_privileged_bash_startup() { + local pid=${1:-$$} + + [[ $- == *p* && $pid =~ ^[1-9][0-9]*$ ]] || return 1 + /usr/bin/env -i /usr/bin/bash -p -c ' + mapfile -d "" -t argv <"/proc/$1/cmdline" || exit 1 + executable=$(/usr/bin/readlink -e -- "/proc/$1/exe") || exit 1 + [[ $executable == /usr/bin/bash ]] + [[ ${argv[0]:-} == /bin/bash || ${argv[0]:-} == /usr/bin/bash ]] + [[ ${argv[1]:-} == -p ]] + ' omarchy-bash-startup "$pid" +} + +omarchy_security_sudo_supports_no_update() { + LC_ALL=C /usr/bin/sudo -h 2>&1 | + /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' +} + +omarchy_security_revoke_sudo_timestamp() { + /usr/bin/sudo -k >/dev/null 2>&1 +} + +omarchy_security_exit_with_revoked_sudo() { + local status=$1 + local message=${2:-Could not invalidate cached sudo authorization.} + + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "$message" >&2 + (( status != 0 )) || status=1 + fi + exit "$status" +} + +omarchy_security_install_signal_exit_traps() { + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM +} + +omarchy_security_run_sudo_cleanup_trap() { + local status=$? + + omarchy_security_exit_with_revoked_sudo "$status" \ + "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" +} + +omarchy_security_install_sudo_cleanup_traps() { + OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} + trap omarchy_security_run_sudo_cleanup_trap EXIT + omarchy_security_install_signal_exit_traps +} + +omarchy_security_assert_root_directory() { + local path=$1 expected_mode=$2 canonical owner actual_mode + + [[ $path == /* && -d $path && ! -L $path ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$path") || return 1 + [[ $canonical == "$path" ]] || return 1 + read -r owner actual_mode < <(/usr/bin/stat -Lc '%u %a' -- "$path") || return 1 + [[ $owner == "0" && $actual_mode == "$expected_mode" ]] +} + +omarchy_security_prepare_private_root_directory() { + local path=$1 parent=$2 + + omarchy_security_assert_root_directory "$parent" 755 || return 1 + if [[ -e $path || -L $path ]]; then + omarchy_security_assert_root_directory "$path" 700 + else + /usr/bin/install -d -o root -g root -m 0700 -- "$path" || return 1 + omarchy_security_assert_root_directory "$path" 700 + fi +} diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 719d881b..717e8aaf 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,70 +1,481 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. # omarchy:args=[MINUTES] # omarchy:requires-sudo=true -NOPASSWD_FILE="/etc/sudoers.d/99-omarchy-nopasswd-${USER}" -TIMER_NAME="omarchy-nopasswd-expire-${USER}" +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 -MINUTES=${1:-15} -if [[ $1 && ! $1 =~ ^[0-9]+$ ]]; then - echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 - exit 1 +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + omarchy_security_require_privileged_bash_startup || { + echo "Refusing an unsafe Bash startup for passwordless sudo." >&2 + exit 126 + } + unset BASH_ENV ENV fi -arm_expiry() { - if sudo systemd-run --on-active=${MINUTES}m --timer-property=AccuracySec=1s --unit="$TIMER_NAME" \ - rm -f -- "$NOPASSWD_FILE"; then - return 0 - fi +set -euo pipefail - echo "Failed to schedule passwordless sudo expiry. Revoking access now." >&2 - if ! sudo rm -f -- "$NOPASSWD_FILE"; then - echo "CRITICAL: Could not remove $NOPASSWD_FILE. Remove it as root immediately." >&2 +readonly DEFAULT_MINUTES=15 +readonly MAX_MINUTES=1440 +readonly STATE_DIR=/var/lib/omarchy/sudo-passwordless +readonly RUNTIME_DIR=/run/omarchy/sudo-passwordless +readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock +readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless + +usage() { + echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 + echo "MINUTES must be between 1 and $MAX_MINUTES." >&2 + exit 1 +} + +valid_minutes() { + [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= MAX_MINUTES)) +} + +valid_uid() { + [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= 4294967294)) +} + +valid_account_name() { + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] +} + +resolve_account() { + local uid="$1" entry + valid_uid "$uid" || return 1 + entry=$(/usr/bin/getent passwd "$((10#$uid))") || return 1 + IFS=: read -r ACCOUNT_NAME _ ACCOUNT_UID _ _ _ _ <<<"$entry" + [[ $ACCOUNT_UID == "$((10#$uid))" ]] || return 1 + # Sudoers names and the legacy filename both have metacharacters. Omarchy + # accounts use this portable subset; refusing anything else is safer than + # attempting to quote privileged policy syntax. + valid_account_name "$ACCOUNT_NAME" || return 1 + ACCOUNT_UID=$((10#$uid)) +} + +verify_sudo_caller() { + local requested_uid="$1" + ((EUID == 0)) || return 1 + valid_uid "$requested_uid" || return 1 + [[ ${SUDO_UID:-} =~ ^[0-9]+$ ]] || return 1 + ((10#$SUDO_UID == 10#$requested_uid)) || return 1 + resolve_account "$requested_uid" +} + +prepare_root_state() { + omarchy_security_assert_root_directory /var 755 || return 1 + [[ -d /var/lib && ! -L /var/lib ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /var/lib) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /var/lib) & 022)) || return 1 + + if [[ ! -e /var/lib/omarchy && ! -L /var/lib/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /var/lib/omarchy || return 1 fi - return 1 + omarchy_security_assert_root_directory /var/lib/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$STATE_DIR" /var/lib/omarchy || return 1 + + omarchy_security_assert_root_directory /run 755 || return 1 + if [[ ! -e /run/omarchy && ! -L /run/omarchy ]]; then + /usr/bin/install -d -o root -g root -m 0755 /run/omarchy || return 1 + fi + omarchy_security_assert_root_directory /run/omarchy 755 || return 1 + omarchy_security_prepare_private_root_directory "$RUNTIME_DIR" /run/omarchy +} + +with_root_lock() { + local fd rc=0 + # The boot cleanup cannot depend on STATE_DIR or RUNTIME_DIR being healthy: + # those are exactly the kinds of partial-install state it must fail closed + # through. /run/lock is established by the OS before sysinit services run. + omarchy_security_assert_root_directory /run 755 || return 1 + [[ -d /run/lock && ! -L /run/lock ]] || return 1 + [[ $(/usr/bin/stat -Lc '%u' /run/lock) == 0 ]] || return 1 + ! ((8#$(/usr/bin/stat -Lc '%a' /run/lock) & 022)) || return 1 + exec {fd}>"$LOCK_FILE" || return 1 + /usr/bin/chown root:root "$LOCK_FILE" || return 1 + /usr/bin/chmod 0600 "$LOCK_FILE" || return 1 + /usr/bin/flock -x "$fd" || return 1 + "$@" || rc=$? + /usr/bin/flock -u "$fd" || rc=1 + exec {fd}>&- + return "$rc" +} + +rule_file() { + printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" +} + +state_file() { + printf '%s/%s.state' "$STATE_DIR" "$1" +} + +read_state_record() { + local uid="$1" file state_uid name expires timer canonical_uid + local -a lines=() + valid_uid "$uid" || return 1 + canonical_uid=$((10#$uid)) + file=$(state_file "$uid") + [[ -f $file && ! -L $file ]] || return 1 + mapfile -t lines <"$file" || return 1 + (( ${#lines[@]} == 4 )) || return 1 + [[ ${lines[0]} == UID=* && ${lines[1]} == USER=* && + ${lines[2]} == EXPIRES=* && ${lines[3]} == TIMER=* ]] || return 1 + state_uid=${lines[0]#UID=} + name=${lines[1]#USER=} + expires=${lines[2]#EXPIRES=} + timer=${lines[3]#TIMER=} + [[ $state_uid == "$canonical_uid" ]] || return 1 + valid_account_name "$name" || return 1 + [[ $expires =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + [[ $timer =~ ^omarchy-nopasswd-expire-${canonical_uid}-[0-9a-f]{32}$ ]] || return 1 + printf '%s\t%s\t%s' "$name" "$expires" "$timer" +} + +read_state_timer() { + local record + record=$(read_state_record "$1") || return 1 + printf '%s' "${record##*$'\t'}" +} + +current_epoch() { + local now + now=$(/usr/bin/date +%s) || return 1 + [[ $now =~ ^[1-9][0-9]{0,10}$ ]] || return 1 + printf '%s' "$now" +} + +valid_expiry() { + [[ $1 =~ ^[1-9][0-9]{0,10}$ ]] +} + +valid_timer_for_uid() { + local uid="$1" timer="$2" + valid_uid "$uid" || return 1 + uid=$((10#$uid)) + [[ $timer =~ ^omarchy-nopasswd-expire-${uid}-[0-9a-f]{32}$ ]] +} + +stop_timer() { + local timer="$1" + [[ $timer =~ ^omarchy-nopasswd-expire-[0-9]+-[0-9a-f]{32}$ ]] || return 0 + /usr/bin/systemctl stop "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true + /usr/bin/systemctl reset-failed "${timer}.timer" "${timer}.service" >/dev/null 2>&1 || true +} + +classify_generated_rule() { + local file=$1 suffix contents name + + GENERATED_RULE_LEGACY_TIMER="" + [[ -f $file && ! -L $file ]] || return 1 + contents=$(/usr/bin/cat -- "$file") || return 2 + suffix=${file##*/99-omarchy-nopasswd-} + + if [[ $suffix =~ ^[0-9]+$ ]]; then + name=${contents%' ALL=(ALL) NOPASSWD: ALL'} + valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]] + elif valid_account_name "$suffix" && [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then + GENERATED_RULE_LEGACY_TIMER="omarchy-nopasswd-expire-${suffix}" + else + return 1 + fi +} + +remove_known_legacy_rules() { + local file classification failed=0 + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + # A crash after publishing the numeric rule but before its state rename + # must not survive the next boot. Do not require the account to still + # exist: a deleted account could otherwise make the rule immortal and a + # later username reuse could activate it again. + /usr/bin/rm -f -- "$file" || failed=1 + [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || + /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ + "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true + else + classification=$? + # An unreadable candidate cannot be proven inert. A symlink, non-file, + # or administrator-authored body is unrelated and remains untouched. + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +cleanup_uid_locked() { + local uid="$1" timer="" + valid_uid "$uid" || return 1 + timer=$(read_state_timer "$uid" 2>/dev/null || true) + # Remove policy first. A failed timer stop can only leave an inert cleanup + # job behind, never extend passwordless access. + /usr/bin/rm -f -- "$(rule_file "$uid")" || return 1 + /usr/bin/rm -f -- "$(state_file "$uid")" || return 1 + [[ -z $timer ]] || stop_timer "$timer" +} + +cleanup_all_locked() { + local state uid failed=0 file classification + shopt -s nullglob + for state in "$STATE_DIR"/*.state; do + uid=${state##*/} + uid=${uid%.state} + if valid_uid "$uid" && ! cleanup_uid_locked "$uid"; then failed=1; fi + done + shopt -u nullglob + remove_known_legacy_rules || failed=1 + + # Never report a successful boot cleanup while an exact rule emitted by any + # Omarchy implementation is still active. Administrator-extended files do + # not match these complete bodies and remain untouched. + shopt -s nullglob + for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + if classify_generated_rule "$file"; then + failed=1 + else + classification=$? + (( classification == 1 )) || failed=1 + fi + done + shopt -u nullglob + return "$failed" +} + +verify_boot_cleanup() { + local owner mode canonical current active_rules + [[ -f $BOOT_CLEANUP_FILE && ! -L $BOOT_CLEANUP_FILE ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$BOOT_CLEANUP_FILE") || return 1 + [[ $canonical == "$BOOT_CLEANUP_FILE" ]] || return 1 + owner=$(/usr/bin/stat -Lc '%u' -- "$BOOT_CLEANUP_FILE") || return 1 + mode=$(/usr/bin/stat -Lc '%a' -- "$BOOT_CLEANUP_FILE") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + + current=${BOOT_CLEANUP_FILE%/*} + while :; do + [[ -d $current && ! -L $current ]] || return 1 + canonical=$(/usr/bin/realpath -e -- "$current") || return 1 + [[ $canonical == "$current" ]] || return 1 + read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 + [[ $owner == 0 && $mode =~ ^[0-7]+$ ]] && ! ((8#$mode & 022)) || return 1 + [[ $current == / ]] && break + current=${current%/*} + [[ -n $current ]] || current=/ + done + + active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 + [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] +} + +prepare_state_file() { + local uid="$1" name="$2" expires="$3" timer="$4" tmp + tmp=$(/usr/bin/mktemp "$STATE_DIR/.state.XXXXXX") || return 1 + if ! /usr/bin/printf 'UID=%s\nUSER=%s\nEXPIRES=%s\nTIMER=%s\n' \ + "$uid" "$name" "$expires" "$timer" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0600 "$tmp"; then + /usr/bin/rm -f -- "$tmp" + return 1 + fi + printf '%s' "$tmp" +} + +start_expiry_timer() { + local uid="$1" expires="$2" timer="$3" + valid_uid "$uid" && valid_expiry "$expires" && valid_timer_for_uid "$uid" "$timer" || return 1 + # Calendar timers use CLOCK_REALTIME and catch up immediately after resume; + # a monotonic OnActiveSec timer pauses while the machine is suspended. + /usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \ + --timer-property=AccuracySec=1s --unit="$timer" \ + -- "$INSTALLED_SELF" __expire "$uid" || return 1 + /usr/bin/systemctl is-active --quiet "${timer}.timer" +} + +publish_rule() { + local uid="$1" name="$2" destination tmp + destination=$(rule_file "$uid") + tmp=$(/usr/bin/mktemp "$STATE_DIR/.sudoers.XXXXXX") || return 1 + if ! /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$name" >"$tmp" || + ! /usr/bin/chown root:root "$tmp" || ! /usr/bin/chmod 0440 "$tmp" || + ! /usr/sbin/visudo -cf "$tmp" >/dev/null || + ! /usr/bin/install -o root -g root -m 0440 -- "$tmp" "$destination"; then + /usr/bin/rm -f -- "$tmp" + return 1 + fi + /usr/bin/rm -f -- "$tmp" +} + +enable_locked() { + local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now + resolve_account "$uid" || return 1 + valid_minutes "$minutes" || return 1 + prepare_root_state || return 1 + verify_boot_cleanup || { + echo "omarchy-sudo-passwordless: package-owned boot cleanup rule is missing or unsafe" >&2 + return 1 + } + + old_timer=$(read_state_timer "$uid" 2>/dev/null || true) + token=$(/usr/bin/tr -d '-' = 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer"; then + # The timer may have expired or failed between its initial verification and + # rule publication. Revoke synchronously so a suspended or heavily loaded + # machine cannot turn a short grant into a reboot-long one. + cleanup_uid_locked "$uid" || true + return 1 + fi + [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" +} + +status_locked() { + local uid="$1" record state_name expires timer now remainder + resolve_account "$uid" || return 1 + [[ -f $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 + record=$(read_state_record "$uid") || { + cleanup_uid_locked "$uid" + return 1 + } + state_name=${record%%$'\t'*} + remainder=${record#*$'\t'} + expires=${remainder%%$'\t'*} + timer=${record##*$'\t'} + [[ $state_name == "$ACCOUNT_NAME" ]] || { + cleanup_uid_locked "$uid" + return 1 + } + now=$(current_epoch) || { + cleanup_uid_locked "$uid" + return 1 + } + ((10#$now < 10#$expires)) || { + cleanup_uid_locked "$uid" + return 1 + } + /usr/bin/systemctl is-active --quiet "${timer}.timer" || { + cleanup_uid_locked "$uid" + return 1 + } +} + +root_dispatch() { + local action="$1" + shift + case "$action" in + __status) + (($# == 1)) && verify_sudo_caller "$1" || return 1 + with_root_lock status_locked "$1" + ;; + __enable) + (($# == 2)) && verify_sudo_caller "$1" && valid_minutes "$2" || return 1 + with_root_lock enable_locked "$1" "$2" + ;; + __disable) + (($# == 1)) && verify_sudo_caller "$1" || return 1 + with_root_lock cleanup_uid_locked "$1" + ;; + __expire) + (($# == 1)) && ((EUID == 0)) && valid_uid "$1" || return 1 + with_root_lock cleanup_uid_locked "$1" + ;; + __cleanup-all) + (($# == 0)) && ((EUID == 0)) || return 1 + with_root_lock cleanup_all_locked + ;; + *) return 1 ;; + esac +} + +case "${1:-}" in + __status|__enable|__disable|__expire|__cleanup-all) + action=$1 + shift + root_dispatch "$action" "$@" + exit + ;; +esac + +(($# <= 1)) || usage +minutes=${1:-$DEFAULT_MINUTES} +valid_minutes "$minutes" || usage +uid=$(/usr/bin/id -u) +valid_uid "$uid" || { + echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2 + exit 1 +} + +omarchy_security_sudo_supports_no_update || { + echo "This sudo does not support --no-update; refusing the passwordless-sudo workflow." >&2 + exit 1 +} + +omarchy_security_install_sudo_cleanup_traps +/usr/bin/sudo -k >/dev/null 2>&1 || { + echo "Could not start from a cold sudo credential state." >&2 + exit 1 } echo "Toggle passwordless sudo..." - -# Safety: if the file exists but the timer doesn't (e.g. after reboot), clean up -if sudo test -f "$NOPASSWD_FILE" && ! systemctl is-active "${TIMER_NAME}.timer" &>/dev/null; then - sudo rm "$NOPASSWD_FILE" -fi - -# Check for the file directly — sudo -n can stay cached or be granted by other rules -if sudo test -f "$NOPASSWD_FILE"; then - if [[ $1 ]]; then - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null - arm_expiry || exit 1 - echo "Passwordless sudo timer updated. It will now automatically disable in ${MINUTES} minutes." - else - sudo rm "$NOPASSWD_FILE" - sudo systemctl stop "${TIMER_NAME}.timer" 2>/dev/null +if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then + if (($# == 0)); then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" echo "Passwordless sudo has been DISABLED. Sudo will require a password again." + else + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + echo "Passwordless sudo timer updated. It will automatically disable in ${minutes} minutes." fi else echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" - echo "execute ANY command as root WITHOUT a password for ${MINUTES} minutes." + echo "execute ANY command as root WITHOUT a password for ${minutes} minutes." echo "" echo "This is useful for AI agents that need to run sudo commands," echo "but it significantly weakens the security of your system." echo "Anyone or anything with access to your user account gets full root." echo "" - echo "Passwordless sudo will automatically disable after ${MINUTES} minutes." + echo "Passwordless sudo will automatically disable after ${minutes} minutes," + echo "including if the machine reboots before the timer fires." echo "Run this command again to disable it early." echo "" - if gum confirm "Enable passwordless sudo for ${MINUTES} minutes? This is a significant security risk!"; then - echo "${USER} ALL=(ALL) NOPASSWD: ALL" | sudo tee "$NOPASSWD_FILE" > /dev/null - sudo chmod 440 "$NOPASSWD_FILE" - arm_expiry || exit 1 - + if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then + /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" echo "" - echo "Passwordless sudo has been ENABLED. It will automatically disable in ${MINUTES} minutes." - echo "A restart removes the passwordless sudo rule as well." + echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." else echo "Aborted. No changes made." fi diff --git a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf index 2c644ff1..be81137a 100644 --- a/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf +++ b/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -1,5 +1,5 @@ -# omarchy-sudo-passwordless writes /etc/sudoers.d/99-omarchy-nopasswd- and -# arms a transient systemd-run timer to remove it again. Transient units do not -# survive a reboot, so remove any remaining grant during early boot. Boot-only -# (r!) ensures a later systemd-tmpfiles --remove cannot cut a live grant short. +# omarchy-sudo-passwordless creates grants in this owned filename namespace. +# Transient expiry timers do not survive reboot, so early boot removes every +# remaining grant. The boot-only modifier prevents later tmpfiles runs from +# shortening a live, explicitly requested window. r! /etc/sudoers.d/99-omarchy-nopasswd-* diff --git a/manual/48-security.md b/manual/48-security.md index 45750398..86028a23 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,7 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 minutes and then puts it back automatically. Run it again before the timer runs out to end it early, and pass your own number of minutes with `omarchy-sudo-passwordless 30` if 15 isn't enough. A restart removes the passwordless sudo rule as well. +Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 wall-clock minutes and then puts it back automatically, including immediately after resuming from a suspend that crossed the deadline. A package-owned boot-time cleanup rule removes the grant before logins if the computer restarts first. Run the command again before the timer runs out to end it early, and pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30` if 15 isn't enough. Be clear-eyed about this one: while it's on, anything running as your user can do anything as root without being asked. That's the whole point, and it's also the whole risk. diff --git a/migrations/1788163635.sh b/migrations/1788163635.sh new file mode 100644 index 00000000..e2fe2272 --- /dev/null +++ b/migrations/1788163635.sh @@ -0,0 +1,6 @@ +echo "Remove legacy temporary passwordless sudo grants" + +# This removes current numeric grants, exact legacy username grants, corrupt or +# orphaned state, and their known timers. Administrator-authored sudoers files +# whose contents do not exactly match Omarchy's generated grammar are preserved. +sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh old mode 100644 new mode 100755 index f332f80e..6d54633c --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -2,122 +2,425 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -script="$ROOT/bin/omarchy-sudo-passwordless" -tmpfiles_file="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +command_path="$ROOT/bin/omarchy-sudo-passwordless" +security_library_path="$ROOT/bin/omarchy-security-functions" +tmpfiles_path="$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +migration_path="$ROOT/migrations/1788163635.sh" test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT -mock_bin="$test_tmp/bin" -grant="$test_tmp/grant" -calls="$test_tmp/calls" -mkdir -p "$mock_bin" - -cat >"$mock_bin/gum" <<'SH' -#!/bin/bash -exit 0 -SH - -cat >"$mock_bin/systemctl" <<'SH' -#!/bin/bash - -printf 'systemctl %s\n' "$*" >>"$TEST_CALLS" -[[ ${1:-} == "is-active" && ${TEST_TIMER_ACTIVE:-false} == "true" ]] -SH - -cat >"$mock_bin/sudo" <<'SH' -#!/bin/bash - -printf 'sudo %s\n' "$*" >>"$TEST_CALLS" - -case ${1:-} in -test) - [[ ${2:-} == "-f" && -f $TEST_GRANT ]] - ;; -tee) - /usr/bin/tee "$TEST_GRANT" - ;; -chmod) - /usr/bin/chmod "$2" "$TEST_GRANT" - ;; -systemd-run) - [[ ${TEST_FAIL_SYSTEMD_RUN:-false} != "true" ]] - ;; -rm) - /usr/bin/rm -f -- "$TEST_GRANT" - ;; -systemctl) - exit 0 - ;; -*) - echo "unexpected sudo command: $*" >&2 - exit 90 - ;; -esac -SH - -chmod +x "$mock_bin/gum" "$mock_bin/sudo" "$mock_bin/systemctl" - -run_command() { - TEST_CALLS="$calls" TEST_GRANT="$grant" PATH="$mock_bin:$PATH" USER=alice \ - "$script" "$@" +function_prefix() { + printf 'source %q\n' "$security_library_path" + awk '/^source .*omarchy-security-functions/ { next } /^case "\$\{1:-\}" in$/ { exit } { print }' "$command_path" } -: >"$calls" -enable_output=$(run_command 15) -[[ -f $grant ]] || fail "successful timer setup leaves the passwordless sudo grant enabled" -[[ $(cat "$grant") == "alice ALL=(ALL) NOPASSWD: ALL" ]] || - fail "the enabled grant belongs to the current user" "$(cat "$grant")" -grep -q '^sudo systemd-run --on-active=15m .* rm -f -- /etc/sudoers.d/99-omarchy-nopasswd-alice$' "$calls" || - fail "enabling arms the expiry timer" "$(cat "$calls")" -[[ $enable_output == *"automatically disable in 15 minutes"* ]] || - fail "success is reported after the timer is armed" "$enable_output" -pass "enabling arms expiry before reporting success" +# Exercise the validation code itself. Leading zeroes remain numeric, but zero, +# negatives, oversized grants, and shell syntax are rejected. +( + source <(function_prefix) + for minutes in 1 15 1440 00015; do + valid_minutes "$minutes" || fail "passwordless sudo accepts bounded duration $minutes" + done + for minutes in 0 1441 -1 1m '1;id' ''; do + ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" + done +) +pass "passwordless sudo validates a bounded positive duration" -: >"$calls" -rm -f "$grant" -if failure_output=$(TEST_FAIL_SYSTEMD_RUN=true run_command 15 2>&1); then - fail "enabling fails when the expiry timer cannot be armed" +# The public entry point uses the kernel-backed numeric identity; $USER is +# never interpolated into a privileged filename or sudoers rule. +grep -F 'uid=$(/usr/bin/id -u)' "$command_path" >/dev/null || + fail "passwordless sudo derives the caller from id -u" +! grep -Eq '\$\{?USER\}?' "$command_path" || + fail "passwordless sudo does not trust USER for privileged policy" +grep -F '[[ ${SUDO_UID:-} =~ ^[0-9]+$ ]]' "$command_path" >/dev/null || + fail "passwordless sudo validates sudo provenance" +pass "passwordless sudo derives and validates trusted account identity" + +# Status inspection and the confirmation UI are mixed-trust: a normal sudo +# status call would publish a timestamp that a hostile prompt helper could use +# even when the user declines the grant. Exercise the public flow with a sudo +# model that publishes a token only when -N is missing. +grep -Fxq '#!/bin/bash -p' "$command_path" || + fail "passwordless sudo no longer suppresses Bash startup injection" +grep -F '[[ ${argv[1]:-} == -p ]]' "$security_library_path" >/dev/null || + fail "passwordless sudo accepts a decoy post-script -p" + +public_sudo_stub="$test_tmp/public-sudo" +public_gum_stub="$test_tmp/public-gum" +public_token="$test_tmp/public-token" +public_exploit="$test_tmp/public-exploit" +cat >"$public_sudo_stub" <<'STUB' +#!/bin/bash +if [[ ${1:-} == -h ]]; then + echo 'usage: sudo [-ABbEHkNnPS] command' + exit 0 fi -[[ ! -e $grant ]] || fail "timer setup failure revokes the new passwordless sudo grant" -[[ $failure_output == *"Revoking access now"* ]] || - fail "timer setup failure explains the fail-closed revocation" "$failure_output" -[[ $failure_output != *"Passwordless sudo has been ENABLED"* ]] || - fail "timer setup failure does not report that passwordless sudo was enabled" "$failure_output" -pass "timer setup failure revokes a new grant" - -: >"$calls" -printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$grant" -if update_output=$(TEST_TIMER_ACTIVE=true TEST_FAIL_SYSTEMD_RUN=true run_command 30 2>&1); then - fail "updating fails when the replacement expiry timer cannot be armed" +if [[ ${1:-} == -k ]]; then + rm -f -- "$TEST_PUBLIC_TOKEN" + exit 0 fi -[[ ! -e $grant ]] || fail "timer update failure revokes the existing passwordless sudo grant" -[[ $update_output != *"timer updated"* ]] || - fail "timer update failure does not report success" "$update_output" -pass "timer update failure revokes the existing grant" +no_update=0 +if [[ ${1:-} == -N ]]; then no_update=1; shift; fi +[[ ${1:-} != -- ]] || shift +((no_update)) || : >"$TEST_PUBLIC_TOKEN" +case "${2:-}" in + __status) exit 1 ;; + __enable|__disable) exit 0 ;; + *) exit 2 ;; +esac +STUB +cat >"$public_gum_stub" <<'STUB' +#!/bin/bash +[[ ! -e $TEST_PUBLIC_TOKEN ]] || : >"$TEST_PUBLIC_EXPLOIT" +exit 1 +STUB +chmod 0755 "$public_sudo_stub" "$public_gum_stub" +public_flow="$test_tmp/passwordless-public-flow" +/usr/bin/cp "$security_library_path" "$test_tmp/omarchy-security-functions" +/usr/bin/sed \ + -e "s#/usr/bin/sudo#$public_sudo_stub#g" \ + -e "s#/usr/bin/gum#$public_gum_stub#g" \ + "$command_path" >"$public_flow" +chmod 0755 "$public_flow" +TEST_PUBLIC_TOKEN="$public_token" TEST_PUBLIC_EXPLOIT="$public_exploit" \ + /usr/bin/bash -p "$public_flow" 15 >/dev/null +[[ ! -e $public_token && ! -e $public_exploit ]] || + fail "passwordless confirmation inherited a reusable status credential" -mapfile -t tmpfiles_rules < <(grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_file") -(( ${#tmpfiles_rules[@]} == 1 )) || - fail "passwordless sudo ships one tmpfiles rule" "${tmpfiles_rules[*]}" +startup_env="$test_tmp/passwordless-bash-env" +startup_marker="$test_tmp/passwordless-bash-env-ran" +cat >"$startup_env" <<'STUB' +: >"$TEST_STARTUP_MARKER" +set -o privileged +unset BASH_ENV +STUB +if BASH_ENV="$startup_env" TEST_STARTUP_MARKER="$startup_marker" \ + /usr/bin/bash "$public_flow" -p >/dev/null 2>&1; then + fail "passwordless sudo accepted an unsafe interpreter with a decoy -p" +fi +[[ -e $startup_marker && ! -e $public_token && ! -e $public_exploit ]] || + fail "unsafe passwordless startup reached its sudo workflow" +pass "passwordless confirmation uses a cold command-scoped credential boundary" -fake_root="$test_tmp/root" +# Source a path-rewritten copy so the real cleanup implementation can be +# exercised without touching /etc. Exact generated numeric rules are removed +# even after account deletion or a crash before state publication. Anything an +# administrator changed, and every symlink, is preserved. +fake_sudoers="$test_tmp/sudoers.d" +mkdir "$fake_sudoers" +rewritten="$test_tmp/passwordless-lib.sh" +function_prefix | sed "s#/etc/sudoers.d#$fake_sudoers#g" >"$rewritten" +( + source "$rewritten" + printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$fake_sudoers/99-omarchy-nopasswd-424242" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' >"$fake_sudoers/99-omarchy-nopasswd-424243" + ln -s "$fake_sudoers/99-omarchy-nopasswd-424243" "$fake_sudoers/99-omarchy-nopasswd-424244" + remove_known_legacy_rules +) +[[ ! -e $fake_sudoers/99-omarchy-nopasswd-424242 ]] || + fail "boot cleanup removes a state-less numeric orphan" +[[ -f $fake_sudoers/99-omarchy-nopasswd-424243 ]] || + fail "boot cleanup preserves administrator-authored policy" +[[ -L $fake_sudoers/99-omarchy-nopasswd-424244 ]] || + fail "boot cleanup refuses sudoers symlinks" +pass "boot cleanup removes crash/deleted-account orphans conservatively" + +# A boot gate must not report success when deletion itself fails. Exercise the +# real cleanup and post-cleanup verification with a deterministic failing rm. +rm_failure_dir="$test_tmp/rm-failure-sudoers" +mkdir "$rm_failure_dir" +printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$rm_failure_dir/99-omarchy-nopasswd-424245" +failing_rm="$test_tmp/failing-rm" +cat >"$failing_rm" <<'FAILING_RM' +#!/bin/bash +exit 1 +FAILING_RM +chmod +x "$failing_rm" +rm_failure_lib="$test_tmp/rm-failure-lib.sh" +function_prefix | + sed -e "s#/etc/sudoers.d#$rm_failure_dir#g" \ + -e "s#/var/lib/omarchy/sudo-passwordless#$test_tmp/empty-state#g" \ + -e "s#/usr/bin/rm#$failing_rm#g" >"$rm_failure_lib" +mkdir "$test_tmp/empty-state" +( + source "$rm_failure_lib" + ! cleanup_all_locked +) || fail "boot cleanup fails when an Omarchy rule cannot be removed" +[[ -f $rm_failure_dir/99-omarchy-nopasswd-424245 ]] || + fail "rm-failure fixture remains available for verification" +pass "boot cleanup fails closed when policy deletion fails" + +# Reproduce the migration's real sudo provenance: sudo sets SUDO_UID. Rewrite +# only the read-only EUID probe so this unprivileged test can exercise the root +# dispatcher, then assert that cleanup (which can only revoke privilege) runs. +dispatch_lib="$test_tmp/dispatch-lib.sh" +function_prefix | sed 's/((EUID == 0))/((TEST_EUID == 0))/g' >"$dispatch_lib" +( + source "$dispatch_lib" + called="" + cleanup_all_locked() { called=cleanup; } + with_root_lock() { "$@"; } + TEST_EUID=0 SUDO_UID=1000 root_dispatch __cleanup-all + [[ $called == cleanup ]] +) || fail "migration cleanup dispatch accepts authenticated sudo provenance" +pass "migration can invoke fail-closed cleanup through sudo" + +# A grant cannot be published until the static unit is verified/enabled, and a +# timer setup failure removes its pending state without calling publish_rule. +transaction_dir="$test_tmp/transaction" +mkdir "$transaction_dir" +transaction_lib="$test_tmp/transaction-lib.sh" +function_prefix | sed "s#/var/lib/omarchy/sudo-passwordless#$transaction_dir#g" >"$transaction_lib" +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 1; } + publish_rule() { return 99; } + ! enable_locked 1000 15 +) +( + source "$transaction_lib" + ACCOUNT_NAME=audituser + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + publish_rule() { printf published >"$transaction_dir/published"; } + cleanup_uid_locked() { : >"$transaction_dir/failed-timer-cleanup"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/pending && ! -e $transaction_dir/published && + -e $transaction_dir/failed-timer-cleanup ]] +) || fail "passwordless sudo fails closed on prerequisite/timer failure" +pass "passwordless sudo publishes no rule after partial setup failure" + +# Erik's predecessor fix revoked an already-active grant when an extension +# could not arm its replacement timer. Keep that fail-closed property while +# the new transaction deliberately leaves the old timer armed until the new +# one is verified. +replacement_state="$transaction_dir/1000.state" +replacement_rule="$transaction_dir/1000.rule" +replacement_stopped="$transaction_dir/old-timer-stopped" +old_timer=omarchy-nopasswd-expire-1000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +printf 'UID=1000\nUSER=audituser\nEXPIRES=2000000000\nTIMER=%s\n' "$old_timer" >"$replacement_state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$replacement_rule" +( + source "$transaction_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + state_file() { printf '%s' "$replacement_state"; } + rule_file() { printf '%s' "$replacement_rule"; } + prepare_state_file() { local pending="$transaction_dir/replacement-pending"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 1; } + stop_timer() { [[ $1 == "$old_timer" ]] && : >"$replacement_stopped"; } + ! enable_locked 1000 30 + [[ ! -e $replacement_state && ! -e $replacement_rule && -e $replacement_stopped ]] +) || fail "passwordless sudo leaves an existing grant live after replacement timer failure" +pass "replacement timer failure revokes the existing grant" + +# Expiry is a wall-clock promise, so the transient timer must carry the exact +# absolute epoch recorded in root state. A monotonic-only --on-active timer +# pauses during suspend and can otherwise extend a short grant by hours. +timer_args="$test_tmp/timer-args" +calendar_systemd_run="$test_tmp/calendar-systemd-run" +calendar_systemctl="$test_tmp/calendar-systemctl" +cat >"$calendar_systemd_run" <<'STUB' +#!/bin/bash +printf '%s\n' "$@" >"$TEST_TIMER_ARGS" +STUB +cat >"$calendar_systemctl" <<'STUB' +#!/bin/bash +exit 0 +STUB +chmod 0755 "$calendar_systemd_run" "$calendar_systemctl" +calendar_lib="$test_tmp/calendar-lib.sh" +function_prefix | + sed -e "s#/usr/bin/systemd-run#$calendar_systemd_run#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$calendar_lib" +( + source "$calendar_lib" + TEST_TIMER_ARGS="$timer_args" start_expiry_timer 1000 2000000000 \ + omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +) || fail "passwordless sudo cannot arm its absolute expiry timer" +grep -Fx -- '--on-calendar=@2000000000' "$timer_args" >/dev/null || + fail "passwordless sudo timer does not advance across suspend" +pass "passwordless sudo arms the recorded absolute wall-clock expiry" + +# A resumed machine can briefly observe the timer as active before systemd +# dispatches its overdue service. Status must independently enforce EXPIRES and +# synchronously remove policy instead of trusting timer activity alone. +expired_state="$test_tmp/expired-state" +expired_sudoers="$test_tmp/expired-sudoers" +mkdir "$expired_state" "$expired_sudoers" +expired_timer=omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef +printf 'UID=1000\nUSER=audituser\nEXPIRES=1\nTIMER=%s\n' "$expired_timer" >"$expired_state/1000.state" +printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$expired_sudoers/99-omarchy-nopasswd-1000" +expired_lib="$test_tmp/expired-lib.sh" +function_prefix | + sed -e "s#/var/lib/omarchy/sudo-passwordless#$expired_state#g" \ + -e "s#/etc/sudoers.d#$expired_sudoers#g" \ + -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$expired_lib" +( + source "$expired_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + ! status_locked 1000 +) || fail "passwordless sudo accepts expired root state while its timer is active" +[[ ! -e $expired_state/1000.state && ! -e $expired_sudoers/99-omarchy-nopasswd-1000 ]] || + fail "passwordless sudo does not synchronously revoke expired state" +pass "passwordless sudo enforces wall-clock expiry independently of timer dispatch" + +# If the transient timer fires between its first active check and publication, +# the just-created rule must be synchronously revoked instead of surviving to +# reboot. Model that narrow transition with the real enable transaction. +inactive_systemctl="$test_tmp/inactive-systemctl" +cat >"$inactive_systemctl" <<'STUB' +#!/bin/bash +exit 1 +STUB +chmod 0755 "$inactive_systemctl" +post_publish_lib="$test_tmp/post-publish-lib.sh" +sed "s#/usr/bin/systemctl#$inactive_systemctl#g" "$transaction_lib" >"$post_publish_lib" +( + source "$post_publish_lib" + resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } + prepare_root_state() { :; } + verify_boot_cleanup() { return 0; } + read_state_timer() { return 1; } + prepare_state_file() { local pending="$transaction_dir/pending-after-arm"; : >"$pending"; printf %s "$pending"; } + start_expiry_timer() { return 0; } + publish_rule() { : >"$transaction_dir/published-after-arm"; } + cleanup_uid_locked() { rm -f "$transaction_dir/published-after-arm"; : >"$transaction_dir/revoked-after-arm"; } + ! enable_locked 1000 15 + [[ ! -e $transaction_dir/published-after-arm && -e $transaction_dir/revoked-after-arm ]] +) || fail "passwordless sudo leaves a grant when its armed timer expires before publication completes" +pass "timer expiry during publication revokes the grant synchronously" + +# Follow the maintainer's package-owned tmpfiles design: one boot-only rule +# owns this filename namespace. A routine --remove leaves live grants alone; +# early boot removes them before a user can log in. The migration only revokes +# legacy runtime state and never writes static policy into /usr. +mapfile -t tmpfiles_rules < <(/usr/bin/grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_path") +(( ${#tmpfiles_rules[@]} == 1 )) || fail "passwordless sudo ships one boot cleanup rule" +[[ ${tmpfiles_rules[0]} == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || + fail "passwordless sudo boot cleanup does not own the exact generated namespace" +fake_root="$test_tmp/tmpfiles-root" sudoers_dir="$fake_root/etc/sudoers.d" mkdir -p "$sudoers_dir" -grant_names=(alice buildbot-2 user.123 'service$') -for grant_name in "${grant_names[@]}"; do - touch "$sudoers_dir/99-omarchy-nopasswd-$grant_name" +for name in alice buildbot-2 424242; do + : >"$sudoers_dir/99-omarchy-nopasswd-$name" done -touch "$sudoers_dir/omarchy-dns" +: >"$sudoers_dir/omarchy-dns" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[0]}" +[[ -e $sudoers_dir/99-omarchy-nopasswd-alice ]] || fail "non-boot tmpfiles run shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[0]}" +! find "$sudoers_dir" -name '99-omarchy-nopasswd-*' -print -quit | /usr/bin/grep -q . || + fail "boot cleanup left a generated passwordless grant" +[[ -e $sudoers_dir/omarchy-dns ]] || fail "boot cleanup removed an unrelated sudoers rule" +/usr/bin/grep -Fx 'sudo /usr/bin/omarchy-sudo-passwordless __cleanup-all' "$migration_path" >/dev/null +! /usr/bin/grep -q 'omarchy-sudo-passwordless-cleanup.service' "$migration_path" || + fail "migration retained a custom boot service instead of package-owned tmpfiles" +pass "package-owned boot cleanup is narrow, boot-only, and migration-safe" -systemd-tmpfiles --root="$fake_root" --remove --inline "${tmpfiles_rules[@]}" -[[ -f $sudoers_dir/99-omarchy-nopasswd-alice ]] || - fail "boot-only cleanup leaves a live grant alone outside boot" - -systemd-tmpfiles --root="$fake_root" --remove --boot --inline "${tmpfiles_rules[@]}" -for grant_name in "${grant_names[@]}"; do - stale_grant="$sudoers_dir/99-omarchy-nopasswd-$grant_name" - [[ ! -e $stale_grant ]] || fail "boot cleanup removes every generated grant" "$stale_grant" +# Removing the settings package also removes the tmpfiles rule. Its package +# lifecycle must therefore revoke the same owned namespace synchronously, while +# preserving every unrelated sudoers file. +pkgs_candidates=( + "${OMARCHY_PKGS_PATH:-}" + "$ROOT/../omarchy-pkgs" + "$ROOT/../../omarchy-pkgs" + "$HOME/Work/omarchy/omarchy-pkgs" + "$HOME/Work/omacom/omarchy-pkgs" +) +pkgs_root="" +for candidate in "${pkgs_candidates[@]}"; do + if [[ -n $candidate && -d $candidate/pkgbuilds/omarchy-settings ]]; then + pkgs_root=$candidate + break + fi done -[[ -f $sudoers_dir/omarchy-dns ]] || fail "boot cleanup preserves unrelated sudoers rules" -pass "systemd-tmpfiles removes generated grants only during boot" +[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" + +for package_name in omarchy-settings omarchy-settings-dev; do + install_script="$pkgs_root/pkgbuilds/$package_name/$package_name.install" + transformed_install="$test_tmp/$package_name.install" + removal_root="$test_tmp/$package_name-remove" + removal_sudoers="$removal_root/etc/sudoers.d" + mkdir -p "$removal_sudoers" + : >"$removal_sudoers/99-omarchy-nopasswd-1000" + : >"$removal_sudoers/99-omarchy-nopasswd-legacy-user" + : >"$removal_sudoers/omarchy-dns" + ln -s ../usr/share/omarchy/etc-overrides/os-release "$removal_root/etc/os-release" + grep -Fq 'ln -s ../usr/share/omarchy/etc-overrides/os-release /etc/os-release' "$install_script" || + fail "$package_name installation does not select package-owned OS metadata" + sed "s#/etc/#$removal_root/etc/#g" "$install_script" >"$transformed_install" + ( + source "$transformed_install" + post_remove + ) || fail "$package_name removal revokes active passwordless grants" + ! find "$removal_sudoers" -name '99-omarchy-nopasswd-*' -print -quit | grep -q . || + fail "$package_name removal leaves a passwordless grant behind" + [[ -e $removal_sudoers/omarchy-dns ]] || + fail "$package_name removal deletes an unrelated sudoers policy" + [[ -L $removal_root/etc/os-release ]] && + [[ $(readlink "$removal_root/etc/os-release") == ../usr/lib/os-release ]] || + fail "$package_name removal does not restore the standard OS selector" + + ln -sfn ../administrator/os-release "$removal_root/etc/os-release" + : >"$removal_sudoers/99-omarchy-nopasswd-1001" + ( + source "$transformed_install" + post_remove + ) || fail "$package_name removal handles administrator OS selector state" + [[ $(readlink "$removal_root/etc/os-release") == ../administrator/os-release ]] || + fail "$package_name removal overwrites an administrator OS selector" + [[ ! -e $removal_sudoers/99-omarchy-nopasswd-1001 ]] || + fail "$package_name removal grant cleanup depends on OS selector state" +done +pass "settings package removal revokes grants and preserves package-selector ownership" + +# Exercise the production flock wrapper under contention. mkdir is an atomic +# overlap detector; all workers must enter and leave the protected region. +lock_dir="$test_tmp/lock-runtime" +mkdir "$lock_dir" +lock_lib="$test_tmp/lock-lib.sh" +function_prefix | + sed -e "s#/run/omarchy/sudo-passwordless#$lock_dir#g" \ + -e "s#/run/lock/omarchy-sudo-passwordless.lock#$test_tmp/passwordless.lock#g" \ + -e 's#/usr/bin/chown root:root "$LOCK_FILE"#/usr/bin/true#' >"$lock_lib" +worker="$test_tmp/worker.sh" +cat >"$worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$LOCK_LIB" +prepare_root_state() { :; } +critical() { + mkdir "$LOCK_SENTINEL" + sleep 0.03 + rmdir "$LOCK_SENTINEL" + printf x >>"$LOCK_RESULTS" +} +with_root_lock critical +WORKER +chmod +x "$worker" +for _ in {1..8}; do + LOCK_LIB="$lock_lib" LOCK_SENTINEL="$test_tmp/held" LOCK_RESULTS="$test_tmp/results" bash "$worker" & +done +wait +[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize" +pass "passwordless sudo serializes concurrent operations" + +# Same-boot expiry calls the fixed installed cleanup command, and cleanup +# removes policy before touching a timer so timer failures cannot extend it. +grep -F '"$INSTALLED_SELF" __expire "$uid"' "$command_path" >/dev/null +cleanup_body=$(awk '/^cleanup_uid_locked\(\) \{/ { in_body=1 } in_body { print } in_body && /^}/ { exit }' "$command_path") +rm_line=$(grep -n '/usr/bin/rm -f' <<<"$cleanup_body" | head -1 | cut -d: -f1) +stop_line=$(grep -n 'stop_timer' <<<"$cleanup_body" | tail -1 | cut -d: -f1) +((rm_line < stop_line)) || fail "expiry removes sudo policy before timer cleanup" +pass "same-boot expiration is fixed-target and fail closed" From 2f816a83af81e6fcbe4196860c958b99880e6050 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Tue, 1 Sep 2026 21:35:15 +0100 Subject: [PATCH 03/29] Document privileged Bash startup exception --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index e4a0084e..6de19cb9 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 From 35b318ed097792a57d74afc8d2ee5624f348c3b5 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sun, 6 Sep 2026 22:26:06 +0100 Subject: [PATCH 04/29] Complete command-scoped authentication across update phases --- AGENTS.md | 2 +- bin/omarchy-channel-set | 5 +- bin/omarchy-refresh-pacman | 261 +---- bin/omarchy-security-functions | 91 ++ bin/omarchy-update | 227 +--- bin/omarchy-update-aur-pkgs | 9 +- bin/omarchy-update-restart | 87 +- bin/omarchy-update-stay-awake | 73 +- default/omarchy/sudo-no-update/sudo | 30 +- docs/update-process.md | 18 +- test/shell.d/channel-test.sh | 14 +- test/shell.d/fixtures/sudo-boundary-test.sh | 124 +++ test/shell.d/update-hook-security-test.sh | 1113 ++----------------- test/shell.d/update-lock-test.sh | 71 +- test/shell.d/update-restart-phases-test.sh | 39 + test/shell.d/update-sequence-test.sh | 62 +- 16 files changed, 583 insertions(+), 1643 deletions(-) create mode 100644 bin/omarchy-security-functions create mode 100644 test/shell.d/fixtures/sudo-boundary-test.sh mode change 100644 => 100755 test/shell.d/update-hook-security-test.sh create mode 100755 test/shell.d/update-restart-phases-test.sh diff --git a/AGENTS.md b/AGENTS.md index e4a0084e..a25ea7a1 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 entrypoint may use `#!/bin/bash -p` when it must suppress inherited startup code before its first command; document the boundary and test rejection of ordinary Bash with a decoy `-p` argument. - Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs # Command Naming diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 7e112388..b711da93 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -83,7 +83,7 @@ if [[ -n $dev_checkout ]]; then omarchy-state set reboot-required fi -omarchy-refresh-pacman "$pacman_channel" +omarchy-refresh-pacman "$pacman_channel" defer-hook # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}" @@ -97,3 +97,6 @@ if [[ -z $dev_checkout ]]; then fi omarchy-update -y + +# No channel-owned privileged work follows the historical refresh hook. +omarchy-refresh-pacman "$pacman_channel" run-deferred diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 612a31de..af552abb 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -4,242 +4,43 @@ # omarchy:requires-sudo=true if [[ $- != *p* ]]; then - echo "Refusing an unsafe Bash startup for pacman refresh." >&2 + echo "Refusing an unsafe Bash startup." >&2 exit 126 fi -require_privileged_bash_startup() { - [[ $- == *p* ]] || return 1 - /usr/bin/env -i /usr/bin/bash -p -c ' - [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 - 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 "$$" -} -if ! require_privileged_bash_startup; then - echo "Refusing an unsafe Bash startup for pacman refresh." >&2 - exit 126 -fi -unset -f require_privileged_bash_startup - +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo -sanitize_bash_startup_environment() { - local environment_entry environment_name - local needs_reexec=0 - local -a environment_unsets=(-u BASH_ENV -u ENV) - - [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 - while IFS= read -r -d '' environment_entry; do - environment_name="${environment_entry%%=*}" - if [[ $environment_name == BASH_FUNC_*%% ]]; then - environment_unsets+=(-u "$environment_name") - needs_reexec=1 - fi - done < <(/usr/bin/env -0) - - if (( needs_reexec )); then - exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" - fi -} -sanitize_bash_startup_environment "$@" -unset -f sanitize_bash_startup_environment - -usage() { - echo "Usage: omarchy-refresh-pacman [stable|rc|edge]" >&2 -} - -# Composite commands can postpone the legacy user hook until their own final -# privilege boundary. The two internal modes are deliberately paired: a caller -# that defers must invoke --run-deferred-hook exactly once after all of its -# sudo-capable work has finished. -channel=stable -hook_mode=normal -case "$#:$1:${2:-}" in - 0::) - ;; - 1:stable: | 1:rc: | 1:edge:) - channel="$1" - ;; - 1:--run-deferred-hook:) - hook_mode=run-deferred - ;; - 2:stable:--defer-hook | 2:rc:--defer-hook | 2:edge:--defer-hook) - channel="$1" - hook_mode=defer - ;; - *) - usage - exit 2 - ;; -esac - -trusted_directory_chain() { - local current="$1" allow_current_user="$2" canonical owner mode current_uid - current_uid=$(/usr/bin/id -u) || return 1 - - while :; do - [[ -d $current && ! -L $current ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$current") || return 1 - [[ $canonical == "$current" ]] || return 1 - [[ $current == / ]] && break - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 - if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then - return 1 - fi - (( (8#$mode & 0022) == 0 )) || return 1 - current=${current%/*} - [[ -n $current ]] || current=/ - done -} - -trusted_omarchy_source_root() { - local config=/etc/omarchy.conf default_root=/usr/share/omarchy configured_root="" canonical="" - local owner="" mode="" links="" size="" line="" encoded="" decoded="" character="" - local index=0 escaped=0 lines=() - - if [[ ! -e $config && ! -L $config ]]; then - configured_root="$default_root" - else - [[ -f $config && ! -L $config ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$config") || return 1 - [[ $canonical == "$config" ]] || return 1 - read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 - [[ $owner == "0" && $links == "1" ]] || return 1 - (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 - trusted_directory_chain /etc false || return 1 - mapfile -t lines <"$config" || return 1 - (( ${#lines[@]} == 1 )) || return 1 - line="${lines[0]}" - [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 - encoded="${line#'export OMARCHY_PATH="'}" - encoded="${encoded%'"'}" - for (( index = 0; index < ${#encoded}; index++ )); do - character="${encoded:index:1}" - if (( escaped )); then - case "$character" in - '\' | '"' | '$' | '`') decoded+="$character" ;; - *) return 1 ;; - esac - escaped=0 - elif [[ $character == '\' ]]; then - escaped=1 - elif [[ $character == '"' ]]; then - return 1 - else - decoded+="$character" - fi - done - (( escaped == 0 )) || return 1 - configured_root="$decoded" - fi - - [[ -d $configured_root && ! -L $configured_root ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 - [[ $canonical == "$configured_root" ]] || return 1 - if [[ $configured_root == "$default_root" ]]; then - trusted_directory_chain "$configured_root" false || return 1 - else - trusted_directory_chain "$configured_root" true || return 1 - fi - printf '%s\n' "$configured_root" -} - -trusted_omarchy_source_file() { - local relative="$1" source="$OMARCHY_PATH/$1" canonical owner mode links directory current_uid - current_uid=$(/usr/bin/id -u) || return 1 - [[ $relative != /* && $relative != ../* && $relative != */../* && $relative != */.. ]] || return 1 - [[ -f $source && ! -L $source ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$source") || return 1 - [[ $canonical == "$source" && $canonical == "$OMARCHY_PATH/"* ]] || return 1 - read -r owner mode links < <(/usr/bin/stat -Lc '%u %a %h' -- "$source") || return 1 - [[ $links == 1 ]] && (( (8#$mode & 0022) == 0 )) || return 1 - if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then - [[ $owner == 0 ]] || return 1 - else - [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 - fi - - directory=${source%/*} - while [[ $directory == "$OMARCHY_PATH" || $directory == "$OMARCHY_PATH/"* ]]; do - [[ -d $directory && ! -L $directory ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$directory") || return 1 - [[ $canonical == "$directory" ]] || return 1 - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$directory") || return 1 - (( (8#$mode & 0022) == 0 )) || return 1 - if [[ $OMARCHY_PATH == /usr/share/omarchy ]]; then - [[ $owner == 0 ]] || return 1 - else - [[ $owner == 0 || $owner == "$current_uid" ]] || return 1 - fi - [[ $directory == "$OMARCHY_PATH" ]] && break - directory=${directory%/*} - done - printf '%s\n' "$source" -} - -if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then - echo "Refusing to refresh pacman from an untrusted Omarchy source root." >&2 - exit 1 +channel="${1:-stable}" +hook_mode="${2:-normal}" +if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then + echo "Invalid channel: $channel" >&2 + exit 2 fi -export OMARCHY_PATH -user_path="${PATH:-/usr/bin:/bin}" -PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" -export PATH - -as_root() { - if ((EUID == 0)); then - "$@" - elif [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then - /usr/bin/sudo -N -- "$@" - else - /usr/bin/sudo -- "$@" - fi -} - -cleanup_sudo_credentials() { - /usr/bin/sudo -k || true -} - -trap cleanup_sudo_credentials EXIT - -if [[ $hook_mode == "run-deferred" ]]; then - /usr/bin/sudo -k || exit 1 - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman - exit +if [[ $hook_mode != "normal" && $hook_mode != "defer-hook" && $hook_mode != "run-deferred" ]]; then + echo "Invalid refresh hook mode: $hook_mode" >&2 + exit 2 fi -pacman_source=$(trusted_omarchy_source_file "default/pacman/pacman-$channel.conf") || { - echo "Refusing an untrusted pacman configuration source." >&2 - exit 1 -} -mirror_source=$(trusted_omarchy_source_file "default/pacman/mirrorlist-$channel") || { - echo "Refusing an untrusted pacman mirror source." >&2 - exit 1 -} - -as_root /usr/bin/cp -f -- /etc/pacman.conf /etc/pacman.conf.bak -as_root /usr/bin/cp -f -- /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak - -echo "Setting channel to $channel" -echo - -# The unprivileged shell opens the authorized source. Root consumes only the -# inherited descriptor, never a caller-writable development-checkout pathname. -as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.conf <"$pacman_source" -as_root /usr/bin/install -T -o root -g root -m 0644 /dev/stdin /etc/pacman.d/mirrorlist <"$mirror_source" - -# Reset all package DBs and then update. -as_root /usr/bin/env OMARCHY_UPDATE_PACMAN=1 /usr/bin/pacman -Syyuu --noconfirm - -# This legacy hook used to run before pacman. Executable user code cannot -# safely precede a later sudo authentication: a child can wait for the new -# timestamp even if the parent invalidates around the hook. Keep the hook, but -# run it only after every privileged refresh step and with a cold credential. -if [[ $hook_mode == "normal" ]]; then - /usr/bin/sudo -k || exit 1 - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman +if [[ $hook_mode != "run-deferred" ]]; then + sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak + sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak + echo "Setting channel to $channel" + sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf + sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist + sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +fi + +# Keep the historical hook name, but finish every privileged refresh operation +# before running user code. Callers with later root work can defer the hook. +if [[ $hook_mode != "defer-hook" ]]; then + omarchy_security_revoke_sudo_timestamp + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ + "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman fi diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions new file mode 100644 index 00000000..5c9bd9e4 --- /dev/null +++ b/bin/omarchy-security-functions @@ -0,0 +1,91 @@ +#!/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_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 + trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "Could not invalidate cached sudo authorization." >&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() { + trap 'omarchy_security_exit_with_revoked_sudo "$?"' 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 +} diff --git a/bin/omarchy-update b/bin/omarchy-update index 4b0b1cc8..a3cc94a9 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -6,210 +6,27 @@ # omarchy:requires-sudo=true if [[ $- != *p* ]]; then - echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 + echo "Refusing an unsafe Bash startup." >&2 exit 126 fi -require_privileged_bash_startup() { - [[ $- == *p* ]] || return 1 - /usr/bin/env -i /usr/bin/bash -p -c ' - [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 - 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 "$$" -} -if ! require_privileged_bash_startup; then - echo "Refusing an unsafe Bash startup for the Omarchy update." >&2 - exit 126 -fi -unset -f require_privileged_bash_startup - +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo -# Privileged mode prevents BASH_ENV and exported functions from running before -# this boundary. Re-exec once without their raw environment records so ordinary -# Bash helpers cannot import them again and bypass the trusted command paths. -sanitize_bash_startup_environment() { - local environment_entry environment_name - local needs_reexec=0 - local -a environment_unsets=(-u BASH_ENV -u ENV) - - [[ -z ${BASH_ENV+x} && -z ${ENV+x} ]] || needs_reexec=1 - while IFS= read -r -d '' environment_entry; do - environment_name="${environment_entry%%=*}" - if [[ $environment_name == BASH_FUNC_*%% ]]; then - environment_unsets+=(-u "$environment_name") - needs_reexec=1 - fi - done < <(/usr/bin/env -0) - - if (( needs_reexec )); then - exec /usr/bin/env "${environment_unsets[@]}" /usr/bin/bash -p "$0" "$@" - fi -} -sanitize_bash_startup_environment "$@" -unset -f sanitize_bash_startup_environment - -trusted_directory_chain() { - local current="$1" allow_current_user="$2" canonical owner mode current_uid - current_uid=$(/usr/bin/id -u) || return 1 - - while :; do - [[ -d $current && ! -L $current ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$current") || return 1 - [[ $canonical == "$current" ]] || return 1 - [[ $current == / ]] && break - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 - if [[ $owner != 0 ]] && ! { [[ $allow_current_user == "true" && $owner == "$current_uid" ]]; }; then - return 1 - fi - (( (8#$mode & 0022) == 0 )) || return 1 - current=${current%/*} - [[ -n $current ]] || current=/ - done -} - -trusted_omarchy_source_root() { - local config=/etc/omarchy.conf - local default_root=/usr/share/omarchy - local configured_root="" - local canonical="" - local owner="" - local mode="" - local links="" - local size="" - local line="" - local encoded="" - local decoded="" - local character="" - local index=0 - local escaped=0 - local lines=() - - if [[ ! -e $config && ! -L $config ]]; then - configured_root="$default_root" - else - [[ -f $config && ! -L $config ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$config") || return 1 - [[ $canonical == "$config" ]] || return 1 - read -r owner mode links size < <(/usr/bin/stat -Lc '%u %a %h %s' -- "$config") || return 1 - [[ $owner == "0" && $links == "1" ]] || return 1 - (( (8#$mode & 0022) == 0 && size > 0 && size <= 4096 )) || return 1 - trusted_directory_chain /etc false || return 1 - - mapfile -t lines <"$config" || return 1 - (( ${#lines[@]} == 1 )) || return 1 - line="${lines[0]}" - [[ $line == 'export OMARCHY_PATH="'*'"' ]] || return 1 - encoded="${line#'export OMARCHY_PATH="'}" - encoded="${encoded%'"'}" - - for (( index = 0; index < ${#encoded}; index++ )); do - character="${encoded:index:1}" - if (( escaped )); then - case "$character" in - '\' | '"' | '$' | '`') decoded+="$character" ;; - *) return 1 ;; - esac - escaped=0 - elif [[ $character == '\' ]]; then - escaped=1 - elif [[ $character == '"' ]]; then - return 1 - else - decoded+="$character" - fi - done - (( escaped == 0 )) || return 1 - configured_root="$decoded" - fi - - [[ -d $configured_root && ! -L $configured_root ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$configured_root") || return 1 - [[ $canonical == "$configured_root" ]] || return 1 - - if [[ $configured_root == "$default_root" ]]; then - trusted_directory_chain "$configured_root" false || return 1 - else - trusted_directory_chain "$configured_root" true || return 1 - fi - - printf '%s\n' "$configured_root" -} - -sudo_supports_no_update() { - LC_ALL=C /usr/bin/sudo -h 2>&1 | /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' -} - -validate_non_reusable_sudo() { - local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" - local wrapper="$wrapper_dir/sudo" canonical="" current="" owner="" mode="" - - sudo_supports_no_update || { - echo "This sudo does not support --no-update; refusing to run a mixed-trust update." >&2 - return 1 - } - [[ -f $wrapper && -x $wrapper && ! -L $wrapper ]] || { - echo "Trusted no-update sudo wrapper is missing; refusing to run a mixed-trust update." >&2 - return 1 - } - canonical=$(/usr/bin/realpath -e -- "$wrapper") || return 1 - [[ $canonical == "$wrapper" ]] || return 1 - if [[ $OMARCHY_PATH == "/usr/share/omarchy" ]]; then - current="$wrapper" - while :; do - [[ ! -L $current ]] || return 1 - read -r owner mode < <(/usr/bin/stat -Lc '%u %a' -- "$current") || return 1 - [[ $owner == "0" ]] || return 1 - (( (8#$mode & 0022) == 0 )) || return 1 - [[ $current == "$OMARCHY_PATH" ]] && break - current=${current%/*} - done - fi -} - -enable_non_reusable_sudo() { - local wrapper_dir="$OMARCHY_PATH/default/omarchy/sudo-no-update" - - validate_non_reusable_sudo - PATH="$wrapper_dir:$PATH" - export PATH -} - -if ! OMARCHY_PATH=$(trusted_omarchy_source_root); then - echo "Refusing to update from an untrusted Omarchy source root." >&2 - exit 1 -fi -export OMARCHY_PATH -user_path="${PATH:-/usr/bin:/bin}" -PATH="$OMARCHY_PATH/bin:/usr/bin:/usr/sbin:/bin:/sbin" -export PATH update_stay_awake_stopped=0 - -# Verify and enable the security primitive before any update-owned privileged -# work. Every authorization in this workflow is command-scoped (`sudo -N`): it -# may prompt for the command being run, but it never publishes a reusable -# timestamp to a dev hook, migration tool, AUR build, or detached child. -validate_non_reusable_sudo || exit 1 -if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]]; then - enable_non_reusable_sudo -fi -/usr/bin/sudo -k || exit 1 -enable_non_reusable_sudo -export OMARCHY_SUDO_NO_UPDATE=1 - cleanup_update() { local status=$? - - trap - EXIT + trap - EXIT HUP INT TERM if (( update_stay_awake_stopped == 0 )); then - omarchy-update-stay-awake stop || true + omarchy-update-stay-awake stop || status=1 fi - /usr/bin/sudo -k || true - exit "$status" + omarchy_security_exit_with_revoked_sudo "$status" } if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then @@ -223,6 +40,7 @@ fi trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR trap cleanup_update EXIT +omarchy_security_install_signal_exit_traps omarchy-update-requires-free-space @@ -243,9 +61,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake start - # A dev link explicitly authorizes its checkout through root-owned system - # configuration (including sudo's secure_path), so preserve the established - # pull-before-packages/migrations ordering for that trusted mode. + # Preserve the established development-checkout update ordering. omarchy-update-dev omarchy-update-keyring @@ -258,7 +74,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # tooling with privileged repairs. The no-update sudo wrapper has covered the # whole update, so neither the package transaction nor a later repair can # publish a timestamp to a detached migration child. - /usr/bin/sudo -k + omarchy_security_revoke_sudo_timestamp omarchy-migrate omarchy-update-orphan-pkgs @@ -275,19 +91,18 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then omarchy-update-stay-awake stop update_stay_awake_stopped=1 - # AUR installation can refresh sudo after running package build code. No - # privileged update stage may follow it: user-controlled code can outlive - # its parent and wait for a later timestamp even if we invalidate in between. + # AUR package installation must also use the no-update wrapper. Finish + # update-owned system work before build code, hooks, or mise can run. omarchy-update-aur-pkgs - /usr/bin/sudo -k + omarchy_security_revoke_sudo_timestamp # Hooks and mise execute user-controlled code. Give each a cold credential # boundary and run mise last so it cannot wait for a legitimate hook sudo. # Only the unprivileged reboot prompt follows them. - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update - /usr/bin/sudo -k - PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" - /usr/bin/sudo -k + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-hook" post-update + omarchy_security_revoke_sudo_timestamp + PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" + omarchy_security_revoke_sudo_timestamp "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only fi diff --git a/bin/omarchy-update-aur-pkgs b/bin/omarchy-update-aur-pkgs index 4f496b33..8cdfb617 100755 --- a/bin/omarchy-update-aur-pkgs +++ b/bin/omarchy-update-aur-pkgs @@ -2,10 +2,17 @@ # omarchy:summary=Update AUR packages if any are installed +sudo_options=() +if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == "1" ]]; then + sudo_wrapper="$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" + [[ -x $sudo_wrapper ]] || exit 1 + sudo_options=(--sudo "$sudo_wrapper" --nosudoloop) +fi + if pacman -Qem >/dev/null; then if omarchy-pkg-aur-accessible; then echo -e "\e[32m\nUpdate AUR packages\e[0m" - yay -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs + yay "${sudo_options[@]}" -Sua --noconfirm --cleanafter --ignore gcc14,gcc14-libs || exit 1 echo else echo -e "\e[31m\nAUR is unavailable (so skipping updates)\e[0m" diff --git a/bin/omarchy-update-restart b/bin/omarchy-update-restart index 05f7ba05..393d95e0 100755 --- a/bin/omarchy-update-restart +++ b/bin/omarchy-update-restart @@ -1,51 +1,66 @@ #!/bin/bash # omarchy:summary=Prompt for required reboot or service restarts after updates +# omarchy:args=[--services-only|--reboot-only] + +mode="${1:-all}" +case "$mode" in + all|--services-only|--reboot-only) ;; + *) echo "Unknown restart phase: $mode" >&2; exit 2 ;; +esac echo - confirm_reboot() { - gum confirm "$1" && { omarchy-system-reboot; exit 0; } + if [[ ${OMARCHY_UPDATE_UNATTENDED:-0} == "1" ]]; then + echo "$1 Run omarchy-system-reboot when ready." + elif gum confirm "$1"; then + omarchy-system-reboot + exit 0 + fi } -running_kernel=$(uname -r) -kernel_updated=true +if [[ $mode != "--services-only" ]]; then + running_kernel=$(uname -r) + kernel_updated=true -for kernel in /usr/lib/modules/*/vmlinuz; do - if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then - installed_kernel=$(basename "$(dirname "$kernel")") + for kernel in /usr/lib/modules/*/vmlinuz; do + if [[ -f $kernel ]] && pacman -Qo "$kernel" &>/dev/null; then + installed_kernel=$(basename "$(dirname "$kernel")") - if [[ $installed_kernel == $running_kernel ]]; then - kernel_updated=false - break + if [[ $installed_kernel == $running_kernel ]]; then + kernel_updated=false + break + fi fi - fi -done + done -if [[ $kernel_updated == "true" ]]; then - confirm_reboot "Linux kernel has been updated. Reboot?" -elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then - confirm_reboot "Updates require reboot. Ready?" + if [[ $kernel_updated == "true" ]]; then + confirm_reboot "Linux kernel has been updated. Reboot?" + elif [[ -f $HOME/.local/state/omarchy/reboot-required ]]; then + confirm_reboot "Updates require reboot. Ready?" + fi + + running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null) + if [[ $running_hyprland == *"(deleted)"* ]]; then + confirm_reboot "Hyprland has been updated. Reboot?" + fi fi -running_hyprland=$(readlink /proc/$(pgrep -x Hyprland)/exe 2>/dev/null) -if [[ $running_hyprland == *"(deleted)"* ]]; then - confirm_reboot "Hyprland has been updated. Reboot?" +if [[ $mode != "--reboot-only" ]]; then + for file in "$HOME"/.local/state/omarchy/restart-*-required; do + if [[ -f $file ]]; then + filename=$(basename "$file") + service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/') + echo "Restarting $service" + omarchy-state clear "$filename" + omarchy-restart-"$service" + fi + done + + # Updates routinely replace the shell's QML, and a stale process can lazy-load + # new files into old code. A restart failure (locked session, ssh, TTY) only + # prints its reason: the next update or login gets a fresh shell anyway. + echo -e "\e[32m\nRestarting shell\e[0m" + echo "All plugins have been reloaded" + omarchy-restart-shell || true fi - -for file in "$HOME"/.local/state/omarchy/restart-*-required; do - if [[ -f $file ]]; then - filename=$(basename "$file") - service=$(echo "$filename" | sed 's/restart-\(.*\)-required/\1/') - echo "Restarting $service" - omarchy-state clear "$filename" - omarchy-restart-"$service" - fi -done - -# Updates routinely replace the shell's QML, and a stale process can lazy-load -# new files into old code. A restart failure (locked session, ssh, TTY) only -# prints its reason: the next update or login gets a fresh shell anyway. -echo -e "\e[32m\nRestarting shell\e[0m" -echo "All plugins have been reloaded" -omarchy-restart-shell || true diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index 5fd9eb15..826fee5e 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -81,46 +81,55 @@ stop() { } start() { - local inhibit_pid="" - local inhibit_start_time="" - local inhibit_runner=() local idle_owner="$$:$RANDOM:$RANDOM" stop mkdir -p "$state_dir" - if omarchy-cmd-present systemd-inhibit; then - if (( EUID != 0 )); then - if [[ -t 0 ]]; then - sudo -v - inhibit_runner=(sudo) - else - inhibit_runner=(pkexec) - fi - fi + # sudo authenticates in the foreground, then backgrounds the inhibitor. + # The held command drops back to this user before publishing its PID, so stop + # can release the inhibitor without another privileged operation or ticket. + local hold_command=( + /usr/bin/systemd-inhibit --what=sleep:idle --who=omarchy-update + --why="Omarchy update in progress" --mode=block + /usr/bin/setpriv --reuid "$UID" --regid "$(id -g)" --clear-groups + /usr/bin/bash -p -c ' + read -r process_stat <"/proc/$$/stat" + process_stat=${process_stat##*) } + read -r -a fields <<< "$process_stat" + printf "%s %s\n" "$$" "${fields[19]}" >"$1" + exec /usr/bin/sleep infinity + ' omarchy-update-inhibitor "$inhibit_pid_file" + ) - if [[ -n ${OMARCHY_UPDATE_LOCK_FD:-} ]]; then - "${inhibit_runner[@]}" systemd-inhibit \ - --what=sleep:idle \ - --who=omarchy-update \ - --why="Omarchy update in progress" \ - --mode=block \ - sleep infinity >/dev/null 2>&1 {OMARCHY_UPDATE_LOCK_FD}>&- & - else - "${inhibit_runner[@]}" systemd-inhibit \ - --what=sleep:idle \ - --who=omarchy-update \ - --why="Omarchy update in progress" \ - --mode=block \ - sleep infinity >/dev/null 2>&1 & - fi - inhibit_pid=$! - inhibit_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - if [[ -n $inhibit_start_time ]]; then - printf '%s %s\n' "$inhibit_pid" "$inhibit_start_time" >"$inhibit_pid_file" - fi + if (( EUID == 0 )); then + ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec "${hold_command[@]}" ) & + elif [[ -t 0 ]]; then + sudo -N -b -- "${hold_command[@]}" + else + ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec pkexec "${hold_command[@]}" ) & fi + # For graphical authentication the launcher may wait for a password. Wait for + # either the user-owned held command to become ready or the launcher to fail. + local launcher_pid=${!:-} + local readiness_attempts=0 + while [[ ! -s $inhibit_pid_file ]]; do + if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then + wait "$launcher_pid" || return 1 + echo "The update sleep inhibitor did not start." >&2 + return 1 + fi + readiness_attempts=$((readiness_attempts + 1)) + if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then + echo "The update sleep inhibitor did not become ready." >&2 + return 1 + fi + sleep 0.05 + done + if [[ ! -f $stay_awake_state ]]; then printf '%s\n' "$idle_owner" >"$idle_owner_file" mkdir -p "$(dirname "$stay_awake_state")" diff --git a/default/omarchy/sudo-no-update/sudo b/default/omarchy/sudo-no-update/sudo index ca94c1e6..de69347b 100755 --- a/default/omarchy/sudo-no-update/sudo +++ b/default/omarchy/sudo-no-update/sudo @@ -1,27 +1,19 @@ #!/bin/bash -p -# Internal update/migration sudo boundary. Authentication may authorize this -# command, but -N prevents it from publishing a timestamp that detached user -# code can silently reuse. - +# Preserve sudo options while preventing authentication from refreshing the +# credential cache. Timestamp maintenance and informational modes stand alone. if [[ $- != *p* ]]; then echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 exit 126 fi +source "${BASH_SOURCE[0]%/*}/../../../bin/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 -require_privileged_bash_startup() { - [[ $- == *p* ]] || return 1 - /usr/bin/env -i /usr/bin/bash -p -c ' - [[ $1 =~ ^[1-9][0-9]*$ ]] || exit 1 - 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 "$$" -} -if ! require_privileged_bash_startup; then - echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 - exit 126 +if (( $# == 1 )); then + case "$1" in + -k|--reset-timestamp|-K|--remove-timestamp|-h|--help|-V|--version) + exec /usr/bin/sudo "$@" + ;; + esac fi -exec /usr/bin/sudo -N -- "$@" +exec /usr/bin/sudo -N "$@" diff --git a/docs/update-process.md b/docs/update-process.md index bb73d271..9ec3b96c 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -26,7 +26,7 @@ The design goal is: | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | | `~/.local/state/omarchy/reboot-required` | user | Optional reboot marker checked by `omarchy-update-restart`. | -| `~/.local/state/omarchy/restart-*-required` | user | Optional allowlisted service/app restart markers checked by `omarchy-update-restart`. Marker names select fixed commands from the system-authorized Omarchy tree; they are not resolved through caller `PATH`. The shell needs no marker: it is restarted unconditionally after every update. | +| `~/.local/state/omarchy/restart-*-required` | user | Optional service/app restart markers checked by `omarchy-update-restart`. The shell needs no marker: it is restarted unconditionally after every update. | ## Migration layout @@ -56,12 +56,7 @@ privileged work should invoke the appropriate helper or privilege prompt. Migrations must be idempotent; if one user already applied a machine-wide repair, the migration should no-op for other users. -The runner derives the migration source from root-owned `/etc/omarchy.conf`, -starts from a cold sudo timestamp, and routes migration sudo calls through -`sudo --no-update`. Historical migrations are strictly ordered and can mix -user-controlled tools or theme hooks with later privileged repairs; no-update -authentication lets those repairs run without publishing a credential a -detached earlier process could reuse. +When invoked by the update, migrations inherit its cold credential state and no-update sudo wrapper. The standalone migration runner has its own security changes in the migration-boundary PR; this update change does not establish that standalone boundary. Historical migrations remain strictly ordered. For watchers and diagnostics, `omarchy-migrate --pending` prints pending migration names and exits `0` when any are pending. When no migrations are @@ -149,9 +144,9 @@ omarchy-update Important behavior: -- `omarchy update` derives its source tree from root-owned `/etc/omarchy.conf`, or the root-owned `/usr/share/omarchy` default when that file is absent. It does not trust inherited `OMARCHY_PATH` or caller `PATH` for the privileged phase. +- `omarchy update` uses the session’s `OMARCHY_PATH` and a fixed command search path for its system phases. User PATH is restored behind the sudo wrapper for hooks and mise. - Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary. -- In dev-link mode, the root-owned configuration is the explicit authorization for the user-writable checkout. `omarchy update` fast-forwards that authorized checkout from its configured upstream before changing system packages or running migrations. +- In dev-link mode, `omarchy update` fast-forwards the active checkout from its configured upstream before changing system packages or running migrations. - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything. @@ -292,7 +287,8 @@ scripts. | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | -| `omarchy-update-system-pkgs` | Runs the ordinary guarded `pacman -Syu --noconfirm`. Package-vs-package conflicts may be retried interactively with `pacman -Su`; filesystem conflicts fail closed without moving, restoring, quarantining, or broadly overwriting live paths. The production Quattro transition performs the sole explicit settings-package takeover with `--overwrite='*'`; all later production and developer package transactions obey Pacman's ownership checks. | **Keep.** Small leaf command with no generic privileged conflict handler. | +| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | +| `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | | `omarchy-update-requires-free-space` | Aborts the update below a 10 GiB free-space threshold on `/`; silently skipped when free space cannot be determined; `OMARCHY_UPDATE_FORCE=1` bypasses. | **Keep internal/hidden.** | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | @@ -304,7 +300,7 @@ scripts. | `omarchy-update-mise` | Runs `MISE_MINIMUM_RELEASE_AGE=0 mise up` for mise-managed tools — the override of mise's release-age cooldown is the point. | **Keep.** Mise-managed tools are intentionally part of the blessed update path. | | `omarchy-update-orphan-pkgs` | Lists orphans and prompts before removal; noninteractive mode never removes. | **Keep for now.** Safe because it is prompt-only. | | `omarchy-update-analyze-logs` | Scans `/tmp/omarchy-update.log` for known failure patterns, currently initramfs generation. | **Keep/expand.** Useful safety net; should grow only for high-signal checks. | -| `omarchy-update-restart` | Restarts allowlisted components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | +| `omarchy-update-restart` | Restarts components selected by `restart-*-required` markers, always restarts the shell, and prompts for reboot after kernel/Hyprland updates. Internal phase flags let the update finish sudo-capable restarts before user hooks and defer only the unprivileged reboot prompt. | **Keep.** Important final step; may eventually include service-restart checks. | | `omarchy-update-firmware` | Manual firmware update command using fwupd. Not part of the normal update pipeline. | **Keep separate.** Firmware is not a routine system update step. | | `omarchy-update-time` | Restarts `systemd-timesyncd`. | **Question.** Not really an update command. Consider renaming/moving under system/time maintenance. | diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 664e17c5..a799e6e0 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -105,7 +105,7 @@ assert_log_line() { } run_channel stable -assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel" +assert_log_line $'refresh\tstable\tdefer-hook' "stable refreshes the stable pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path" @@ -115,13 +115,13 @@ fi pass "stable does not require reboot when already package-backed" run_channel rc -assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel" +assert_log_line $'refresh\trc\tdefer-hook' "rc refreshes the rc pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path" OMARCHY_TEST_PATH="$ROOT" run_channel edge -assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel" +assert_log_line $'refresh\tedge\tdefer-hook' "edge refreshes the edge pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev" @@ -137,7 +137,7 @@ if run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then fi grep -q "already exists and is not a git checkout" "$test_tmp/occupied.err" || fail "dev explains occupied checkout paths" "$(cat "$test_tmp/occupied.err")" -if grep -Fx $'refresh\tedge' "$log_file" >/dev/null; then +if grep -Fx $'refresh\tedge\tdefer-hook' "$log_file" >/dev/null; then fail "dev validates checkout path before changing packages" "$(cat "$log_file")" fi pass "dev refuses occupied non-checkout paths before package changes" @@ -145,15 +145,17 @@ pass "dev refuses occupied non-checkout paths before package changes" rmdir "$checkout" run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" -assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" +assert_log_line $'refresh\tedge\tdefer-hook' "dev refreshes the edge pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file" | sed '/run-deferred/d') == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\tdefer-hook\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" +[[ $(tail -1 "$log_file") == $'refresh\tedge\trun-deferred' ]] || fail "channel refresh hook must run after the complete update" +pass "channel changes defer the refresh hook until all update work finishes" OMARCHY_TEST_PATH="$checkout" run_channel stable assert_log_line $'unlink\t--no-reboot' "switching from dev to stable unlinks without an early reboot prompt" diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh new file mode 100644 index 00000000..cabad278 --- /dev/null +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# Test the real orchestration with fixed privileged paths redirected to harmless +# stand-ins. No host sudo, package transaction, namespace root, or exploit runs. +boundary_tmp=$(mktemp -d) +trap 'rm -rf "$boundary_tmp"' EXIT +export SUDO_TEST_ROOT="$boundary_tmp/omarchy" +export SUDO_TEST_LOG="$boundary_tmp/events" +export SUDO_TEST_CACHE="$boundary_tmp/cache" +export OMARCHY_PATH="$SUDO_TEST_ROOT" +export SUDO_TEST_HOME="$boundary_tmp/home" +mkdir -p "$SUDO_TEST_HOME" +mkdir -p "$SUDO_TEST_ROOT/bin" "$SUDO_TEST_ROOT/mock" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update" +: >"$SUDO_TEST_LOG" + +copy_boundary_file() { + python3 - "$ROOT" "$SUDO_TEST_ROOT" "$1" <<'PY' +import sys +from pathlib import Path +source, target, name = map(Path,sys.argv[1:]) +p=target/name +p.parent.mkdir(parents=True,exist_ok=True) +s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME') +for command in ['sudo','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']: + s=s.replace('/usr/bin/'+command, str(target/'mock'/command)) +s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"') +p.write_text(s) +p.chmod((source/name).stat().st_mode & 0o777) +PY +} + +copy_boundary_file bin/omarchy-security-functions +copy_boundary_file default/omarchy/sudo-no-update/sudo + +cat >"$SUDO_TEST_ROOT/mock/sudo" <<'STUB' +#!/bin/bash +set -euo pipefail +printf 'sudo' >>"$SUDO_TEST_LOG" +printf ' %q' "$@" >>"$SUDO_TEST_LOG" +printf '\n' >>"$SUDO_TEST_LOG" +if [[ ${1:-} == "-h" ]]; then + if [[ ${SUDO_TEST_UNSUPPORTED:-0} == "1" ]]; then + echo 'usage: sudo [-ABbEHknPS] command' + else + echo 'usage: sudo [-ABbEHkNnPS] command' + fi + exit 0 +fi +if [[ ${1:-} == "-k" || ${1:-} == "-K" ]]; then + [[ ${SUDO_TEST_REVOKE_FAIL:-0} != "1" ]] || exit 1 + rm -f "$SUDO_TEST_CACHE" + exit 0 +fi +if [[ ${1:-} == "-N" ]]; then + shift +else + touch "$SUDO_TEST_CACHE" +fi +[[ ${SUDO_TEST_SUDO_FAIL:-0} != "1" ]] || exit 1 +background=0 +while (( $# )); do + case "$1" in + -N|-n) shift ;; + -b) background=1; shift ;; + -v) exit 0 ;; + -u|--user) shift 2 ;; + --) shift; break ;; + *) break ;; + esac +done +(( $# )) || exit 0 +if (( background )); then + "$@" & +else + "$@" +fi +STUB +chmod +x "$SUDO_TEST_ROOT/mock/sudo" + +cat >"$SUDO_TEST_ROOT/bin/test-step" <<'STUB' +#!/bin/bash +set -euo pipefail +step=${0##*/} +printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG" +if [[ $step == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then + [[ ! -e $SUDO_TEST_CACHE ]] || exit 91 +fi +if [[ ${SUDO_TEST_FAIL_STEP:-} == "$step" ]]; then + # Model a misbehaving child leaving state behind, then failing. Cleanup must + # still revoke it. This never invokes real sudo or exercises a privilege flaw. + touch "$SUDO_TEST_CACHE" + exit 17 +fi +if [[ ${SUDO_TEST_SIGNAL_STEP:-} == "$step" ]]; then + touch "$SUDO_TEST_CACHE" + kill -TERM "$PPID" + exit 0 +fi +case "$step" in + omarchy-update-system-pkgs|omarchy-update-keyring|omarchy-snapshot) + sudo /usr/bin/true + ;; + pacman) exit 0 ;; + yay) + [[ $* == *"--sudo $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"* ]] || exit 92 + [[ $* == *"--nosudoloop"* ]] || exit 93 + ;; +esac +STUB +chmod +x "$SUDO_TEST_ROOT/bin/test-step" +for step in omarchy-update-lock omarchy-update-requires-free-space omarchy-update-confirm omarchy-update-pkg-prune omarchy-snapshot omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate omarchy-hook omarchy-update-aur-pkgs omarchy-update-mise omarchy-update-orphan-pkgs omarchy-update-analyze-logs omarchy-update-status omarchy-update-restart omarchy-pkg-aur-accessible omarchy-notification-dismiss pacman cp yay; do + ln -s test-step "$SUDO_TEST_ROOT/bin/$step" +done +ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" + +reset_boundary() { + : >"$SUDO_TEST_LOG" + rm -f "$SUDO_TEST_CACHE" + unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED +} +assert_boundary_cold() { + [[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization" + [[ $(tail -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "$1 did not revoke at exit" "$(<"$SUDO_TEST_LOG")" +} diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh old mode 100644 new mode 100755 index 6245205f..4e0922c3 --- a/test/shell.d/update-hook-security-test.sh +++ b/test/shell.d/update-hook-security-test.sh @@ -2,1039 +2,116 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-update +copy_boundary_file bin/omarchy-refresh-pacman +# Replace the step symlink, preserving the real fixture dispatcher. +rm "$SUDO_TEST_ROOT/bin/omarchy-update-aur-pkgs" +copy_boundary_file bin/omarchy-update-aur-pkgs +export OMARCHY_UPDATE_LOGGED=1 -# The setuid helper below models the strongest sudo timestamp mode: one token -# shared by every process for this uid. That makes a detached hook child a -# faithful regression for both global timestamps and the easier tty-sharing -# case. The helper is mounted over /usr/bin/sudo only in this private namespace -# so the scripts must use the fixed trusted invalidation path. -if [[ ${OMARCHY_UPDATE_HOOK_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 group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) - - if [[ -z $subuid || -z $subgid ]]; then - pass "no subordinate uid/gid range; skipping update-hook namespace proof" - exit 0 - fi - - exec 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" \ - env OMARCHY_UPDATE_HOOK_SECURITY_NS=1 bash "$0" -fi - -[[ $(id -u) == 0 ]] || fail "update-hook proof entered its root namespace" - -mount -t tmpfs -o mode=0755 tmpfs /run -run_bound=1 -test_tmp=$(mktemp -d -p /run omarchy-update-hook-security.XXXXXXXX) -mount -t tmpfs -o mode=0755 tmpfs "$test_tmp" -chmod 0755 "$test_tmp" -sudo_path_bound=0 -channel_paths_bound=0 -channel_wrapper_tree_bound=0 -aur_paths_bound=0 -font_paths_bound=0 -migration_pkg_paths_bound=0 -etc_bound=0 -persistent_pids=() -cleanup() { - local pid pid_file - for pid_file in "$test_home"/*.pid; do - if [[ -s $pid_file ]]; then - persistent_pids+=("$(<"$pid_file")") - fi - done - for pid in "${persistent_pids[@]}"; do - kill "$pid" 2>/dev/null || true - done - sleep 0.05 - for pid in "${persistent_pids[@]}"; do - kill -KILL "$pid" 2>/dev/null || true - done - if (( sudo_path_bound )); then - umount /usr/bin/sudo - fi - if (( channel_paths_bound )); then - umount /usr/bin/pacman - umount /usr/bin/omarchy-dev-unlink - umount /usr/bin/omarchy-state - umount /usr/bin/omarchy-refresh-pacman - umount /usr/bin/omarchy-update - fi - if (( channel_wrapper_tree_bound )); then - umount /usr/share/omarchy - fi - if (( aur_paths_bound )); then - umount /usr/bin/omarchy-pkg-aur-accessible - umount /usr/bin/yay - fi - if (( font_paths_bound )); then - umount /usr/bin/omarchy-launch-floating-terminal-with-presentation - umount /usr/bin/omarchy-pkg-add - umount /usr/bin/omarchy-font-set - fi - if (( migration_pkg_paths_bound )); then - umount /usr/bin/omarchy-pkg-missing - umount /usr/bin/omarchy-pkg-add - fi - if (( etc_bound )); then - umount /etc - fi - rm -rf "$test_tmp"/* - umount "$test_tmp" - rmdir "$test_tmp" - if (( run_bound )); then - umount /run - fi -} -trap cleanup EXIT - -stub_bin="$test_tmp/bin" -test_home="$test_tmp/home" -root_dir="$test_tmp/root" -token="$test_tmp/sudo-token" -event_log="$test_tmp/events" -hook_log="$test_home/hook-events" -mkdir -p "$stub_bin" "$test_home/.config/omarchy/hooks" "$root_dir" -mkdir -p "$test_tmp/default/omarchy/sudo-no-update" -cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$test_tmp/default/omarchy/sudo-no-update/sudo" -chmod 0755 "$test_tmp/default/omarchy/sudo-no-update/sudo" -mkdir -p "$test_tmp/default/pacman" -cp "$ROOT/default/pacman"/* "$test_tmp/default/pacman/" -chmod 0755 "$test_tmp/default" "$test_tmp/default/omarchy" \ - "$test_tmp/default/omarchy/sudo-no-update" "$test_tmp/default/pacman" -chmod 0644 "$test_tmp/default/pacman"/* -touch "$event_log" "$hook_log" -chown -R 1000:1000 "$test_home" -chown 1000:1000 "$event_log" -chmod 0700 "$test_home" -chmod 0600 "$event_log" "$hook_log" -chmod 0755 "$stub_bin" "$root_dir" - -cat >"$test_tmp/sudo.c" <<'C' -#include -#include -#include -#include -#include -#include -#include - -static const char *required_env(const char *name) { - const char *value = getenv(name); - if (!value || !*value) exit(125); - return value; +run_update() { + "$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$boundary_tmp/output" 2>&1 } -static void log_event(const char *event) { - int fd = open(required_env("TEST_SUDO_EVENT_LOG"), O_WRONLY | O_APPEND); - if (fd < 0) exit(125); - if (dprintf(fd, "%s\n", event) < 0) exit(125); - close(fd); -} - -static int authenticate(void) { - int fd = open(required_env("TEST_SUDO_TOKEN"), O_WRONLY | O_CREAT | O_TRUNC, 0600); - if (fd < 0) return 125; - close(fd); - return 0; -} - -static int token_valid(void) { - struct stat st; - return stat(required_env("TEST_SUDO_TOKEN"), &st) == 0 && st.st_uid == 0; -} - -static int is_pacman_command(int argc, char **argv) { - int index; - for (index = 1; index < argc; index++) { - const char *base = strrchr(argv[index], '/'); - base = base ? base + 1 : argv[index]; - if (strcmp(base, "pacman") == 0) return 1; - } - return 0; -} - -int main(int argc, char **argv) { - int no_update = 0; - if (argc == 2 && strcmp(argv[1], "-h") == 0) { - const char *disable = getenv("TEST_SUDO_NO_N"); - if (disable && strcmp(disable, "1") == 0) { - fputs("usage: sudo [-ABbEHknPS] command\n", stdout); - } else { - fputs("usage: sudo [-ABbEHkNnPS] command\n", stdout); - } - return 0; - } - if (argc > 1 && strcmp(argv[1], "-N") == 0) { - no_update = 1; - argc--; - argv++; - } - if (argc > 1 && strcmp(argv[1], "--") == 0) { - argc--; - argv++; - } - if (argc == 2 && strcmp(argv[1], "--authenticate-for-test") == 0) { - if (no_update) { - log_event("authenticate-no-update"); - return 0; - } else { - log_event("authenticate"); - return authenticate(); - } - } - if (argc == 2 && strcmp(argv[1], "-k") == 0) { - log_event("invalidate"); - if (unlink(required_env("TEST_SUDO_TOKEN")) < 0 && errno != ENOENT) return 125; - return 0; - } - if (!token_valid() && !no_update) { - if (is_pacman_command(argc, argv)) { - log_event("authenticate-command"); - if (authenticate() != 0) return 125; - } else { - log_event("deny"); - fputs("sudo: a password is required\n", stderr); - return 1; - } - } - log_event(no_update ? "grant-no-update" : "grant"); - 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 -sudo_path_bound=1 -mount --bind "$ROOT/bin/omarchy-refresh-pacman" /usr/bin/omarchy-refresh-pacman -mount --bind "$ROOT/bin/omarchy-update" /usr/bin/omarchy-update -mount -t tmpfs -o mode=0755 tmpfs /usr/share/omarchy -mkdir -p /usr/share/omarchy/default/omarchy/sudo-no-update -cp "$ROOT/default/omarchy/sudo-no-update/sudo" /usr/share/omarchy/default/omarchy/sudo-no-update/sudo -chmod 0755 /usr/share/omarchy/default /usr/share/omarchy/default/omarchy \ - /usr/share/omarchy/default/omarchy/sudo-no-update -chmod 0755 /usr/share/omarchy/default/omarchy/sudo-no-update/sudo -channel_wrapper_tree_bound=1 - -# Authorize this private tree exactly as omarchy-dev-link authorizes a -# development checkout. Hiding the host /etc keeps the proof self-contained. -mount -t tmpfs -o mode=0755 tmpfs /etc -etc_bound=1 -mkdir -p /etc/pacman.d -touch /etc/pacman.conf /etc/pacman.d/mirrorlist -chmod 0644 /etc/pacman.conf /etc/pacman.d/mirrorlist -printf 'root:x:0:0:root:/root:/bin/bash\n' >/etc/passwd -printf 'root:x:0:\n' >/etc/group -chmod 0644 /etc/passwd /etc/group -write_authorized_source_root() { - local source_root="$1" - local quoted="$source_root" - - quoted=${quoted//\\/\\\\} - quoted=${quoted//\"/\\\"} - quoted=${quoted//\$/\\\$} - quoted=${quoted//\`/\\\`} - printf 'export OMARCHY_PATH="%s"\n' "$quoted" >/etc/omarchy.conf - chown 0:0 /etc/omarchy.conf - chmod 0644 /etc/omarchy.conf -} -write_authorized_source_root "$test_tmp" - -cat >"$test_home/launch-persistent-attack" <<'ATTACK' -#!/bin/bash -/usr/bin/setsid --fork /bin/bash -c ' - printf "%s\n" "$$" >"$3" - for (( attempt = 0; attempt < 200; attempt++ )); do - if /usr/bin/sudo /usr/bin/install -o 0 -g 0 -m 0600 "$1" "$2" 2>/dev/null; then - exit 0 - fi - /usr/bin/sleep 0.01 - done -' omarchy-hook-child "$HOME/payload" "$1" "$2" -ATTACK -cat >"$test_home/payload" <<'PAYLOAD' -RUN+="/tmp/update-hook-payload" -PAYLOAD -chown 1000:1000 "$test_home/launch-persistent-attack" "$test_home/payload" -chmod 0700 "$test_home/launch-persistent-attack" -chmod 0600 "$test_home/payload" - -cat >"$stub_bin/omarchy-update-lock" <<'STUB' -#!/bin/bash -[[ ${1:-} == held ]] -STUB -cat >"$stub_bin/omarchy-update-system-pkgs" <<'STUB' -#!/bin/bash -[[ ${TEST_SKIP_UPDATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test -STUB -cat >"$stub_bin/omarchy-update-orphan-pkgs" <<'STUB' -#!/bin/bash -[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test -STUB -cat >"$stub_bin/omarchy-update-aur-pkgs" <<'STUB' -#!/bin/bash -[[ ${TEST_SKIP_LATE_AUTH:-0} == 1 ]] || sudo --authenticate-for-test -STUB -cat >"$stub_bin/omarchy-update-mise" <<'STUB' -#!/bin/bash -sudo /usr/bin/true 2>/dev/null && exit 97 -"$HOME/launch-persistent-attack" "$TEST_MISE_VICTIM" "$TEST_MISE_PID" -STUB -cat >"$stub_bin/omarchy-update-restart" <<'STUB' -#!/bin/bash -printf 'restart:%s\n' "$1" >>"$TEST_HOOK_LOG" -if [[ $1 == --services-only && ${TEST_SKIP_LATE_AUTH:-0} != 1 ]]; then - sudo --authenticate-for-test -fi -STUB -cat >"$stub_bin/omarchy-update-confirm" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/omarchy-migrate" <<'STUB' -#!/bin/bash -if [[ ${TEST_REAL_MIGRATE:-0} == 1 ]]; then - exec "$TEST_ROOT/bin/omarchy-migrate" -fi -if [[ ${TEST_FAILING_STAGE:-} == signal ]]; then - kill -TERM "$PPID" - sleep 0.1 -fi -[[ ${TEST_FAILING_STAGE:-} != migration ]] -STUB -cat >"$stub_bin/omarchy-hook" <<'STUB' -#!/bin/bash -exec bash "$TEST_ROOT/bin/omarchy-hook" "$@" -STUB -cat >"$stub_bin/omarchy-dev-unlink" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/omarchy-state" <<'STUB' -#!/bin/bash -exit 0 -STUB -for command in \ - omarchy-update-requires-free-space omarchy-update-pkg-prune omarchy-snapshot \ - omarchy-update-stay-awake omarchy-update-dev omarchy-update-keyring \ - omarchy-update-analyze-logs omarchy-update-status; do - cat >"$stub_bin/$command" <<'STUB' -#!/bin/bash -exit 0 -STUB +for args in '-y' ''; do + reset_boundary + touch "$SUDO_TEST_CACHE" + run_update $args || fail "update failed" "$(<"$boundary_tmp/output")" + assert_boundary_cold "successful update" + grep -q '^sudo -N /usr/bin/true$' "$SUDO_TEST_LOG" || fail "update package helpers must use no-update sudo" + python3 - "$SUDO_TEST_LOG" <<'PY' +import sys +s=open(sys.argv[1]).read().splitlines() +positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step:omarchy-update-stay-awake stop','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-restart --reboot-only']] +assert positions==sorted(positions), s +assert not any(line.startswith('sudo -N ') for line in s[positions[3]:]), s +PY + pass "update $args runs privileged phases before hooks and exits cold" done -cat >"$stub_bin/cp" <<'STUB' -#!/bin/bash -printf 'cp:%s\n' "$*" >>"$TEST_SUDO_EVENT_LOG" -exit 0 -STUB -cat >"$stub_bin/pacman" <<'STUB' -#!/bin/bash -[[ -z ${TEST_PACMAN_DELAY:-} ]] || /usr/bin/sleep "$TEST_PACMAN_DELAY" -exit "${TEST_PACMAN_STATUS:-0}" -STUB -chmod 0755 "$stub_bin"/* -chmod 4755 "$stub_bin/sudo" -mount --bind "$stub_bin/omarchy-dev-unlink" /usr/bin/omarchy-dev-unlink -mount --bind "$stub_bin/omarchy-state" /usr/bin/omarchy-state -mount --bind "$stub_bin/pacman" /usr/bin/pacman -channel_paths_bound=1 -evil_update_bin="$test_home/evil-update-bin" -evil_update_root="$test_home/evil-update-root" -evil_update_marker="$test_home/evil-update-ran" -mkdir -p "$evil_update_bin" "$evil_update_root/migrations" -for command in script omarchy-migrate omarchy-update-system-pkgs; do - cat >"$evil_update_bin/$command" <<'STUB' -#!/bin/bash -touch "$TEST_EVIL_UPDATE_MARKER" -exit 97 -STUB +for step in omarchy-update-system-pkgs yay omarchy-hook omarchy-update-mise; do + reset_boundary + export SUDO_TEST_FAIL_STEP=$step + if run_update -y; then fail "$step failure must fail the update"; fi + assert_boundary_cold "failed $step" + pass "update revokes credentials after $step fails" done -cat >"$evil_update_bin/omarchy-dev-unlink" <<'STUB' -#!/bin/bash -touch "$TEST_EVIL_CHANNEL_HELPER_MARKER" -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_CHANNEL_VICTIM" -STUB -cat >"$evil_update_root/migrations/9999999999.sh" <<'STUB' -#!/bin/bash -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_EVIL_MIGRATION_VICTIM" -touch "$TEST_EVIL_UPDATE_MARKER" -STUB -chown -R 1000:1000 "$evil_update_bin" "$evil_update_root" -chmod 0755 "$evil_update_bin"/* "$evil_update_root/migrations/9999999999.sh" -write_attack_hook() { - local hook_name="$1" +reset_boundary +export SUDO_TEST_SIGNAL_STEP=omarchy-hook +if run_update -y; then fail "interrupted update must fail"; fi +assert_boundary_cold "interrupted update" +pass "update revokes credentials on TERM" - mkdir -p "$test_home/.config/omarchy/hooks/$hook_name.d" - cat >"$test_home/.config/omarchy/hooks/$hook_name" <<'HOOK' -#!/bin/bash -printf 'file:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_VICTIM" 2>/dev/null || true -"$HOME/launch-persistent-attack" "$TEST_PERSISTENT_VICTIM" "$TEST_PERSISTENT_PID" -HOOK - cat >"$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" <<'HOOK' -#!/bin/bash -printf 'directory:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" -sudo /usr/bin/install -o 0 -g 0 -m 0600 "$HOME/payload" "$TEST_ROOT_DIR_VICTIM" 2>/dev/null || true -HOOK - chown -R 1000:1000 "$test_home/.config/omarchy/hooks/$hook_name" \ - "$test_home/.config/omarchy/hooks/$hook_name.d" - chmod 0700 "$test_home/.config/omarchy/hooks/$hook_name" \ - "$test_home/.config/omarchy/hooks/$hook_name.d/10-attack" -} +reset_boundary +export SUDO_TEST_REVOKE_FAIL=1 +if run_update -y; then fail "failed initial revocation must fail the update"; fi +if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "failed revocation must precede update work"; fi +pass "a failed cold start prevents update work" -reset_case() { - local pid_file - for pid_file in "$test_home"/*.pid; do - if [[ -s $pid_file ]]; then - persistent_pids+=("$(<"$pid_file")") - kill "$(<"$pid_file")" 2>/dev/null || true - fi - done - rm -f "$test_home"/*.pid "$token" "$root_dir"/* - : >"$event_log" - setpriv --reuid 1000 --regid 1000 --clear-groups /usr/bin/truncate -s 0 "$hook_log" -} +reset_boundary +export SUDO_TEST_UNSUPPORTED=1 +if run_update -y; then fail "unsupported sudo must prevent mixed-trust work"; fi +assert_boundary_cold "unsupported sudo" +pass "unsupported sudo fails without running update steps" -run_as_user() { - setpriv --reuid 1000 --regid 1000 --clear-groups \ - env HOME="$test_home" PATH="$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$ROOT" \ - TEST_ROOT="$ROOT" TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ - TEST_HOOK_LOG="$hook_log" "$@" -} +for mode in normal defer-hook run-deferred; do + reset_boundary + "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable "$mode" >"$boundary_tmp/output" 2>&1 || fail "refresh $mode failed" "$(<"$boundary_tmp/output")" + assert_boundary_cold "refresh $mode" + python3 - "$SUDO_TEST_LOG" "$mode" <<'PY' +import sys +s=open(sys.argv[1]).read().splitlines();mode=sys.argv[2] +hooks=[i for i,l in enumerate(s) if l.startswith('step:omarchy-hook')] +priv=[i for i,l in enumerate(s) if l.startswith('sudo -N ')] +assert bool(hooks)==(mode!='defer-hook'), s +assert bool(priv)==(mode!='run-deferred'), s +if hooks: assert not any(i>hooks[0] for i in priv),s +PY + pass "refresh $mode preserves the final cold hook boundary" +done -authenticate_for_test() { - TEST_SUDO_TOKEN="$token" TEST_SUDO_EVENT_LOG="$event_log" \ - /usr/bin/sudo --authenticate-for-test -} +for step in pacman omarchy-hook; do + reset_boundary + export SUDO_TEST_FAIL_STEP=$step + if "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1; then fail "refresh must propagate $step failure"; fi + assert_boundary_cold "failed refresh $step" + pass "refresh revokes after $step failure" +done -wait_for_persistent_attempts() { - local pid_file="$1" - local pid="" - for (( attempt = 0; attempt < 100; attempt++ )); do - [[ -s $pid_file ]] && break - sleep 0.01 - done - [[ -s $pid_file ]] || fail "detached hook child did not start" - pid=$(<"$pid_file") - persistent_pids+=("$pid") - sleep 0.2 -} - -assert_hook_sandboxed() { - local direct_victim="$1" - local directory_victim="$2" - local persistent_victim="$3" - - [[ ! -e $direct_victim && ! -e $directory_victim && ! -e $persistent_victim ]] || - fail "hook code reused an Omarchy sudo credential" - grep -qxF 'file:1000' "$hook_log" || fail "the regular hook did not run as the desktop user" - grep -qxF 'directory:1000' "$hook_log" || fail "the hook-directory entry did not run as the desktop user" - [[ ! -e $token ]] || fail "the workflow left its modeled sudo credential live" -} - -write_attack_hook post-update -update_victim="$root_dir/80-update-hook.rules" -update_dir_victim="$root_dir/81-update-hook-dir.rules" -update_persistent_victim="$root_dir/82-update-hook-child.rules" -mise_victim="$root_dir/83-mise-child.rules" -reset_case -set +e -run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ - TEST_EVIL_UPDATE_MARKER="$evil_update_marker" \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/update.out" 2>"$test_tmp/update.err" -status=$? -set -e -(( status == 0 )) || fail "isolated unattended update failed" "$(<"$test_tmp/update.err")" -wait_for_persistent_attempts "$test_home/update.pid" -wait_for_persistent_attempts "$test_home/mise.pid" -assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" -[[ ! -e $mise_victim ]] || fail "detached mise code observed a later update authorization" -[[ ! -e $evil_update_marker ]] || fail "update trusted an inherited PATH or OMARCHY_PATH override" -grep -qxF 'restart:--services-only' "$hook_log" || fail "privileged restart phase did not run before user code" -grep -qxF 'restart:--reboot-only' "$hook_log" || fail "reboot-only phase did not run after user code" -pass "update leaves no later sudo authentication for mise or persistent hook children" - -# OM-SEC-14 ends at the final cold hook boundary. Later sections exercise -# separate migration, restart-marker, channel, and installer findings in their -# own PRs. -exit 0 - -# Exercise the real migration dispatcher separately from PATH spoofing. An old -# update inherited the evil root here and ran its migration with the live -# system-package credential; the fixed update exports the authorized root. -evil_migration_victim="$root_dir/90-evil-migration.rules" -reset_case -set +e -run_as_user env OMARCHY_PATH="$evil_update_root" TEST_REAL_MIGRATE=1 \ - TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_MIGRATION_STATE="$test_home/migration-state" OMARCHY_UPDATE_LOGGED=1 \ - "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-root-spoof.out" 2>"$test_tmp/update-root-spoof.err" -status=$? -set -e -(( status == 0 )) || fail "authorized-root update with real migration dispatcher failed" -[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || - fail "update dispatched a migration from inherited OMARCHY_PATH" -pass "update rejects inherited OMARCHY_PATH for real migration dispatch" - -# The login-notification workflow invokes omarchy-migrate directly, outside an -# update that already normalized OMARCHY_PATH. It must independently reject an -# inherited attacker tree rather than running the migration placed there. -reset_case -set +e -run_as_user env OMARCHY_PATH="$evil_update_root" \ - TEST_EVIL_UPDATE_MARKER="$evil_update_marker" TEST_EVIL_MIGRATION_VICTIM="$evil_migration_victim" \ - OMARCHY_MIGRATION_STATE="$test_home/direct-migration-state" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/direct-migrate.out" 2>"$test_tmp/direct-migrate.err" -status=$? -set -e -(( status == 0 )) || fail "direct authorized migration dispatch failed" -[[ ! -e $evil_update_marker && ! -e $evil_migration_victim ]] || - fail "direct migration dispatch trusted inherited OMARCHY_PATH" -pass "direct migration dispatch derives its source from root-owned configuration" - -# Reproduce the real historical ordering that exposed the remaining gap: the -# mise-wrapper migration can execute user tooling at 1784909971, while the -# 1784914435 migration invokes sudo later. A following migration also invokes -# the real absolute-path package helper, which cannot be protected by PATH -# alone. Every later authorization must inherit the exported no-update policy. -mkdir -p "$test_tmp/migrations" "$test_home/.local/bin" -chmod 0755 "$test_tmp/migrations" -cp "$ROOT/migrations/1784909971.sh" "$test_tmp/migrations/1784909971.sh" -cp "$ROOT/migrations/1784914435.sh" "$test_tmp/migrations/1784914435.sh" -cat >"$test_tmp/migrations/1784914436.sh" <<'STUB' -#!/bin/bash -/usr/bin/omarchy-pkg-add migration-security-fixture -STUB -chmod 0644 "$test_tmp/migrations/1784909971.sh" "$test_tmp/migrations/1784914435.sh" \ - "$test_tmp/migrations/1784914436.sh" -cat >"$test_home/.local/bin/legacy-mise-wrapper" <<'STUB' -#!/bin/bash -mise use -g "github:attacker/tool" -exec "attacker-tool" "$@" -STUB -cat >"$stub_bin/omarchy-mise-install" <<'STUB' -#!/bin/bash -"$HOME/launch-persistent-attack" "$TEST_MIGRATION_VICTIM" "$TEST_MIGRATION_PID" -STUB -cat >"$stub_bin/nmcli" <<'STUB' -#!/bin/bash -printf '%s\n' "$(id -u)" >"$TEST_PRIV_MIGRATION_MARKER" -STUB -cat >"$stub_bin/omarchy-notification-dismiss" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$stub_bin/omarchy-mise-install" "$stub_bin/nmcli" \ - "$stub_bin/omarchy-notification-dismiss" "$test_home/.local/bin/legacy-mise-wrapper" -chown -R 1000:1000 "$test_home/.local" -cat >"$stub_bin/omarchy-pkg-missing" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$stub_bin/omarchy-pkg-missing" -mount --bind "$ROOT/bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add -mount --bind "$stub_bin/omarchy-pkg-missing" /usr/bin/omarchy-pkg-missing -migration_pkg_paths_bound=1 -migration_victim="$root_dir/95-migration-child.rules" -migration_marker="$root_dir/96-privileged-migration-ran" -reset_case -set +e -run_as_user env OMARCHY_PATH="$evil_update_root" \ - TEST_MIGRATION_VICTIM="$migration_victim" TEST_MIGRATION_PID="$test_home/migration.pid" \ - TEST_PRIV_MIGRATION_MARKER="$migration_marker" TEST_PACMAN_DELAY=0.2 \ - OMARCHY_MIGRATION_STATE="$test_home/mixed-migration-state" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/mixed-migrate.out" 2>"$test_tmp/mixed-migrate.err" -status=$? -set -e -(( status == 0 )) || fail "real mixed-trust migration sequence failed" "$(<"$test_tmp/mixed-migrate.err")" -wait_for_persistent_attempts "$test_home/migration.pid" -[[ -f $migration_marker && $(<"$migration_marker") == 0 ]] || fail "later privileged migration did not run as root" -[[ ! -e $migration_victim && ! -e $token ]] || fail "mise migration child reused a later migration authorization" -(( $(grep -c '^grant-no-update$' "$event_log") >= 2 )) || - fail "direct package-helper migration did not inherit the no-update policy" -pass "real mise-before-sudo and absolute package-helper migrations publish no reusable timestamp" -umount /usr/bin/omarchy-pkg-missing -umount /usr/bin/omarchy-pkg-add -migration_pkg_paths_bound=0 - -# Bash can import both BASH_ENV startup code and exported functions before an -# ordinary script body. An exported sudo function used to bypass the PATH -# wrapper in a later helper, publishing a global token to a BASH_ENV child that -# had started before migration invalidation. Exercise both injection channels -# through a normal-shebang child of the privileged migration shell. -cat >"$test_tmp/migrations/9999999998.sh" <<'STUB' -#!/bin/bash -"$HOME/launch-persistent-attack" "$TEST_BASH_STARTUP_VICTIM" "$TEST_BASH_STARTUP_PID" -omarchy-exported-function-auth -STUB -cat >"$stub_bin/omarchy-exported-function-auth" <<'STUB' -#!/bin/bash -sudo --authenticate-for-test -STUB -cat >"$test_home/bash-env-attack" <<'STUB' -#!/bin/bash -if [[ ! -e $TEST_BASH_ENV_MARKER ]]; then - /usr/bin/touch "$TEST_BASH_ENV_MARKER" - "$HOME/launch-persistent-attack" "$TEST_BASH_ENV_VICTIM" "$TEST_BASH_ENV_PID" -fi -STUB -chmod 0755 "$test_tmp/migrations/9999999998.sh" "$stub_bin/omarchy-exported-function-auth" -chown 1000:1000 "$test_home/bash-env-attack" -chmod 0600 "$test_home/bash-env-attack" -bash_startup_victim="$root_dir/99-exported-function-child.rules" -bash_env_victim="$root_dir/100-bash-env-child.rules" -bash_env_marker="$test_home/bash-env-ran" -reset_case -set +e -run_as_user env \ - 'BASH_FUNC_sudo%%=() { /usr/bin/sudo "$@"; }' \ - TEST_MULTILINE_ENV=$'value\nBASH_FUNC_fake%%=not-an-environment-record' \ - BASH_ENV="$test_home/bash-env-attack" \ - TEST_BASH_ENV_MARKER="$bash_env_marker" \ - TEST_BASH_ENV_VICTIM="$bash_env_victim" TEST_BASH_ENV_PID="$test_home/bash-env.pid" \ - TEST_BASH_STARTUP_VICTIM="$bash_startup_victim" TEST_BASH_STARTUP_PID="$test_home/bash-startup.pid" \ - OMARCHY_MIGRATION_STATE="$test_home/bash-startup-migration-state" \ - "$ROOT/bin/omarchy-migrate" >"$test_tmp/bash-startup.out" 2>"$test_tmp/bash-startup.err" -status=$? -set -e -(( status == 0 )) || fail "migration rejected a sanitized Bash startup environment" "$(<"$test_tmp/bash-startup.err")" -wait_for_persistent_attempts "$test_home/bash-startup.pid" -sleep 0.2 -[[ ! -e $bash_env_marker && ! -e $bash_env_victim ]] || - fail "BASH_ENV ran before or beneath the migration security boundary" -[[ ! -e $bash_startup_victim && ! -e $token ]] || - fail "an exported sudo function published a reusable migration credential" -grep -q '^grant-no-update$' "$event_log" || fail "sanitized helper did not use sudo --no-update" -grep -qxF 'exec /usr/bin/sudo -N -- "$@"' "$ROOT/default/omarchy/sudo-no-update/sudo" || - fail "no-update sudo wrapper omitted the option terminator" -pass "Bash startup injection cannot bypass the no-update sudo boundary" - -# Invoking a mixed-trust entrypoint with an ordinary explicit Bash bypasses its -# shebang. A BASH_ENV can erase its own environment record and retain a DEBUG -# trap, so environment-record cleanup alone is not a sufficient startup gate. -# The exact interpreter argv/privileged-mode check must reject this process -# before update authentication, leaving even its already-detached child cold. -cat >"$test_home/self-erasing-bash-env" <<'STUB' -#!/bin/bash -unset BASH_ENV ENV -trap ' - if [[ ! -e $TEST_DEBUG_TRAP_MARKER ]]; then - /usr/bin/touch "$TEST_DEBUG_TRAP_MARKER" - "$HOME/launch-persistent-attack" "$TEST_DEBUG_TRAP_VICTIM" "$TEST_DEBUG_TRAP_PID" - fi -' DEBUG -STUB -chown 1000:1000 "$test_home/self-erasing-bash-env" -chmod 0600 "$test_home/self-erasing-bash-env" -debug_trap_marker="$test_home/debug-trap-ran" -debug_trap_victim="$root_dir/101-debug-trap-child.rules" -reset_case -set +e -run_as_user env BASH_ENV="$test_home/self-erasing-bash-env" \ - TEST_DEBUG_TRAP_MARKER="$debug_trap_marker" TEST_DEBUG_TRAP_VICTIM="$debug_trap_victim" \ - TEST_DEBUG_TRAP_PID="$test_home/debug-trap.pid" OMARCHY_UPDATE_LOGGED=1 \ - /usr/bin/bash "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/unsafe-bash.out" 2>"$test_tmp/unsafe-bash.err" -status=$? -set -e -(( status == 126 )) || fail "update did not reject an unsafe explicit Bash interpreter" -[[ -e $debug_trap_marker ]] || fail "self-erasing BASH_ENV regression did not install its DEBUG trap" -wait_for_persistent_attempts "$test_home/debug-trap.pid" -[[ ! -e $debug_trap_victim && ! -e $token ]] || - fail "unsafe Bash startup reached update authentication" -! grep -qE '^(authenticate|authenticate-command|grant|grant-no-update)$' "$event_log" || - fail "unsafe Bash startup reached privileged update work" -grep -q 'unsafe Bash startup' "$test_tmp/unsafe-bash.err" || - fail "unsafe Bash startup rejection lacked a diagnostic" -pass "self-erasing BASH_ENV and DEBUG traps cannot cross the interpreter gate" - -# Model a hostile yay configuration that selects absolute /usr/bin/sudo and a -# refresh loop. The real AUR helper must override both on its command line, so -# even a migration child already polling the global token sees no credential. -cat >"$stub_bin/omarchy-pkg-aur-accessible" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/yay" <<'STUB' -#!/bin/bash -sudo_command=/usr/bin/sudo -sudoflags="" -sudoloop=true -while (($#)); do - case "$1" in - --sudo) - sudo_command="$2" - shift 2 - ;; - --sudoloop=false) - sudoloop=false - shift - ;; - --sudoflags=-N) - sudoflags=-N - shift - ;; - *) - shift - ;; +# The wrapper must preserve sudo's own option parser, including validation and +# explicit --, while standalone timestamp maintenance cannot be combined with N. +for args in '-v' '-n /usr/bin/true' '--user test -- /usr/bin/true' '-- /usr/bin/true' '-k' '-K'; do + reset_boundary + "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" $args + case "$args" in + -k|-K) expected="sudo $args" ;; + *) expected="sudo -N $args" ;; esac + [[ $(<"$SUDO_TEST_LOG") == "$expected" ]] || fail "wrapper changed options: $args" "$(<"$SUDO_TEST_LOG")" + [[ ! -e $SUDO_TEST_CACHE ]] || fail "wrapper refreshed credentials" + pass "sudo wrapper preserves $args" done -printf 'sudo=%s sudoflags=%s sudoloop=%s\n' "$sudo_command" "$sudoflags" "$sudoloop" >"$TEST_YAY_LOG" -"$sudo_command" $sudoflags --authenticate-for-test -STUB -chmod 0755 "$stub_bin/omarchy-pkg-aur-accessible" "$stub_bin/yay" -mount --bind "$stub_bin/omarchy-pkg-aur-accessible" /usr/bin/omarchy-pkg-aur-accessible -mount --bind "$stub_bin/yay" /usr/bin/yay -aur_paths_bound=1 -yay_victim="$root_dir/97-yay-override-child.rules" -reset_case -run_as_user env TEST_YAY_LOG="$test_home/yay.log" \ - "$test_home/launch-persistent-attack" "$yay_victim" "$test_home/yay-child.pid" -wait_for_persistent_attempts "$test_home/yay-child.pid" -run_as_user env OMARCHY_PATH="$test_tmp" OMARCHY_SUDO_NO_UPDATE=1 TEST_YAY_LOG="$test_home/yay.log" \ - "$ROOT/bin/omarchy-update-aur-pkgs" >"$test_tmp/yay.out" 2>"$test_tmp/yay.err" -sleep 0.2 -grep -qxF "sudo=/usr/bin/sudo sudoflags=-N sudoloop=false" "$test_home/yay.log" || - fail "AUR update did not override hostile yay sudo settings" -[[ ! -e $yay_victim && ! -e $token ]] || fail "hostile yay sudo configuration published a reusable timestamp" -pass "AUR updates force no-update sudo and disable yay's credential loop" -# A pre-existing credential and skipped package paths exercise the interactive -# branch independently of authority acquired by update helpers. -reset_case -authenticate_for_test -set +e -run_as_user env TEST_SKIP_UPDATE_AUTH=1 TEST_SKIP_LATE_AUTH=1 \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" \ - >"$test_tmp/update-interactive.out" 2>"$test_tmp/update-interactive.err" -status=$? -set -e -(( status == 0 )) || fail "isolated interactive update failed" -wait_for_persistent_attempts "$test_home/update.pid" -assert_hook_sandboxed "$update_victim" "$update_dir_victim" "$update_persistent_victim" -pass "interactive update invalidates a pre-existing credential before user code" - -for failing_stage in migration signal; do - reset_case - set +e - run_as_user env TEST_FAILING_STAGE="$failing_stage" TEST_SKIP_LATE_AUTH=1 \ - TEST_ROOT_VICTIM="$update_victim" TEST_ROOT_DIR_VICTIM="$update_dir_victim" \ - TEST_PERSISTENT_VICTIM="$update_persistent_victim" TEST_PERSISTENT_PID="$test_home/update.pid" \ - TEST_MISE_VICTIM="$mise_victim" TEST_MISE_PID="$test_home/mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/update-$failing_stage.out" 2>"$test_tmp/update-$failing_stage.err" - status=$? - set -e - (( status != 0 )) || fail "update $failing_stage case unexpectedly succeeded" - [[ ! -e $token ]] || fail "update $failing_stage exit left its credential live" - [[ ! -s $hook_log ]] || fail "update $failing_stage case reached user-controlled stages" +for script in bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do + reset_boundary + if /usr/bin/bash "$SUDO_TEST_ROOT/$script" -p >"$boundary_tmp/output" 2>&1; then fail "$script accepted an ordinary Bash launch"; fi + [[ ! -s $SUDO_TEST_LOG ]] || fail "$script reached sudo through an invalid interpreter" + pass "$script rejects a decoy privileged-mode argument" done -pass "failed and signaled updates invalidate credentials before user code" -reset_case -set +e -run_as_user env TEST_SUDO_NO_N=1 OMARCHY_UPDATE_LOGGED=1 \ - "$ROOT/bin/omarchy-update" -y >"$test_tmp/no-update-unsupported.out" 2>"$test_tmp/no-update-unsupported.err" -status=$? -set -e -(( status != 0 )) || fail "update accepted sudo without --no-update support" -! grep -qE '^(authenticate|grant)' "$event_log" || fail "unsupported sudo reached privileged update work" -grep -q 'does not support --no-update' "$test_tmp/no-update-unsupported.err" || - fail "unsupported sudo failure did not explain the missing security primitive" -pass "update fails closed before privileged work when sudo lacks --no-update" +reset_boundary +printf '%s\n' 'printf startup-ran >>"$SUDO_TEST_ROOT/startup-marker"' >"$boundary_tmp/startup" +BASH_ENV="$boundary_tmp/startup" ENV="$boundary_tmp/startup" run_update -y || fail "sanitized update failed" "$(<"$boundary_tmp/output")" +[[ ! -e $SUDO_TEST_ROOT/startup-marker ]] || fail "startup code leaked into an update helper" +pass "inherited startup files do not run in the updater or its child scripts" -# The config parser is the authority for all three scoped commands. Exercise a -# different unsafe shape through each copy before restoring the valid config. -chmod 0666 /etc/omarchy.conf -if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/untrusted-update.out" 2>"$test_tmp/untrusted-update.err"; then - fail "update accepted a writable source-root authorization" -fi - -/usr/bin/mv /etc/omarchy.conf /etc/omarchy.real -/usr/bin/ln -s /etc/omarchy.real /etc/omarchy.conf -if run_as_user "$ROOT/bin/omarchy-refresh-pacman" stable \ - >"$test_tmp/untrusted-refresh.out" 2>"$test_tmp/untrusted-refresh.err"; then - fail "pacman refresh accepted a symlinked source-root authorization" -fi -/usr/bin/rm /etc/omarchy.conf -/usr/bin/mv /etc/omarchy.real /etc/omarchy.conf - -chown 1000:1000 /etc/omarchy.conf -if run_as_user "$ROOT/bin/omarchy-update-restart" --services-only \ - >"$test_tmp/untrusted-restart.out" 2>"$test_tmp/untrusted-restart.err"; then - fail "update restart accepted a non-root source-root authorization" -fi -write_authorized_source_root "$test_tmp" -pass "update commands reject writable, symlinked, and non-root source-root authorization" - -# A root-owned config may authorize a development checkout, but not a tree -# another local account (or every account) can rewrite. Exercise both unsafe -# directory-chain shapes before restoring the valid authorized fixture. -writable_source_root="$test_tmp/writable-source-root" -foreign_source_root="$test_tmp/foreign-source-root" -mkdir -p "$writable_source_root" "$foreign_source_root" -chown 1000:1000 "$writable_source_root" -chmod 0777 "$writable_source_root" -chown 1001:1001 "$foreign_source_root" -chmod 0755 "$foreign_source_root" - -write_authorized_source_root "$writable_source_root" -if run_as_user env OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-update" -y \ - >"$test_tmp/writable-source.out" 2>"$test_tmp/writable-source.err"; then - fail "update accepted a group/world-writable authorized source tree" -fi -grep -q 'untrusted Omarchy source root' "$test_tmp/writable-source.err" || - fail "writable source-root rejection happened after the trust parser" - -write_authorized_source_root "$foreign_source_root" -if run_as_user "$ROOT/bin/omarchy-migrate" --pending \ - >"$test_tmp/foreign-source.out" 2>"$test_tmp/foreign-source.err"; then - fail "migration runner accepted a foreign-owned authorized source tree" -fi -grep -q 'untrusted Omarchy source root' "$test_tmp/foreign-source.err" || - fail "foreign source-root rejection happened after the trust parser" -write_authorized_source_root "$test_tmp" -pass "authorized source roots reject foreign-owned and group/world-writable path components" - -# The restart marker is attacker-writable, but its value is now an allowlisted -# selector into the configured Omarchy tree rather than a command resolved by -# PATH. A dev-linked tree is honored only through root-owned /etc/omarchy.conf. -reset_case -restart_home="$test_tmp/restart-home" -restart_log="$test_tmp/restart.log" -mkdir -p "$restart_home/.local/state/omarchy" -touch "$restart_log" -chown -R 1000:1000 "$restart_home" -chown 1000:1000 "$restart_log" -touch "$restart_home/.local/state/omarchy/restart-btop-required" \ - "$restart_home/.local/state/omarchy/restart-evil-required" -chown 1000:1000 "$restart_home/.local/state/omarchy"/* -cat >"$stub_bin/omarchy-restart-btop" <<'STUB' -#!/bin/bash -echo trusted-btop >>"$TEST_RESTART_LOG" -STUB -cat >"$stub_bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash -echo trusted-shell >>"$TEST_RESTART_LOG" -STUB -evil_bin="$test_home/evil-bin" -mkdir -p "$evil_bin" -cat >"$evil_bin/omarchy-restart-btop" <<'STUB' -#!/bin/bash -echo path-btop >>"$TEST_RESTART_LOG" -STUB -cat >"$evil_bin/omarchy-restart-evil" <<'STUB' -#!/bin/bash -echo path-evil >>"$TEST_RESTART_LOG" -sudo /usr/bin/true -STUB -chown -R 1000:1000 "$evil_bin" -chmod 0755 "$stub_bin/omarchy-restart-btop" "$stub_bin/omarchy-restart-shell" "$evil_bin"/* -run_as_user env HOME="$restart_home" PATH="$evil_bin:$stub_bin:/usr/bin:/bin" \ - OMARCHY_PATH="$test_home/evil-root" TEST_RESTART_LOG="$restart_log" \ - "$ROOT/bin/omarchy-update-restart" --services-only >"$test_tmp/restart.out" 2>"$test_tmp/restart.err" -grep -qxF trusted-btop "$restart_log" || fail "allowed marker did not use the configured Omarchy command" -grep -qxF trusted-shell "$restart_log" || fail "shell restart did not use the configured Omarchy command" -! grep -q '^path-' "$restart_log" || fail "restart marker resolved an attacker PATH command" -[[ ! -e $restart_home/.local/state/omarchy/restart-evil-required ]] || fail "unsupported restart marker was retained" -pass "restart markers use an allowlist and fixed configured command paths" - -special_root="$test_tmp/dev root\\checkout\$cash" -special_home="$test_tmp/special-home" -special_log="$test_tmp/special.log" -mkdir -p "$special_root/bin" "$special_home/.local/state/omarchy" -touch "$special_home/.local/state/omarchy/restart-btop-required" "$special_log" -chown -R 1000:1000 "$special_home" "$special_log" -cat >"$special_root/bin/omarchy-restart-btop" <<'STUB' -#!/bin/bash -echo special-btop >>"$TEST_RESTART_LOG" -STUB -cat >"$special_root/bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$special_root/bin"/* -chown -R 1000:1000 "$special_root" -write_authorized_source_root "$special_root" -run_as_user env HOME="$special_home" TEST_RESTART_LOG="$special_log" \ - "$ROOT/bin/omarchy-update-restart" --services-only \ - >"$test_tmp/special-root.out" 2>"$test_tmp/special-root.err" -grep -qxF special-btop "$special_log" || fail "authorized quoted dev root did not dispatch its restart helper" -write_authorized_source_root "$test_tmp" -pass "source-root authorization decodes spaces, backslashes, and dollar signs" - -# The legacy pre-refresh hook now runs only after pacman; a detached child can -# no longer wait for a later authentication in either tty or global mode. -write_attack_hook pre-refresh-pacman -refresh_victim="$root_dir/84-refresh-hook.rules" -refresh_dir_victim="$root_dir/85-refresh-hook-dir.rules" -refresh_persistent_victim="$root_dir/86-refresh-hook-child.rules" -reset_case -authenticate_for_test -cat >"$evil_bin/cp" <<'STUB' -#!/bin/bash -touch "$TEST_EVIL_REFRESH_MARKER" -exit 97 -STUB -chmod 0755 "$evil_bin/cp" -evil_refresh_marker="$test_home/evil-refresh-ran" -set +e -run_as_user env PATH="$evil_bin:$stub_bin:/usr/bin:/bin" OMARCHY_PATH="$evil_update_root" \ - TEST_EVIL_REFRESH_MARKER="$evil_refresh_marker" \ - TEST_ROOT_VICTIM="$refresh_victim" TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" \ - TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" TEST_PERSISTENT_PID="$test_home/refresh.pid" \ - "$ROOT/bin/omarchy-refresh-pacman" stable >"$test_tmp/refresh.out" 2>"$test_tmp/refresh.err" -status=$? -set -e -(( status == 0 )) || fail "isolated pacman refresh failed" "$(<"$test_tmp/refresh.err")" -wait_for_persistent_attempts "$test_home/refresh.pid" -assert_hook_sandboxed "$refresh_victim" "$refresh_dir_victim" "$refresh_persistent_victim" -[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh resolved cp through caller PATH" -/usr/bin/cmp -s "$test_tmp/default/pacman/pacman-stable.conf" /etc/pacman.conf || - fail "pacman refresh did not copy config from the authorized source root" -[[ ! -e $evil_refresh_marker ]] || fail "pacman refresh copied from inherited OMARCHY_PATH or PATH" -pass "pacman refresh runs its legacy hook only after all privileged work" - -reset_case -authenticate_for_test -set +e -run_as_user env TEST_PACMAN_STATUS=1 TEST_ROOT_VICTIM="$refresh_victim" \ - TEST_ROOT_DIR_VICTIM="$refresh_dir_victim" TEST_PERSISTENT_VICTIM="$refresh_persistent_victim" \ - TEST_PERSISTENT_PID="$test_home/refresh.pid" "$ROOT/bin/omarchy-refresh-pacman" stable \ - >"$test_tmp/refresh-fail.out" 2>"$test_tmp/refresh-fail.err" -status=$? -set -e -(( status != 0 )) || fail "failing pacman refresh unexpectedly succeeded" -[[ ! -e $token ]] || fail "failed pacman refresh left its credential live" -[[ ! -s $hook_log ]] || fail "failed pacman transaction reached the refresh hook" -pass "failed pacman refresh invalidates and does not run its hook" - -# Channel switching is a composite refresh caller: after refreshing it -# authenticates for the package swap and runs the full update. Exercise the -# real channel, refresh, update, and hook commands against the global-token -# model. A detached legacy refresh-hook child must not start until that entire -# chain has finished. -cat >"$test_home/.config/omarchy/hooks/post-update" <<'HOOK' -#!/bin/bash -printf 'post-update:%s\n' "$(id -u)" >>"$TEST_HOOK_LOG" -HOOK -chown 1000:1000 "$test_home/.config/omarchy/hooks/post-update" -chmod 0700 "$test_home/.config/omarchy/hooks/post-update" -channel_victim="$root_dir/91-channel-refresh-hook.rules" -channel_dir_victim="$root_dir/92-channel-refresh-dir.rules" -channel_persistent_victim="$root_dir/93-channel-refresh-child.rules" -channel_mise_victim="$root_dir/94-channel-mise-child.rules" -reset_case -set +e -evil_channel_helper_marker="$test_home/evil-channel-helper-ran" -evil_channel_helper_victim="$root_dir/98-channel-path-helper.rules" -run_as_user env PATH="$evil_update_bin:$stub_bin:/usr/bin:/bin" \ - TEST_EVIL_CHANNEL_HELPER_MARKER="$evil_channel_helper_marker" \ - TEST_EVIL_CHANNEL_VICTIM="$evil_channel_helper_victim" \ - TEST_ROOT_VICTIM="$channel_victim" TEST_ROOT_DIR_VICTIM="$channel_dir_victim" \ - TEST_PERSISTENT_VICTIM="$channel_persistent_victim" TEST_PERSISTENT_PID="$test_home/channel.pid" \ - TEST_MISE_VICTIM="$channel_mise_victim" TEST_MISE_PID="$test_home/channel-mise.pid" \ - OMARCHY_UPDATE_LOGGED=1 "$ROOT/bin/omarchy-channel-set" stable \ - >"$test_tmp/channel.out" 2>"$test_tmp/channel.err" -status=$? -set -e -(( status == 0 )) || fail "isolated channel switch failed" "$(<"$test_tmp/channel.err")" -wait_for_persistent_attempts "$test_home/channel.pid" -wait_for_persistent_attempts "$test_home/channel-mise.pid" -assert_hook_sandboxed "$channel_victim" "$channel_dir_victim" "$channel_persistent_victim" -[[ ! -e $channel_mise_victim ]] || fail "channel mise child reused a later refresh-hook credential" -[[ ! -e $evil_channel_helper_marker && ! -e $evil_channel_helper_victim ]] || - fail "channel switch resolved a post-pacman helper through caller PATH" -[[ $(grep -c '^file:1000$' "$hook_log") == 1 ]] || fail "channel switch did not run the deferred refresh hook exactly once" -pass "channel switching defers its refresh hook past every later authentication" - -cat >"$stub_bin/omarchy-launch-floating-terminal-with-presentation" <<'STUB' -#!/bin/bash -exec bash -c "$1" -STUB -cat >"$stub_bin/omarchy-pkg-add" <<'STUB' -#!/bin/bash -[[ ${OMARCHY_SUDO_NO_UPDATE:-0} == 1 ]] || exit 98 -/usr/bin/sudo -N -- /usr/bin/true -exit "${TEST_PKG_STATUS:-0}" -STUB -cat >"$stub_bin/omarchy-font-set" <<'STUB' -#!/bin/bash -exec bash "$TEST_ROOT/bin/omarchy-font-set" "$@" -STUB -cat >"$stub_bin/fc-list" <<'STUB' -#!/bin/bash -echo 'Example Family' -STUB -cat >"$stub_bin/omarchy-restart-shell" <<'STUB' -#!/bin/bash -exit 0 -STUB -cat >"$stub_bin/pgrep" <<'STUB' -#!/bin/bash -exit 1 -STUB -cat >"$stub_bin/sleep" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$stub_bin"/* -chmod 4755 "$stub_bin/sudo" -mount --bind "$stub_bin/omarchy-launch-floating-terminal-with-presentation" \ - /usr/bin/omarchy-launch-floating-terminal-with-presentation -mount --bind "$stub_bin/omarchy-pkg-add" /usr/bin/omarchy-pkg-add -mount --bind "$stub_bin/omarchy-font-set" /usr/bin/omarchy-font-set -font_paths_bound=1 - -write_attack_hook font-set -font_victim="$root_dir/87-font-hook.rules" -font_dir_victim="$root_dir/88-font-hook-dir.rules" -font_persistent_victim="$root_dir/89-font-hook-child.rules" -reset_case -set +e -run_as_user env TEST_ROOT_VICTIM="$font_victim" TEST_ROOT_DIR_VICTIM="$font_dir_victim" \ - TEST_PERSISTENT_VICTIM="$font_persistent_victim" TEST_PERSISTENT_PID="$test_home/font.pid" \ - "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ - >"$test_tmp/font.out" 2>"$test_tmp/font.err" -status=$? -set -e -(( status == 0 )) || fail "isolated font install failed" "$(<"$test_tmp/font.err")" -wait_for_persistent_attempts "$test_home/font.pid" -assert_hook_sandboxed "$font_victim" "$font_dir_victim" "$font_persistent_victim" -pass "font installation invalidates before file, directory, and persistent hooks" - -reset_case -set +e -run_as_user env TEST_PKG_STATUS=1 TEST_ROOT_VICTIM="$font_victim" \ - TEST_ROOT_DIR_VICTIM="$font_dir_victim" TEST_PERSISTENT_VICTIM="$font_persistent_victim" \ - TEST_PERSISTENT_PID="$test_home/font.pid" \ - "$ROOT/bin/omarchy-install-font" 'Example Font' example-font 'Example Family' \ - >"$test_tmp/font-fail.out" 2>"$test_tmp/font-fail.err" -status=$? -set -e -(( status != 0 )) || fail "failing font package installation unexpectedly succeeded" -[[ ! -e $token ]] || fail "failed font package installation left its credential live" -[[ ! -s $hook_log ]] || fail "failed font package installation reached the hook" -pass "failed font installation invalidates without running its hook" +reset_boundary +function printf() { /usr/bin/touch "$SUDO_TEST_ROOT/function-marker"; } +export -f printf +run_update -y || fail "update failed with inherited function" "$(<"$boundary_tmp/output")" +unset -f printf +[[ ! -e $SUDO_TEST_ROOT/function-marker ]] || fail "an inherited function reached an update helper" +pass "exported functions do not reach update helper interpreters" diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index 6c65428d..7348848c 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -4,16 +4,31 @@ set -euo pipefail source "$(dirname "$0")/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -test_home="$test_tmp/home" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" +test_home="$SUDO_TEST_HOME" runtime_dir="$test_tmp/runtime" -mkdir -p "$stub_bin" "$test_home" "$runtime_dir" +mkdir -p "$runtime_dir" +for command in omarchy-update omarchy-update-lock omarchy-update-stay-awake; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" +done +cat >"$SUDO_TEST_ROOT/mock/setpriv" <<'STUB' +#!/bin/bash +while [[ ${1:-} == --* ]]; do + case "$1" in + --reuid|--regid) shift 2 ;; + --clear-groups) shift ;; + *) exit 90 ;; + esac +done +exec "$@" +STUB +chmod +x "$SUDO_TEST_ROOT/mock/setpriv" run_with_lock_env() { - HOME="$test_home" \ + SUDO_TEST_HOME="$test_home" \ XDG_RUNTIME_DIR="$runtime_dir" \ XDG_STATE_HOME="$test_tmp/state" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ @@ -24,6 +39,7 @@ write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" <"$TEST_MARKER"; sleep 2; exit 0' -OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 & +OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$update_snapshot_marker" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-first.out" 2>&1 & update_pid=$! for _ in {1..50}; do @@ -67,7 +85,7 @@ done [[ -f $update_snapshot_marker ]] || fail "first omarchy-update reached snapshot under lock" set +e -OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1 +OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$test_tmp/update-second-snapshot-started" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-second.out" 2>&1 update_second_status=$? set -e @@ -85,11 +103,11 @@ pass "omarchy-update prevents overlapping top-level updates" inhibit_pid_file="$test_tmp/inhibit-pid" keyring_marker="$test_tmp/keyring-started" write_stub omarchy-snapshot 'exit 0' -write_stub systemd-inhibit 'echo "$$" >"$INHIBIT_PID_FILE"; exec sleep 30' +write_stub systemd-inhibit '[[ -z ${INHIBIT_PID_FILE:-} ]] || echo "$$" >"$INHIBIT_PID_FILE"; while [[ $1 == --* ]]; do shift; done; exec "$@"' write_stub omarchy-update-keyring 'echo started >"$TEST_MARKER"; sleep 3; exit 0' OMARCHY_UPDATE_LOGGED=1 TEST_MARKER="$keyring_marker" INHIBIT_PID_FILE="$inhibit_pid_file" \ - run_with_lock_env "$ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 & + run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$test_tmp/update-inhibit.out" 2>&1 & inhibit_update_pid=$! for _ in {1..100}; do @@ -123,11 +141,10 @@ if (( EUID != 0 )); then terminal_inhibit_pid_file="$test_tmp/terminal-inhibit-pid" write_stub sudo ' printf "%s\n" "$*" >>"$SUDO_LOG" -if [[ $1 == "-v" ]]; then - exit 0 -fi -exec "$@"' - write_stub pkexec 'touch "$PKEXEC_MARKER"; exec "$@"' +[[ $1 == "-N" && $2 == "-b" && $3 == "--" ]] || exit 90 +shift 3 +"$@" &' + write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; exec "$@"' # start leaves the inhibitor running on purpose, but script tears the pty down # the moment its command returns, which SIGHUPs that inhibitor before it can @@ -137,7 +154,7 @@ exec "$@"' #!/bin/bash omarchy-update-stay-awake start for _ in {1..200}; do - grep -q '^systemd-inhibit ' "$SUDO_LOG" && break + grep -q -- '^-N -b -- ' "$SUDO_LOG" && break sleep 0.05 done SH @@ -146,10 +163,10 @@ SH SUDO_LOG="$sudo_log" PKEXEC_MARKER="$pkexec_marker" INHIBIT_PID_FILE="$terminal_inhibit_pid_file" \ run_with_lock_env script -qefc "$terminal_driver" /dev/null >/dev/null - grep -qx -- '-v' "$sudo_log" || fail "terminal sleep inhibition validates sudo in the foreground" - grep -q '^systemd-inhibit ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo" + grep -q -- '^-N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp" + grep -q -- '^-N -b -- ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo" [[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec" - run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop + run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop pass "terminal updates use sudo instead of Polkit for sleep inhibition" fi @@ -158,7 +175,7 @@ fi write_stub omarchy-snapshot 'exit 0' write_stub omarchy-update-keyring 'exit 0' write_stub omarchy-toggle-idle ' -state_file="$HOME/.local/state/omarchy/indicators/stay-awake" +state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake" case "$1" in stay-awake) mkdir -p "$(dirname "$state_file")" @@ -169,20 +186,20 @@ case "$1" in ;; esac' write_stub omarchy-update-restart ' -state_file="$HOME/.local/state/omarchy/indicators/stay-awake" -if [[ ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then +state_file="$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake" +if [[ ${1:-} == "--services-only" || ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then [[ -f $state_file ]] else [[ ! -f $state_file ]] fi' rm -f "$test_home/.local/state/omarchy/indicators/stay-awake" -OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y +OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y [[ ! -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update clears its Stay Awake state before restart handling" mkdir -p "$test_home/.local/state/omarchy/indicators" touch "$test_home/.local/state/omarchy/indicators/stay-awake" -OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y +OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y [[ -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update preserves pre-existing Stay Awake state" pass "omarchy-update restores only its own Stay Awake state before restart handling" @@ -194,7 +211,7 @@ mkdir -p "$stay_awake_helper_state" "$(dirname "$stay_awake_state")" printf '%s\n' "old-update-owner" >"$stay_awake_helper_state/idle-owner" printf '%s\n' "user-choice" >"$stay_awake_state" -run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop +run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop [[ $(<"$stay_awake_state") == "user-choice" ]] || fail "stale update ownership does not remove a newer Stay Awake choice" pass "stale update ownership preserves a newer Stay Awake choice" @@ -206,7 +223,7 @@ unrelated_start_time=$(awk '{ print $22 }' "/proc/$unrelated_pid/stat") mkdir -p "$stay_awake_helper_state" printf '%s %s\n' "$unrelated_pid" "$((unrelated_start_time + 1))" >"$stay_awake_helper_state/inhibit-pid" -run_with_lock_env "$ROOT/bin/omarchy-update-stay-awake" stop +run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop kill -0 "$unrelated_pid" 2>/dev/null || fail "stale inhibitor state does not terminate a reused PID" kill "$unrelated_pid" diff --git a/test/shell.d/update-restart-phases-test.sh b/test/shell.d/update-restart-phases-test.sh new file mode 100755 index 00000000..baddde91 --- /dev/null +++ b/test/shell.d/update-restart-phases-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +rm "$SUDO_TEST_ROOT/bin/omarchy-update-restart" +copy_boundary_file bin/omarchy-update-restart +for step in omarchy-state omarchy-restart-sshd omarchy-restart-shell omarchy-system-reboot; do + ln -s test-step "$SUDO_TEST_ROOT/bin/$step" +done +cat >"$SUDO_TEST_ROOT/bin/gum" <<'STUB' +#!/bin/bash +printf 'prompt:%s\n' "$*" >>"$SUDO_TEST_LOG" +exit 1 +STUB +chmod +x "$SUDO_TEST_ROOT/bin/gum" +mkdir -p "$SUDO_TEST_HOME/.local/state/omarchy" +touch "$SUDO_TEST_HOME/.local/state/omarchy/reboot-required" "$SUDO_TEST_HOME/.local/state/omarchy/restart-sshd-required" + +for mode in --services-only --reboot-only; do + reset_boundary + PATH="$SUDO_TEST_ROOT/bin:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update-restart" "$mode" >"$boundary_tmp/output" 2>&1 + if [[ $mode == "--services-only" ]]; then + grep -q '^step:omarchy-restart-sshd ' "$SUDO_TEST_LOG" || fail "service phase did not restart a marked service" + grep -q '^step:omarchy-restart-shell ' "$SUDO_TEST_LOG" || fail "service phase did not restart the shell" + if grep -q '^prompt:' "$SUDO_TEST_LOG"; then fail "service phase offered a reboot before update cleanup"; fi + else + grep -q '^prompt:' "$SUDO_TEST_LOG" || fail "reboot phase did not offer the required reboot" + if grep -q '^step:omarchy-restart-' "$SUDO_TEST_LOG"; then fail "reboot phase performed later service work"; fi + fi + pass "restart $mode performs only its selected phase" +done +reset_boundary +OMARCHY_UPDATE_UNATTENDED=1 PATH="$SUDO_TEST_ROOT/bin:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update-restart" --reboot-only >"$boundary_tmp/output" 2>&1 +if grep -Eq "^(prompt:|step:omarchy-restart-|step:omarchy-system-reboot)" "$SUDO_TEST_LOG"; then + fail "unattended reboot phase prompted or performed service work" +fi +pass "unattended reboot phase reports a required reboot without prompting" diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index edac1209..994dc493 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -2,60 +2,11 @@ set -euo pipefail -source "$(dirname "$0")/base-test.sh" - -if [[ -z ${OMARCHY_UPDATE_SEQUENCE_NS:-} ]]; 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 group="$(id -gn)" '$1 == group { print $2; exit }' /etc/subgid) - if [[ -z $subuid || -z $subgid ]]; then - pass "no subordinate uid/gid range; skipping authorized update-sequence test" - exit 0 - fi - exec 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" \ - env OMARCHY_UPDATE_SEQUENCE_NS=setup bash "$0" -elif [[ $OMARCHY_UPDATE_SEQUENCE_NS == setup ]]; then - mount -t tmpfs -o mode=0755 tmpfs /run - namespace_tmp=$(mktemp -d -p /run omarchy-update-sequence.XXXXXXXX) - chmod 0755 "$namespace_tmp" - mkdir -p "$namespace_tmp/default/omarchy/sudo-no-update" - cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$namespace_tmp/default/omarchy/sudo-no-update/sudo" - chmod 0755 "$namespace_tmp/default/omarchy/sudo-no-update/sudo" - cat >"$namespace_tmp/fixed-sudo" <<'STUB' -#!/bin/bash -if [[ ${1:-} == "-h" ]]; then - echo 'usage: sudo [-ABbEHkNnPS] command' -fi -exit 0 -STUB - chmod 0755 "$namespace_tmp/fixed-sudo" - mount --bind "$namespace_tmp/fixed-sudo" /usr/bin/sudo - mount -t tmpfs -o mode=0755 tmpfs /etc - printf 'export OMARCHY_PATH="%s"\n' "$namespace_tmp" >/etc/omarchy.conf - chmod 0644 /etc/omarchy.conf - chown -R 1000:1000 "$namespace_tmp" - - set +e - setpriv --reuid 1000 --regid 1000 --clear-groups \ - env OMARCHY_UPDATE_SEQUENCE_NS=run OMARCHY_AUTHORIZED_TEST_ROOT="$namespace_tmp" bash "$0" - status=$? - set -e - - umount /usr/bin/sudo - umount /etc - rm -rf "$namespace_tmp" - umount /run - exit "$status" -fi - -test_tmp="$OMARCHY_AUTHORIZED_TEST_ROOT" -trap 'rm -rf "$test_tmp"/*' EXIT - -stub_bin="$test_tmp/bin" -mkdir -p "$stub_bin" +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-update +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" # Every step omarchy-update runs, recorded in order with the unattended flag it # was handed. One of them can be told to fail. @@ -80,6 +31,7 @@ steps=( ) for step in "${steps[@]}"; do + rm -f "$stub_bin/$step" cat >"$stub_bin/$step" <<'STUB' #!/bin/bash printf '%s unattended=%s\n' "${0##*/}" "${OMARCHY_UPDATE_UNATTENDED:-}" >>"$STEP_LOG" @@ -96,7 +48,7 @@ run_update() { FAILING_STEP="${FAILING_STEP:-}" \ OMARCHY_UPDATE_LOGGED=1 \ PATH="$stub_bin:$PATH" \ - "$ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" + "$SUDO_TEST_ROOT/bin/omarchy-update" "$@" >"$test_tmp/out" 2>"$test_tmp/err" } steps_run() { From c52381f09225e4a6956c272dff0d5ef706985ae0 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sun, 6 Sep 2026 22:32:34 +0100 Subject: [PATCH 05/29] Keep update regressions isolated from host authentication --- test/shell.d/fixtures/sudo-boundary-test.sh | 4 ++-- test/shell.d/update-disk-space-test.sh | 20 ++++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index cabad278..6c6bada3 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -48,7 +48,7 @@ if [[ ${1:-} == "-h" ]]; then fi if [[ ${1:-} == "-k" || ${1:-} == "-K" ]]; then [[ ${SUDO_TEST_REVOKE_FAIL:-0} != "1" ]] || exit 1 - rm -f "$SUDO_TEST_CACHE" + /usr/bin/rm -f "$SUDO_TEST_CACHE" exit 0 fi if [[ ${1:-} == "-N" ]]; then @@ -115,7 +115,7 @@ ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" reset_boundary() { : >"$SUDO_TEST_LOG" - rm -f "$SUDO_TEST_CACHE" + /usr/bin/rm -f "$SUDO_TEST_CACHE" unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED } assert_boundary_cold() { diff --git a/test/shell.d/update-disk-space-test.sh b/test/shell.d/update-disk-space-test.sh index 2c7368ea..e40401eb 100644 --- a/test/shell.d/update-disk-space-test.sh +++ b/test/shell.d/update-disk-space-test.sh @@ -9,18 +9,21 @@ unset OMARCHY_UPDATE_FORCE unset TEST_AVAILABLE_BYTES unset TEST_DF_INVALID -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT - -stub_bin="$test_tmp/bin" -test_home="$test_tmp/home" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +stub_bin="$SUDO_TEST_ROOT/bin" +test_home="$SUDO_TEST_HOME" runtime_dir="$test_tmp/runtime" snapshot_marker="$test_tmp/snapshot" gum_marker="$test_tmp/gum" -mkdir -p "$stub_bin" "$test_home" "$runtime_dir" +mkdir -p "$runtime_dir" +for command in omarchy-update omarchy-update-requires-free-space omarchy-update-confirm; do + rm -f "$stub_bin/$command" + copy_boundary_file "bin/$command" +done run_update() { - HOME="$test_home" \ + SUDO_TEST_HOME="$test_home" \ XDG_RUNTIME_DIR="$runtime_dir" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ LC_ALL=C \ @@ -30,13 +33,14 @@ run_update() { SNAPSHOT_MARKER="$snapshot_marker" \ GUM_MARKER="$gum_marker" \ GUM_STATUS=${GUM_STATUS:-1} \ - "$ROOT/bin/omarchy-update" "$@" + "$SUDO_TEST_ROOT/bin/omarchy-update" "$@" } write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" < Date: Sun, 6 Sep 2026 22:49:43 +0100 Subject: [PATCH 06/29] Revoke before session cleanup and protect standalone inhibition --- bin/omarchy-update | 5 ++++ bin/omarchy-update-stay-awake | 15 ++++++++++-- test/shell.d/update-hook-security-test.sh | 6 +++++ test/shell.d/update-lock-test.sh | 29 ++++++++++++++++------- 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/bin/omarchy-update b/bin/omarchy-update index a3cc94a9..b6e5c7bd 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -23,6 +23,10 @@ update_stay_awake_stopped=0 cleanup_update() { local status=$? trap - EXIT HUP INT TERM + if ! omarchy_security_revoke_sudo_timestamp; then + echo "Could not invalidate sudo before update cleanup." >&2 + omarchy_security_exit_with_revoked_sudo 1 + fi if (( update_stay_awake_stopped == 0 )); then omarchy-update-stay-awake stop || status=1 fi @@ -88,6 +92,7 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # Release update-owned inhibitors before offering a reboot. A confirmed # reboot can terminate this process before its EXIT trap gets a chance to # remove the persistent Stay Awake marker. + omarchy_security_revoke_sudo_timestamp omarchy-update-stay-awake stop update_stay_awake_stopped=1 diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index 826fee5e..4b214ca0 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -1,10 +1,21 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Manage sleep and idle inhibition during an update # omarchy:args= # omarchy:hidden=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup." >&2 + exit 126 +fi + +source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -e +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo state_dir="${XDG_RUNTIME_DIR:-/tmp/omarchy-$UID}/omarchy-update-stay-awake" idle_owner_file="$state_dir/idle-owner" @@ -106,7 +117,7 @@ start() { ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- exec "${hold_command[@]}" ) & elif [[ -t 0 ]]; then - sudo -N -b -- "${hold_command[@]}" + /usr/bin/sudo -N -b -- "${hold_command[@]}" else ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- exec pkexec "${hold_command[@]}" ) & diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh index 4e0922c3..0181b800 100755 --- a/test/shell.d/update-hook-security-test.sh +++ b/test/shell.d/update-hook-security-test.sh @@ -36,6 +36,12 @@ for step in omarchy-update-system-pkgs yay omarchy-hook omarchy-update-mise; do export SUDO_TEST_FAIL_STEP=$step if run_update -y; then fail "$step failure must fail the update"; fi assert_boundary_cold "failed $step" + python3 - "$SUDO_TEST_LOG" <<'PY' +import sys +s=open(sys.argv[1]).read().splitlines() +for i,line in enumerate(s): + if line=='step:omarchy-update-stay-awake stop': assert i>0 and s[i-1]=='sudo -k',s +PY pass "update revokes credentials after $step fails" done diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index 7348848c..5b0e3b54 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -136,14 +136,10 @@ kill -0 "$inhibitor_pid" 2>/dev/null && pass "omarchy-update waits for its sleep inhibitor to stop" if (( EUID != 0 )); then - sudo_log="$test_tmp/sudo.log" + sudo_log="$SUDO_TEST_LOG" + : >"$sudo_log" pkexec_marker="$test_tmp/pkexec-used" terminal_inhibit_pid_file="$test_tmp/terminal-inhibit-pid" - write_stub sudo ' -printf "%s\n" "$*" >>"$SUDO_LOG" -[[ $1 == "-N" && $2 == "-b" && $3 == "--" ]] || exit 90 -shift 3 -"$@" &' write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; exec "$@"' # start leaves the inhibitor running on purpose, but script tears the pty down @@ -154,7 +150,7 @@ shift 3 #!/bin/bash omarchy-update-stay-awake start for _ in {1..200}; do - grep -q -- '^-N -b -- ' "$SUDO_LOG" && break + grep -q -- '^sudo -N -b -- ' "$SUDO_LOG" && break sleep 0.05 done SH @@ -163,8 +159,7 @@ SH SUDO_LOG="$sudo_log" PKEXEC_MARKER="$pkexec_marker" INHIBIT_PID_FILE="$terminal_inhibit_pid_file" \ run_with_lock_env script -qefc "$terminal_driver" /dev/null >/dev/null - grep -q -- '^-N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp" - grep -q -- '^-N -b -- ' "$sudo_log" || fail "terminal sleep inhibition runs through sudo" + grep -q -- '^sudo -N -b -- ' "$sudo_log" || fail "terminal inhibition authenticates its background command without a reusable timestamp" [[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop pass "terminal updates use sudo instead of Polkit for sleep inhibition" @@ -229,3 +224,19 @@ kill -0 "$unrelated_pid" 2>/dev/null || kill "$unrelated_pid" wait "$unrelated_pid" 2>/dev/null || true pass "stale inhibitor state does not terminate a reused PID" + +# The hidden helper also establishes its own boundary when invoked directly. +reset_boundary +touch "$SUDO_TEST_CACHE" +run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop +[[ $(head -1 "$SUDO_TEST_LOG") == "sudo -k" ]] || fail "standalone inhibitor cleanup did not start cold" +assert_boundary_cold "standalone inhibitor cleanup" +pass "standalone inhibitor cleanup revokes before and after session work" + +reset_boundary +export SUDO_TEST_REVOKE_FAIL=1 +if run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" start; then + fail "inhibitor started after failed initial revocation" +fi +[[ ! -e $stay_awake_helper_state/inhibit-pid ]] || fail "failed revocation started an inhibitor" +pass "failed initial revocation prevents standalone inhibition" From a9e9954e3eb147b26e742c5a23e75b815cd030f4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sun, 6 Sep 2026 22:59:08 +0100 Subject: [PATCH 07/29] Clarify unattended updates still require sudo authorization --- bin/omarchy-update | 4 ++-- docs/update-process.md | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-update b/bin/omarchy-update index b6e5c7bd..68582cd0 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -48,8 +48,8 @@ omarchy_security_install_signal_exit_traps omarchy-update-requires-free-space -# -y is a promise not to ask anything. Steps that would need an answer report -# and move on instead of waiting on a prompt nobody is here to give. +# -y suppresses Omarchy confirmation prompts; sudo authorization is still +# required. Interactive review steps report and move on instead of waiting. [[ ${1:-} != "-y" ]] || export OMARCHY_UPDATE_UNATTENDED=1 if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then diff --git a/docs/update-process.md b/docs/update-process.md index 9ec3b96c..52a8b423 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -149,9 +149,7 @@ Important behavior: - In dev-link mode, `omarchy update` fast-forwards the active checkout from its configured upstream before changing system packages or running migrations. - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. -- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` — a promise not to ask anything. - Steps that would prompt (orphan removal, conflict handoff) report and skip - instead of blocking. +- `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the check is silently skipped. Set `OMARCHY_UPDATE_FORCE=1` to bypass the check. From 978dffaa3e0fdc92855c14855b22595b3d72604d Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Sun, 6 Sep 2026 23:32:28 +0100 Subject: [PATCH 08/29] Use required gum directly in AI removal prompts --- bin/omarchy-remove-ai-hermes | 2 +- bin/omarchy-remove-ai-openclaw | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-remove-ai-hermes b/bin/omarchy-remove-ai-hermes index f6b9f292..9525164a 100755 --- a/bin/omarchy-remove-ai-hermes +++ b/bin/omarchy-remove-ai-hermes @@ -73,7 +73,7 @@ fi # never owned, a yes takes that with it, and saying so is the prompt's job. # Without a terminal to ask in, keeping everything is the answer. data_removed=false -if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]] && command -v gum >/dev/null; then +if [[ -d $HOME/.hermes || -d $HOME/.config/Hermes ]] && [[ -t 0 ]]; then # du answers non-zero when either directory is missing, and pipefail would # turn that into an aborted removal; the size is worth no such thing. size=$(du -shc "$HOME/.hermes" "$HOME/.config/Hermes" 2>/dev/null | tail -1 | cut -f1 || true) diff --git a/bin/omarchy-remove-ai-openclaw b/bin/omarchy-remove-ai-openclaw index 47658a20..a5b15a23 100755 --- a/bin/omarchy-remove-ai-openclaw +++ b/bin/omarchy-remove-ai-openclaw @@ -61,7 +61,7 @@ gtk-update-icon-cache "$HOME/.local/share/icons/hicolor" &>/dev/null || true # front of the user with the size rather than left silent. Without a terminal # to ask in, keeping it is the answer. state_removed=false -if [[ -d $HOME/.openclaw && -t 0 ]] && command -v gum >/dev/null; then +if [[ -d $HOME/.openclaw && -t 0 ]]; then size=$(du -sh "$HOME/.openclaw" 2>/dev/null | cut -f1) if gum confirm --default=false "Also delete ~/.openclaw ($size: chats, memories, credentials, and downloaded plugins)?"; then rm -rf "$HOME/.openclaw" From d41ca88f767f93dc6e2c60443aecf7fde82eb32a Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 00:03:53 +0100 Subject: [PATCH 09/29] Wait for the actual Stay Awake launcher --- bin/omarchy-update-stay-awake | 6 +++++- test/shell.d/update-lock-test.sh | 14 +++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index 4b214ca0..ff311561 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -93,6 +93,7 @@ stop() { start() { local idle_owner="$$:$RANDOM:$RANDOM" + local launcher_pid="" stop mkdir -p "$state_dir" @@ -116,16 +117,19 @@ start() { if (( EUID == 0 )); then ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- exec "${hold_command[@]}" ) & + launcher_pid=$! elif [[ -t 0 ]]; then /usr/bin/sudo -N -b -- "${hold_command[@]}" else ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- exec pkexec "${hold_command[@]}" ) & + launcher_pid=$! fi # For graphical authentication the launcher may wait for a password. Wait for # either the user-owned held command to become ready or the launcher to fail. - local launcher_pid=${!:-} + # sudo -b backgrounds internally, so Bash has no launcher PID in the TTY path; + # $! there can still refer to a completed startup process substitution. local readiness_attempts=0 while [[ ! -s $inhibit_pid_file ]]; do if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index 5b0e3b54..846e968a 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -141,18 +141,18 @@ if (( EUID != 0 )); then pkexec_marker="$test_tmp/pkexec-used" terminal_inhibit_pid_file="$test_tmp/terminal-inhibit-pid" write_stub pkexec '[[ -z ${PKEXEC_MARKER:-} ]] || touch "$PKEXEC_MARKER"; exec "$@"' + write_stub systemd-inhibit 'sleep 0.2; while [[ $1 == --* ]]; do shift; done; exec "$@"' - # start leaves the inhibitor running on purpose, but script tears the pty down - # the moment its command returns, which SIGHUPs that inhibitor before it can - # exec. Keep the session open from the inside until the stub has logged. + # sudo -b returns before its child is ready. Require start to wait for the + # delayed child and succeed, then stop it before script tears down the PTY. terminal_driver="$test_tmp/terminal-stay-awake" cat >"$terminal_driver" <<'SH' #!/bin/bash +set -euo pipefail omarchy-update-stay-awake start -for _ in {1..200}; do - grep -q -- '^sudo -N -b -- ' "$SUDO_LOG" && break - sleep 0.05 -done +[[ -s $XDG_RUNTIME_DIR/omarchy-update-stay-awake/inhibit-pid ]] +omarchy-update-stay-awake stop +[[ ! -e $XDG_RUNTIME_DIR/omarchy-update-stay-awake/inhibit-pid ]] SH chmod +x "$terminal_driver" From 177d5729c9b84db418377c8265890bb3fc1e32b5 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 00:08:07 +0100 Subject: [PATCH 10/29] Wait for notifications when restarting the shell --- bin/omarchy-restart-shell | 16 +++++++++++++++- test/shell.d/restart-shell-test.sh | 22 +++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index dfc21724..9891e7e8 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -61,6 +61,19 @@ relock_session() { return 1 } +notifications_ready() { + [[ $(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus \ + org.freedesktop.DBus NameHasOwner s org.freedesktop.Notifications 2>/dev/null) == "b true" ]] +} + +# Core IPC can answer before the notification plugin has registered its bus +# name. Restore an existing notification service before update hooks or setup +# invitations send their one-time toasts; a disabled service need not appear. +notifications_were_running=0 +if notifications_ready; then + notifications_were_running=1 +fi + # Each kill stops the oldest matching instance and only returns once it has # fully exited, so the no-duplicate launch below can't race a dying shell. while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done @@ -70,7 +83,8 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; hyprctl dispatch 'hl.dsp.exec_cmd("omarchy-launch-shell")' >/dev/null for (( attempt = 0; attempt < 20; attempt++ )); do - if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then + if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1 && + { (( notifications_were_running == 0 )) || notifications_ready; }; then # The session stays compositor-locked after the old lock client died, so # re-acquire the lock and let the user authenticate out of it. if (( relock )) && ! relock_session; then diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index 094c74f6..31901736 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -172,7 +172,24 @@ else fi SH -chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" +cat >"$restart_bin/busctl" <<'SH' +#!/bin/bash +if [[ -z ${OMARCHY_TEST_NOTIFICATION_CHECKS:-} ]]; then + echo 'b false' +else + checks=0 + [[ ! -f $OMARCHY_TEST_NOTIFICATION_CHECKS ]] || read -r checks <"$OMARCHY_TEST_NOTIFICATION_CHECKS" + (( checks += 1 )) + printf '%s\n' "$checks" >"$OMARCHY_TEST_NOTIFICATION_CHECKS" + if (( checks == 1 || checks >= 4 )); then + echo 'b true' + else + echo 'b false' + fi +fi +SH + +chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemd-cat" "$restart_bin/systemctl" "$restart_bin/busctl" sleep 30 & restart_pid_one=$! @@ -194,6 +211,7 @@ OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \ OMARCHY_TEST_IPC_LOG="$ipc_log" \ OMARCHY_TEST_SESSION_PATH="$restart_root" \ OMARCHY_TEST_TRANSIENT_ENV=leaked \ +OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \ timeout 5 "$ROOT/bin/omarchy-restart-shell" if kill -0 "$restart_pid_one" 2>/dev/null; then @@ -213,6 +231,8 @@ grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null || grep -F 'hl.dsp.exec_cmd("omarchy-launch-shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland" grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout" pass "restart replaces duplicate shell instances from the session checkout" +[[ $(<"$test_tmp/notification-checks") == 4 ]] || fail "restart waits for the existing notification service after core IPC is ready" +pass "restart waits for notification readiness before one-time update hooks" : >"$restart_log" printf '303\n' >"$restart_state" From 35551b04daf5b338b9a85754e2a05154f45989d6 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 00:24:43 +0100 Subject: [PATCH 11/29] Disable the Yay sudo loop with its supported option --- bin/omarchy-update-aur-pkgs | 2 +- test/shell.d/fixtures/sudo-boundary-test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-update-aur-pkgs b/bin/omarchy-update-aur-pkgs index 8cdfb617..a860a97d 100755 --- a/bin/omarchy-update-aur-pkgs +++ b/bin/omarchy-update-aur-pkgs @@ -6,7 +6,7 @@ sudo_options=() if [[ ${OMARCHY_SUDO_NO_UPDATE:-0} == "1" ]]; then sudo_wrapper="$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" [[ -x $sudo_wrapper ]] || exit 1 - sudo_options=(--sudo "$sudo_wrapper" --nosudoloop) + sudo_options=(--sudo "$sudo_wrapper" --sudoloop=false) fi if pacman -Qem >/dev/null; then diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 6c6bada3..2ed8438b 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -103,7 +103,7 @@ case "$step" in pacman) exit 0 ;; yay) [[ $* == *"--sudo $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo"* ]] || exit 92 - [[ $* == *"--nosudoloop"* ]] || exit 93 + [[ $* == *"--sudoloop=false"* ]] || exit 93 ;; esac STUB From 5b692c5b30bba4d7adcd34ec2e71184f4b69661f Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 14:31:07 +0100 Subject: [PATCH 12/29] Preserve user PATH across updater relaunches --- bin/omarchy-update | 9 ++-- test/shell.d/update-user-path-test.sh | 67 +++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 test/shell.d/update-user-path-test.sh diff --git a/bin/omarchy-update b/bin/omarchy-update index 68582cd0..9a809351 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -14,7 +14,10 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" -user_path=$PATH +# Logging and lock acquisition re-exec this command with a sanitized PATH. +# Preserve the caller's path only for the later unprivileged hook/mise phases. +user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} +unset OMARCHY_UPDATE_USER_PATH omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_install_sudo_cleanup_traps omarchy_security_enable_no_update_sudo @@ -35,11 +38,11 @@ cleanup_update() { if [[ -z ${OMARCHY_UPDATE_LOGGED:-} ]]; then script_command=$(printf '%q ' "$0" "$@") - exec env OMARCHY_UPDATE_LOGGED=1 script -qefc "$script_command" "/tmp/omarchy-update.log" + exec env OMARCHY_UPDATE_LOGGED=1 OMARCHY_UPDATE_USER_PATH="$user_path" script -qefc "$script_command" "/tmp/omarchy-update.log" fi if ! omarchy-update-lock held; then - exec omarchy-update-lock run "$0" "$@" + exec env OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-update-lock run "$0" "$@" fi trap 'echo ""; echo -e "\033[0;31mSomething went wrong during the update!\n\nPlease review the output above carefully, correct the error, and retry the update.\n\nIf you need assistance, get help from the community at https://omarchy.org/discord\033[0m"' ERR diff --git a/test/shell.d/update-user-path-test.sh b/test/shell.d/update-user-path-test.sh new file mode 100644 index 00000000..f76f0faf --- /dev/null +++ b/test/shell.d/update-user-path-test.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-update + +# Model the two exec boundaries without a host update log or a real lock. +# Both child processes inherit the environment exactly as script/lock would. +cat >"$SUDO_TEST_ROOT/bin/script" <<'STUB' +#!/bin/bash +printf 'logged-reexec\n' >>"$SUDO_TEST_LOG" +[[ $1 == "-qefc" ]] || exit 90 +exec /usr/bin/bash -p -c "$2" +STUB +rm "$SUDO_TEST_ROOT/bin/omarchy-update-lock" +cat >"$SUDO_TEST_ROOT/bin/omarchy-update-lock" <<'STUB' +#!/bin/bash +case "$1" in + held) [[ ${SUDO_TEST_LOCKED:-0} == "1" ]] ;; + run) + shift + printf 'locked-reexec\n' >>"$SUDO_TEST_LOG" + export SUDO_TEST_LOCKED=1 + exec "$@" + ;; +esac +STUB +mkdir "$boundary_tmp/user commands" +cat >"$boundary_tmp/user commands/update-user-tool" <<'STUB' +#!/bin/bash +printf 'user-tool:%s\n' "$1" >>"$SUDO_TEST_LOG" +STUB +chmod +x "$SUDO_TEST_ROOT/bin/script" "$SUDO_TEST_ROOT/bin/omarchy-update-lock" "$boundary_tmp/user commands/update-user-tool" + +for step in omarchy-hook omarchy-update-mise; do + rm "$SUDO_TEST_ROOT/bin/$step" + cat >"$SUDO_TEST_ROOT/bin/$step" <<'STUB' +#!/bin/bash +[[ ! -e $SUDO_TEST_CACHE ]] || exit 91 +[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92 +update-user-tool "${0##*/}" +STUB + chmod +x "$SUDO_TEST_ROOT/bin/$step" +done + +for entry in fresh logged locked; do + reset_boundary + unset OMARCHY_UPDATE_LOGGED OMARCHY_UPDATE_USER_PATH SUDO_TEST_LOCKED + case "$entry" in + logged) export OMARCHY_UPDATE_LOGGED=1 ;; + locked) export OMARCHY_UPDATE_LOGGED=1 SUDO_TEST_LOCKED=1 ;; + esac + PATH="$boundary_tmp/user commands:$PATH" "$SUDO_TEST_ROOT/bin/omarchy-update" -y >"$boundary_tmp/output" 2>&1 || + fail "$entry update lost the original user PATH" "$(<"$boundary_tmp/output")" + grep -q '^user-tool:omarchy-hook$' "$SUDO_TEST_LOG" || fail "$entry hook could not run a user-installed tool" + grep -q '^user-tool:omarchy-update-mise$' "$SUDO_TEST_LOG" || fail "$entry mise could not run a user-installed tool" + if [[ $entry == "fresh" ]]; then + grep -q '^logged-reexec$' "$SUDO_TEST_LOG" || fail "fresh update did not exercise the logging exec" + fi + if [[ $entry != "locked" ]]; then + grep -q '^locked-reexec$' "$SUDO_TEST_LOG" || fail "$entry update did not exercise the lock exec" + fi + assert_boundary_cold "$entry update" + pass "$entry update preserves the original user PATH through logging and locking with no-update sudo first" +done From f37c73fa2028f54958779ad5320a07775a9f2a68 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 17:27:04 +0100 Subject: [PATCH 13/29] Bind protected update commands to their source root --- bin/omarchy-refresh-pacman | 1 + bin/omarchy-security-functions | 14 +++++ bin/omarchy-update | 1 + bin/omarchy-update-stay-awake | 1 + docs/update-process.md | 3 +- test/shell.d/security-source-root-test.sh | 65 +++++++++++++++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) create mode 100755 test/shell.d/security-source-root-test.sh diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index af552abb..de6ca405 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -12,6 +12,7 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" user_path=$PATH omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_install_sudo_cleanup_traps diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions index 5c9bd9e4..703fd630 100644 --- a/bin/omarchy-security-functions +++ b/bin/omarchy-security-functions @@ -44,6 +44,20 @@ omarchy_security_sanitize_bash_environment() { 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 diff --git a/bin/omarchy-update b/bin/omarchy-update index 9a809351..2026e5e9 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -14,6 +14,7 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" # Logging and lock acquisition re-exec this command with a sanitized PATH. # Preserve the caller's path only for the later unprivileged hook/mise phases. user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index ff311561..4cb0af52 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -13,6 +13,7 @@ source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_install_sudo_cleanup_traps omarchy_security_enable_no_update_sudo diff --git a/docs/update-process.md b/docs/update-process.md index 52a8b423..ab073e83 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -144,11 +144,12 @@ omarchy-update Important behavior: -- `omarchy update` uses the session’s `OMARCHY_PATH` and a fixed command search path for its system phases. User PATH is restored behind the sudo wrapper for hooks and mise. +- Protected update entrypoints require the session's canonical `OMARCHY_PATH` to match their own checkout or the packaged `/usr/bin` entrypoint before selecting commands or the sudo wrapper. This preserves intentionally trusted development checkouts while rejecting a command paired with a different source root. System phases use a fixed command search path; user PATH is restored behind the sudo wrapper for hooks and mise. - Mixed-trust update entrypoints start Bash in privileged mode, discard `BASH_ENV`, `ENV`, and exported-function records before launching helpers, and reject an ordinary `bash path/to/command` invocation. Run them as executables (normally through the `omarchy` CLI); `/usr/bin/bash -p path/to/command` is the explicit interpreter form. This keeps shell startup injection from replacing the no-update sudo boundary. - In dev-link mode, `omarchy update` fast-forwards the active checkout from its configured upstream before changing system packages or running migrations. - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. +- This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the diff --git a/test/shell.d/security-source-root-test.sh b/test/shell.d/security-source-root-test.sh new file mode 100755 index 00000000..c872e78d --- /dev/null +++ b/test/shell.d/security-source-root-test.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +source "$SUDO_TEST_ROOT/bin/omarchy-security-functions" + +rm -f "$SUDO_TEST_ROOT/bin/omarchy-update" +copy_boundary_file bin/omarchy-update +omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" || fail "matching checkout root was rejected" +pass "a canonical checkout matches its own entrypoint" + +mkdir "$boundary_tmp/other-root" +ln -s "$SUDO_TEST_ROOT" "$boundary_tmp/root-link" +for root in "$boundary_tmp/other-root" "$boundary_tmp/root-link" .; do + if OMARCHY_PATH="$root" omarchy_security_require_source_root "$SUDO_TEST_ROOT/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then + fail "a different or noncanonical source root was accepted" + fi +done +pass "different, symlink and relative roots are rejected" + +# Redirect only the two package-layout literals into the fixture. Resolution +# still uses real readlink/realpath; no host /usr/bin file is changed or run. +package_root="$boundary_tmp/usr/share/omarchy" +package_bin="$boundary_tmp/usr/bin" +mkdir -p "$package_root/bin" "$package_bin" +cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/omarchy-update" +cp "$SUDO_TEST_ROOT/bin/omarchy-update" "$package_bin/different-command" +ln -s "$package_bin/omarchy-update" "$package_root/bin/omarchy-update" +python3 - "$SUDO_TEST_ROOT/bin/omarchy-security-functions" "$boundary_tmp/package-library" "$package_root" "$package_bin" <<'PY' +import sys +from pathlib import Path +source, output, root, binaries = sys.argv[1:] +text = Path(source).read_text() +text = text.replace('"/usr/share/omarchy"', f'"{root}"') +text = text.replace('"/usr/bin/$command_name"', f'"{binaries}/$command_name"') +Path(output).write_text(text) +PY +source "$boundary_tmp/package-library" +OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_bin/omarchy-update" || fail "package binary was rejected" +OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" || fail "package link was rejected" +pass "the package binary and its matching source-tree link are accepted" + +ln -sfn "$package_bin/different-command" "$package_root/bin/omarchy-update" +if OMARCHY_PATH="$package_root" omarchy_security_require_source_root "$package_root/bin/omarchy-update" >"$boundary_tmp/output" 2>&1; then + fail "a package link to a different command was accepted" +fi +pass "a package link must resolve to its named command" + +# Run the protected entrypoints themselves with a mismatched root. These must +# stop before any sudo or operational fixture command, not merely validate in +# an isolated library test. +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" + for root in "$boundary_tmp/other-root" .; do + reset_boundary + if OMARCHY_PATH="$root" "$SUDO_TEST_ROOT/bin/$command" >"$boundary_tmp/output" 2>&1; then + fail "$command accepted a mismatched root" + fi + [[ ! -s $SUDO_TEST_LOG ]] || fail "$command ran work before rejecting its root" + done + pass "$command rejects mismatched and relative roots before work" +done From 5bc41b2585d664e5a37708055d9a2621f44c5ac4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 17:31:54 +0100 Subject: [PATCH 14/29] Resolve security libraries beside canonical entrypoints --- bin/omarchy-refresh-pacman | 3 +- bin/omarchy-security-functions | 2 +- bin/omarchy-update | 3 +- bin/omarchy-update-stay-awake | 3 +- default/omarchy/sudo-no-update/sudo | 3 +- .../security-entrypoint-symlink-test.sh | 34 +++++++++++++++++++ 6 files changed, 43 insertions(+), 5 deletions(-) create mode 100755 test/shell.d/security-entrypoint-symlink-test.sh diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index de6ca405..d70ffd9c 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -8,7 +8,8 @@ if [[ $- != *p* ]]; then exit 126 fi -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions index 703fd630..5826c825 100644 --- a/bin/omarchy-security-functions +++ b/bin/omarchy-security-functions @@ -50,7 +50,7 @@ omarchy_security_require_source_root() { # 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" ]] || + 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 diff --git a/bin/omarchy-update b/bin/omarchy-update index 2026e5e9..3873a6bc 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -10,7 +10,8 @@ if [[ $- != *p* ]]; then exit 126 fi -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index 4cb0af52..af941c4a 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -9,7 +9,8 @@ if [[ $- != *p* ]]; then exit 126 fi -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" diff --git a/default/omarchy/sudo-no-update/sudo b/default/omarchy/sudo-no-update/sudo index de69347b..7019a4dc 100755 --- a/default/omarchy/sudo-no-update/sudo +++ b/default/omarchy/sudo-no-update/sudo @@ -6,7 +6,8 @@ if [[ $- != *p* ]]; then echo "Refusing an unsafe Bash startup for the sudo boundary." >&2 exit 126 fi -source "${BASH_SOURCE[0]%/*}/../../../bin/omarchy-security-functions" || exit 126 +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/../../../bin/omarchy-security-functions" || exit 126 omarchy_security_require_privileged_bash_startup || exit 126 if (( $# == 1 )); then diff --git a/test/shell.d/security-entrypoint-symlink-test.sh b/test/shell.d/security-entrypoint-symlink-test.sh new file mode 100755 index 00000000..678a6742 --- /dev/null +++ b/test/shell.d/security-entrypoint-symlink-test.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +export OMARCHY_UPDATE_LOGGED=1 + +# The real fixed entrypoints run only fixture operations. The sibling library +# is a harmless sentinel: invoking a command through another directory must +# source the library beside the resolved command instead of this file. +mkdir "$boundary_tmp/links" +printf '%s\n' 'touch "$SUDO_TEST_HOME/wrong-library"' >"$boundary_tmp/links/omarchy-security-functions" +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake; do + rm -f "$SUDO_TEST_ROOT/bin/$command" + copy_boundary_file "bin/$command" + ln -s "$SUDO_TEST_ROOT/bin/$command" "$boundary_tmp/links/$command" + reset_boundary + args=(unexpected) + [[ $command != "omarchy-update" ]] || args=(-y) + status=0 + "$boundary_tmp/links/$command" "${args[@]}" >"$boundary_tmp/output" 2>&1 || status=$? + [[ ! -e $SUDO_TEST_HOME/wrong-library ]] || fail "$command sourced a library beside its symlink" + (( status != 126 )) || fail "$command failed to locate its actual library" "$(<"$boundary_tmp/output")" + [[ -s $SUDO_TEST_LOG ]] || fail "$command did not reach the protected fixture boundary" + assert_boundary_cold "$command symlink" + pass "$command resolves its own library when invoked through a symlink" +done + +ln -s "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/links/sudo" +reset_boundary +"$boundary_tmp/links/sudo" -k || fail "symlinked sudo wrapper lost its source library" +[[ $(<"$SUDO_TEST_LOG") == "sudo -k" ]] || fail "symlinked wrapper did not reach the fixed sudo stand-in" +pass "the sudo wrapper resolves its source library independently of its invocation link" From c8697407cb54213f971652e1a89d9757882ca823 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 17:33:33 +0100 Subject: [PATCH 15/29] Keep channel transitions inside command-scoped sudo --- bin/omarchy-channel-set | 24 +++- docs/update-process.md | 1 + test/shell.d/channel-sudo-boundary-test.sh | 131 ++++++++++++++++++ test/shell.d/channel-test.sh | 50 ++++--- .../security-entrypoint-symlink-test.sh | 2 +- test/shell.d/security-source-root-test.sh | 2 +- 6 files changed, 188 insertions(+), 22 deletions(-) create mode 100755 test/shell.d/channel-sudo-boundary-test.sh diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index b711da93..f1f05063 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -1,10 +1,23 @@ -#!/bin/bash +#!/bin/bash -p # omarchy:summary=Set the Omarchy package channel. # omarchy:args= # omarchy:requires-sudo=true +if [[ $- != *p* ]]; then + echo "Refusing an unsafe Bash startup for channel switching." >&2 + exit 126 +fi +security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126 +source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126 +omarchy_security_require_privileged_bash_startup || exit 126 set -euo pipefail +omarchy_security_sanitize_bash_environment "$0" "$@" +omarchy_security_require_source_root "$0" +user_path=$PATH +omarchy_security_revoke_sudo_timestamp || exit 1 +omarchy_security_install_sudo_cleanup_traps +omarchy_security_enable_no_update_sudo usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; } fail() { echo "Error: $*" >&2; exit 1; } @@ -79,7 +92,7 @@ fi if [[ -n $dev_checkout ]]; then link_dev_checkout "$dev_checkout" export OMARCHY_PATH="$dev_checkout" - export PATH="$OMARCHY_PATH/bin:$PATH" + omarchy_security_enable_no_update_sudo omarchy-state set reboot-required fi @@ -90,13 +103,16 @@ sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packa if [[ -z $dev_checkout ]]; then omarchy-dev-unlink --no-reboot export OMARCHY_PATH=/usr/share/omarchy + omarchy_security_enable_no_update_sudo if (( leaving_dev )); then omarchy-state set reboot-required fi fi -omarchy-update -y +OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y # No channel-owned privileged work follows the historical refresh hook. -omarchy-refresh-pacman "$pacman_channel" run-deferred +omarchy_security_revoke_sudo_timestamp +PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ + "$OMARCHY_PATH/bin/omarchy-refresh-pacman" "$pacman_channel" run-deferred diff --git a/docs/update-process.md b/docs/update-process.md index ab073e83..b530c590 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -150,6 +150,7 @@ Important behavior: - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. +- Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and runs the deferred refresh hook only after the full update succeeds and authorization is revoked again. Failed and interrupted channel switches revoke on exit. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh new file mode 100755 index 00000000..a2aba440 --- /dev/null +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -0,0 +1,131 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +copy_boundary_file bin/omarchy-channel-set +copy_boundary_file bin/omarchy-refresh-pacman +copy_boundary_file bin/omarchy-update +export OMARCHY_UPDATE_LOGGED=1 + +# Relocate the package root into the fixture, including the explicit handoff +# from the development checkout. All privileged operations remain stand-ins. +python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$SUDO_TEST_ROOT" <<'PY' +import sys +from pathlib import Path +p = Path(sys.argv[1]) +p.write_text(p.read_text().replace('/usr/share/omarchy', sys.argv[2])) +PY + +for command in omarchy-dev-link omarchy-dev-unlink omarchy-state gum git; do + cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB' +#!/bin/bash +set -euo pipefail +step=${0##*/} +printf 'step:%s %s\n' "$step" "$*" >>"$SUDO_TEST_LOG" +case "$step" in + omarchy-dev-link|omarchy-dev-unlink) sudo /usr/bin/true ;; + git) + [[ $1 == "clone" ]] || exit 90 + /usr/bin/cp -a "$SUDO_TEST_ROOT" "${@: -1}" + mkdir -p "${@: -1}/.git" "${@: -1}/shell" + ;; +esac +STUB + chmod +x "$SUDO_TEST_ROOT/bin/$command" +done + +assert_scoped_channel() { + local label=$1 + assert_boundary_cold "$label" + python3 - "$SUDO_TEST_LOG" <<'PY' +import sys +events = open(sys.argv[1]).read().splitlines() +assert events[0] == 'sudo -k', events +sudo = [event for event in events if event.startswith('sudo ')] +assert all(event in ('sudo -h', 'sudo -k') or event.startswith('sudo -N ') for event in sudo), events +hooks = [i for i, event in enumerate(events) if event.startswith('step:omarchy-hook ')] +assert len(hooks) == 2, events +assert events[hooks[-1]] == 'step:omarchy-hook pre-refresh-pacman', events +assert not any(event.startswith('sudo -N ') for event in events[hooks[0]:]), events +PY +} + +run_channel() { + "$OMARCHY_PATH/bin/omarchy-channel-set" "$@" >"$boundary_tmp/output" 2>&1 +} +for channel in stable rc edge dev; do + reset_boundary + run_channel "$channel" || fail "$channel failed" "$(<"$boundary_tmp/output")" + assert_scoped_channel "$channel" + pass "$channel starts cold, authorizes only individual commands, defers hooks and exits cold" +done + +reset_boundary +OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")" +assert_scoped_channel "dev to stable" +pass "leaving dev preserves no-update sudo through unlink and the packaged update" + +mkdir "$boundary_tmp/user tools" +cat >"$boundary_tmp/user tools/channel-user-tool" <<'STUB' +#!/bin/bash +printf 'user-tool:%s\n' "$*" >>"$SUDO_TEST_LOG" +STUB +chmod +x "$boundary_tmp/user tools/channel-user-tool" +for command in omarchy-hook omarchy-update-mise; do + rm "$SUDO_TEST_ROOT/bin/$command" + cat >"$SUDO_TEST_ROOT/bin/$command" <<'STUB' +#!/bin/bash +[[ ! -e $SUDO_TEST_CACHE ]] || exit 91 +[[ $(command -v sudo) == "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" ]] || exit 92 +channel-user-tool "${0##*/}" "$@" +STUB + chmod +x "$SUDO_TEST_ROOT/bin/$command" +done +reset_boundary +PATH="$boundary_tmp/user tools:$PATH" run_channel stable || fail "channel hooks lost the user's PATH" "$(<"$boundary_tmp/output")" +for event in 'omarchy-hook post-update' 'omarchy-update-mise' 'omarchy-hook pre-refresh-pacman'; do + grep -Fxq "user-tool:$event" "$SUDO_TEST_LOG" || fail "user PATH was not preserved for $event" +done +assert_boundary_cold "channel user PATH" +for command in omarchy-hook omarchy-update-mise; do + ln -sfn test-step "$SUDO_TEST_ROOT/bin/$command" +done +pass "channel switching preserves user tools behind the wrapper for both hooks and mise" + +for step in pacman omarchy-update-system-pkgs omarchy-hook; do + reset_boundary + if SUDO_TEST_FAIL_STEP="$step" run_channel stable; then fail "$step failure was ignored"; fi + assert_boundary_cold "$step failure" + if grep -q '^step:omarchy-hook pre-refresh-pacman$' "$SUDO_TEST_LOG"; then fail "$step failure reached the deferred hook"; fi + pass "$step failure exits cold without the deferred hook" +done + +for signal in HUP INT TERM; do + reset_boundary + cat >"$SUDO_TEST_ROOT/bin/omarchy-dev-unlink" <<'STUB' +#!/bin/bash +sudo /usr/bin/true || exit 1 +kill -s "$SUDO_TEST_CHANNEL_SIGNAL" "$PPID" +STUB + if SUDO_TEST_CHANNEL_SIGNAL="$signal" run_channel stable; then fail "$signal was ignored"; fi + assert_boundary_cold "$signal" + if grep -q '^step:omarchy-hook ' "$SUDO_TEST_LOG"; then fail "$signal reached an update hook"; fi + pass "$signal stops the channel transition and revokes authorization" +done + +for refusal in unsupported-sudo failed-revocation ordinary-bash; do + reset_boundary + case "$refusal" in + unsupported-sudo) export SUDO_TEST_UNSUPPORTED=1 ;; + failed-revocation) export SUDO_TEST_REVOKE_FAIL=1 ;; + esac + if [[ $refusal == "ordinary-bash" ]]; then + if /usr/bin/bash "$SUDO_TEST_ROOT/bin/omarchy-channel-set" -p >"$boundary_tmp/output" 2>&1; then fail "$refusal was accepted"; fi + elif run_channel stable; then + fail "$refusal was accepted" + fi + if grep -q '^step:' "$SUDO_TEST_LOG"; then fail "$refusal reached channel work"; fi + pass "$refusal is rejected before channel work" +done diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index a799e6e0..712ef8a4 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -4,10 +4,18 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" -test_tmp=$(mktemp -d) -trap 'rm -rf "$test_tmp"' EXIT +source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" +test_tmp="$boundary_tmp" +package_root="$SUDO_TEST_ROOT" +copy_boundary_file bin/omarchy-channel-set +python3 - "$SUDO_TEST_ROOT/bin/omarchy-channel-set" "$package_root" <<'PYTHON' +import sys +from pathlib import Path +p = Path(sys.argv[1]) +p.write_text(p.read_text().replace("/usr/share/omarchy", sys.argv[2])) +PYTHON -stub_bin="$test_tmp/bin" +stub_bin="$SUDO_TEST_ROOT/bin" log_file="$test_tmp/channel.log" mkdir -p "$stub_bin" "$test_tmp/home" @@ -15,6 +23,7 @@ write_stub() { local name="$1" local body="$2" + rm -f "$stub_bin/$name" cat >"$stub_bin/$name" <<<"$body" chmod +x "$stub_bin/$name" } @@ -26,11 +35,17 @@ printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' write_stub sudo '#!/bin/bash +case "${1:-}" in + -h) echo "usage: sudo [-ABbEHkNnPS] command"; exit 0 ;; + -k|-K) exit 0 ;; +esac printf "sudo" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' +cp "$stub_bin/sudo" "$SUDO_TEST_ROOT/mock/sudo" + write_stub omarchy-dev-unlink '#!/bin/bash printf "unlink" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done @@ -63,7 +78,8 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" if [[ $1 == "clone" ]]; then dest="${@: -1}" - mkdir -p "$dest/.git" "$dest/bin" "$dest/default" "$dest/shell" + /usr/bin/cp -a "$SUDO_TEST_ROOT" "$dest" + mkdir -p "$dest/.git" "$dest/shell" fi ' @@ -90,10 +106,10 @@ esac run_channel() { : >"$log_file" OMARCHY_CHANNEL_TEST_LOG="$log_file" \ - OMARCHY_PATH="${OMARCHY_TEST_PATH:-/usr/share/omarchy}" \ + OMARCHY_PATH="${OMARCHY_TEST_PATH:-$package_root}" \ HOME="$test_tmp/home" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ - "$ROOT/bin/omarchy-channel-set" "$@" + "${OMARCHY_TEST_PATH:-$package_root}/bin/omarchy-channel-set" "$@" } assert_log_line() { @@ -106,9 +122,9 @@ assert_log_line() { run_channel stable assert_log_line $'refresh\tstable\tdefer-hook' "stable refreshes the stable pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" +assert_log_line $'sudo\t-N\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt" -assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path" +assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "stable runs the normal update pipeline from the package-backed path" if grep -q $'^state\tset\treboot-required$' "$log_file"; then fail "stable does not require reboot when already package-backed" "$(cat "$log_file")" fi @@ -116,17 +132,19 @@ pass "stable does not require reboot when already package-backed" run_channel rc assert_log_line $'refresh\trc\tdefer-hook' "rc refreshes the rc pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" +assert_log_line $'sudo\t-N\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt" -assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path" +assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "rc runs the normal update pipeline from the package-backed path" -OMARCHY_TEST_PATH="$ROOT" run_channel edge +active_checkout="$test_tmp/active-checkout" +cp -a "$package_root" "$active_checkout" +OMARCHY_TEST_PATH="$active_checkout" run_channel edge assert_log_line $'refresh\tedge\tdefer-hook' "edge refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" +assert_log_line $'sudo\t-N\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev" -assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path" -[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || +assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "edge runs the normal update pipeline from the package-backed path" +[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH='"$package_root" ]] || fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")" pass "edge defers the reboot prompt until the update restart stage" @@ -146,12 +164,12 @@ rmdir "$checkout" run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" assert_log_line $'refresh\tedge\tdefer-hook' "dev refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" +assert_log_line $'sudo\t-N\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file" | sed '/run-deferred/d') == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\tdefer-hook\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file" | sed '/run-deferred/d') == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\tdefer-hook\nsudo\t-N\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" [[ $(tail -1 "$log_file") == $'refresh\tedge\trun-deferred' ]] || fail "channel refresh hook must run after the complete update" diff --git a/test/shell.d/security-entrypoint-symlink-test.sh b/test/shell.d/security-entrypoint-symlink-test.sh index 678a6742..1b00cd13 100755 --- a/test/shell.d/security-entrypoint-symlink-test.sh +++ b/test/shell.d/security-entrypoint-symlink-test.sh @@ -11,7 +11,7 @@ export OMARCHY_UPDATE_LOGGED=1 # source the library beside the resolved command instead of this file. mkdir "$boundary_tmp/links" printf '%s\n' 'touch "$SUDO_TEST_HOME/wrong-library"' >"$boundary_tmp/links/omarchy-security-functions" -for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake; do +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set; do rm -f "$SUDO_TEST_ROOT/bin/$command" copy_boundary_file "bin/$command" ln -s "$SUDO_TEST_ROOT/bin/$command" "$boundary_tmp/links/$command" diff --git a/test/shell.d/security-source-root-test.sh b/test/shell.d/security-source-root-test.sh index c872e78d..25ff2e98 100755 --- a/test/shell.d/security-source-root-test.sh +++ b/test/shell.d/security-source-root-test.sh @@ -51,7 +51,7 @@ pass "a package link must resolve to its named command" # Run the protected entrypoints themselves with a mismatched root. These must # stop before any sudo or operational fixture command, not merely validate in # an isolated library test. -for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake; do +for command in omarchy-update omarchy-refresh-pacman omarchy-update-stay-awake omarchy-channel-set; do rm -f "$SUDO_TEST_ROOT/bin/$command" copy_boundary_file "bin/$command" for root in "$boundary_tmp/other-root" .; do From 0add43f9b70f54b26de0b87846d219b1cdb14e68 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 17:38:36 +0100 Subject: [PATCH 16/29] Check dev update support before changing the system --- bin/omarchy-channel-set | 11 ++++++++++- test/shell.d/channel-sudo-boundary-test.sh | 12 ++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index f1f05063..cf8e515f 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -46,9 +46,18 @@ validate_dev_checkout() { } link_dev_checkout() { - local checkout="$1" + local checkout="$1" required [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + # Check the destination before changing /etc/omarchy.conf or sudo's path. + # An existing checkout is not pulled automatically and may predate this policy. + for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do + if [[ ! -f $checkout/$required || ! -r $checkout/$required || + ( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then + fail "Update the checkout before switching to dev; missing required update support in $required." + fi + done + omarchy-dev-link "$checkout" --no-reboot } diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh index a2aba440..b7fabf02 100755 --- a/test/shell.d/channel-sudo-boundary-test.sh +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -62,6 +62,18 @@ for channel in stable rc edge dev; do pass "$channel starts cold, authorizes only individual commands, defers hooks and exits cold" done +reset_boundary +wrapper="$SUDO_TEST_HOME/omarchy/default/omarchy/sudo-no-update/sudo" +mv "$wrapper" "$boundary_tmp/saved-wrapper" +if run_channel dev; then fail "an old checkout without the wrapper was accepted"; fi +if grep -Eq '^step:omarchy-(dev-link|state)|^sudo -N ' "$SUDO_TEST_LOG"; then + fail "an incompatible dev checkout changed the system before rejection" +fi +grep -q 'Update the checkout before switching to dev' "$boundary_tmp/output" || fail "stale checkout rejection lacks recovery guidance" +assert_boundary_cold "stale checkout" +mv "$boundary_tmp/saved-wrapper" "$wrapper" +pass "a stale dev checkout is rejected before linking or privileged work" + reset_boundary OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev failed" "$(<"$boundary_tmp/output")" assert_scoped_channel "dev to stable" From 4ae25cd4d241392158607ac96edd2e92717ee7ba Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 21:50:47 +0100 Subject: [PATCH 17/29] Keep temporary sudo grants bounded through lifecycle failures --- bin/omarchy-security-functions | 90 ++++++-- bin/omarchy-sudo-passwordless | 133 ++++++++---- docs/passwordless-sudo.md | 25 +++ manual/48-security.md | 2 + test/shell.d/nopasswd-sudo-expiry-test.sh | 70 ++++-- .../passwordless-grant-lifecycle-test.sh | 199 ++++++++++++++++++ 6 files changed, 440 insertions(+), 79 deletions(-) create mode 100644 docs/passwordless-sudo.md create mode 100644 test/shell.d/passwordless-grant-lifecycle-test.sh diff --git a/bin/omarchy-security-functions b/bin/omarchy-security-functions index 2f3d2242..890d4430 100755 --- a/bin/omarchy-security-functions +++ b/bin/omarchy-security-functions @@ -1,10 +1,7 @@ #!/bin/bash # omarchy:hidden=true -# omarchy:summary=Provide internal fail-closed helpers for security-sensitive commands - -# Shared fail-closed primitives for security-sensitive Omarchy commands. This -# file is sourced from the same package-owned bin directory as its consumers. +# 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 @@ -12,25 +9,63 @@ if [[ ${BASH_SOURCE[0]} == "$0" ]]; then fi omarchy_security_require_privileged_bash_startup() { - local pid=${1:-$$} - - [[ $- == *p* && $pid =~ ^[1-9][0-9]*$ ]] || return 1 + [[ $- == *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 "$pid" + [[ $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() { - LC_ALL=C /usr/bin/sudo -h 2>&1 | - /usr/bin/grep -Eq '^usage: sudo .*\[[^]]*N[^]]*\]' + 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 >/dev/null 2>&1 + /usr/bin/sudo -k } omarchy_security_exit_with_revoked_sudo() { @@ -51,6 +86,27 @@ omarchy_security_install_signal_exit_traps() { 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=$? @@ -58,12 +114,6 @@ omarchy_security_run_sudo_cleanup_trap() { "${OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE:-Could not invalidate cached sudo authorization.}" } -omarchy_security_install_sudo_cleanup_traps() { - OMARCHY_SECURITY_SUDO_CLEANUP_MESSAGE=${1:-Could not invalidate cached sudo authorization.} - trap omarchy_security_run_sudo_cleanup_trap EXIT - omarchy_security_install_signal_exit_traps -} - omarchy_security_assert_root_directory() { local path=$1 expected_mode=$2 canonical owner actual_mode diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 717e8aaf..5f7cd1dc 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -4,14 +4,20 @@ # omarchy:args=[MINUTES] # omarchy:requires-sudo=true -source "${BASH_SOURCE[0]%/*}/omarchy-security-functions" || exit 126 +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 } - unset BASH_ENV ENV + omarchy_security_sanitize_bash_environment "$0" "$@" || exit 126 fi set -euo pipefail @@ -22,7 +28,9 @@ 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 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 @@ -31,15 +39,15 @@ usage() { } valid_minutes() { - [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= MAX_MINUTES)) + [[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES)) } valid_uid() { - [[ $1 =~ ^[0-9]+$ ]] && ((10#$1 >= 1 && 10#$1 <= 4294967294)) + [[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294)) } valid_account_name() { - [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] + [[ $1 =~ ^[a-z_][a-z0-9_-]{0,31}\$?$ ]] && (( ${#1} <= 32 )) } resolve_account() { @@ -191,10 +199,13 @@ remove_known_legacy_rules() { # must not survive the next boot. Do not require the account to still # exist: a deleted account could otherwise make the rule immortal and a # later username reuse could activate it again. - /usr/bin/rm -f -- "$file" || failed=1 - [[ -z $GENERATED_RULE_LEGACY_TIMER ]] || - /usr/bin/systemctl stop "${GENERATED_RULE_LEGACY_TIMER}.timer" \ - "${GENERATED_RULE_LEGACY_TIMER}.service" >/dev/null 2>&1 || true + 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, @@ -213,6 +224,7 @@ cleanup_uid_locked() { # 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" } @@ -246,6 +258,7 @@ cleanup_all_locked() { verify_boot_cleanup() { local owner mode canonical current active_rules + [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 [[ -f $BOOT_CLEANUP_FILE && ! -L $BOOT_CLEANUP_FILE ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$BOOT_CLEANUP_FILE") || return 1 [[ $canonical == "$BOOT_CLEANUP_FILE" ]] || return 1 @@ -288,7 +301,7 @@ start_expiry_timer() { # a monotonic OnActiveSec timer pauses while the machine is suspended. /usr/bin/systemd-run --quiet --collect --on-calendar="@${expires}" \ --timer-property=AccuracySec=1s --unit="$timer" \ - -- "$INSTALLED_SELF" __expire "$uid" || return 1 + -- "$INSTALLED_SELF" __expire "$uid" "$timer" || return 1 /usr/bin/systemctl is-active --quiet "${timer}.timer" } @@ -306,6 +319,20 @@ publish_rule() { /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 +} + enable_locked() { local uid="$1" minutes="$2" old_timer="" timer token expires pending_state now resolve_account "$uid" || return 1 @@ -329,34 +356,26 @@ enable_locked() { # grant rather than extending it. pending_state=$(prepare_state_file "$uid" "$ACCOUNT_NAME" "$expires" "$timer") || return 1 if ! start_expiry_timer "$uid" "$expires" "$timer"; then - /usr/bin/rm -f -- "$pending_state" - # Preserve the predecessor fix's fail-closed extension semantics: a caller - # must never mistake a failed replacement for a safely extended grant. - # The old timer is still armed here, but revoking the old rule as well is - # the unambiguous failure state. - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi if ! /usr/bin/mv -fT -- "$pending_state" "$(state_file "$uid")"; then - stop_timer "$timer" - /usr/bin/rm -f -- "$pending_state" - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi - if ! publish_rule "$uid" "$ACCOUNT_NAME"; then - stop_timer "$timer" - /usr/bin/rm -f -- "$(state_file "$uid")" "$(rule_file "$uid")" + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME"; then + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi now=$(current_epoch) || { - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 } - if ((10#$now >= 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer"; then + 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. - cleanup_uid_locked "$uid" || true + abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi [[ -z $old_timer || $old_timer == "$timer" ]] || stop_timer "$old_timer" @@ -364,40 +383,68 @@ enable_locked() { status_locked() { local uid="$1" record state_name expires timer now remainder - resolve_account "$uid" || return 1 - [[ -f $(rule_file "$uid") && ! -L $(rule_file "$uid") ]] || return 1 + resolve_account "$uid" || return 2 + if [[ ! -e $(rule_file "$uid") && ! -L $(rule_file "$uid") ]]; then + return "$STATUS_INACTIVE" + fi record=$(read_state_record "$uid") || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } state_name=${record%%$'\t'*} remainder=${record#*$'\t'} expires=${remainder%%$'\t'*} timer=${record##*$'\t'} [[ $state_name == "$ACCOUNT_NAME" ]] || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } now=$(current_epoch) || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } ((10#$now < 10#$expires)) || { - cleanup_uid_locked "$uid" - return 1 + revoke_inactive_grant "$uid" + return $? } /usr/bin/systemctl is-active --quiet "${timer}.timer" || { - cleanup_uid_locked "$uid" - return 1 + 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 + status=$? + (( status == STATUS_INACTIVE )) + fi +} + root_dispatch() { local action="$1" shift case "$action" in __status) - (($# == 1)) && verify_sudo_caller "$1" || return 1 + (($# == 1)) && verify_sudo_caller "$1" || return 2 with_root_lock status_locked "$1" ;; __enable) @@ -409,8 +456,9 @@ root_dispatch() { with_root_lock cleanup_uid_locked "$1" ;; __expire) - (($# == 1)) && ((EUID == 0)) && valid_uid "$1" || return 1 - with_root_lock cleanup_uid_locked "$1" + (($# == 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 @@ -459,6 +507,11 @@ if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then 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." diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md new file mode 100644 index 00000000..b97160e4 --- /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. 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` and `omarchy-nopasswd-sudo.conf` 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, its scriptlet 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. Failed scriptlet cleanup returns an error and prints recovery guidance; a package-manager scriptlet failure must not be represented as an automatic transaction rollback. + +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/manual/48-security.md b/manual/48-security.md index 86028a23..6db0dff5 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -22,6 +22,8 @@ It works by restoring the baseline snapshot the installer takes, so it's only av 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. ## Signing Keys diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index 6d54633c..06965215 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -13,7 +13,7 @@ trap 'rm -rf "$test_tmp"' EXIT function_prefix() { printf 'source %q\n' "$security_library_path" - awk '/^source .*omarchy-security-functions/ { next } /^case "\$\{1:-\}" in$/ { exit } { print }' "$command_path" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path" } # Exercise the validation code itself. Leading zeroes remain numeric, but zero, @@ -23,7 +23,7 @@ function_prefix() { for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || fail "passwordless sudo accepts bounded duration $minutes" done - for minutes in 0 1441 -1 1m '1;id' ''; do + for minutes in 0 1441 -1 1m '1;id' '' 18446744073709551617; do ! valid_minutes "$minutes" || fail "passwordless sudo rejects invalid duration '$minutes'" done ) @@ -45,8 +45,6 @@ pass "passwordless sudo derives and validates trusted account identity" # model that publishes a token only when -N is missing. grep -Fxq '#!/bin/bash -p' "$command_path" || fail "passwordless sudo no longer suppresses Bash startup injection" -grep -F '[[ ${argv[1]:-} == -p ]]' "$security_library_path" >/dev/null || - fail "passwordless sudo accepts a decoy post-script -p" public_sudo_stub="$test_tmp/public-sudo" public_gum_stub="$test_tmp/public-gum" @@ -67,19 +65,20 @@ if [[ ${1:-} == -N ]]; then no_update=1; shift; fi [[ ${1:-} != -- ]] || shift ((no_update)) || : >"$TEST_PUBLIC_TOKEN" case "${2:-}" in - __status) exit 1 ;; + __status) exit "${TEST_PUBLIC_STATUS:-3}" ;; __enable|__disable) exit 0 ;; *) exit 2 ;; esac 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/cp "$security_library_path" "$test_tmp/omarchy-security-functions" +/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" \ @@ -89,6 +88,16 @@ 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 startup_env="$test_tmp/passwordless-bash-env" startup_marker="$test_tmp/passwordless-bash-env-ran" @@ -341,6 +350,9 @@ pkgs_candidates=( 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 @@ -348,31 +360,36 @@ done [[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" for package_name in omarchy-settings omarchy-settings-dev; do - install_script="$pkgs_root/pkgbuilds/$package_name/$package_name.install" + 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" + 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 ../usr/share/omarchy/etc-overrides/os-release "$removal_root/etc/os-release" - grep -Fq 'ln -s ../usr/share/omarchy/etc-overrides/os-release /etc/os-release' "$install_script" || - fail "$package_name installation does not select package-owned OS metadata" - sed "s#/etc/#$removal_root/etc/#g" "$install_script" >"$transformed_install" + 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" - [[ -L $removal_root/etc/os-release ]] && - [[ $(readlink "$removal_root/etc/os-release") == ../usr/lib/os-release ]] || - fail "$package_name removal does not restore the standard OS selector" - - ln -sfn ../administrator/os-release "$removal_root/etc/os-release" + [[ $(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" @@ -382,8 +399,23 @@ for package_name in omarchy-settings omarchy-settings-dev; do 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" + + ( + 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 removal revokes grants and preserves package-selector ownership" +pass "settings package transitions revoke grants and preserve unrelated configuration" # Exercise the production flock wrapper under contention. mkdir is an atomic # overlap detector; all workers must enter and leave the protected region. @@ -418,7 +450,7 @@ pass "passwordless sudo serializes concurrent operations" # Same-boot expiry calls the fixed installed cleanup command, and cleanup # removes policy before touching a timer so timer failures cannot extend it. -grep -F '"$INSTALLED_SELF" __expire "$uid"' "$command_path" >/dev/null +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) 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 00000000..c70d69e5 --- /dev/null +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -0,0 +1,199 @@ +#!/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" +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|/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" + +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" From a6385e60b836f41895fd016bf170c9b62128c59a Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 22:17:32 +0100 Subject: [PATCH 18/29] Bound sudo policy natively and guard expiry package transactions --- bin/omarchy-sudo-passwordless | 70 ++++++++++++++----- .../hooks/05-omarchy-passwordless-revoke.hook | 12 ++++ docs/passwordless-sudo.md | 6 +- .../passwordless-grant-lifecycle-test.sh | 38 +++++++++- 4 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 default/libalpm/hooks/05-omarchy-passwordless-revoke.hook diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 5f7cd1dc..92d7ae16 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -28,6 +28,7 @@ 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 @@ -182,7 +183,11 @@ classify_generated_rule() { if [[ $suffix =~ ^[0-9]+$ ]]; then name=${contents%' ALL=(ALL) NOPASSWD: ALL'} - valid_account_name "$name" && [[ $contents == "$name 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 @@ -256,17 +261,16 @@ cleanup_all_locked() { return "$failed" } -verify_boot_cleanup() { - local owner mode canonical current active_rules - [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 - [[ -f $BOOT_CLEANUP_FILE && ! -L $BOOT_CLEANUP_FILE ]] || return 1 - canonical=$(/usr/bin/realpath -e -- "$BOOT_CLEANUP_FILE") || return 1 - [[ $canonical == "$BOOT_CLEANUP_FILE" ]] || return 1 - owner=$(/usr/bin/stat -Lc '%u' -- "$BOOT_CLEANUP_FILE") || return 1 - mode=$(/usr/bin/stat -Lc '%a' -- "$BOOT_CLEANUP_FILE") || return 1 +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=${BOOT_CLEANUP_FILE%/*} + current=${file%/*} while :; do [[ -d $current && ! -L $current ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$current") || return 1 @@ -277,9 +281,36 @@ verify_boot_cleanup() { 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-*' ]] + [[ $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() { @@ -306,10 +337,13 @@ start_expiry_timer() { } publish_rule() { - local uid="$1" name="$2" destination tmp + 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) NOPASSWD: ALL\n' "$name" >"$tmp" || + 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 @@ -339,7 +373,7 @@ enable_locked() { valid_minutes "$minutes" || return 1 prepare_root_state || return 1 verify_boot_cleanup || { - echo "omarchy-sudo-passwordless: package-owned boot cleanup rule is missing or unsafe" >&2 + echo "omarchy-sudo-passwordless: package-owned boot cleanup or transaction hook is missing or unsafe" >&2 return 1 } @@ -363,7 +397,7 @@ enable_locked() { abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi - if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME"; then + if ! verify_boot_cleanup || ! publish_rule "$uid" "$ACCOUNT_NAME" "$expires"; then abort_enable_locked "$uid" "$timer" "$old_timer" "$pending_state" return 1 fi @@ -464,12 +498,16 @@ root_dispatch() { (($# == 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) + __status|__enable|__disable|__expire|__cleanup-all|__package-removing) action=$1 shift root_dispatch "$action" "$@" 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 00000000..f0bce15d --- /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 index b97160e4..0e805e6a 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -4,7 +4,7 @@ ## 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. 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. +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. @@ -12,9 +12,9 @@ Each new expiry callback carries its timer identity. A delayed predecessor canno ## Package ownership -The packaging companion must put the publication/expiry command, `omarchy-security-functions` and `omarchy-nopasswd-sudo.conf` 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. +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, its scriptlet 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. Failed scriptlet cleanup returns an error and prints recovery guidance; a package-manager scriptlet failure must not be represented as an automatic transaction rollback. +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. diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index c70d69e5..b3198405 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -19,7 +19,7 @@ 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" +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 @@ -60,6 +60,7 @@ library="$test_tmp/grant-functions.sh" -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" \ @@ -68,6 +69,8 @@ library="$test_tmp/grant-functions.sh" -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" @@ -197,3 +200,36 @@ if TEST_FAIL_RULE_DELETE=1 bash -euo pipefail -c 'source "$1"; post_remove' bash 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" From 5bfc8b5cc3403971388e03c192e0afec9b570574 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Tue, 8 Sep 2026 19:47:11 +0200 Subject: [PATCH 19/29] Update packaging companion instructions for clarity Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/passwordless-sudo.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 0e805e6a..9c58b291 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -12,7 +12,7 @@ Each new expiry callback carries its timer identity. A delayed predecessor canno ## 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. +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. From 212a4540396101c02b8c31bbe5cb1428191e0624 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 10 Sep 2026 15:12:11 +0100 Subject: [PATCH 20/29] Clarify update hook timing in the user manual --- manual/31-dotfiles.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/manual/31-dotfiles.md b/manual/31-dotfiles.md index ec148b44..a81a6596 100644 --- a/manual/31-dotfiles.md +++ b/manual/31-dotfiles.md @@ -37,12 +37,14 @@ Omarchy fires hooks at a handful of moments, and you can hang your own scripts o | Event | When it runs | | ----- | ------------ | | `post-boot` | Right after the desktop has started | -| `post-update` | During `omarchy update`, after packages and migrations | -| `pre-refresh-pacman` | Before `omarchy refresh pacman` re-syncs the package config | +| `post-update` | Near the end of `omarchy update`, after packages, migrations, and service restarts, before mise tools are updated | +| `pre-refresh-pacman` | After `omarchy refresh pacman` re-syncs the package config and finishes updating packages; during a channel switch, after the package switch and complete update finish | | `theme-set` | After a theme change (theme name in `$1`) | | `font-set` | After a font change (font name in `$1`) | | `battery-low` | When the battery gets low (percentage in `$1`) | +The `pre-refresh-pacman` name is kept for compatibility. Its edits to the refreshed package config apply to future package operations, after the current transaction has finished. Both update-related hooks run as your user after Omarchy clears its cached sudo authorization, so a hook that uses `sudo` needs its own authorization and may ask for your password. + Each of those directories already holds a `.sample` file showing the shape of a hook — drop the `.sample` from the name to put it to work. To install a script you've written elsewhere, use `omarchy hook install post-boot ~/my-hook`, which copies it in and makes it executable. ### Adding your own menu entries From c46f321680744bc8a53c069e4571595fdabde067 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 10 Sep 2026 22:01:09 +0100 Subject: [PATCH 21/29] Simplify passwordless sudo grant lifecycle --- bin/omarchy-sudo-passwordless | 420 ++++--------- docs/passwordless-sudo.md | 10 +- migrations/1788163635.sh | 8 +- .../fixtures/passwordless-sudo-test.sh | 125 ++++ test/shell.d/nopasswd-sudo-expiry-test.sh | 579 +++++------------- .../passwordless-grant-lifecycle-test.sh | 327 ++++------ 6 files changed, 525 insertions(+), 944 deletions(-) create mode 100644 test/shell.d/fixtures/passwordless-sudo-test.sh diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 92d7ae16..91695c69 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -24,12 +24,11 @@ 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 MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635 readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 @@ -73,26 +72,6 @@ verify_sudo_caller() { 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: @@ -116,67 +95,23 @@ 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 +# The sudoers rule is the only grant record. A missing file is distinct from +# an unreadable, unsafe, or administrator-modified file. +read_grant() { + local file contents + file=$(rule_file "$1") + [[ -e $file || -L $file ]] || return "$STATUS_INACTIVE" + verify_root_path "$file" && [[ -f $file ]] || return 2 + contents=$(/usr/bin/cat -- "$file") || return 2 + [[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOTAFTER=([0-9]{14}Z)\ NOPASSWD:\ ALL$ ]] || return 2 + GRANT_NAME=${BASH_REMATCH[1]} + GRANT_DEADLINE=${BASH_REMATCH[2]} + valid_account_name "$GRANT_NAME" || return 2 } classify_generated_rule() { local file=$1 suffix contents name - GENERATED_RULE_LEGACY_TIMER="" [[ -f $file && ! -L $file ]] || return 1 contents=$(/usr/bin/cat -- "$file") || return 2 suffix=${file##*/99-omarchy-nopasswd-} @@ -189,81 +124,40 @@ classify_generated_rule() { 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}" + return 0 else return 1 fi } -remove_known_legacy_rules() { +cleanup_uid_locked() { + local file + file=$(rule_file "$1") + [[ -e $file || -L $file ]] || return 0 + verify_root_path "$file" && classify_generated_rule "$file" || return 1 + /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]] +} + +cleanup_all_locked() { local file classification failed=0 - shopt -s nullglob + verify_root_path /etc/sudoers.d || return 1 for file in /etc/sudoers.d/99-omarchy-nopasswd-*; do + [[ -e $file || -L $file ]] || continue if classify_generated_rule "$file"; then - # 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 + if ! /usr/bin/rm -f -- "$file" || [[ -e $file || -L $file ]]; then 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() { +verify_root_path() { local file=$1 owner mode canonical current - [[ -f $file && ! -L $file ]] || return 1 + [[ ( -f $file || -d $file ) && ! -L $file ]] || return 1 canonical=$(/usr/bin/realpath -e -- "$file") || return 1 [[ $canonical == "$file" ]] || return 1 owner=$(/usr/bin/stat -Lc '%u' -- "$file") || return 1 @@ -286,10 +180,10 @@ verify_root_policy_file() { verify_boot_cleanup() { local active_rules hook [[ ! -e $REMOVAL_BLOCKER && ! -L $REMOVAL_BLOCKER ]] || return 1 - verify_root_policy_file "$BOOT_CLEANUP_FILE" || return 1 + verify_root_path "$BOOT_CLEANUP_FILE" || return 1 active_rules=$(/usr/bin/awk '!/^[[:space:]]*(#|$)/ { print }' "$BOOT_CLEANUP_FILE") || return 1 [[ $active_rules == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || return 1 - verify_root_policy_file "$PACKAGE_HOOK" || return 1 + verify_root_path "$PACKAGE_HOOK" || return 1 hook=$(/usr/bin/cat -- "$PACKAGE_HOOK") || return 1 [[ $hook == '[Trigger] Operation = Upgrade @@ -313,166 +207,113 @@ package_removing_locked() { 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 +migration_complete() { + [[ -f $MIGRATION_MARKER && ! -s $MIGRATION_MARKER ]] && verify_root_path "$MIGRATION_MARKER" +} + +migrate_locked() { + local directory + if migration_complete; then + return 0 fi - printf '%s' "$tmp" + [[ ! -e $MIGRATION_MARKER && ! -L $MIGRATION_MARKER ]] || return 1 + verify_root_path /var/lib || return 1 + for directory in /var/lib/omarchy /var/lib/omarchy/migrations; do + if [[ ! -e $directory && ! -L $directory ]]; then + /usr/bin/install -d -o root -g root -m 0755 -- "$directory" || return 1 + fi + verify_root_path "$directory" || return 1 + done + cleanup_all_locked || return 1 + # The empty marker is written only after cleanup succeeds, under the same + # machine lock. Later accounts need no sudo and cannot revoke newer grants. + /usr/bin/install -o root -g root -m 0644 /dev/null "$MIGRATION_MARKER" } -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" -} - -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" +# Old callbacks only remove an expired current rule. Renewing a grant never +# needs a second state file or a stored timer generation to identify it. +expire_locked() { + local status now + if read_grant "$1"; then + now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 + [[ $now < $GRANT_DEADLINE ]] && return 0 + cleanup_uid_locked "$1" else - echo "Could not revoke passwordless sudo after a failed grant; expiry jobs remain armed. Administrator cleanup is required." >&2 + status=$? + if (( status == STATUS_INACTIVE )); then + return 0 + else + cleanup_uid_locked "$1" + fi fi - /usr/bin/rm -f -- "$pending_state" || true - return 1 -} - -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 - } - - old_timer=$(read_state_timer "$uid" 2>/dev/null || true) - token=$(/usr/bin/tr -d '-' = 10#$expires)) || ! /usr/bin/systemctl is-active --quiet "${timer}.timer" || ! 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 + local status now + resolve_account "$1" || return 2 + if read_grant "$1"; then + [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2 + now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 + if [[ $now < $GRANT_DEADLINE ]]; then + return 0 + fi + cleanup_uid_locked "$1" || return 2 return "$STATUS_INACTIVE" - 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 status=$? - (( status == STATUS_INACTIVE )) + return "$status" fi } +finish_enable() { + local status=$? + trap - EXIT HUP INT TERM + if (( status != 0 )); then + if cleanup_uid_locked "$uid"; then + [[ -z $timer ]] || /usr/bin/systemctl stop "$timer.timer" "$timer.service" >/dev/null 2>&1 || true + else + echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2 + fi + fi + [[ -z $pending ]] || /usr/bin/rm -f -- "$pending" + exit "$status" +} + +enable_locked() ( + local uid=$1 minutes=$2 now expires deadline token timer="" pending="" file status + resolve_account "$uid" && valid_minutes "$minutes" || return 1 + verify_boot_cleanup && verify_root_path /etc/sudoers.d || return 1 + file=$(rule_file "$uid") + if read_grant "$uid"; then + [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1 + else + status=$? + (( status == STATUS_INACTIVE )) || return 1 + fi + trap finish_enable EXIT + omarchy_security_install_signal_exit_traps + now=$(/usr/bin/date +%s) || return 1 + expires=$((now + 10#$minutes * 60)) + deadline=$(/usr/bin/date -u -d "@$expires" +%Y%m%d%H%M%SZ) || return 1 + pending=$(/usr/bin/mktemp /etc/sudoers.d/.omarchy-nopasswd.XXXXXX) || return 1 + /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1 + /usr/bin/chown root:root "$pending" && /usr/bin/chmod 0440 "$pending" || return 1 + /usr/sbin/visudo -cf "$pending" >/dev/null || return 1 + token=$(/usr/bin/tr -d '-' /dev/null || true + wait "${children[@]}" 2>/dev/null || true + fi + rm -rf "$test_tmp" + exit "$status" +} +trap cleanup_grant_fixture EXIT +export TEST_GRANT_ROOT=$test_tmp +mkdir -p "$test_tmp/bin" "$test_tmp/etc/sudoers.d" "$test_tmp/etc/tmpfiles.d" "$test_tmp/run/lock" "$test_tmp/var/lib" "$test_tmp/hooks" +cat >"$test_tmp/bin/mock" <<'STUB' +#!/bin/bash +set -euo pipefail +name=${0##*/} +printf '%s %s\n' "$name" "$*" >>"$TEST_GRANT_ROOT/commands" +case "$name" in + stat) + path=${@: -1} + owner=0 + mode=$(/usr/bin/stat -Lc '%a' -- "$path") + [[ $path != /tmp ]] || mode=755 + [[ $path != "${TEST_BAD_PATH:-}" ]] || owner=1000 + case $2 in + '%u') echo "$owner" ;; + '%a') echo "$mode" ;; + '%u %a') echo "$owner $mode" ;; + *) exec /usr/bin/stat "$@" ;; + esac + ;; + chown) exit 0 ;; + install) + args=() + while (($#)); do + case $1 in -o|-g) shift 2 ;; *) args+=("$1"); shift ;; esac + done + exec /usr/bin/install "${args[@]}" + ;; + rm) + for path in "$@"; do + if [[ ${TEST_DELETE_FAIL:-0} == 1 && $path == "$TEST_GRANT_ROOT/etc/sudoers.d/99-omarchy-nopasswd-1000" ]]; then exit 1; fi + done + exec /usr/bin/rm "$@" + ;; + mv) + [[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1 + /usr/bin/mv "$@" + [[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing" + ;; + systemd-run) + [[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1 + if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi + ;; + systemctl) + [[ $1 != "is-active" || ${TEST_INACTIVE_TIMER:-0} != 1 ]] + ;; + date) + if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi + ;; + getent) printf '%s:x:1000:1000:Test:/nonexistent:/bin/bash\n' "${TEST_ACCOUNT:-audituser}" ;; + sudo) + if [[ ${1:-} == -h ]]; then echo 'usage: sudo [-N] command'; exit 0; fi + if [[ ${1:-} == -k ]]; then exit 0; fi + if [[ ${1:-} == -N ]]; then shift; fi + if [[ ${1:-} == -- ]]; then shift; fi + if [[ ${TEST_MIGRATION:-0} == 1 ]]; then + [[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1 + TEST_EUID=0 /usr/bin/bash -p "$@" + else + [[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}" + fi + ;; + gum) exit 1 ;; + *) exit 99 ;; +esac +STUB +chmod +x "$test_tmp/bin/mock" +for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do + ln -s mock "$test_tmp/bin/$name" +done + +python3 - "$ROOT" "$test_tmp" <<'PY' +from pathlib import Path +import sys +root, temp = map(Path, sys.argv[1:]) +for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'): + text = (root/'bin'/name).read_text() + for path in ('/etc/', '/var/lib', '/run/', '/usr/share/libalpm/hooks'): + target = str(temp/'hooks') if path == '/usr/share/libalpm/hooks' else str(temp) + path + text = text.replace(path, target) + text = text.replace('((EUID == 0))', '((${TEST_EUID:-1} == 0))') + for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum'): + text = text.replace('/usr/bin/' + command, str(temp/'bin'/command)) + (temp/name).write_text(text) + (temp/name).chmod(0o755) +PY +library="$test_tmp/functions.sh" +{ + printf 'source %q\n' "$test_tmp/omarchy-security-functions" + awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$test_tmp/omarchy-sudo-passwordless" +} >"$library" +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" +sed "s|/etc/|$test_tmp/etc/|g" "$ROOT/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" >"$test_tmp/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf" +: >"$test_tmp/commands" + +# New subshell per case prevents one test's overrides and readonly constants +# from affecting the next. External commands log enough to verify ordering. +assert_status() { + local expected=$1 actual=0 + shift + "$@" || actual=$? + (( actual == expected )) || fail "expected status $expected, got $actual from $*" +} +reset_grant() { + rm -f "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" "$test_tmp/run/omarchy-sudo-passwordless-package-removing" + : >"$test_tmp/commands" +} diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index 06965215..173ea1b0 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -1,458 +1,163 @@ #!/bin/bash set -euo pipefail - source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" +source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" -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 - -function_prefix() { - printf 'source %q\n' "$security_library_path" - awk '/^set -euo pipefail$/ { functions=1 } /^case "\$\{1:-\}" in$/ { exit } functions { print }' "$command_path" -} - -# 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 + source "$library" + for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done + for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done + for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done + for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done + ! valid_uid 18446744073709551617 ) -pass "passwordless sudo validates a bounded positive duration" +pass "duration and account validation retains bounded inputs and trailing-dollar usernames" -# 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" +( + source "$library" + assert_status 2 root_dispatch __status 1000 + assert_status 2 env TEST_EUID=0 SUDO_UID=1001 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 + assert_status 3 env TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 +) +pass "internal actions reject missing root and mismatched sudo identity" -# 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" - -public_sudo_stub="$test_tmp/public-sudo" -public_gum_stub="$test_tmp/public-gum" -public_token="$test_tmp/public-token" -public_exploit="$test_tmp/public-exploit" -cat >"$public_sudo_stub" <<'STUB' -#!/bin/bash -if [[ ${1:-} == -h ]]; then - echo 'usage: sudo [-ABbEHkNnPS] command' - exit 0 -fi -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 -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" +for status in 1 2 3; do + : >"$test_tmp/commands" + result=0 + TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 || result=$? + if (( status == 3 )); then + (( result == 0 )) && grep -q '^gum confirm ' "$test_tmp/commands" || fail "inactive status must allow confirmation" + else + (( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement" fi - [[ ! -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" + grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization" + [[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] || fail "public exit must revoke its authorization" done +pass "public status distinguishes inactive from errors and revokes authorization on exit" -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" +printf ': >"$TEST_STARTUP_MARKER"\nset -o privileged\nunset BASH_ENV\n' >"$test_tmp/startup" +: >"$test_tmp/commands" +if TEST_STARTUP_MARKER="$test_tmp/startup-ran" BASH_ENV="$test_tmp/startup" bash "$test_tmp/omarchy-sudo-passwordless" -p >/dev/null 2>&1; then + fail "ordinary Bash with a decoy -p was accepted" fi -[[ -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" +[[ -f $test_tmp/startup-ran && ! -s $test_tmp/commands ]] || fail "startup rejection must precede sudo" +pass "startup validation rejects ordinary Bash before authorization" -# Source a path-rewritten copy so the real cleanup implementation can be -# exercised without touching /etc. Exact generated numeric rules are removed -# even after account deletion or a crash before state publication. Anything an -# administrator changed, and every symlink, is preserved. -fake_sudoers="$test_tmp/sudoers.d" -mkdir "$fake_sudoers" -rewritten="$test_tmp/passwordless-lib.sh" -function_prefix | sed "s#/etc/sudoers.d#$fake_sudoers#g" >"$rewritten" ( - source "$rewritten" - printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$fake_sudoers/99-omarchy-nopasswd-424242" - printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/pacman\n' >"$fake_sudoers/99-omarchy-nopasswd-424243" - ln -s "$fake_sudoers/99-omarchy-nopasswd-424243" "$fake_sudoers/99-omarchy-nopasswd-424244" - remove_known_legacy_rules -) -[[ ! -e $fake_sudoers/99-omarchy-nopasswd-424242 ]] || - fail "boot cleanup removes a state-less numeric orphan" -[[ -f $fake_sudoers/99-omarchy-nopasswd-424243 ]] || - fail "boot cleanup preserves administrator-authored policy" -[[ -L $fake_sudoers/99-omarchy-nopasswd-424244 ]] || - fail "boot cleanup refuses sudoers symlinks" -pass "boot cleanup removes crash/deleted-account orphans conservatively" - -# A boot gate must not report success when deletion itself fails. Exercise the -# real cleanup and post-cleanup verification with a deterministic failing rm. -rm_failure_dir="$test_tmp/rm-failure-sudoers" -mkdir "$rm_failure_dir" -printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$rm_failure_dir/99-omarchy-nopasswd-424245" -failing_rm="$test_tmp/failing-rm" -cat >"$failing_rm" <<'FAILING_RM' -#!/bin/bash -exit 1 -FAILING_RM -chmod +x "$failing_rm" -rm_failure_lib="$test_tmp/rm-failure-lib.sh" -function_prefix | - sed -e "s#/etc/sudoers.d#$rm_failure_dir#g" \ - -e "s#/var/lib/omarchy/sudo-passwordless#$test_tmp/empty-state#g" \ - -e "s#/usr/bin/rm#$failing_rm#g" >"$rm_failure_lib" -mkdir "$test_tmp/empty-state" -( - source "$rm_failure_lib" - ! cleanup_all_locked -) || fail "boot cleanup fails when an Omarchy rule cannot be removed" -[[ -f $rm_failure_dir/99-omarchy-nopasswd-424245 ]] || - fail "rm-failure fixture remains available for verification" -pass "boot cleanup fails closed when policy deletion fails" - -# Reproduce the migration's real sudo provenance: sudo sets SUDO_UID. Rewrite -# only the read-only EUID probe so this unprivileged test can exercise the root -# dispatcher, then assert that cleanup (which can only revoke privilege) runs. -dispatch_lib="$test_tmp/dispatch-lib.sh" -function_prefix | sed 's/((EUID == 0))/((TEST_EUID == 0))/g' >"$dispatch_lib" -( - source "$dispatch_lib" - called="" - cleanup_all_locked() { called=cleanup; } - with_root_lock() { "$@"; } - TEST_EUID=0 SUDO_UID=1000 root_dispatch __cleanup-all - [[ $called == cleanup ]] -) || fail "migration cleanup dispatch accepts authenticated sudo provenance" -pass "migration can invoke fail-closed cleanup through sudo" - -# A grant cannot be published until the static unit is verified/enabled, and a -# timer setup failure removes its pending state without calling publish_rule. -transaction_dir="$test_tmp/transaction" -mkdir "$transaction_dir" -transaction_lib="$test_tmp/transaction-lib.sh" -function_prefix | sed "s#/var/lib/omarchy/sudo-passwordless#$transaction_dir#g" >"$transaction_lib" -( - source "$transaction_lib" - ACCOUNT_NAME=audituser - resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } - prepare_root_state() { :; } - verify_boot_cleanup() { return 1; } - publish_rule() { return 99; } - ! enable_locked 1000 15 -) -( - source "$transaction_lib" - ACCOUNT_NAME=audituser - resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } - prepare_root_state() { :; } - verify_boot_cleanup() { return 0; } - read_state_timer() { return 1; } - prepare_state_file() { local pending="$transaction_dir/pending"; : >"$pending"; printf %s "$pending"; } - start_expiry_timer() { return 1; } - publish_rule() { printf published >"$transaction_dir/published"; } - cleanup_uid_locked() { : >"$transaction_dir/failed-timer-cleanup"; } - ! enable_locked 1000 15 - [[ ! -e $transaction_dir/pending && ! -e $transaction_dir/published && - -e $transaction_dir/failed-timer-cleanup ]] -) || fail "passwordless sudo fails closed on prerequisite/timer failure" -pass "passwordless sudo publishes no rule after partial setup failure" - -# Erik's predecessor fix revoked an already-active grant when an extension -# could not arm its replacement timer. Keep that fail-closed property while -# the new transaction deliberately leaves the old timer armed until the new -# one is verified. -replacement_state="$transaction_dir/1000.state" -replacement_rule="$transaction_dir/1000.rule" -replacement_stopped="$transaction_dir/old-timer-stopped" -old_timer=omarchy-nopasswd-expire-1000-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -printf 'UID=1000\nUSER=audituser\nEXPIRES=2000000000\nTIMER=%s\n' "$old_timer" >"$replacement_state" -printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$replacement_rule" -( - source "$transaction_lib" - resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } - prepare_root_state() { :; } - verify_boot_cleanup() { return 0; } - state_file() { printf '%s' "$replacement_state"; } - rule_file() { printf '%s' "$replacement_rule"; } - prepare_state_file() { local pending="$transaction_dir/replacement-pending"; : >"$pending"; printf %s "$pending"; } - start_expiry_timer() { return 1; } - stop_timer() { [[ $1 == "$old_timer" ]] && : >"$replacement_stopped"; } - ! enable_locked 1000 30 - [[ ! -e $replacement_state && ! -e $replacement_rule && -e $replacement_stopped ]] -) || fail "passwordless sudo leaves an existing grant live after replacement timer failure" -pass "replacement timer failure revokes the existing grant" - -# Expiry is a wall-clock promise, so the transient timer must carry the exact -# absolute epoch recorded in root state. A monotonic-only --on-active timer -# pauses during suspend and can otherwise extend a short grant by hours. -timer_args="$test_tmp/timer-args" -calendar_systemd_run="$test_tmp/calendar-systemd-run" -calendar_systemctl="$test_tmp/calendar-systemctl" -cat >"$calendar_systemd_run" <<'STUB' -#!/bin/bash -printf '%s\n' "$@" >"$TEST_TIMER_ARGS" -STUB -cat >"$calendar_systemctl" <<'STUB' -#!/bin/bash -exit 0 -STUB -chmod 0755 "$calendar_systemd_run" "$calendar_systemctl" -calendar_lib="$test_tmp/calendar-lib.sh" -function_prefix | - sed -e "s#/usr/bin/systemd-run#$calendar_systemd_run#g" \ - -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$calendar_lib" -( - source "$calendar_lib" - TEST_TIMER_ARGS="$timer_args" start_expiry_timer 1000 2000000000 \ - omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef -) || fail "passwordless sudo cannot arm its absolute expiry timer" -grep -Fx -- '--on-calendar=@2000000000' "$timer_args" >/dev/null || - fail "passwordless sudo timer does not advance across suspend" -pass "passwordless sudo arms the recorded absolute wall-clock expiry" - -# A resumed machine can briefly observe the timer as active before systemd -# dispatches its overdue service. Status must independently enforce EXPIRES and -# synchronously remove policy instead of trusting timer activity alone. -expired_state="$test_tmp/expired-state" -expired_sudoers="$test_tmp/expired-sudoers" -mkdir "$expired_state" "$expired_sudoers" -expired_timer=omarchy-nopasswd-expire-1000-0123456789abcdef0123456789abcdef -printf 'UID=1000\nUSER=audituser\nEXPIRES=1\nTIMER=%s\n' "$expired_timer" >"$expired_state/1000.state" -printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$expired_sudoers/99-omarchy-nopasswd-1000" -expired_lib="$test_tmp/expired-lib.sh" -function_prefix | - sed -e "s#/var/lib/omarchy/sudo-passwordless#$expired_state#g" \ - -e "s#/etc/sudoers.d#$expired_sudoers#g" \ - -e "s#/usr/bin/systemctl#$calendar_systemctl#g" >"$expired_lib" -( - source "$expired_lib" - resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } - ! status_locked 1000 -) || fail "passwordless sudo accepts expired root state while its timer is active" -[[ ! -e $expired_state/1000.state && ! -e $expired_sudoers/99-omarchy-nopasswd-1000 ]] || - fail "passwordless sudo does not synchronously revoke expired state" -pass "passwordless sudo enforces wall-clock expiry independently of timer dispatch" - -# If the transient timer fires between its first active check and publication, -# the just-created rule must be synchronously revoked instead of surviving to -# reboot. Model that narrow transition with the real enable transaction. -inactive_systemctl="$test_tmp/inactive-systemctl" -cat >"$inactive_systemctl" <<'STUB' -#!/bin/bash -exit 1 -STUB -chmod 0755 "$inactive_systemctl" -post_publish_lib="$test_tmp/post-publish-lib.sh" -sed "s#/usr/bin/systemctl#$inactive_systemctl#g" "$transaction_lib" >"$post_publish_lib" -( - source "$post_publish_lib" - resolve_account() { ACCOUNT_NAME=audituser; ACCOUNT_UID=1000; } - prepare_root_state() { :; } - verify_boot_cleanup() { return 0; } - read_state_timer() { return 1; } - prepare_state_file() { local pending="$transaction_dir/pending-after-arm"; : >"$pending"; printf %s "$pending"; } - start_expiry_timer() { return 0; } - publish_rule() { : >"$transaction_dir/published-after-arm"; } - cleanup_uid_locked() { rm -f "$transaction_dir/published-after-arm"; : >"$transaction_dir/revoked-after-arm"; } - ! enable_locked 1000 15 - [[ ! -e $transaction_dir/published-after-arm && -e $transaction_dir/revoked-after-arm ]] -) || fail "passwordless sudo leaves a grant when its armed timer expires before publication completes" -pass "timer expiry during publication revokes the grant synchronously" - -# Follow the maintainer's package-owned tmpfiles design: one boot-only rule -# owns this filename namespace. A routine --remove leaves live grants alone; -# early boot removes them before a user can log in. The migration only revokes -# legacy runtime state and never writes static policy into /usr. -mapfile -t tmpfiles_rules < <(/usr/bin/grep -vE '^[[:space:]]*(#|$)' "$tmpfiles_path") -(( ${#tmpfiles_rules[@]} == 1 )) || fail "passwordless sudo ships one boot cleanup rule" -[[ ${tmpfiles_rules[0]} == 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' ]] || - fail "passwordless sudo boot cleanup does not own the exact generated namespace" -fake_root="$test_tmp/tmpfiles-root" -sudoers_dir="$fake_root/etc/sudoers.d" -mkdir -p "$sudoers_dir" -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 + source "$library" + enable_locked 1000 15 + read_grant 1000 + [[ $GRANT_NAME == audituser && $(stat -c '%a' "$(rule_file 1000)") == 440 ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + expiry=$(sed -n 's/^systemd-run .*--on-calendar=@\([0-9]*\).*$/\1/p' "$test_tmp/commands" | tail -1) + [[ $GRANT_DEADLINE == "$(/usr/bin/date -u -d "@$expiry" +%Y%m%d%H%M%SZ)" ]] + if [[ -n ${OMARCHY_TEST_SUDOERS:-} ]]; then + [[ -x $OMARCHY_TEST_SUDOERS ]] || fail "OMARCHY_TEST_SUDOERS must name an executable" + printf 'root:x:0:0:root:/root:/bin/bash\naudituser:x:1000:1000:Test:/nonexistent:/bin/bash\n' >"$test_tmp/passwd" + printf 'root:x:0:\naudituser:x:1000:\n' >"$test_tmp/group" + { printf 'audituser ALL=(ALL) ALL\n'; cat "$(rule_file 1000)"; } >"$test_tmp/policy" + for offset in -1 1; do + when=$(/usr/bin/date -u -d "@$((expiry + offset))" +%Y%m%d%H%M%SZ) + "$OMARCHY_TEST_SUDOERS" -p "$test_tmp/passwd" -P "$test_tmp/group" -T "$when" audituser /usr/bin/true <"$test_tmp/policy" >"$test_tmp/policy-result" + if (( offset < 0 )); then + ! grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy requires a password before expiry" + else + grep -q 'Password required' "$test_tmp/policy-result" || fail "native policy remains passwordless after expiry" + fi + done + pass "native sudoers evaluation requires authentication after the generated deadline" fi -done -[[ -n $pkgs_root ]] || fail "omarchy-pkgs checkout found for passwordless package-removal coverage" + assert_status 0 status_locked 1000 + TEST_INACTIVE_TIMER=1 assert_status 0 status_locked 1000 + [[ ! -e $test_tmp/var/lib/omarchy/sudo-passwordless ]] + ! compgen -G "$test_tmp/etc/sudoers.d/.omarchy-nopasswd.*" +) +pass "one complete mode-0440 sudoers rule holds the deadline with no separate grant state" -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 "$library" + before=$(cat "$(rule_file 1000)") + expire_locked 1000 omarchy-nopasswd-expire-1000-ffffffffffffffffffffffffffffffff + [[ $(cat "$(rule_file 1000)") == "$before" ]] + enable_locked 1000 30 + renewed=$(cat "$(rule_file 1000)") + [[ $renewed != "$before" ]] + expire_locked 1000 + [[ $(cat "$(rule_file 1000)") == "$renewed" ]] + TEST_EXPIRED=1 expire_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "legacy and current callbacks preserve renewed grants and remove expired ones" + +( + source "$library" + enable_locked 1000 1 + assert_status 2 env TEST_EXPIRED=1 TEST_DELETE_FAIL=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __status 1000 + [[ -e $(rule_file 1000) ]] + TEST_EXPIRED=1 assert_status 3 status_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "expired status reports cleanup failure separately from confirmed inactivity" + +for failure in TEST_TIMER_FAIL TEST_INACTIVE_TIMER TEST_PUBLISH_FAIL TEST_POST_PUBLISH_FAIL TEST_CANCEL_ENABLE; do + reset_grant + expected=1 + if [[ $failure == "TEST_CANCEL_ENABLE" ]]; then expected=143; fi ( - source "$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" - - ( - 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" + source "$library" + enable_locked 1000 15 + assert_status "$expected" env "$failure=1" TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 30 + [[ ! -e $(rule_file 1000) ]] + ) done -pass "settings package transitions revoke grants and preserve unrelated configuration" +pass "timer, publication, post-publication and cancellation failures revoke renewed access" -# Exercise the production flock wrapper under contention. mkdir is an atomic -# overlap detector; all workers must enter and leave the protected region. -lock_dir="$test_tmp/lock-runtime" -mkdir "$lock_dir" -lock_lib="$test_tmp/lock-lib.sh" -function_prefix | - sed -e "s#/run/omarchy/sudo-passwordless#$lock_dir#g" \ - -e "s#/run/lock/omarchy-sudo-passwordless.lock#$test_tmp/passwordless.lock#g" \ - -e 's#/usr/bin/chown root:root "$LOCK_FILE"#/usr/bin/true#' >"$lock_lib" -worker="$test_tmp/worker.sh" -cat >"$worker" <<'WORKER' -#!/bin/bash -set -euo pipefail -source "$LOCK_LIB" -prepare_root_state() { :; } -critical() { - mkdir "$LOCK_SENTINEL" - sleep 0.03 - rmdir "$LOCK_SENTINEL" - printf x >>"$LOCK_RESULTS" -} -with_root_lock critical -WORKER -chmod +x "$worker" -for _ in {1..8}; do - LOCK_LIB="$lock_lib" LOCK_SENTINEL="$test_tmp/held" LOCK_RESULTS="$test_tmp/results" bash "$worker" & -done -wait -[[ $(wc -c <"$test_tmp/results") == 8 ]] || fail "concurrent passwordless operations serialize" -pass "passwordless sudo serializes concurrent operations" +reset_grant +( + source "$library" + TEST_POST_PUBLISH_FAIL=1 TEST_DELETE_FAIL=1 assert_status 1 enable_locked 1000 15 + [[ -e $(rule_file 1000) ]] + ! grep -q '^systemctl stop ' "$test_tmp/commands" +) +pass "failed policy deletion retains the timer and reports failure" -# 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" +reset_grant +( + source "$library" + printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$(rule_file 1000)" + cp "$(rule_file 1000)" "$test_tmp/admin-rule" + assert_status 2 status_locked 1000 + assert_status 1 enable_locked 1000 15 + assert_status 1 cleanup_uid_locked 1000 + cmp "$(rule_file 1000)" "$test_tmp/admin-rule" + rm "$(rule_file 1000)" + ln -s "$test_tmp/admin-rule" "$(rule_file 1000)" + assert_status 2 status_locked 1000 + assert_status 1 cleanup_uid_locked 1000 + [[ -L $(rule_file 1000) ]] +) +pass "grant operations preserve administrator policies and reject symlinks" + +reset_grant +( + source "$library" + TEST_BAD_PATH="$test_tmp/etc/sudoers.d" assert_status 1 enable_locked 1000 15 + [[ ! -e $(rule_file 1000) ]] + rm "$PACKAGE_HOOK" + assert_status 1 enable_locked 1000 15 +) +pass "publication requires trusted paths and the packaged cleanup hook" + +reset_grant +cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/hooks/" +( + source "$library" + TEST_ACCOUNT='buildbot$' enable_locked 1000 15 + read_grant 1000 + [[ $GRANT_NAME == 'buildbot$' ]] + /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null + cleanup_uid_locked 1000 + [[ ! -e $(rule_file 1000) ]] +) +pass "trailing-dollar accounts publish and revoke valid native policy" diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index b3198405..a0be9d4f 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -2,234 +2,135 @@ 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 "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" ( 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 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" - 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" + printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" + TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked + [[ -e $(rule_file 1000) ]] + cleanup_all_locked + [[ ! -e $(rule_file 1000) && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom ]] + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" +) +pass "legacy cleanup removes generated orphan rules and preserves custom policy" -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"; } +# Run the actual migration queue for separate temporary homes. Sudo only calls +# the mapped helper and can be refused without requesting host authorization. +mkdir -p "$test_tmp/source/migrations" +sed "s|/usr/bin/omarchy-sudo-passwordless|$test_tmp/omarchy-sudo-passwordless|g" \ + "$ROOT/migrations/1788163635.sh" >"$test_tmp/source/migrations/1788163635.sh" +printf 'echo "later migration ran"\n' >"$test_tmp/source/migrations/1788163636.sh" +run_migrations() { + TEST_MIGRATION=1 OMARCHY_PATH="$test_tmp/source" OMARCHY_MIGRATION_STATE="$test_tmp/$1" \ + PATH="$test_tmp/bin:$PATH" /usr/bin/bash "$ROOT/bin/omarchy-migrate" >"$test_tmp/migrations.log" 2>&1 } +marker="$test_tmp/var/lib/omarchy/migrations/1788163635" +( + source "$library" + printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" + TEST_DELETE_FAIL=1 assert_status 1 run_migrations first + [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] + run_migrations first + [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] + enable_locked 1000 15 + cp "$(rule_file 1000)" "$test_tmp/renewed" + : >"$test_tmp/commands" + TEST_NO_SUDO=1 run_migrations second + [[ -f $test_tmp/second/1788163636.sh ]] + ! grep -q '^sudo ' "$test_tmp/commands" + cmp "$(rule_file 1000)" "$test_tmp/renewed" +) +pass "migration completion is machine-wide, retryable, and needs no sudo for later users" ( source "$library" - 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" + TEST_BAD_PATH="$marker" assert_status 1 migration_complete + rm "$marker" + ln -s "$test_tmp/renewed" "$marker" + assert_status 1 migration_complete + assert_status 1 migrate_locked + [[ -L $marker ]] + rm "$marker" +) +pass "migration checks marker ownership and rejects symlinks" +# Keep real package scripts in the contract: source and packaging share the +# same lock and blocker, including the legacy scriptlet fallback. pkgs_path=${OMARCHY_PKGS_PATH:-$ROOT/../omarchy-pkgs} [[ ! -d $pkgs_path/pkgbuilds ]] || pkgs_path=$pkgs_path/pkgbuilds -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" +for name in omarchy-settings omarchy-settings-dev; do + script="$pkgs_path/$name/$name.install" + [[ -f $script ]] || fail "set OMARCHY_PKGS_PATH to the companion package checkout" + sed -e "s|/etc/|$test_tmp/etc/|g" -e "s|/run|$test_tmp/run|g" \ + -e "s|/usr/bin/stat|$test_tmp/bin/stat|g" -e "s|/usr/bin/rm|$test_tmp/bin/rm|g" \ + "$script" >"$test_tmp/$name.install" + reset_grant + ( + source "$library" + source "$test_tmp/$name.install" + _etc_overrides_apply() { :; } + enable_locked 1000 15 + TEST_DELETE_FAIL=1 assert_status 1 pre_remove + [[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]] + assert_status 1 enable_locked 1000 15 + pre_remove && post_remove + [[ ! -e $(rule_file 1000) ]] + post_install + [[ ! -e $REMOVAL_BLOCKER ]] + enable_locked 1000 15 + pre_upgrade && post_upgrade + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + ) +done +pass "both settings packages revoke grants, block publication, and recover on installation" -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")" +reset_grant +# Hold the source lock, then start package removal. A native flock on the +# mapped file must serialize both implementations. +cat >"$test_tmp/worker" <<'WORKER' +#!/bin/bash +set -euo pipefail +source "$TEST_LIBRARY" +critical() { + touch "$TEST_GRANT_ROOT/entered" + for (( attempt=0; attempt<500; attempt++ )); do + [[ ! -e $TEST_GRANT_ROOT/release ]] || break + sleep 0.01 + done + [[ -e $TEST_GRANT_ROOT/release ]] || return 1 + enable_locked 1000 15 } -with_root_lock enable_locked 1000 15 +with_root_lock critical 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 +TEST_LIBRARY="$library" /usr/bin/bash "$test_tmp/worker" >"$test_tmp/publisher.log" 2>&1 & +publisher=$! +children+=("$publisher") +for (( attempt=0; attempt<200; attempt++ )); do + [[ ! -e $test_tmp/entered ]] || break + sleep 0.01 done -[[ -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 +[[ -f $test_tmp/entered ]] || fail "publisher failed to acquire the lock" +/usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 & +removal=$! +children+=("$removal") +touch "$test_tmp/release" +wait "$publisher" || fail "publisher failed" "$(cat "$test_tmp/publisher.log")" +wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")" children=() -[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || 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" +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] +pass "native lock serializes grant publication with package removal" -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" +# systemd-tmpfiles operates on an explicit disposable root, never the host. +reset_grant +: >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000" +: >"$test_tmp/etc/sudoers.d/unrelated" +rule='r! /etc/sudoers.d/99-omarchy-nopasswd-*' +/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --inline "$rule" +[[ -f $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] || fail "routine tmpfiles shortened a live grant" +/usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline "$rule" +[[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 && -f $test_tmp/etc/sudoers.d/unrelated ]] || fail "boot cleanup boundary" +pass "native boot cleanup removes grants while routine tmpfiles preserves them" From 3eb314231358c4206d51a818df98ffb18136e7a4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 16 Sep 2026 22:55:37 +0100 Subject: [PATCH 22/29] Recognize legacy sudo grants for any account name The legacy command wrote the caller's unvalidated name into both the sudoers filename and the rule. Cleanup applied the current lower-case account pattern to that suffix, so an exact legacy grant for an account such as Alice was classified as administrator policy, left active, and the machine-wide migration marker was written anyway. Match a legacy grant by its exact filename and rule relationship instead of the account policy, and cover it in the lifecycle suite through both the unit cleanup and the real migration runner. The account pattern itself stays lower-case: sudoers reads an upper-case word such as ALICE as a User_Alias reference, and ALL as every user, so such names must never reach the generated rule. Pin that with a test. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-sudo-passwordless | 31 +++++++++++-------- docs/passwordless-sudo.md | 2 +- test/shell.d/nopasswd-sudo-expiry-test.sh | 7 +++-- .../passwordless-grant-lifecycle-test.sh | 12 +++++-- 4 files changed, 34 insertions(+), 18 deletions(-) diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 91695c69..3b0af89d 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -56,9 +56,10 @@ resolve_account() { 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. + # Sudoers has metacharacters, and it reads an upper-case word such as ALICE + # as an alias reference rather than a user. Accounts use this portable + # lower-case subset; refusing anything else is safer than attempting to + # quote privileged policy syntax. valid_account_name "$ACCOUNT_NAME" || return 1 ACCOUNT_UID=$((10#$uid)) } @@ -109,6 +110,8 @@ read_grant() { valid_account_name "$GRANT_NAME" || return 2 } +# Returns 0 for a rule this command generated, 1 for anything else under the +# owned prefix (preserved as administrator policy), and 2 when unreadable. classify_generated_rule() { local file=$1 suffix contents name @@ -116,18 +119,20 @@ classify_generated_rule() { 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 + # The legacy command wrote the caller's unvalidated name into both the + # filename and the rule. That exact relationship is its fingerprint, so an + # account the current policy would reject still has its old grant removed. + if [[ $contents == "$suffix ALL=(ALL) NOPASSWD: ALL" ]]; then return 0 - else - return 1 fi + + [[ $suffix =~ ^[0-9]+$ ]] || return 1 + name=${contents%' ALL=(ALL) NOPASSWD: ALL'} + if valid_account_name "$name" && [[ $contents == "$name ALL=(ALL) NOPASSWD: ALL" ]]; then + return 0 + fi + name=${contents%%' ALL=(ALL) NOTAFTER='*} + valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] } cleanup_uid_locked() { diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 39355504..1a846995 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -10,7 +10,7 @@ An internal status result is `0` for an active, validated grant and `3` for conf Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone. -Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted; generated legacy policy is removed conservatively and administrator-modified policy is preserved by the migration. +Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up; administrator-modified policy under the owned prefix is preserved by the migration. ## Package ownership diff --git a/test/shell.d/nopasswd-sudo-expiry-test.sh b/test/shell.d/nopasswd-sudo-expiry-test.sh index 173ea1b0..d6b1a505 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -9,7 +9,8 @@ source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" for minutes in 1 15 1440 00015; do valid_minutes "$minutes" || exit 1; done for minutes in 0 1441 -1 1m '' 18446744073709551617; do ! valid_minutes "$minutes" || exit 1; done for name in audituser 'buildbot$'; do valid_account_name "$name" || exit 1; done - for name in 'a$b' '$' aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done + # Upper-case words are sudoers alias references, so ALICE must never publish. + for name in 'a$b' '$' 'a b' 'a#b' Alice ALICE ALL aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa; do ! valid_account_name "$name" || exit 1; done ! valid_uid 18446744073709551617 ) pass "duration and account validation retains bounded inputs and trailing-dollar usernames" @@ -159,5 +160,7 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/ /usr/sbin/visudo -cf "$(rule_file 1000)" >/dev/null cleanup_uid_locked 1000 [[ ! -e $(rule_file 1000) ]] + TEST_ACCOUNT=ALICE assert_status 1 enable_locked 1000 15 + [[ ! -e $(rule_file 1000) ]] ) -pass "trailing-dollar accounts publish and revoke valid native policy" +pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish" diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index a0be9d4f..91a0ba89 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -8,14 +8,20 @@ source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" source "$library" printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" printf 'buildbot$ ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot$" + # The legacy command never validated the account name, so a manual or NSS + # account outside the current policy still has its exact old grant removed. + printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" + printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked [[ -e $(rule_file 1000) ]] cleanup_all_locked - [[ ! -e $(rule_file 1000) && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom ]] + [[ ! -e $(rule_file 1000) && ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] + [[ -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten ]] ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" + rm "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" ) -pass "legacy cleanup removes generated orphan rules and preserves custom policy" +pass "legacy cleanup removes generated orphan rules for any account and preserves custom policy" # Run the actual migration queue for separate temporary homes. Sudo only calls # the mapped helper and can be refused without requesting host authorization. @@ -31,10 +37,12 @@ marker="$test_tmp/var/lib/omarchy/migrations/1788163635" ( source "$library" printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" + printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" TEST_DELETE_FAIL=1 assert_status 1 run_migrations first [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] run_migrations first [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] enable_locked 1000 15 cp "$(rule_file 1000)" "$test_tmp/renewed" : >"$test_tmp/commands" From ea617b9126e9d3ae88544687080af0b6849d205a Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Wed, 16 Sep 2026 23:23:23 +0100 Subject: [PATCH 23/29] Quarantine unrecognized policy under the generated sudoers prefix Matching a legacy grant by its filename and rule relationship is not a complete fingerprint: the legacy writer took the filename from $USER but produced the rule with echo, and under BASH_ENV with xpg_echo a name such as ali\0143e yields an alice rule in a mismatched file. Preserving that as administrator policy let the migration certify success with an unrestricted grant still live until the next boot. The prefix is reserved anyway: boot cleanup and the package hook remove everything under it. Move any file the classifier does not recognize into a fresh root-only directory under /var/lib/omarchy/sudoers-quarantine/ as `policy`, with the original name stored beside it, so nothing there stays live, the administrator keeps the content, and a legacy filename already close to NAME_MAX still fits. An untrusted quarantine directory keeps the migration pending. Cover the mismatched and maximum-length legacy files in the unit cleanup and through the real migration runner. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-sudo-passwordless | 23 ++++++++++- docs/passwordless-sudo.md | 2 +- .../passwordless-grant-lifecycle-test.sh | 38 +++++++++++++++---- 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 3b0af89d..58a9b15c 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -29,6 +29,7 @@ readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf readonly PACKAGE_HOOK=/usr/share/libalpm/hooks/05-omarchy-passwordless-revoke.hook readonly REMOVAL_BLOCKER=/run/omarchy-sudo-passwordless-package-removing readonly MIGRATION_MARKER=/var/lib/omarchy/migrations/1788163635 +readonly QUARANTINE_DIR=/var/lib/omarchy/sudoers-quarantine readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 @@ -143,6 +144,24 @@ cleanup_uid_locked() { /usr/bin/rm -f -- "$file" && [[ ! -e $file && ! -L $file ]] } +# The generated prefix is reserved: boot cleanup and the package hook already +# remove everything in it, and the legacy writer could produce a rule whose +# body differs from its filename. Nothing unrecognized may stay live there, but +# its content is kept for the administrator instead of being deleted. +quarantine_foreign_rule() { + local file=$1 target + 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_prepare_private_root_directory "$QUARANTINE_DIR" /var/lib/omarchy || return 1 + # A legacy filename can already be close to NAME_MAX, so the destination + # name is fixed and the original name travels beside it. + target=$(/usr/bin/mktemp -d "$QUARANTINE_DIR/XXXXXXXXXX") || return 1 + /usr/bin/printf '%s\n' "${file##*/}" >"$target/name" || return 1 + /usr/bin/mv -fT -- "$file" "$target/policy" && [[ ! -e $file && ! -L $file ]] || return 1 + echo "Moved unrecognized sudoers policy $file to $target/policy" >&2 +} + cleanup_all_locked() { local file classification failed=0 verify_root_path /etc/sudoers.d || return 1 @@ -154,7 +173,9 @@ cleanup_all_locked() { fi else classification=$? - (( classification == 1 )) || failed=1 + if (( classification != 1 )) || ! quarantine_foreign_rule "$file"; then + failed=1 + fi fi done return "$failed" diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 1a846995..4aa6e31d 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -10,7 +10,7 @@ An internal status result is `0` for an active, validated grant and `3` for conf Calendar timers clean up expired files; their liveness does not define authorization. Callbacks read the current rule and remove it only when expired. Earlier callbacks cannot shorten a renewed grant, so no timer identity needs to be persisted. Old UID-only and token-bearing callbacks remain accepted. Pending callbacks after renewal or manual disable are harmless and expire within the maximum 24-hour grant window. Boot-time tmpfiles cleanup removes the reserved generated filename namespace before users log in; routine non-boot tmpfiles maintenance leaves live grants alone. -Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up; administrator-modified policy under the owned prefix is preserved by the migration. +Legacy cleanup uses a root-owned machine marker under `/var/lib/omarchy/migrations/`, written only after successful cleanup under the grant lock. Later accounts can finish their migration queues without sudo and without revoking grants created after the repair. Old grant state files are no longer consulted. A legacy grant is recognized by its exact filename and rule relationship, since the old command wrote the caller's unvalidated name into both, so accounts outside the current name policy are still cleaned up. The generated filename prefix is reserved: boot cleanup and the package hook already remove everything under it, and the old writer could emit a rule whose body differs from its filename, so the migration moves any other file found there into a fresh root-only directory under `/var/lib/omarchy/sudoers-quarantine/`, as `policy` with the original name stored beside it, rather than leaving it live or deleting its content. ## Package ownership diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index 91a0ba89..71f34b24 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -4,6 +4,19 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" +quarantine="$test_tmp/var/lib/omarchy/sudoers-quarantine" +quarantined_policy() { + local entry + for entry in "$quarantine"/*/; do + if [[ $(cat "$entry/name") == "$1" ]]; then + cat "$entry/policy" + return 0 + fi + done + return 1 +} +# The old writer accepted any $USER, so a basename can sit just under NAME_MAX. +long_suffix=$(printf 'l%.0s' {1..223}) ( source "$library" printf 'deleteduser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" @@ -11,17 +24,23 @@ source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" # The legacy command never validated the account name, so a manual or NSS # account outside the current policy still has its exact old grant removed. printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" + # The legacy writer produced the body with echo. Under BASH_ENV with + # xpg_echo, USER='ali\0143e' yields this filename with an 'alice' rule, so + # a suffix/body mismatch does not prove administrator authorship. + printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-ali\\0143e" + printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-$long_suffix" printf 'admin ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" - printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" TEST_DELETE_FAIL=1 assert_status 1 cleanup_all_locked [[ -e $(rule_file 1000) ]] cleanup_all_locked - [[ ! -e $(rule_file 1000) && ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] - [[ -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten ]] - ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-buildbot*" - rm "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-custom" "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-handwritten" + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*" + [[ $(stat -c '%a' "$quarantine") == 700 ]] + [[ $(quarantined_policy '99-omarchy-nopasswd-ali\0143e') == 'alice ALL=(ALL) NOPASSWD: ALL' ]] + [[ $(quarantined_policy "99-omarchy-nopasswd-$long_suffix") == 'alice ALL=(ALL) NOPASSWD: ALL' ]] + [[ $(quarantined_policy 99-omarchy-nopasswd-custom) == 'admin ALL=(ALL) NOPASSWD: /usr/bin/true' ]] + (( $(ls -A "$quarantine" | wc -l) == 3 )) ) -pass "legacy cleanup removes generated orphan rules for any account and preserves custom policy" +pass "legacy cleanup removes generated rules for any account and quarantines everything else in the prefix" # Run the actual migration queue for separate temporary homes. Sudo only calls # the mapped helper and can be refused without requesting host authorization. @@ -36,13 +55,18 @@ run_migrations() { marker="$test_tmp/var/lib/omarchy/migrations/1788163635" ( source "$library" + # A quarantine that cannot be trusted keeps the migration pending. + printf 'alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch" + TEST_BAD_PATH="$test_tmp/var/lib/omarchy" assert_status 1 run_migrations first + [[ ! -e $marker && -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-mismatch ]] printf 'audituser ALL=(ALL) NOPASSWD: ALL\n' >"$(rule_file 1000)" printf 'Alice ALL=(ALL) NOPASSWD: ALL\n' >"$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice" TEST_DELETE_FAIL=1 assert_status 1 run_migrations first [[ ! -e $marker && ! -e $test_tmp/first/1788163636.sh ]] run_migrations first [[ -f $marker && -f $test_tmp/first/1788163636.sh ]] - [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-Alice ]] + ! compgen -G "$test_tmp/etc/sudoers.d/99-omarchy-nopasswd-*" + [[ $(quarantined_policy 99-omarchy-nopasswd-mismatch) == 'alice ALL=(ALL) NOPASSWD: ALL' ]] enable_locked 1000 15 cp "$(rule_file 1000)" "$test_tmp/renewed" : >"$test_tmp/commands" From 13a4306a8edd8708d8e2d5814593312a8f321921 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 17 Sep 2026 10:52:34 +0100 Subject: [PATCH 24/29] Run the refresh hook before its transaction and keep the inhibitor through user work Three review findings on the update-hook boundary: The pre-refresh-pacman hook had been moved after the refresh transaction and, during a channel switch, deferred to the very end. That defeated the hook's purpose: custom repositories and IgnorePkg entries were not in place when the downgrade-capable -Syyuu ran. Run the hook where it used to run, after the package config is re-synced and before the transaction, but cold: revoke the timestamp, run it behind the no-update wrapper with the caller's original PATH, and revoke again before continuing. Every later privileged command authenticates with --no-update, so a detached child left by the hook has no reusable timestamp to wait for. Channel switching hands the caller's PATH to the refresh the same way the updater receives it, and no longer defers or re-runs the hook. Stay Awake was released before AUR builds, hooks and mise, so the machine could sleep during the longest part of an update. Releasing the inhibitor needs no privilege because the held command already dropped to the user, so stop it after mise and before the reboot prompt, as before. A packaged channel destination cannot be inspected before its package is installed, and a transaction can replace the running tree with a release that predates the command-scoped wrapper; from then on a bare sudo would resolve to /usr/bin/sudo and publish a timestamp, and the destination's own updater authenticates the same way. The switch used to abort only after the packages had changed, with generic rerun advice. Now it checks for the wrapper after each transaction before any further privileged step, completes what it safely can, and stops cold with instructions to run that release's update from a fresh session instead of launching it. Boundary tests pin the hook between the config copies and the transaction with a cold timestamp on both sides, the older-destination stop with its guidance and no launched updater, the new inhibitor position, and the post-update hook staying unreached on failures and signals. Docs, the manual and the sample hook describe the restored timing. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-channel-set | 31 +++++++-- bin/omarchy-refresh-pacman | 43 ++++++------ bin/omarchy-update | 16 +++-- .../add-custom-repo.sample | 15 ++-- default/agents/skills/omarchy/hooks.md | 4 +- docs/update-process.md | 13 ++-- manual/31-dotfiles.md | 4 +- test/shell.d/channel-sudo-boundary-test.sh | 69 +++++++++++++++++-- test/shell.d/channel-test.sh | 14 ++-- test/shell.d/fixtures/sudo-boundary-test.sh | 7 +- test/shell.d/update-hook-security-test.sh | 29 ++++---- test/shell.d/update-sequence-test.sh | 2 +- 12 files changed, 160 insertions(+), 87 deletions(-) diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 86130903..a00c9d92 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -61,6 +61,25 @@ link_dev_checkout() { omarchy-dev-link "$checkout" --no-reboot } +# A packaged destination cannot be inspected before its package is installed, +# and a package transaction can replace the running tree with a release that +# predates the command-scoped wrapper. After that, a bare sudo would resolve to +# /usr/bin/sudo and publish a timestamp, and the destination's own updater +# authenticates the same way. Neither may run from a flow that has just run +# user hooks: stop at a consistent point and say how to finish from a fresh +# session. +stop_for_older_destination() { + cat >&2 < 0 )) || { usage; exit 1; } dev_checkout="" @@ -109,23 +128,21 @@ if [[ -n $dev_checkout ]]; then omarchy-state set reboot-required fi -omarchy-refresh-pacman "$pacman_channel" defer-hook +OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel" +# Each transaction can have replaced this tree; check before the next sudo. +wrapper_present || stop_for_older_destination # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}" +wrapper_present || stop_for_older_destination if [[ -z $dev_checkout ]]; then omarchy-dev-unlink --no-reboot export OMARCHY_PATH=/usr/share/omarchy - omarchy_security_enable_no_update_sudo if (( leaving_dev )); then omarchy-state set reboot-required fi + omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination fi OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y - -# No channel-owned privileged work follows the historical refresh hook. -omarchy_security_revoke_sudo_timestamp -PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ - "$OMARCHY_PATH/bin/omarchy-refresh-pacman" "$pacman_channel" run-deferred diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index f09a0925..cd9eab11 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -14,36 +14,35 @@ omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" omarchy_security_require_source_root "$0" -user_path=$PATH +# Channel switching calls this behind its own sanitized PATH and hands the +# caller's original path over the same way the updater receives it. +user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} +unset OMARCHY_UPDATE_USER_PATH omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_install_sudo_cleanup_traps omarchy_security_enable_no_update_sudo channel="${1:-stable}" -hook_mode="${2:-normal}" if [[ $channel != "stable" && $channel != "rc" && $channel != "edge" ]]; then echo "Invalid channel: $channel" >&2 exit 2 fi -if [[ $hook_mode != "normal" && $hook_mode != "defer-hook" && $hook_mode != "run-deferred" ]]; then - echo "Invalid refresh hook mode: $hook_mode" >&2 - exit 2 -fi -if [[ $hook_mode != "run-deferred" ]]; then - sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak - sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak - echo "Setting channel to $channel" - sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf - sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist - # Reset all package DBs and then update - omarchy-update-pacman -Syyuu --noconfirm -fi +sudo cp -f /etc/pacman.conf /etc/pacman.conf.bak +sudo cp -f /etc/pacman.d/mirrorlist /etc/pacman.d/mirrorlist.bak +echo "Setting channel to $channel" +sudo cp -f "$OMARCHY_PATH/default/pacman/pacman-$channel.conf" /etc/pacman.conf +sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirrorlist -# Keep the historical hook name, but finish every privileged refresh operation -# before running user code. Callers with later root work can defer the hook. -if [[ $hook_mode != "defer-hook" ]]; then - omarchy_security_revoke_sudo_timestamp - PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ - "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman -fi +# Allow user customization of /etc/pacman.conf before the upgrade runs, so +# custom repositories and IgnorePkg entries shape the downgrade-capable +# transaction below. The hook is user code: it runs cold behind the no-update +# wrapper, and the timestamp is revoked again afterwards so any authorization +# the hook obtained for itself cannot carry into the transaction. +omarchy_security_revoke_sudo_timestamp +PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" \ + "$OMARCHY_PATH/bin/omarchy-hook" pre-refresh-pacman +omarchy_security_revoke_sudo_timestamp + +# Reset all package DBs and then update +omarchy-update-pacman -Syyuu --noconfirm diff --git a/bin/omarchy-update b/bin/omarchy-update index 3873a6bc..2d5ad474 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -94,15 +94,9 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then # update tooling; the reboot-only phase below performs no privileged work. omarchy-update-restart --services-only - # Release update-owned inhibitors before offering a reboot. A confirmed - # reboot can terminate this process before its EXIT trap gets a chance to - # remove the persistent Stay Awake marker. - omarchy_security_revoke_sudo_timestamp - omarchy-update-stay-awake stop - update_stay_awake_stopped=1 - # AUR package installation must also use the no-update wrapper. Finish # update-owned system work before build code, hooks, or mise can run. + omarchy_security_revoke_sudo_timestamp omarchy-update-aur-pkgs omarchy_security_revoke_sudo_timestamp @@ -114,5 +108,13 @@ if [[ ${1:-} == "-y" ]] || omarchy-update-confirm; then PATH="$OMARCHY_PATH/default/omarchy/sudo-no-update:$user_path" "$OMARCHY_PATH/bin/omarchy-update-mise" omarchy_security_revoke_sudo_timestamp + # The sleep inhibitor covers AUR builds, hooks and mise as well; releasing it + # needs no privilege because the held command already dropped to this user. + # Release it before offering a reboot: a confirmed reboot can terminate this + # process before its EXIT trap gets a chance to remove the persistent Stay + # Awake marker. + omarchy-update-stay-awake stop + update_stay_awake_stopped=1 + "$OMARCHY_PATH/bin/omarchy-update-restart" --reboot-only fi diff --git a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample index 0c8ba7c4..fdc3a871 100644 --- a/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample +++ b/config/omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample @@ -1,20 +1,17 @@ #!/bin/bash -# This legacy-named hook is called by `omarchy refresh pacman` AFTER the -# channel template is copied and the privileged package transaction finishes. -# During `omarchy channel set`, it is deferred until the package switch and -# complete update have also finished. -# Use it to layer customizations onto the freshly-written pacman.conf for -# subsequent package operations — common cases are adding a custom repository -# (e.g. CachyOS, Chaotic-AUR, an internal company repo) or extra IgnorePkg -# lines. +# This hook is called by `omarchy refresh pacman` AFTER the channel template +# is copied and BEFORE the package transaction runs, so your changes shape +# that transaction. Use it to layer customizations onto the freshly-written +# pacman.conf — common cases are adding a custom repository (e.g. CachyOS, +# Chaotic-AUR, an internal company repo) or extra IgnorePkg lines. # # The hook runs as the invoking user after Omarchy invalidates its sudo cache. # A sudo command here therefore requires its own explicit authorization. # # To put it into use, remove .sample from this file name. -# Example: add an Include line above [core] for future package operations. +# Example: add an Include line above [core]. # Maintain the repo entries in /etc/pacman.d/custom-repos.conf yourself. CONF=/etc/pacman.conf diff --git a/default/agents/skills/omarchy/hooks.md b/default/agents/skills/omarchy/hooks.md index 4878c7e1..5da16fcc 100644 --- a/default/agents/skills/omarchy/hooks.md +++ b/default/agents/skills/omarchy/hooks.md @@ -15,7 +15,7 @@ file first, if one exists. ├── font-set.d/ # After font change (font name in $1) ├── post-boot.d/ # After the desktop starts ├── post-update.d/ # At the end of `omarchy update`, after privileged work -├── pre-refresh-pacman.d/ # After `omarchy refresh pacman` finishes (legacy name) +├── pre-refresh-pacman.d/ # After `omarchy refresh pacman` re-syncs the package config, before it updates packages └── theme-set.d/ # After theme change (theme slug in $1) ``` @@ -27,4 +27,4 @@ echo "Theme changed to: $THEME_NAME" # Add custom actions here ``` -Update-related hooks run only after the workflow's privileged work and after Omarchy invalidates its sudo timestamp. A hook that invokes `sudo` must therefore request its own explicit authorization. The legacy-named `pre-refresh-pacman` hook runs after the refresh transaction; during `omarchy channel set`, it is deferred further until the package switch and complete update finish. Executable user code cannot safely run before a later sudo authentication because a detached child could wait for the new timestamp. +Update-related hooks run as your user after Omarchy invalidates its sudo timestamp, behind the no-update wrapper. A hook that invokes `sudo` must therefore request its own explicit authorization, and Omarchy revokes the timestamp again before continuing. `post-update` runs after every sudo-capable update stage. `pre-refresh-pacman` runs after `omarchy refresh pacman` re-syncs the package config and before the package transaction, so custom repositories and `IgnorePkg` entries shape that transaction; every later privileged command still authenticates without publishing a reusable timestamp, so a detached child left behind by the hook has nothing to wait for. diff --git a/docs/update-process.md b/docs/update-process.md index 97511a5c..f96b28c4 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -139,11 +139,10 @@ omarchy-update ├─ omarchy-update-status │ └─ refresh or clear the shell update indicator ├─ restart marked services and the shell + ├─ invalidate sudo credentials, then update AUR packages + ├─ invalidate again, run the post-update hook, invalidate again, then update mise tools ├─ omarchy-update-stay-awake stop │ └─ release the sleep inhibitor and restore shell idle state, if changed - ├─ update AUR packages - ├─ invalidate sudo credentials - ├─ run the post-update hook, invalidate again, then update mise tools └─ offer the unprivileged reboot prompt ``` @@ -155,7 +154,7 @@ Important behavior: - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. -- Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and runs the deferred refresh hook only after the full update succeeds and authorization is revoked again. Failed and interrupted channel switches revoke on exit. +- Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare `sudo` or an updater that authenticates without `--no-update` publish a timestamp. Failed and interrupted channel switches revoke on exit. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before confirmation when it is not met. If free space cannot be determined, the @@ -268,9 +267,9 @@ which pacman repo the mirrorlist points at (and swap between the `omarchy` and `omarchy-dev` packages through a guard-allowed pacman run), while `dev` links the runtime to a git checkout via the dev-link mechanism, after which `omarchy update` fast-forwards that checkout instead of upgrading a package. -Channel switching defers the legacy `pre-refresh-pacman` hook across the package -swap and the complete update. The hook runs exactly once at the final cold -credential boundary; it is skipped if the composite operation fails earlier. +Channel switching runs the `pre-refresh-pacman` hook once, during its refresh +step: cold, behind the no-update wrapper, after the package config is re-synced +and before the refresh transaction. It does not run if the switch fails earlier. There is no version file at runtime. `omarchy-version` derives the version from `pacman -Q` on whichever package is installed, or reports `dev ()` for a diff --git a/manual/31-dotfiles.md b/manual/31-dotfiles.md index a81a6596..420350fe 100644 --- a/manual/31-dotfiles.md +++ b/manual/31-dotfiles.md @@ -38,12 +38,12 @@ Omarchy fires hooks at a handful of moments, and you can hang your own scripts o | ----- | ------------ | | `post-boot` | Right after the desktop has started | | `post-update` | Near the end of `omarchy update`, after packages, migrations, and service restarts, before mise tools are updated | -| `pre-refresh-pacman` | After `omarchy refresh pacman` re-syncs the package config and finishes updating packages; during a channel switch, after the package switch and complete update finish | +| `pre-refresh-pacman` | After `omarchy refresh pacman` re-syncs the package config, before it updates packages; a channel switch runs it during that same refresh step | | `theme-set` | After a theme change (theme name in `$1`) | | `font-set` | After a font change (font name in `$1`) | | `battery-low` | When the battery gets low (percentage in `$1`) | -The `pre-refresh-pacman` name is kept for compatibility. Its edits to the refreshed package config apply to future package operations, after the current transaction has finished. Both update-related hooks run as your user after Omarchy clears its cached sudo authorization, so a hook that uses `sudo` needs its own authorization and may ask for your password. +The `pre-refresh-pacman` hook is where custom repositories or `IgnorePkg` lines belong, since it runs before the package transaction. Both update-related hooks run as your user after Omarchy clears its cached sudo authorization, so a hook that uses `sudo` needs its own authorization and may ask for your password. Each of those directories already holds a `.sample` file showing the shape of a hook — drop the `.sample` from the name to put it to work. To install a script you've written elsewhere, use `omarchy hook install post-boot ~/my-hook`, which copies it in and makes it executable. diff --git a/test/shell.d/channel-sudo-boundary-test.sh b/test/shell.d/channel-sudo-boundary-test.sh index b7fabf02..336ed26c 100755 --- a/test/shell.d/channel-sudo-boundary-test.sh +++ b/test/shell.d/channel-sudo-boundary-test.sh @@ -47,8 +47,12 @@ sudo = [event for event in events if event.startswith('sudo ')] assert all(event in ('sudo -h', 'sudo -k') or event.startswith('sudo -N ') for event in sudo), events hooks = [i for i, event in enumerate(events) if event.startswith('step:omarchy-hook ')] assert len(hooks) == 2, events -assert events[hooks[-1]] == 'step:omarchy-hook pre-refresh-pacman', events -assert not any(event.startswith('sudo -N ') for event in events[hooks[0]:]), events +assert events[hooks[0]] == 'step:omarchy-hook pre-refresh-pacman', events +assert events[hooks[1]] == 'step:omarchy-hook post-update', events +assert events[hooks[0] - 1] == 'sudo -k' and events[hooks[0] + 1] == 'sudo -k', events +transaction = next(i for i, event in enumerate(events) if event.startswith('step:pacman ')) +assert hooks[0] < transaction, events +assert not any(event.startswith('sudo -N ') for event in events[hooks[1]:]), events PY } @@ -59,7 +63,7 @@ for channel in stable rc edge dev; do reset_boundary run_channel "$channel" || fail "$channel failed" "$(<"$boundary_tmp/output")" assert_scoped_channel "$channel" - pass "$channel starts cold, authorizes only individual commands, defers hooks and exits cold" + pass "$channel starts cold, authorizes only individual commands, runs the refresh hook cold before its transaction and exits cold" done reset_boundary @@ -79,6 +83,59 @@ OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable || fail "leaving dev f assert_scoped_channel "dev to stable" pass "leaving dev preserves no-update sudo through unlink and the packaged update" +# A packaged destination that predates the wrapper cannot be checked before its +# package is installed. Its updater authenticates without --no-update, so the +# switch must not launch it: it stops at a consistent point with instructions. +reset_boundary +mv "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper" +mv "$SUDO_TEST_ROOT/bin/omarchy-update" "$boundary_tmp/saved-package-update" +ln -s test-step "$SUDO_TEST_ROOT/bin/omarchy-update" +if OMARCHY_PATH="$SUDO_TEST_HOME/omarchy" run_channel stable; then fail "an older packaged destination was updated with ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi +grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "an older packaged destination was not reported" "$(<"$boundary_tmp/output")" +grep -q "Run 'omarchy update' from a new terminal to finish" "$boundary_tmp/output" || fail "an older packaged destination lacks recovery guidance" "$(<"$boundary_tmp/output")" +grep -Fxq 'step:omarchy-dev-unlink --no-reboot' "$SUDO_TEST_LOG" || fail "the package switch was not completed before stopping" "$(<"$SUDO_TEST_LOG")" +grep -Fxq 'step:omarchy-state set reboot-required' "$SUDO_TEST_LOG" || fail "leaving dev for an older release did not mark the reboot" "$(<"$SUDO_TEST_LOG")" +if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "an older packaged updater was launched from the hardened switch" "$(<"$SUDO_TEST_LOG")"; fi +grep -q 'The channel switch did not complete' "$boundary_tmp/output" && fail "the stop was reported as an error needing a rerun" "$(<"$boundary_tmp/output")" +assert_boundary_cold "older packaged destination" +rm "$SUDO_TEST_ROOT/bin/omarchy-update" +mv "$boundary_tmp/saved-package-update" "$SUDO_TEST_ROOT/bin/omarchy-update" +mv "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" +pass "an older packaged destination stops the switch cold with instructions instead of running its updater" + +# A package-backed source can be downgraded by its own transaction to a release +# without the wrapper. From then on a bare sudo would be the real one, so no +# privileged step may follow either transaction without checking first. A decoy +# sudo in the package bin catches any such call instead of reaching the host. +cat >"$SUDO_TEST_ROOT/bin/sudo" <<'STUB' +#!/bin/bash +printf 'unwrapped-sudo %s\n' "$*" >>"$SUDO_TEST_LOG" +exit 97 +STUB +chmod +x "$SUDO_TEST_ROOT/bin/sudo" +cp "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" "$boundary_tmp/saved-package-wrapper" +for pattern in 'pacman -Syyuu*' 'pacman -S --needed*'; do + reset_boundary + export SUDO_TEST_REMOVE_WRAPPER_STEP=$pattern + if run_channel rc; then fail "a downgrade during '$pattern' was not detected" "$(<"$SUDO_TEST_LOG")"; fi + unset SUDO_TEST_REMOVE_WRAPPER_STEP + grep -q "predates command-scoped sudo" "$boundary_tmp/output" || fail "downgrade during '$pattern' was not reported" "$(<"$boundary_tmp/output")" + if grep -q '^unwrapped-sudo ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' reached ordinary sudo" "$(<"$SUDO_TEST_LOG")"; fi + if grep -q '^step:omarchy-dev-unlink' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still unlinked" "$(<"$SUDO_TEST_LOG")"; fi + if grep -q '^step:omarchy-update ' "$SUDO_TEST_LOG"; then fail "downgrade during '$pattern' still updated" "$(<"$SUDO_TEST_LOG")"; fi + python3 - "$SUDO_TEST_LOG" "$pattern" <<'PY' +import sys +events = open(sys.argv[1]).read().splitlines() +transactions = [e for e in events if e.startswith('step:pacman ')] +assert len(transactions) == (1 if sys.argv[2].startswith('pacman -Syyuu') else 2), events +assert all(e in ('sudo -h', 'sudo -k') or e.startswith('sudo -N ') for e in events if e.startswith('sudo ')), events +PY + assert_boundary_cold "downgrade during $pattern" + cp "$boundary_tmp/saved-package-wrapper" "$SUDO_TEST_ROOT/default/omarchy/sudo-no-update/sudo" + pass "a transaction that removes the wrapper stops the switch before any further sudo ($pattern)" +done +rm "$SUDO_TEST_ROOT/bin/sudo" + mkdir "$boundary_tmp/user tools" cat >"$boundary_tmp/user tools/channel-user-tool" <<'STUB' #!/bin/bash @@ -110,8 +167,8 @@ for step in pacman omarchy-update-system-pkgs omarchy-hook; do reset_boundary if SUDO_TEST_FAIL_STEP="$step" run_channel stable; then fail "$step failure was ignored"; fi assert_boundary_cold "$step failure" - if grep -q '^step:omarchy-hook pre-refresh-pacman$' "$SUDO_TEST_LOG"; then fail "$step failure reached the deferred hook"; fi - pass "$step failure exits cold without the deferred hook" + if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$step failure reached the post-update hook"; fi + pass "$step failure exits cold without the post-update hook" done for signal in HUP INT TERM; do @@ -123,7 +180,7 @@ kill -s "$SUDO_TEST_CHANNEL_SIGNAL" "$PPID" STUB if SUDO_TEST_CHANNEL_SIGNAL="$signal" run_channel stable; then fail "$signal was ignored"; fi assert_boundary_cold "$signal" - if grep -q '^step:omarchy-hook ' "$SUDO_TEST_LOG"; then fail "$signal reached an update hook"; fi + if grep -q '^step:omarchy-hook post-update$' "$SUDO_TEST_LOG"; then fail "$signal reached the post-update hook"; fi pass "$signal stops the channel transition and revokes authorization" done diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 5640e292..cbbfc6e8 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -127,7 +127,7 @@ assert_log_line() { } run_channel stable -assert_log_line $'refresh\tstable\tdefer-hook' "stable refreshes the stable pacman channel" +assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel" assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "stable runs the normal update pipeline from the package-backed path" @@ -137,7 +137,7 @@ fi pass "stable does not require reboot when already package-backed" run_channel rc -assert_log_line $'refresh\trc\tdefer-hook' "rc refreshes the rc pacman channel" +assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel" assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "rc runs the normal update pipeline from the package-backed path" @@ -145,7 +145,7 @@ assert_log_line $'update\t-y\tOMARCHY_PATH='"$package_root" "rc runs the normal active_checkout="$test_tmp/active-checkout" cp -a "$package_root" "$active_checkout" OMARCHY_TEST_PATH="$active_checkout" run_channel edge -assert_log_line $'refresh\tedge\tdefer-hook' "edge refreshes the edge pacman channel" +assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel" assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev" @@ -161,7 +161,7 @@ if run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then fi grep -q "already exists and is not a git checkout" "$test_tmp/occupied.err" || fail "dev explains occupied checkout paths" "$(cat "$test_tmp/occupied.err")" -if grep -Fx $'refresh\tedge\tdefer-hook' "$log_file" >/dev/null; then +if grep -Fx $'refresh\tedge' "$log_file" >/dev/null; then fail "dev validates checkout path before changing packages" "$(cat "$log_file")" fi pass "dev refuses occupied non-checkout paths before package changes" @@ -169,17 +169,15 @@ pass "dev refuses occupied non-checkout paths before package changes" rmdir "$checkout" run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" -assert_log_line $'refresh\tedge\tdefer-hook' "dev refreshes the edge pacman channel" +assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" assert_log_line $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file" | sed '/run-deferred/d') == $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\tdefer-hook\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" -[[ $(tail -1 "$log_file") == $'refresh\tedge\trun-deferred' ]] || fail "channel refresh hook must run after the complete update" -pass "channel changes defer the refresh hook until all update work finishes" OMARCHY_TEST_PATH="$checkout" run_channel stable assert_log_line $'unlink\t--no-reboot' "switching from dev to stable unlinks without an early reboot prompt" diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index a8c05d45..43b0a66f 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -92,6 +92,11 @@ fi if [[ $step == "omarchy-hook" || $step == "omarchy-update-mise" ]]; then [[ ! -e $SUDO_TEST_CACHE ]] || exit 91 fi +if [[ -n ${SUDO_TEST_REMOVE_WRAPPER_STEP:-} && "$step $*" == $SUDO_TEST_REMOVE_WRAPPER_STEP ]]; then + # Model a package transaction replacing the running tree with a release + # that predates the wrapper. + /usr/bin/rm -f "$OMARCHY_PATH/default/omarchy/sudo-no-update/sudo" +fi if [[ ${SUDO_TEST_FAIL_STEP:-} == "$step" ]]; then # Model a misbehaving child leaving state behind, then failing. Cleanup must # still revoke it. This never invokes real sudo or exercises a privilege flaw. @@ -123,7 +128,7 @@ ln -s ../bin/test-step "$SUDO_TEST_ROOT/mock/pacman" reset_boundary() { : >"$SUDO_TEST_LOG" /usr/bin/rm -f "$SUDO_TEST_CACHE" - unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED + unset SUDO_TEST_FAIL_STEP SUDO_TEST_SIGNAL_STEP SUDO_TEST_SUDO_FAIL SUDO_TEST_REVOKE_FAIL SUDO_TEST_UNSUPPORTED SUDO_TEST_REMOVE_WRAPPER_STEP } assert_boundary_cold() { [[ ! -e $SUDO_TEST_CACHE ]] || fail "$1 left cached authorization" diff --git a/test/shell.d/update-hook-security-test.sh b/test/shell.d/update-hook-security-test.sh index 0181b800..ed18c756 100755 --- a/test/shell.d/update-hook-security-test.sh +++ b/test/shell.d/update-hook-security-test.sh @@ -24,9 +24,9 @@ for args in '-y' ''; do python3 - "$SUDO_TEST_LOG" <<'PY' import sys s=open(sys.argv[1]).read().splitlines() -positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step:omarchy-update-stay-awake stop','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-restart --reboot-only']] +positions=[next(i for i,line in enumerate(s) if line.startswith(prefix)) for prefix in ['step:omarchy-update-restart --services-only','step:yay','step:omarchy-hook post-update','step:omarchy-update-mise','step:omarchy-update-stay-awake stop','step:omarchy-update-restart --reboot-only']] assert positions==sorted(positions), s -assert not any(line.startswith('sudo -N ') for line in s[positions[3]:]), s +assert not any(line.startswith('sudo -N ') for line in s[positions[2]:]), s PY pass "update $args runs privileged phases before hooks and exits cold" done @@ -63,21 +63,20 @@ if run_update -y; then fail "unsupported sudo must prevent mixed-trust work"; fi assert_boundary_cold "unsupported sudo" pass "unsupported sudo fails without running update steps" -for mode in normal defer-hook run-deferred; do - reset_boundary - "$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable "$mode" >"$boundary_tmp/output" 2>&1 || fail "refresh $mode failed" "$(<"$boundary_tmp/output")" - assert_boundary_cold "refresh $mode" - python3 - "$SUDO_TEST_LOG" "$mode" <<'PY' +reset_boundary +"$SUDO_TEST_ROOT/bin/omarchy-refresh-pacman" stable >"$boundary_tmp/output" 2>&1 || fail "refresh failed" "$(<"$boundary_tmp/output")" +assert_boundary_cold "refresh" +python3 - "$SUDO_TEST_LOG" <<'PY' import sys -s=open(sys.argv[1]).read().splitlines();mode=sys.argv[2] -hooks=[i for i,l in enumerate(s) if l.startswith('step:omarchy-hook')] -priv=[i for i,l in enumerate(s) if l.startswith('sudo -N ')] -assert bool(hooks)==(mode!='defer-hook'), s -assert bool(priv)==(mode!='run-deferred'), s -if hooks: assert not any(i>hooks[0] for i in priv),s +s=open(sys.argv[1]).read().splitlines() +hook=next(i for i,l in enumerate(s) if l=='step:omarchy-hook pre-refresh-pacman') +copies=[i for i,l in enumerate(s) if l.startswith('sudo -N cp ')] +transaction=next(i for i,l in enumerate(s) if l.startswith('step:pacman ')) +assert len(copies)==4 and max(copies) < hook < transaction, s +assert s[hook-1]=='sudo -k' and s[hook+1]=='sudo -k', s +assert all(l in ('sudo -h','sudo -k') or l.startswith('sudo -N ') for l in s if l.startswith('sudo ')), s PY - pass "refresh $mode preserves the final cold hook boundary" -done +pass "refresh runs the hook cold between the config re-sync and the transaction" for step in pacman omarchy-hook; do reset_boundary diff --git a/test/shell.d/update-sequence-test.sh b/test/shell.d/update-sequence-test.sh index 994dc493..a1013308 100755 --- a/test/shell.d/update-sequence-test.sh +++ b/test/shell.d/update-sequence-test.sh @@ -73,10 +73,10 @@ expected_steps() { omarchy-update-analyze-logs \ omarchy-update-status \ omarchy-update-restart \ - omarchy-update-stay-awake \ omarchy-update-aur-pkgs \ omarchy-hook \ omarchy-update-mise \ + omarchy-update-stay-awake \ omarchy-update-restart } From b34b117fd03278b8578f331d0f7eb3cdb434b196 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 17 Sep 2026 17:02:43 +0100 Subject: [PATCH 25/29] Recover the session lock before waiting for notifications Restarting the shell waited for the notification bus name to reappear before it would re-acquire a lock whose client had died. A slow or failed notification plugin then left the user stranded behind Hyprland's failsafe even though the lock service worked. Wait for the shell's core IPC, re-secure the lock immediately, and only then wait for a previously running notification service, reporting it separately if it never returns. Cover the never-returning case: the lock comes back and the restart still reports the missing service. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-restart-shell | 60 +++++++++++++++++++++--------- test/shell.d/restart-shell-test.sh | 38 +++++++++++++++++++ 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index 9891e7e8..4ca6ba29 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -82,26 +82,50 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; # not transient variables from a terminal, SSH connection, or development tool. hyprctl dispatch 'hl.dsp.exec_cmd("omarchy-launch-shell")' >/dev/null +shell_ready=0 for (( attempt = 0; attempt < 20; attempt++ )); do - if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1 && - { (( notifications_were_running == 0 )) || notifications_ready; }; then - # The session stays compositor-locked after the old lock client died, so - # re-acquire the lock and let the user authenticate out of it. - if (( relock )) && ! relock_session; then - echo "Omarchy shell restarted, but the session lock was not re-secured." >&2 - exit 1 - fi - # Invitation toasts (like Voxtype/fingerprint setup) die with the old - # shell, and their notify-send waiters hang forever: the dying server - # never emits NotificationClosed. A still-running omarchy-*-invitation - # unit is therefore an unanswered invitation — re-run it so its toast - # reappears on the new shell. Answered invitations have already exited - # and been collected, so the glob no longer matches them. - systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true - exit 0 + if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then + shell_ready=1 + break fi sleep 0.1 done +if (( shell_ready == 0 )); then + echo "Omarchy shell did not become ready after restart." >&2 + exit 1 +fi -echo "Omarchy shell did not become ready after restart." >&2 -exit 1 +# The session stays compositor-locked after the old lock client died, so +# re-acquire the lock and let the user authenticate out of it. This comes +# first and depends on nothing else: a user stranded behind the failsafe must +# not wait on the notification plugin, which may be slow or absent. +if (( relock )) && ! relock_session; then + echo "Omarchy shell restarted, but the session lock was not re-secured." >&2 + exit 1 +fi + +# Core IPC answers before the notification plugin has registered its bus +# name, so wait for it separately before one-time toasts are sent. +if (( notifications_were_running )); then + notifications_restored=0 + for (( attempt = 0; attempt < 20; attempt++ )); do + if notifications_ready; then + notifications_restored=1 + break + fi + sleep 0.1 + done + if (( notifications_restored == 0 )); then + echo "Omarchy shell restarted, but its notification service did not become ready." >&2 + exit 1 + fi +fi + +# Invitation toasts (like Voxtype/fingerprint setup) die with the old +# shell, and their notify-send waiters hang forever: the dying server +# never emits NotificationClosed. A still-running omarchy-*-invitation +# unit is therefore an unanswered invitation — re-run it so its toast +# reappears on the new shell. Answered invitations have already exited +# and been collected, so the glob no longer matches them. +systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true +exit 0 diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index 31901736..e6d81e62 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -181,6 +181,12 @@ else [[ ! -f $OMARCHY_TEST_NOTIFICATION_CHECKS ]] || read -r checks <"$OMARCHY_TEST_NOTIFICATION_CHECKS" (( checks += 1 )) printf '%s\n' "$checks" >"$OMARCHY_TEST_NOTIFICATION_CHECKS" + # The service was running before the restart and, when asked to, never + # comes back afterwards. + if [[ ${OMARCHY_TEST_NOTIFICATIONS_DIE:-0} == 1 ]]; then + (( checks == 1 )) && echo 'b true' || echo 'b false' + exit 0 + fi if (( checks == 1 || checks >= 4 )); then echo 'b true' else @@ -285,3 +291,35 @@ restart_pid_one="" grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "dead-lock recovery re-acquires the session lock" grep -F "ipc -n -p $restart_root/shell call -- lock status" "$ipc_log" >/dev/null || fail "dead-lock recovery waits for the lock to become secure" pass "restart recovers a locked session whose lock client died" + +# Lock recovery must not wait on the notification plugin: a stranded user gets +# the lock back even when notifications never return, and the restart then +# reports the missing service rather than claiming success. +sleep 30 & +restart_pid_one=$! +printf '%s\n' "$restart_pid_one" >"$restart_state" +rm -f "$restart_state.locked" "$test_tmp/notification-checks" +: >"$restart_log" +: >"$ipc_log" + +if PATH="$restart_bin:$PATH" \ + OMARCHY_PATH="$restart_root" \ + XDG_RUNTIME_DIR="$runtime_dir" \ + OMARCHY_TEST_SESSION_LOCKED=1 \ + OMARCHY_TEST_QS_STATE="$restart_state" \ + OMARCHY_TEST_QS_LOG="$restart_log" \ + OMARCHY_TEST_QS_ENV_LOG="$restart_env_log" \ + OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \ + OMARCHY_TEST_IPC_LOG="$ipc_log" \ + OMARCHY_TEST_SESSION_PATH="$restart_root" \ + OMARCHY_TEST_NOTIFICATION_CHECKS="$test_tmp/notification-checks" \ + OMARCHY_TEST_NOTIFICATIONS_DIE=1 \ + timeout 10 "$ROOT/bin/omarchy-restart-shell" >"$test_tmp/dead-notifications.out" 2>&1; then + fail "a restart whose notification service never returns must not report success" +fi +wait "$restart_pid_one" 2>/dev/null || true +restart_pid_one="" +grep -F "ipc -n -p $restart_root/shell call -- lock lock" "$ipc_log" >/dev/null || fail "lock recovery waited on the notification service" "$(cat "$ipc_log")" +[[ -f $restart_state.locked ]] || fail "lock recovery did not re-secure the session without notifications" +grep -q "notification service did not become ready" "$test_tmp/dead-notifications.out" || fail "a missing notification service is not reported" "$(cat "$test_tmp/dead-notifications.out")" +pass "restart recovers the lock even when the notification service never returns" From 43b91163f6033f3a562653e4c4217cb407eb72f0 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 17 Sep 2026 20:33:59 +0100 Subject: [PATCH 26/29] Install cleanup traps before the entry revocation and bound the bus probe The protected entrypoints revoked the sudo timestamp before installing their cleanup traps, so a signal or failure during that first sudo -k exited without the cleanup path. Install the traps first. The shell restart probed the notification bus name with busctl's default 25 second timeout, so an unresponsive user bus could stall the restart by that much per probe. Bound each probe to one second. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-channel-set | 4 +++- bin/omarchy-refresh-pacman | 4 +++- bin/omarchy-restart-shell | 4 +++- bin/omarchy-update | 4 +++- bin/omarchy-update-stay-awake | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index a00c9d92..039d3292 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -15,8 +15,10 @@ set -euo pipefail omarchy_security_sanitize_bash_environment "$0" "$@" omarchy_security_require_source_root "$0" user_path=$PATH -omarchy_security_revoke_sudo_timestamp || exit 1 +# Traps first, so a signal or failure during the entry revocation still +# exits through the cleanup path. omarchy_security_install_sudo_cleanup_traps +omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; } diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index cd9eab11..7406e6ad 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -18,8 +18,10 @@ omarchy_security_require_source_root "$0" # caller's original path over the same way the updater receives it. user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} unset OMARCHY_UPDATE_USER_PATH -omarchy_security_revoke_sudo_timestamp || exit 1 +# Traps first, so a signal or failure during the entry revocation still +# exits through the cleanup path. omarchy_security_install_sudo_cleanup_traps +omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo channel="${1:-stable}" diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index 4ca6ba29..8d3d6ce9 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -61,8 +61,10 @@ relock_session() { return 1 } +# A short timeout keeps an unresponsive user bus from stalling the restart; +# busctl would otherwise wait 25 seconds per probe. notifications_ready() { - [[ $(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus \ + [[ $(busctl --user --timeout=1s call org.freedesktop.DBus /org/freedesktop/DBus \ org.freedesktop.DBus NameHasOwner s org.freedesktop.Notifications 2>/dev/null) == "b true" ]] } diff --git a/bin/omarchy-update b/bin/omarchy-update index 2d5ad474..9c6d21d7 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -20,8 +20,10 @@ omarchy_security_require_source_root "$0" # Preserve the caller's path only for the later unprivileged hook/mise phases. user_path=${OMARCHY_UPDATE_USER_PATH:-$PATH} unset OMARCHY_UPDATE_USER_PATH -omarchy_security_revoke_sudo_timestamp || exit 1 +# Traps first, so a signal or failure during the entry revocation still +# exits through the cleanup path. omarchy_security_install_sudo_cleanup_traps +omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo update_stay_awake_stopped=0 diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index af941c4a..be27a2cc 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -15,8 +15,10 @@ omarchy_security_require_privileged_bash_startup || exit 126 set -e omarchy_security_sanitize_bash_environment "$0" "$@" omarchy_security_require_source_root "$0" -omarchy_security_revoke_sudo_timestamp || exit 1 +# Traps first, so a signal or failure during the entry revocation still +# exits through the cleanup path. omarchy_security_install_sudo_cleanup_traps +omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo state_dir="${XDG_RUNTIME_DIR:-/tmp/omarchy-$UID}/omarchy-update-stay-awake" From c8407ee73fa5ba056ef213c00df59b786765bbb4 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 17 Sep 2026 20:30:27 +0100 Subject: [PATCH 27/29] Cover completion failures in the package scriptlet contract pacman continues a transaction after a failed scriptlet. Model that in the shared package-scriptlet contract: a failed pre_upgrade followed by post_upgrade must keep the removal blocker while a rule remains, a clean retry recovers, a stranded blocker with a live rule is cleaned by the next completed installation, a fresh install that cannot remove a leftover rule leaves publication refused, and the sweep does not depend on inherited glob state. Co-Authored-By: Claude Fable 5.1 --- .../passwordless-grant-lifecycle-test.sh | 54 +++++++++++++++++-- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/test/shell.d/passwordless-grant-lifecycle-test.sh b/test/shell.d/passwordless-grant-lifecycle-test.sh index 71f34b24..2eee2dea 100644 --- a/test/shell.d/passwordless-grant-lifecycle-test.sh +++ b/test/shell.d/passwordless-grant-lifecycle-test.sh @@ -115,9 +115,49 @@ for name in omarchy-settings omarchy-settings-dev; do enable_locked 1000 15 pre_upgrade && post_upgrade [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + # pacman does not stop a transaction on a failed scriptlet, so a failed + # pre_upgrade can be followed directly by post_upgrade. Completion must + # not clear the blocker while a rule remains, and a clean retry recovers. + enable_locked 1000 15 + TEST_DELETE_FAIL=1 assert_status 1 pre_upgrade + TEST_DELETE_FAIL=1 assert_status 1 post_upgrade + [[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]] + assert_status 1 enable_locked 1000 15 + post_upgrade + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + # A stranded blocker plus a live rule from an interrupted removal is + # cleaned by the next completed installation, not merely unblocked. + enable_locked 1000 15 + : >"$REMOVAL_BLOCKER" + post_install + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + # A fresh install has no earlier step, so completion must hold the blocker + # itself while it sweeps: a leftover rule it cannot remove leaves + # publication refused rather than merely reporting an error. + enable_locked 1000 15 + rm -f "$REMOVAL_BLOCKER" + TEST_DELETE_FAIL=1 assert_status 1 post_install + [[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]] + assert_status 1 enable_locked 1000 15 + post_install + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + # The sweep must not depend on the glob state pacman's shell inherits. + enable_locked 1000 15 + ( set -f; GLOBIGNORE='*' post_upgrade ) + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] + # A failure before the lock is even taken, such as an untrusted lock + # directory, must still leave publication refused. + enable_locked 1000 15 + rm -f "$REMOVAL_BLOCKER" + TEST_BAD_PATH="$test_tmp/run/lock" assert_status 1 post_install + [[ -e $REMOVAL_BLOCKER && -e $(rule_file 1000) ]] + TEST_BAD_PATH="$test_tmp/run/lock" assert_status 1 pre_upgrade + [[ -e $REMOVAL_BLOCKER ]] + post_install + [[ ! -e $(rule_file 1000) && ! -e $REMOVAL_BLOCKER ]] ) done -pass "both settings packages revoke grants, block publication, and recover on installation" +pass "both settings packages revoke grants, block publication, and recover on installation only with the namespace empty" reset_grant # Hold the source lock, then start package removal. A native flock on the @@ -148,13 +188,21 @@ done /usr/bin/bash -euo pipefail -c 'source "$1"; pre_remove; post_remove' bash "$test_tmp/omarchy-settings.install" >"$test_tmp/removal.log" 2>&1 & removal=$! children+=("$removal") +# The removal announces itself before waiting for the lock, so a publisher +# still holding it is refused rather than allowed to publish a rule that the +# removal would delete a moment later. +for (( attempt=0; attempt<200; attempt++ )); do + [[ ! -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || break + sleep 0.01 +done +[[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] || fail "removal did not announce itself before waiting for the lock" touch "$test_tmp/release" -wait "$publisher" || fail "publisher failed" "$(cat "$test_tmp/publisher.log")" +if wait "$publisher"; then fail "publisher was allowed to publish after removal announced itself" "$(cat "$test_tmp/publisher.log")"; fi wait "$removal" || fail "removal failed" "$(cat "$test_tmp/removal.log")" children=() [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-nopasswd-1000 ]] [[ -f $test_tmp/run/omarchy-sudo-passwordless-package-removing ]] -pass "native lock serializes grant publication with package removal" +pass "an announced package removal refuses a waiting publisher and clears the namespace" # systemd-tmpfiles operates on an explicit disposable root, never the host. reset_grant From 67a813d8d23d0783898b56aa8ed99d87d038a5ca Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Thu, 17 Sep 2026 20:37:17 +0100 Subject: [PATCH 28/29] Bound the grant lock wait The settings package's pre-transaction hook runs this command under pacman's transaction. Its lock wait was unbounded, so a stalled grant operation could hang pacman indefinitely before AbortOnFail ever saw a result. Grant operations are short; wait at most 60 seconds. Co-Authored-By: Claude Fable 5.1 --- bin/omarchy-sudo-passwordless | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 58a9b15c..b5f88a94 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -86,7 +86,9 @@ with_root_lock() { 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 + # Grant operations are short. A stalled holder must not hang a caller + # indefinitely, least of all pacman's pre-transaction hook. + /usr/bin/flock -x -w 60 "$fd" || return 1 "$@" || rc=$? /usr/bin/flock -u "$fd" || rc=1 exec {fd}>&- From 6af052fcc3c68f2fe4a3cdbcb9afbd2bc780c48c Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 21 Sep 2026 14:27:23 +0100 Subject: [PATCH 29/29] Bind update inhibitor cleanup to owned process identity Keep inhibitor state in validated private directories and verify the recorded owner, PID, start time and launch token before signaling. Authenticate the held command before detaching and drop it back to the invoking user. Serialize launch and cancellation, identify the child before publishing its state, and preserve caller-owned idle choices. Cover cross-account fallback state, process identity, cancellation, retry, and update-lock handling with isolated regressions. --- bin/omarchy-update-stay-awake | 557 +++++++++++++--- docs/update-process.md | 2 + test/shell.d/fixtures/sudo-boundary-test.sh | 2 +- test/shell.d/update-lock-test.sh | 135 +++- .../update-stay-awake-security-test.sh | 626 ++++++++++++++++++ 5 files changed, 1237 insertions(+), 85 deletions(-) create mode 100644 test/shell.d/update-stay-awake-security-test.sh diff --git a/bin/omarchy-update-stay-awake b/bin/omarchy-update-stay-awake index be27a2cc..f5dbd3ae 100755 --- a/bin/omarchy-update-stay-awake +++ b/bin/omarchy-update-stay-awake @@ -21,10 +21,211 @@ omarchy_security_install_sudo_cleanup_traps omarchy_security_revoke_sudo_timestamp || exit 1 omarchy_security_enable_no_update_sudo -state_dir="${XDG_RUNTIME_DIR:-/tmp/omarchy-$UID}/omarchy-update-stay-awake" -idle_owner_file="$state_dir/idle-owner" -inhibit_pid_file="$state_dir/inhibit-pid" stay_awake_state="$HOME/.local/state/omarchy/indicators/stay-awake" +caller_uid="" +state_base="" +state_dir="" +idle_owner_file="" +inhibit_pid_file="" +launch_control_file="" +launch_pending=0 +launch_token="" + +fail_state_boundary() { + echo "Refusing to use an unsafe Omarchy update inhibitor state path." >&2 + return 1 +} + +directory_is_private() { + local directory="$1" + local expected_owner="$2" + local legacy="${3:-0}" + local canonical="" + local owner="" + local mode="" + + [[ -d $directory && ! -L $directory ]] || return 1 + canonical=$(readlink -e -- "$directory") || return 1 + [[ $canonical == "$directory" ]] || return 1 + read -r owner mode < <(stat -Lc '%u %a' -- "$directory") || return 1 + [[ $owner == "$expected_owner" ]] || return 1 + [[ $mode == "700" ]] || (( legacy == 1 && (8#$mode & 022) == 0 )) +} + +root_owned_parent_chain() { + local directory="$1" + local parent owner mode type canonical + + parent=$(/usr/bin/dirname -- "$directory") || return 1 + while :; do + [[ -d $parent && ! -L $parent ]] || return 1 + canonical=$(/usr/bin/readlink -e -- "$parent") || return 1 + [[ $canonical == "$parent" ]] || return 1 + read -r owner mode type < <(/usr/bin/stat -Lc '%u %a %F' -- "$parent") || return 1 + [[ $owner == 0 && $type == "directory" ]] || return 1 + ! ((8#$mode & 022)) || return 1 + [[ $parent == / ]] && break + parent=$(/usr/bin/dirname -- "$parent") || return 1 + done +} + +runtime_directory_is_private() { + local directory="$1" expected_owner="$2" + directory_is_private "$directory" "$expected_owner" && + root_owned_parent_chain "$directory" +} + +ensure_private_directory() { + local directory="$1" + + if [[ ! -e $directory && ! -L $directory ]]; then + mkdir -m 700 -- "$directory" 2>/dev/null || true + fi + # Older helpers used mkdir -p with the caller's umask. Tighten only a + # canonical, caller-owned directory that other accounts cannot write. + directory_is_private "$directory" "$caller_uid" 1 || return 1 + chmod 700 -- "$directory" || return 1 + directory_is_private "$directory" "$caller_uid" +} + +initialize_state_boundary() { + local canonical_tmp="" + local tmp_owner="" + local tmp_mode="" + + caller_uid="$EUID" + [[ $caller_uid =~ ^[0-9]+$ ]] || return 1 + + if [[ -n ${XDG_RUNTIME_DIR:-} ]]; then + runtime_directory_is_private "$XDG_RUNTIME_DIR" "$caller_uid" || fail_state_boundary + state_base="$XDG_RUNTIME_DIR" + else + [[ -d /tmp && ! -L /tmp ]] || fail_state_boundary + canonical_tmp=$(readlink -e -- /tmp) || fail_state_boundary + read -r tmp_owner tmp_mode < <(stat -Lc '%u %a' -- /tmp) || fail_state_boundary + [[ $canonical_tmp == "/tmp" && $tmp_owner == "0" && $tmp_mode == "1777" ]] || fail_state_boundary + + state_base="/tmp/omarchy-$caller_uid" + ensure_private_directory "$state_base" || fail_state_boundary + fi + + state_dir="$state_base/omarchy-update-stay-awake" + idle_owner_file="$state_dir/idle-owner" + inhibit_pid_file="$state_dir/inhibit-pid" + launch_control_file="$state_dir/launch-control" +} + +state_file_is_private() { + local state_file="$1" + local legacy="${2:-0}" + local owner="" + local mode="" + local links="" + + [[ -f $state_file && ! -L $state_file ]] || return 1 + read -r owner mode links < <(stat -Lc '%u %a %h' -- "$state_file") || return 1 + [[ $owner == "$caller_uid" && $links == "1" ]] || return 1 + [[ $mode == "600" ]] || (( legacy == 1 && (8#$mode & 022) == 0 )) +} + +read_state_record() { + local state_file="$1" + local legacy="${2:-0}" + local records=() + local file_size="" + local LC_ALL=C + + state_file_is_private "$state_file" "$legacy" || return 1 + mapfile -t records <"$state_file" || return 1 + (( ${#records[@]} == 1 )) || return 1 + file_size=$(stat -Lc '%s' -- "$state_file") || return 1 + (( file_size == ${#records[0]} + 1 )) || return 1 + state_file_is_private "$state_file" "$legacy" || return 1 + printf '%s\n' "${records[0]}" +} + +atomic_write_state() { + local state_file="$1" + local record="$2" + local temporary="" + + [[ $record != *$'\n'* ]] || return 1 + temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1 + chmod 600 "$temporary" || { + rm -f -- "$temporary" + return 1 + } + if ! printf '%s\n' "$record" >"$temporary" || ! state_file_is_private "$temporary"; then + rm -f -- "$temporary" + return 1 + fi + if [[ -e $state_file || -L $state_file ]]; then + state_file_is_private "$state_file" || { + rm -f -- "$temporary" + return 1 + } + fi + mv -fT -- "$temporary" "$state_file" || { + rm -f -- "$temporary" + return 1 + } + state_file_is_private "$state_file" +} + +rollback_pending_launch() { + local control_fd="" + local inhibit_record="" + local inhibit_pid="" + local recorded_start_time="" + local recorded_owner="" + local token="" + + (( launch_pending == 1 )) || return 0 + + if [[ -e $launch_control_file || -L $launch_control_file ]]; then + state_file_is_private "$launch_control_file" || return 1 + exec {control_fd}<>"$launch_control_file" || return 1 + /usr/bin/flock -x "$control_fd" || { + exec {control_fd}>&- + return 1 + } + : >"/proc/self/fd/$control_fd" + printf 'cancelled %s\n' "$launch_token" >&"$control_fd" + rm -f -- "$launch_control_file" + /usr/bin/flock -u "$control_fd" + exec {control_fd}>&- + fi + + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($launch_token)$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="${BASH_REMATCH[3]}" + token="${BASH_REMATCH[4]}" + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + discard_launched_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" + fi + [[ $(read_state_record "$inhibit_pid_file" 2>/dev/null || true) != "$inhibit_record" ]] || + rm -f -- "$inhibit_pid_file" + fi + + # A signal may interrupt either parent-side atomic write before its rename. + rm -f -- "$state_dir"/.launch-control.* "$state_dir"/.idle-owner.* + stop_locked || return 1 + launch_pending=0 +} + +cleanup_pending_launch() { + local status=$? + + trap - EXIT HUP INT TERM + if ! rollback_pending_launch; then + echo "Failed to roll back the pending Omarchy update sleep inhibitor." >&2 + (( status != 0 )) || status=1 + fi + omarchy_security_exit_with_revoked_sudo "$status" \ + "Failed to invalidate sudo credentials after the update sleep inhibitor." +} process_start_time() { local process_pid="$1" @@ -36,9 +237,67 @@ process_start_time() { process_stat="${process_stat##*) }" read -r -a stat_fields <<<"$process_stat" (( ${#stat_fields[@]} > 19 )) || return 1 + [[ ${stat_fields[19]} =~ ^[0-9]+$ ]] || return 1 printf '%s\n' "${stat_fields[19]}" } +process_owner() { + local process_pid="$1" + local token="$2" + local owner="" + + if [[ -n $token ]]; then + owner=$(stat -Lc '%u' -- "/proc/$process_pid") || return 1 + else + # Legacy state can name sudo: its real UID remains the invoking account, + # even when /proc ownership reflects its effective root credentials. + owner=$(awk '/^Uid:/ { print $2; exit }' "/proc/$process_pid/status") || return 1 + fi + [[ $owner =~ ^[0-9]+$ ]] || return 1 + printf '%s\n' "$owner" +} + +process_has_token() { + local process_pid="$1" + local token="$2" + local argument="" + local expected="--why=Omarchy update in progress [$token]" + + [[ -r /proc/$process_pid/cmdline ]] || return 1 + while IFS= read -r -d '' argument; do + [[ $argument == "$expected" ]] && return 0 + done <"/proc/$process_pid/cmdline" + return 1 +} + +process_identity() { + local process_pid="$1" + local token="$2" + local start_before="" + local start_after="" + local owner_before="" + local owner_after="" + + start_before=$(process_start_time "$process_pid") || return 1 + owner_before=$(process_owner "$process_pid" "$token") || return 1 + [[ -z $token ]] || process_has_token "$process_pid" "$token" || return 1 + start_after=$(process_start_time "$process_pid") || return 1 + owner_after=$(process_owner "$process_pid" "$token") || return 1 + [[ $start_before == "$start_after" && $owner_before == "$owner_after" ]] || return 1 + printf '%s %s\n' "$start_before" "$owner_before" +} + +process_matches() { + local process_pid="$1" + local expected_start="$2" + local expected_owner="$3" + local token="$4" + local identity="" + + identity=$(process_identity "$process_pid" "$token" 2>/dev/null) || return 1 + [[ $identity == "$expected_start $expected_owner" ]] +} + process_state() { local process_pid="$1" local process_stat="" @@ -49,126 +308,276 @@ process_state() { printf '%s\n' "${process_stat%% *}" } -stop() { - local inhibit_pid="" - local recorded_start_time="" - local current_start_time="" - local idle_owner="" - local current_idle_owner="" +discard_launched_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" - if [[ -s $idle_owner_file ]]; then - idle_owner=$(<"$idle_owner_file") - if [[ -f $stay_awake_state ]]; then - current_idle_owner=$(<"$stay_awake_state") - fi - if [[ -n $idle_owner && $current_idle_owner == "$idle_owner" ]]; then - omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true - fi - rm -f "$idle_owner_file" + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true + for (( attempt = 0; attempt < 25; attempt++ )); do + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || break + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && break + sleep 0.02 + done + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" && + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" KILL || true fi - if [[ -s $inhibit_pid_file ]]; then - read -r inhibit_pid recorded_start_time <"$inhibit_pid_file" || true - if [[ $inhibit_pid =~ ^[0-9]+$ ]]; then - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) + if [[ ! -e /proc/$inhibit_pid ]] || [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]]; then + wait "$inhibit_pid" 2>/dev/null || true + fi +} + +signal_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + local signal="$5" + + [[ $recorded_owner == "$caller_uid" ]] || return 1 + [[ $signal == "TERM" || $signal == "KILL" ]] || return 1 + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 1 + builtin kill -s "$signal" -- "$inhibit_pid" 2>/dev/null +} + +terminate_inhibitor() { + local inhibit_pid="$1" + local recorded_start_time="$2" + local recorded_owner="$3" + local token="$4" + + signal_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" TERM || true + + for (( attempt = 0; attempt < 50; attempt++ )); do + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0 + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || return 0 + sleep 0.02 + done + + process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token" || return 0 + [[ $(process_state "$inhibit_pid" 2>/dev/null || true) == "Z" ]] && return 0 + return 1 +} + +stop_locked() { + local inhibit_record="" + local inhibit_pid="" + local recorded_start_time="" + local recorded_owner="" + local token="" + local idle_owner="" + local current_idle_owner="" + local current_start="" + local failed=0 + local remove_inhibit_state=1 + + if [[ ! -e $state_dir && ! -L $state_dir ]]; then + return 0 + fi + if ! ensure_private_directory "$state_dir"; then + fail_state_boundary + return 1 + fi + + if [[ -e $idle_owner_file || -L $idle_owner_file ]]; then + idle_owner=$(read_state_record "$idle_owner_file" 1 2>/dev/null || true) + if [[ $idle_owner =~ ^[0-9]+:[0-9]+:[0-9]+$ ]]; then + if [[ -f $stay_awake_state ]]; then + current_idle_owner=$(<"$stay_awake_state") + fi + if [[ $current_idle_owner == "$idle_owner" ]]; then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + fi + else + echo "Ignoring unsafe Omarchy update idle ownership state." >&2 + failed=1 fi - if [[ -n $recorded_start_time && $current_start_time == "$recorded_start_time" ]]; then - kill "$inhibit_pid" >/dev/null 2>&1 || true + rm -f -- "$idle_owner_file" + fi - for (( attempt = 0; attempt < 50; attempt++ )); do - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - [[ $current_start_time == "$recorded_start_time" ]] || break - [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] || break - sleep 0.02 - done - - current_start_time=$(process_start_time "$inhibit_pid" 2>/dev/null || true) - if [[ $current_start_time == "$recorded_start_time" ]] && - [[ $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]]; then - echo "Failed to stop the Omarchy update sleep inhibitor." >&2 - return 1 + if [[ -e $inhibit_pid_file || -L $inhibit_pid_file ]]; then + inhibit_record=$(read_state_record "$inhibit_pid_file" 1 2>/dev/null || true) + if state_file_is_private "$inhibit_pid_file" && + [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ([0-9a-f]{32})$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="${BASH_REMATCH[3]}" + token="${BASH_REMATCH[4]}" + elif [[ $inhibit_record =~ ^([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})$ ]]; then + # The first update starts the old helper and installs this one before + # cleanup. Its protected same-account record predates launch tokens. + inhibit_pid="${BASH_REMATCH[1]}" + recorded_start_time="${BASH_REMATCH[2]}" + recorded_owner="$caller_uid" + if [[ -e /proc/$inhibit_pid && $(process_state "$inhibit_pid" 2>/dev/null || true) != "Z" ]] && + ! process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" ""; then + current_start=$(process_start_time "$inhibit_pid" 2>/dev/null || true) + if [[ -z $current_start || $current_start == "$recorded_start_time" ]]; then + echo "Cannot verify permission to stop the legacy update inhibitor; retained its state for recovery." >&2 + failed=1 + remove_inhibit_state=0 + fi + fi + else + echo "Ignoring unsafe Omarchy update sleep inhibitor state." >&2 + failed=1 + fi + if [[ -n $inhibit_pid ]] && (( remove_inhibit_state == 1 )); then + if process_matches "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + if ! terminate_inhibitor "$inhibit_pid" "$recorded_start_time" "$recorded_owner" "$token"; then + echo "Failed to stop the Omarchy update sleep inhibitor." >&2 + failed=1 + remove_inhibit_state=0 + fi fi fi - rm -f "$inhibit_pid_file" + (( remove_inhibit_state == 0 )) || rm -f -- "$inhibit_pid_file" fi rmdir "$state_dir" 2>/dev/null || true + (( failed == 0 )) } -start() { +start_locked() { local idle_owner="$$:$RANDOM:$RANDOM" + local token="" local launcher_pid="" + local inhibit_record="" + local inhibit_pid="" + local inhibit_start_time="" + local inhibit_owner="" + local readiness_attempts=0 - stop - mkdir -p "$state_dir" + stop_locked || return 1 + ensure_private_directory "$state_dir" || fail_state_boundary - # sudo authenticates in the foreground, then backgrounds the inhibitor. - # The held command drops back to this user before publishing its PID, so stop - # can release the inhibitor without another privileged operation or ticket. + token=$(LC_ALL=C /usr/bin/od -An -N16 -tx1 /dev/urandom | /usr/bin/tr -d ' \n') + [[ $token =~ ^[0-9a-f]{32}$ ]] || return 1 + launch_token="$token" + launch_pending=1 + trap cleanup_pending_launch EXIT + atomic_write_state "$launch_control_file" "active $token" || return 1 + + # The child is identifiable before it publishes state, including before exec. + # exec -a retains that identity without keeping an extra shell alive. local hold_command=( /usr/bin/systemd-inhibit --what=sleep:idle --who=omarchy-update - --why="Omarchy update in progress" --mode=block - /usr/bin/setpriv --reuid "$UID" --regid "$(id -g)" --clear-groups + --why="Omarchy update in progress [$token]" --mode=block + /usr/bin/setpriv --reuid "$caller_uid" --regid "$(/usr/bin/id -g)" --clear-groups /usr/bin/bash -p -c ' + set -e + umask 077 + state_dir=$1 + token=$2 + owner=$3 + control=$4 + expected="--why=Omarchy update in progress [$token]" + temporary="" + cleanup() { [[ -z $temporary ]] || /usr/bin/rm -f -- "$temporary"; } + trap cleanup EXIT read -r process_stat <"/proc/$$/stat" process_stat=${process_stat##*) } - read -r -a fields <<< "$process_stat" - printf "%s %s\n" "$$" "${fields[19]}" >"$1" - exec /usr/bin/sleep infinity - ' omarchy-update-inhibitor "$inhibit_pid_file" + read -r -a fields <<<"$process_stat" + temporary=$(/usr/bin/mktemp "$state_dir/.inhibit-pid.XXXXXXXX") + /usr/bin/chmod 600 "$temporary" + printf "1 %s %s %s %s\n" "$$" "${fields[19]}" "$owner" "$token" >"$temporary" + exec {control_fd}<"$control" + /usr/bin/flock -x "$control_fd" + IFS= read -r control_record <&"$control_fd" + [[ $control_record == "active $token" ]] + /usr/bin/mv -fT -- "$temporary" "$state_dir/inhibit-pid" + temporary="" + /usr/bin/flock -u "$control_fd" + exec {control_fd}>&- + trap - EXIT + exec -a "$expected" /usr/bin/sleep infinity + ' "--why=Omarchy update in progress [$token]" "$state_dir" "$token" "$caller_uid" "$launch_control_file" ) if (( EUID == 0 )); then - ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- - exec "${hold_command[@]}" ) & + ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec "${hold_command[@]}" + ) & launcher_pid=$! elif [[ -t 0 ]]; then - /usr/bin/sudo -N -b -- "${hold_command[@]}" + if ! ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec /usr/bin/sudo -N -b -- "${hold_command[@]}" + ); then + return 1 + fi else - ( [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- - exec pkexec "${hold_command[@]}" ) & + ( + [[ -z ${OMARCHY_UPDATE_LOCK_FD:-} ]] || exec {OMARCHY_UPDATE_LOCK_FD}>&- + exec {state_lock_fd}>&- + exec /usr/bin/pkexec "${hold_command[@]}" + ) & launcher_pid=$! fi - # For graphical authentication the launcher may wait for a password. Wait for - # either the user-owned held command to become ready or the launcher to fail. - # sudo -b backgrounds internally, so Bash has no launcher PID in the TTY path; - # $! there can still refer to a completed startup process substitution. - local readiness_attempts=0 - while [[ ! -s $inhibit_pid_file ]]; do + while :; do + inhibit_record=$(read_state_record "$inhibit_pid_file" 2>/dev/null || true) + if [[ $inhibit_record =~ ^1\ ([1-9][0-9]{0,18})\ ([1-9][0-9]{0,18})\ ([0-9]{1,10})\ ($token)$ ]]; then + inhibit_pid="${BASH_REMATCH[1]}" + inhibit_start_time="${BASH_REMATCH[2]}" + inhibit_owner="${BASH_REMATCH[3]}" + process_matches "$inhibit_pid" "$inhibit_start_time" "$inhibit_owner" "$token" && break + fi if [[ -n $launcher_pid ]] && ! kill -0 "$launcher_pid" 2>/dev/null; then wait "$launcher_pid" || return 1 echo "The update sleep inhibitor did not start." >&2 return 1 fi - readiness_attempts=$((readiness_attempts + 1)) + (( readiness_attempts += 1 )) if [[ -t 0 ]] && (( EUID != 0 && readiness_attempts >= 100 )); then echo "The update sleep inhibitor did not become ready." >&2 return 1 fi - sleep 0.05 + /usr/bin/sleep 0.05 done + if [[ -e $launch_control_file || -L $launch_control_file ]]; then + state_file_is_private "$launch_control_file" || return 1 + rm -f -- "$launch_control_file" + fi if [[ ! -f $stay_awake_state ]]; then - printf '%s\n' "$idle_owner" >"$idle_owner_file" - mkdir -p "$(dirname "$stay_awake_state")" - if omarchy-toggle-idle stay-awake >/dev/null 2>&1; then - printf '%s\n' "$idle_owner" >"$stay_awake_state" - else - rm -f "$idle_owner_file" + atomic_write_state "$idle_owner_file" "$idle_owner" || return 1 + mkdir -p "$(dirname "$stay_awake_state")" || return 1 + # The idle toggle uses this marker. Publish its owner in the same write so + # cancellation cannot leave an unowned Stay Awake setting behind. + if ! printf '%s\n' "$idle_owner" >"$stay_awake_state"; then + omarchy-toggle-idle allow-idle >/dev/null 2>&1 || true + return 1 fi fi + launch_pending=0 + omarchy_security_install_sudo_cleanup_traps } case "${1:-}" in - start) - start - ;; - stop) - stop - ;; + start | stop) ;; *) echo "Usage: omarchy-update-stay-awake " >&2 exit 2 ;; esac + +initialize_state_boundary +exec {state_lock_fd}<"$state_base" || fail_state_boundary +flock -x "$state_lock_fd" || fail_state_boundary +directory_is_private "$state_base" "$caller_uid" || fail_state_boundary + +case "$1" in + start) + start_locked + ;; + stop) + stop_locked + ;; +esac diff --git a/docs/update-process.md b/docs/update-process.md index f96b28c4..d486c4c9 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -22,6 +22,7 @@ The design goal is: | Path | Owner | Purpose | | --- | --- | --- | | `${XDG_RUNTIME_DIR:-/tmp}/omarchy-update.lock` | user | Prevent overlapping update runs. Owned by `omarchy-update-lock`; compatibility wrappers inherit/respect it. | +| `${XDG_RUNTIME_DIR}/omarchy-update-stay-awake/` | user | Private mode-0700 inhibitor coordination state. If no runtime directory is available, the helper uses the validated mode-0700 `/tmp/omarchy-$UID/` fallback. | | `/tmp/omarchy-update.log` | user | Transcript of `omarchy update`, used by `omarchy-update-analyze-logs`. | | `~/.local/state/omarchy/current/` | user | Generated active theme, selected theme name, and current background symlink. | | `~/.local/state/omarchy/migrations/` | user | Per-user migration markers. | @@ -154,6 +155,7 @@ Important behavior: - Migrations remain in chronological order even though historical entries mix user-controlled code with later privileged repairs. Before entering that mixed-trust tail, Omarchy invalidates its timestamp and forces every later sudo call—including AUR's configurable sudo command—to use `--no-update`; prompts authorize one command without publishing a reusable timestamp. Yay's credential loop is disabled for the update. - User-controlled post-update hooks and mise tools run only after every sudo-capable update stage. Omarchy invalidates its sudo timestamp before each boundary and on every exit; detached children therefore have no later reusable update authorization to wait for. - This lifecycle controls authorization created by the protected workflow. `sudo -N` prevents cache updates but can use an existing valid credential, and `sudo -k` revokes the current session's timestamp. It does not isolate the account from unrelated concurrent authentication in another workflow. +- Sleep inhibition authenticates before detaching, drops the held command back to the caller, and closes both update lock descriptors before the persistent process starts. Cleanup accepts only caller-owned, mode-0600, single-link state and revalidates the recorded PID, process start time, owner, and random token immediately before every signal. - Channel switching establishes the same boundary before dev link/unlink, refresh and package operations. It keeps the wrapper first when changing source roots, carries the original user PATH into update hooks and mise, and checks after each package transaction that the wrapper still exists before any further privileged step, since a transaction can replace the running tree with a release that predates it; when it is gone, or the destination otherwise lacks it, the switch stops after the package switch with instructions to run that release's update from a fresh session rather than letting a bare `sudo` or an updater that authenticates without `--no-update` publish a timestamp. Failed and interrupted channel switches revoke on exit. - `-y` exports `OMARCHY_UPDATE_UNATTENDED=1` and suppresses Omarchy confirmation prompts. Interactive review steps (orphan removal, conflict handoff) report and skip instead of blocking. Privileged commands still require sudo authorization, and command-scoped authentication can prompt separately for each command. - The free-space requirement uses a 10 GiB threshold and stops the update before diff --git a/test/shell.d/fixtures/sudo-boundary-test.sh b/test/shell.d/fixtures/sudo-boundary-test.sh index 43b0a66f..31f9759d 100644 --- a/test/shell.d/fixtures/sudo-boundary-test.sh +++ b/test/shell.d/fixtures/sudo-boundary-test.sh @@ -21,7 +21,7 @@ source, target, name = map(Path,sys.argv[1:]) p=target/name p.parent.mkdir(parents=True,exist_ok=True) s=(source/name).read_text().replace('$HOME', '$SUDO_TEST_HOME') -for command in ['sudo','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']: +for command in ['sudo','pkexec','pacman','omarchy-pkg-missing','systemd-inhibit','setpriv','snapper']: s=s.replace('/usr/bin/'+command, str(target/'mock'/command)) s=s.replace('PATH=/usr/bin:/usr/sbin:/bin:/sbin', 'PATH="'+str(target/'bin')+':/usr/bin:/usr/sbin:/bin:/sbin"') p.write_text(s) diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index f8f3bc90..72b2bdb1 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -8,12 +8,21 @@ source "$SHELL_TEST_DIR/fixtures/sudo-boundary-test.sh" test_tmp="$boundary_tmp" stub_bin="$SUDO_TEST_ROOT/bin" test_home="$SUDO_TEST_HOME" -runtime_dir="$test_tmp/runtime" -mkdir -p "$runtime_dir" +runtime_dir="/run/user/$(id -u)" +test_run_id="test-$BASHPID-$RANDOM" +update_lock_name="omarchy-update-$test_run_id.lock" +stay_awake_dir_name="omarchy-update-stay-awake-$test_run_id" +trap 'rm -rf -- "$runtime_dir/$stay_awake_dir_name"; rm -f -- "$runtime_dir/$update_lock_name"; rm -rf -- "$boundary_tmp"' EXIT for command in omarchy-update omarchy-update-lock omarchy-update-stay-awake; do rm -f "$SUDO_TEST_ROOT/bin/$command" copy_boundary_file "bin/$command" done +sed -i \ + -e "s/omarchy-update\.lock/$update_lock_name/g" \ + -e "s#state_dir=\"\$state_base/omarchy-update-stay-awake\"#state_dir=\"\$state_base/$stay_awake_dir_name\"#" \ + "$SUDO_TEST_ROOT/bin/omarchy-update" \ + "$SUDO_TEST_ROOT/bin/omarchy-update-lock" \ + "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" cat >"$SUDO_TEST_ROOT/mock/setpriv" <<'STUB' #!/bin/bash while [[ ${1:-} == --* ]]; do @@ -67,6 +76,7 @@ for command in \ done write_stub omarchy-update-available 'exit 1' write_stub pkexec 'exec "$@"' +ln -s ../bin/pkexec "$SUDO_TEST_ROOT/mock/pkexec" write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"' ln -s ../bin/systemd-inhibit "$SUDO_TEST_ROOT/mock/systemd-inhibit" @@ -119,7 +129,7 @@ done inhibitor_pid=$(<"$inhibit_pid_file") kill -0 "$inhibitor_pid" 2>/dev/null || fail "sleep inhibitor is still running when its descriptors are inspected" -lock_target=$(readlink -f "$runtime_dir/omarchy-update.lock") +lock_target=$(readlink -f "$runtime_dir/$update_lock_name") inhibitor_holds_lock=0 for fd in /proc/"$inhibitor_pid"/fd/*; do [[ -e $fd ]] || continue @@ -150,10 +160,11 @@ if (( EUID != 0 )); then #!/bin/bash set -euo pipefail omarchy-update-stay-awake start -[[ -s $XDG_RUNTIME_DIR/omarchy-update-stay-awake/inhibit-pid ]] +[[ -s $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]] omarchy-update-stay-awake stop -[[ ! -e $XDG_RUNTIME_DIR/omarchy-update-stay-awake/inhibit-pid ]] +[[ ! -e $XDG_RUNTIME_DIR/REPLACE_STAY_AWAKE_DIR/inhibit-pid ]] SH + sed -i "s/REPLACE_STAY_AWAKE_DIR/$stay_awake_dir_name/g" "$terminal_driver" chmod +x "$terminal_driver" SUDO_LOG="$sudo_log" PKEXEC_MARKER="$pkexec_marker" INHIBIT_PID_FILE="$terminal_inhibit_pid_file" \ @@ -163,6 +174,76 @@ SH [[ ! -e $pkexec_marker ]] || fail "terminal sleep inhibition does not use pkexec" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop pass "terminal updates use sudo instead of Polkit for sleep inhibition" + + wait_for_process_exit() { + local process_pid="$1" + + for _ in {1..100}; do + kill -0 "$process_pid" 2>/dev/null || return 0 + [[ $(awk '{ print $3 }' "/proc/$process_pid/stat" 2>/dev/null || true) == "Z" ]] && return 0 + sleep 0.02 + done + return 1 + } + + delayed_marker="$test_tmp/delayed-inhibitor" + delayed_helper_pid_file="$test_tmp/delayed-helper-pid" + write_stub systemd-inhibit 'echo "$$" >"$DELAYED_MARKER"; sleep 0.4; while [[ $1 == --* ]]; do shift; done; exec "$@"' + + # Keep the start helper's stdin attached to the private PTY so it takes the + # sudo -b branch, then signal only that helper before the held child publishes. + delayed_terminal_driver="$test_tmp/delayed-terminal-stay-awake" + cat >"$delayed_terminal_driver" <<'SH' +#!/bin/bash +set +e +omarchy-update-stay-awake start "$DELAYED_HELPER_PID_FILE" +wait "$helper_pid" +exit $? +SH + chmod +x "$delayed_terminal_driver" + DELAYED_MARKER="$delayed_marker" DELAYED_HELPER_PID_FILE="$delayed_helper_pid_file" \ + run_with_lock_env script -qefc "$delayed_terminal_driver" /dev/null >"$test_tmp/delayed-terminal.out" 2>&1 & + delayed_terminal_driver_pid=$! + for _ in {1..100}; do + [[ -s $delayed_marker && -s $delayed_helper_pid_file ]] && break + sleep 0.02 + done + [[ -s $delayed_marker && -s $delayed_helper_pid_file ]] || fail "terminal cancellation reaches the delayed launch window" + kill -TERM "$(<"$delayed_helper_pid_file")" + wait "$delayed_terminal_driver_pid" || true + delayed_inhibitor_pid=$(<"$delayed_marker") + wait_for_process_exit "$delayed_inhibitor_pid" || fail "terminal cancellation leaves no delayed inhibitor" + [[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "terminal cancellation leaves no launch state" + pass "terminal cancellation rolls back delayed publication" + + # With redirected stdin the same helper takes the graphical pkexec branch. + : >"$delayed_marker" + delayed_graphical_helper_pid_file="$test_tmp/delayed-graphical-helper-pid" + delayed_graphical_driver="$test_tmp/delayed-graphical-stay-awake" + cat >"$delayed_graphical_driver" <<'SH' +#!/bin/bash +echo "$$" >"$DELAYED_HELPER_PID_FILE" +exec omarchy-update-stay-awake start "$test_tmp/delayed-graphical.out" 2>&1 & + delayed_graphical_driver_pid=$! + for _ in {1..100}; do + [[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] && break + sleep 0.02 + done + [[ -s $delayed_marker && -s $delayed_graphical_helper_pid_file ]] || fail "graphical cancellation reaches the delayed launch window" + delayed_graphical_helper_pid=$(<"$delayed_graphical_helper_pid_file") + kill -TERM "$delayed_graphical_helper_pid" + wait "$delayed_graphical_driver_pid" || true + delayed_inhibitor_pid=$(<"$delayed_marker") + wait_for_process_exit "$delayed_inhibitor_pid" || fail "graphical cancellation leaves no delayed inhibitor" + [[ ! -e $runtime_dir/$stay_awake_dir_name ]] || fail "graphical cancellation leaves no launch state" + pass "graphical cancellation rolls back delayed publication" + write_stub systemd-inhibit 'while [[ $1 == --* ]]; do shift; done; exec "$@"' fi # Update-owned Stay Awake state must be cleared before the restart helper can @@ -198,12 +279,45 @@ OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$SUDO_TEST_ROOT/b [[ -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update preserves pre-existing Stay Awake state" pass "omarchy-update restores only its own Stay Awake state before restart handling" +# Model package replacement while the existing updater is still running: +# start writes the old two-field state; the transaction installs the real new +# helper, whose stop must clean that state before reboot handling. +cp "$stub_bin/omarchy-update-stay-awake" "$test_tmp/inhibitor-after-upgrade" +write_stub omarchy-update-stay-awake ' +set -e +[[ $1 == "start" ]] +umask 022 +state="$XDG_RUNTIME_DIR/$LEGACY_STATE_NAME" +mkdir -p "$state" "$SUDO_TEST_HOME/.local/state/omarchy/indicators" +( exec {OMARCHY_UPDATE_LOCK_FD}>&-; exec sleep infinity ) & +pid=$! +printf "%s %s\n" "$pid" "$(awk '\''{ print $22 }'\'' /proc/$pid/stat)" >"$state/inhibit-pid" +printf "%s:1:1\n" "$$" >"$state/idle-owner" +/usr/bin/cp "$state/idle-owner" "$SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake"' +write_stub omarchy-update-system-pkgs ' +/usr/bin/cp "$INHIBITOR_AFTER_UPGRADE" "$OMARCHY_PATH/bin/omarchy-update-stay-awake"' +write_stub omarchy-update-restart ' +if [[ $1 == "--reboot-only" ]]; then + [[ ! -e $SUDO_TEST_HOME/.local/state/omarchy/indicators/stay-awake ]] || exit 91 + [[ ! -e $XDG_RUNTIME_DIR/$LEGACY_STATE_NAME ]] || exit 92 + touch "$UPGRADE_RESTARTED" +fi' +rm -f "$test_home/.local/state/omarchy/indicators/stay-awake" +OMARCHY_UPDATE_LOGGED=1 LEGACY_STATE_NAME="$stay_awake_dir_name" \ + INHIBITOR_AFTER_UPGRADE="$test_tmp/inhibitor-after-upgrade" \ + UPGRADE_RESTARTED="$test_tmp/upgrade-restarted" \ + run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update" -y +[[ -e $test_tmp/upgrade-restarted ]] || fail "first upgrade did not reach reboot handling" +pass "first upgrade cleans old inhibitor state with the newly installed helper" + # Stale cleanup state from a killed update must not override a Stay Awake choice # the user made afterward. -stay_awake_helper_state="$runtime_dir/omarchy-update-stay-awake" +stay_awake_helper_state="$runtime_dir/$stay_awake_dir_name" stay_awake_state="$test_home/.local/state/omarchy/indicators/stay-awake" -mkdir -p "$stay_awake_helper_state" "$(dirname "$stay_awake_state")" -printf '%s\n' "old-update-owner" >"$stay_awake_helper_state/idle-owner" +mkdir -m 700 -p "$stay_awake_helper_state" +mkdir -p "$(dirname "$stay_awake_state")" +printf '%s\n' "123:456:789" >"$stay_awake_helper_state/idle-owner" +chmod 600 "$stay_awake_helper_state/idle-owner" printf '%s\n' "user-choice" >"$stay_awake_state" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop @@ -215,8 +329,9 @@ pass "stale update ownership preserves a newer Stay Awake choice" sleep 30 >/dev/null & unrelated_pid=$! unrelated_start_time=$(awk '{ print $22 }' "/proc/$unrelated_pid/stat") -mkdir -p "$stay_awake_helper_state" -printf '%s %s\n' "$unrelated_pid" "$((unrelated_start_time + 1))" >"$stay_awake_helper_state/inhibit-pid" +mkdir -m 700 -p "$stay_awake_helper_state" +printf '1 %s %s %s %032x\n' "$unrelated_pid" "$((unrelated_start_time + 1))" "$(id -u)" 1 >"$stay_awake_helper_state/inhibit-pid" +chmod 600 "$stay_awake_helper_state/inhibit-pid" run_with_lock_env "$SUDO_TEST_ROOT/bin/omarchy-update-stay-awake" stop kill -0 "$unrelated_pid" 2>/dev/null || diff --git a/test/shell.d/update-stay-awake-security-test.sh b/test/shell.d/update-stay-awake-security-test.sh new file mode 100644 index 00000000..08000e85 --- /dev/null +++ b/test/shell.d/update-stay-awake-security-test.sh @@ -0,0 +1,626 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +test_processes=() +test_runtime_created="" +cleanup_test() { + for pid in "${test_processes[@]}"; do kill "$pid" 2>/dev/null || true; done + [[ -z ${state_dir:-} ]] || rm -rf -- "$state_dir" + [[ -z ${state_hardlink:-} ]] || rm -f -- "$state_hardlink" + rm -rf -- "$test_tmp" + [[ -z $test_runtime_created ]] || rmdir -- "$test_runtime_created" 2>/dev/null || true +} +trap cleanup_test EXIT + +stub_bin="$test_tmp/bin" +mapped_root="$test_tmp/omarchy" +test_home="$test_tmp/home" +runtime_dir=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} +if [[ ! -d $runtime_dir || -L $runtime_dir || $(stat -Lc '%u %a' "$runtime_dir" 2>/dev/null || true) != "$(id -u) 700" ]]; then + if (( EUID != 0 )); then + fail "test needs a private XDG runtime directory or root namespace" + fi + runtime_dir=$(mktemp -d -p /run omarchy-stay-awake-runtime.XXXXXXXX) + chmod 0700 "$runtime_dir" + test_runtime_created="$runtime_dir" +fi +test_run_id="test-$BASHPID-$RANDOM" +state_dir="$runtime_dir/omarchy-update-stay-awake-$test_run_id" +state_hardlink="$runtime_dir/.omarchy-update-stay-awake-hardlink-$test_run_id" +inhibitor_log="$test_tmp/inhibitors" +mkdir -p "$stub_bin" "$test_home" "$mapped_root/bin" "$mapped_root/default/omarchy/sudo-no-update" +: >"$inhibitor_log" + +cat >"$stub_bin/pkexec" <<'SH' +#!/bin/bash +exec "$@" +SH + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash +case ${1:-} in + -h) echo 'usage: sudo [-bHkNnPS] command'; exit 0 ;; + -k|-K|-v) exit 0 ;; +esac +background=0 +while (( $# )); do + case "$1" in + -N|-n) shift ;; + -b) background=1; shift ;; + --) shift; break ;; + *) break ;; + esac +done +if (( background )); then + "$@" & +else + exec "$@" +fi +SH + +cat >"$stub_bin/setpriv" <<'SH' +#!/bin/bash +while [[ ${1:-} == --* ]]; do + case "$1" in + --reuid|--regid) shift 2 ;; + --clear-groups) shift ;; + *) exit 90 ;; + esac +done +exec "$@" +SH + +cat >"$stub_bin/systemd-inhibit" <<'SH' +#!/bin/bash +[[ ${SYSTEMD_FAIL:-0} == "0" ]] || exit 42 +printf '%s\n' "$$" >>"$INHIBITOR_LOG" +if [[ -n ${CREATE_BAD_IDLE:-} ]]; then + ln -s "$CREATE_BAD_IDLE" "$TEST_STATE_DIR/idle-owner" +fi +trap 'exit 0' TERM +while [[ ${1:-} == --* ]]; do shift; done +exec "$@" +SH + +cat >"$stub_bin/omarchy-toggle-idle" <<'SH' +#!/bin/bash +state_file="$HOME/.local/state/omarchy/indicators/stay-awake" +case "$1" in + stay-awake) + mkdir -p "$(dirname "$state_file")" + touch "$state_file" + ;; + allow-idle) + rm -f "$state_file" + ;; +esac +SH +chmod +x "$stub_bin"/* +ln -s "$stub_bin/omarchy-toggle-idle" "$mapped_root/bin/omarchy-toggle-idle" + +mapped_helper="$mapped_root/bin/omarchy-update-stay-awake" +cp "$ROOT/bin/omarchy-update-stay-awake" "$mapped_helper" +cp "$ROOT/bin/omarchy-security-functions" "$mapped_root/bin/omarchy-security-functions" +cp "$ROOT/default/omarchy/sudo-no-update/sudo" "$mapped_root/default/omarchy/sudo-no-update/sudo" +for mapped_file in \ + "$mapped_helper" \ + "$mapped_root/bin/omarchy-security-functions" \ + "$mapped_root/default/omarchy/sudo-no-update/sudo"; do + sed -i \ + -e "s#/usr/bin/sudo#$stub_bin/sudo#g" \ + -e "s#/usr/bin/pkexec#$stub_bin/pkexec#g" \ + -e "s#/usr/bin/systemd-inhibit#$stub_bin/systemd-inhibit#g" \ + -e "s#/usr/bin/setpriv#$stub_bin/setpriv#g" \ + -e 's#state_dir="$state_base/omarchy-update-stay-awake"#state_dir="$state_base/omarchy-update-stay-awake-${OMARCHY_TEST_RUN_ID:?}"#' \ + "$mapped_file" +done +chmod +x "$mapped_helper" "$mapped_root/default/omarchy/sudo-no-update/sudo" + +run_helper() { + HOME="$test_home" \ + XDG_RUNTIME_DIR="$runtime_dir" \ + INHIBITOR_LOG="$inhibitor_log" \ + TEST_STATE_DIR="$state_dir" \ + OMARCHY_TEST_RUN_ID="$test_run_id" \ + OMARCHY_PATH="$mapped_root" \ + PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + "$mapped_helper" "$@" +} + +wait_dead() { + local pid="$1" + + for _ in {1..100}; do + kill -0 "$pid" 2>/dev/null || return 0 + [[ $(awk '{ print $3 }' "/proc/$pid/stat" 2>/dev/null || true) == "Z" ]] && return 0 + sleep 0.02 + done + return 1 +} + +prepare_state_dir() { + rm -rf "$state_dir" + mkdir -m 700 "$state_dir" +} + +write_inhibit_state() { + local record="$1" + + printf '%s\n' "$record" >"$state_dir/inhibit-pid" + chmod 600 "$state_dir/inhibit-pid" +} + +start_identity_process() { + local token="$1" + + /usr/bin/bash -c 'trap "exit 0" TERM; while :; do sleep 0.05; done' \ + omarchy-test "--why=Omarchy update in progress [$token]" & + identity_pid=$! + test_processes+=("$identity_pid") + identity_start=$(awk '{ print $22 }' "/proc/$identity_pid/stat") + identity_owner=$(stat -Lc '%u' "/proc/$identity_pid") +} + +run_helper start +[[ -s $state_dir/inhibit-pid ]] || fail "valid XDG runtime publishes inhibitor state" +read -r version valid_pid valid_start valid_owner valid_token <"$state_dir/inhibit-pid" +[[ $version == "1" && $valid_token =~ ^[0-9a-f]{32}$ ]] || fail "inhibitor state is an exact versioned identity" +[[ $(stat -Lc '%u %a %h' "$state_dir/inhibit-pid") == "$(id -u) 600 1" ]] || + fail "inhibitor state is private, caller-owned, and singly linked" +run_helper stop +wait_dead "$valid_pid" || fail "valid inhibitor identity is stopped" +[[ ! -e $state_dir ]] || fail "valid state is cleaned after stop" +pass "valid XDG runtime uses private atomic inhibitor state" + +permissive_runtime="$test_tmp/permissive-runtime" +mkdir -m 755 "$permissive_runtime" +if HOME="$test_home" XDG_RUNTIME_DIR="$permissive_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then + fail "permissive XDG runtime is rejected" +fi +symlink_runtime="$test_tmp/runtime-link" +ln -s "$runtime_dir" "$symlink_runtime" +if HOME="$test_home" XDG_RUNTIME_DIR="$symlink_runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then + fail "symlink XDG runtime is rejected" +fi +if HOME="$test_home" XDG_RUNTIME_DIR="$test_tmp/../${test_tmp##*/}/runtime" PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" \ + OMARCHY_TEST_RUN_ID="$test_run_id" "$mapped_helper" stop 2>/dev/null; then + fail "non-canonical XDG runtime is rejected" +fi +pass "unsafe XDG runtime directories are rejected" + +mkdir -m 700 "$test_tmp/state-target" +ln -s "$test_tmp/state-target" "$state_dir" +if run_helper stop 2>/dev/null; then + fail "symlink inhibitor state directory is rejected" +fi +rm -f "$state_dir" +mkdir -m 777 "$state_dir" +chmod 777 "$state_dir" +if run_helper stop 2>/dev/null; then + fail "writable-by-others inhibitor state directory is rejected" +fi +rm -rf "$state_dir" +pass "unsafe inhibitor state directories are rejected" + +# Package replacement leaves the preceding helper's PID/start pair and its +# umask-derived modes for the newly installed stop command to consume. +idle_marker="$test_home/.local/state/omarchy/indicators/stay-awake" +for modes in '755 644' '750 640' '700 600'; do + read -r directory_mode file_mode <<<"$modes" + for idle_choice in update user; do + prepare_state_dir + chmod "$directory_mode" "$state_dir" + sleep infinity & + legacy_pid=$! + test_processes+=("$legacy_pid") + legacy_start=$(awk '{ print $22 }' "/proc/$legacy_pid/stat") + printf '%s %s\n' "$legacy_pid" "$legacy_start" >"$state_dir/inhibit-pid" + printf '123:456:789\n' >"$state_dir/idle-owner" + chmod "$file_mode" "$state_dir/"{inhibit-pid,idle-owner} + mkdir -p "${idle_marker%/*}" + if [[ $idle_choice == "update" ]]; then + cp "$state_dir/idle-owner" "$idle_marker" + else + printf 'user-choice\n' >"$idle_marker" + fi + run_helper stop || fail "legacy $modes cleanup fails after helper replacement" + wait_dead "$legacy_pid" || fail "legacy $modes inhibitor survives cleanup" + [[ ! -e $state_dir ]] || fail "legacy $modes state survives cleanup" + if [[ $idle_choice == "update" ]]; then + [[ ! -e $idle_marker ]] || fail "legacy cleanup leaves update-owned Stay Awake enabled" + else + [[ $(<"$idle_marker") == "user-choice" ]] || fail "legacy cleanup changes the user's Stay Awake choice" + rm -f "$idle_marker" + fi + done +done + +for legacy_record in stale multiline nul; do + prepare_state_dir + chmod 755 "$state_dir" + sleep infinity & + legacy_pid=$! + test_processes+=("$legacy_pid") + legacy_start=$(awk '{ print $22 }' "/proc/$legacy_pid/stat") + case "$legacy_record" in + stale) printf '%s %s\n' "$legacy_pid" "$((legacy_start + 1))" ;; + multiline) printf '%s %s\nextra\n' "$legacy_pid" "$legacy_start" ;; + nul) printf '%s\0 %s\n' "$legacy_pid" "$legacy_start" ;; + esac >"$state_dir/inhibit-pid" + chmod 644 "$state_dir/inhibit-pid" + if [[ $legacy_record == "stale" ]]; then + run_helper stop + elif run_helper stop 2>/dev/null; then + fail "malformed legacy $legacy_record record was accepted" + fi + kill -0 "$legacy_pid" || fail "legacy $legacy_record record signaled an unrelated process" + kill "$legacy_pid" + wait "$legacy_pid" 2>/dev/null || true +done +pass "legacy upgrade cleanup preserves process identity and the user's idle choice" + +prepare_state_dir +printf 'not a record\n' >"$state_dir/inhibit-pid" +chmod 600 "$state_dir/inhibit-pid" +if run_helper stop 2>/dev/null; then + fail "malformed inhibitor state is rejected" +fi + +token=11111111111111111111111111111111 +start_identity_process "$token" +prepare_state_dir +printf '1 %s %s %s %s\nextra\n' "$identity_pid" "$identity_start" "$identity_owner" "$token" >"$state_dir/inhibit-pid" +chmod 600 "$state_dir/inhibit-pid" +if run_helper stop 2>/dev/null; then + fail "multiline inhibitor state is rejected" +fi +kill -0 "$identity_pid" 2>/dev/null || fail "multiline state cannot signal its target" + +prepare_state_dir +write_inhibit_state "1 $identity_pid $((identity_start + 1)) $identity_owner $token" +run_helper stop +kill -0 "$identity_pid" 2>/dev/null || fail "reused PID state cannot signal its target" + +prepare_state_dir +write_inhibit_state "1 $identity_pid $identity_start $identity_owner 22222222222222222222222222222222" +run_helper stop +kill -0 "$identity_pid" 2>/dev/null || fail "wrong process identity cannot signal its target" +kill "$identity_pid" +wait_dead "$identity_pid" || true +pass "malformed, multiline, reused-PID, and wrong-identity records are harmless" + +retry_flag="$test_tmp/allow-termination" +token=44444444444444444444444444444444 +/usr/bin/bash -c ' + trap "" TERM + while [[ ! -e $1 ]]; do sleep 0.05; done + trap "exit 0" TERM + while :; do sleep 0.05; done +' omarchy-retry "$retry_flag" "--why=Omarchy update in progress [$token]" & +retry_pid=$! +test_processes+=("$retry_pid") +retry_start=$(awk '{ print $22 }' "/proc/$retry_pid/stat") +retry_owner=$(stat -Lc '%u' "/proc/$retry_pid") +prepare_state_dir +write_inhibit_state "1 $retry_pid $retry_start $retry_owner $token" +if run_helper stop 2>/dev/null; then + fail "failed termination reports success" +fi +[[ -s $state_dir/inhibit-pid ]] || fail "failed termination retains authenticated retry state" +touch "$retry_flag" +sleep 0.1 +run_helper stop +wait_dead "$retry_pid" || fail "retained inhibitor state permits a successful retry" +pass "failed termination retains its authenticated retry handle" + +for unsafe_kind in symlink permissive hardlink legacy-symlink legacy-permissive legacy-hardlink; do + token=33333333333333333333333333333333 + start_identity_process "$token" + prepare_state_dir + record="1 $identity_pid $identity_start $identity_owner $token" + [[ $unsafe_kind != legacy-* ]] || record="$identity_pid $identity_start" + case "${unsafe_kind#legacy-}" in + symlink) + printf '%s\n' "$record" >"$test_tmp/state-victim" + chmod 600 "$test_tmp/state-victim" + ln -s "$test_tmp/state-victim" "$state_dir/inhibit-pid" + ;; + permissive) + write_inhibit_state "$record" + if [[ $unsafe_kind == "legacy-permissive" ]]; then + chmod 666 "$state_dir/inhibit-pid" + else + chmod 644 "$state_dir/inhibit-pid" + fi + ;; + hardlink) + write_inhibit_state "$record" + ln "$state_dir/inhibit-pid" "$state_hardlink" + ;; + esac + if run_helper stop 2>/dev/null; then + fail "$unsafe_kind inhibitor state is rejected" + fi + kill -0 "$identity_pid" 2>/dev/null || fail "$unsafe_kind state cannot signal its target" + kill "$identity_pid" + wait_dead "$identity_pid" || true + rm -f "$test_tmp/state-victim" "$state_hardlink" +done +pass "symlink, permissive, and multiply-linked records are harmless" + +: >"$inhibitor_log" +run_helper start +first_pid=$(tail -n 1 "$inhibitor_log") +run_helper start +second_pid=$(tail -n 1 "$inhibitor_log") +[[ $first_pid != "$second_pid" ]] || fail "repeated start replaces the inhibitor" +wait_dead "$first_pid" || fail "repeated start stops the prior inhibitor" +run_helper stop +run_helper stop +wait_dead "$second_pid" || fail "repeated stop remains idempotent" +pass "repeated start and stop preserve one inhibitor" + +: >"$inhibitor_log" +concurrent_jobs=() +for _ in {1..4}; do + (run_helper start; run_helper stop) & + concurrent_jobs+=("$!") +done +for job in "${concurrent_jobs[@]}"; do + wait "$job" || fail "concurrent start and stop are serialized" +done +run_helper stop +while read -r pid; do + [[ -n $pid ]] || continue + wait_dead "$pid" || fail "concurrent operation leaves no inhibitor behind" +done <"$inhibitor_log" +pass "concurrent state operations are serialized" + +if SYSTEMD_FAIL=1 run_helper start; then + fail "failed systemd-inhibit launch reports success" +fi +[[ ! -e $state_dir/inhibit-pid ]] || fail "failed inhibitor launch publishes no PID state" +run_helper stop +pass "failed inhibitor launch leaves no stale process state" + +: >"$inhibitor_log" +rollback_victim="$test_tmp/rollback-victim" +: >"$rollback_victim" +if CREATE_BAD_IDLE="$rollback_victim" run_helper start 2>/dev/null; then + fail "unsafe idle publication reports success" +fi +rollback_pid=$(tail -n 1 "$inhibitor_log") +wait_dead "$rollback_pid" || fail "post-publication failure rolls the inhibitor back" +[[ ! -e $state_dir ]] || fail "rollback removes published inhibitor and idle ownership state" +pass "state publication failures roll back a launched inhibitor" + +# Hold each cancellation window open, including publication before child exec, +# readiness before idle setup, and publication of the update-owned idle marker. +cp "$mapped_helper" "$test_tmp/helper-before-pause" +idle_marker="$test_home/.local/state/omarchy/indicators/stay-awake" +for cancel_phase in published ready idle-temporary idle user-idle; do + rm -f "$test_tmp/cancel-ready" "$test_tmp/release-child" + if [[ $cancel_phase == "user-idle" ]]; then + mkdir -p "${idle_marker%/*}" + printf 'user-choice\n' >"$idle_marker" + fi + python3 - "$mapped_helper" "$cancel_phase" <<'PY' +from pathlib import Path +import sys +p = Path(sys.argv[1]) +s = p.read_text() +pause = ': >"$TEST_CANCEL_READY"; while :; do /usr/bin/sleep 0.02; done' +if sys.argv[2] == 'published': + anchor = ' while :; do\n inhibit_record=' + edits = [(anchor, ' ' + pause + '\n' + anchor), + (' exec -a "$expected"', + ' while [[ ! -e $TEST_RELEASE_CHILD ]]; do /usr/bin/sleep 0.02; done\n exec -a "$expected"')] +elif sys.argv[2] == 'idle-temporary': + anchor = ' temporary=$(mktemp "$state_dir/.${state_file##*/}.XXXXXXXX") || return 1' + edits = [(anchor, anchor + '\n if [[ $state_file == "$idle_owner_file" ]]; then ' + pause + '; fi')] +elif sys.argv[2] == 'idle': + anchor = '''printf '%s\\n' "$idle_owner" >"$stay_awake_state"''' + edits = [(anchor, '{ ' + anchor + ' && { ' + pause + '; }; }')] +else: + anchor = ' if [[ ! -f $stay_awake_state ]]; then' + edits = [(anchor, ' ' + pause + '\n' + anchor)] +for old, new in edits: + assert s.count(old) == 1, old + s = s.replace(old, new) +p.write_text(s) +PY + ( + export HOME="$test_home" XDG_RUNTIME_DIR="$runtime_dir" OMARCHY_PATH="$mapped_root" + export INHIBITOR_LOG="$inhibitor_log" OMARCHY_TEST_RUN_ID="$test_run_id" + export TEST_CANCEL_READY="$test_tmp/cancel-ready" TEST_RELEASE_CHILD="$test_tmp/release-child" + export PATH="$stub_bin:$ROOT/bin:/usr/bin:/bin" + exec "$mapped_helper" start + ) >"$test_tmp/$cancel_phase-cancel.log" 2>&1 & + cancelled_launcher=$! + test_processes+=("$cancelled_launcher") + for _ in {1..100}; do + [[ -e $test_tmp/cancel-ready && -s $state_dir/inhibit-pid ]] && break + sleep 0.02 + done + [[ -e $test_tmp/cancel-ready && -s $state_dir/inhibit-pid ]] || fail "$cancel_phase cancellation reached its barrier" + read -r _ published_pid _ <"$state_dir/inhibit-pid" + test_processes+=("$published_pid") + kill -TERM "$cancelled_launcher" + cancelled_status=0 + wait "$cancelled_launcher" || cancelled_status=$? + (( cancelled_status == 143 )) || fail "$cancel_phase launch preserves cancellation status" + touch "$test_tmp/release-child" + wait_dead "$published_pid" || fail "$cancel_phase cancellation leaked the held child" + [[ ! -e $state_dir ]] || fail "$cancel_phase cancellation left launch state" + if [[ $cancel_phase == "user-idle" ]]; then + [[ $(<"$idle_marker") == "user-choice" ]] || fail "cancellation changed the user's Stay Awake choice" + rm -f "$idle_marker" + else + [[ ! -e $idle_marker ]] || fail "$cancel_phase cancellation left Stay Awake enabled" + fi + cp "$test_tmp/helper-before-pause" "$mapped_helper" +done +pass "cancellation through idle setup stops the child and restores only update-owned idle state" + +namespace_args=() +namespace_probe_error="$test_tmp/namespace-probe.err" +if (( EUID == 0 )); then + namespace_args=( + unshare --user --mount --fork + --map-users=0:0:1 --map-users=1000:1000:2 + --map-groups=0:0:1 --map-groups=1000:1000:2 + --setuid=0 --setgid=0 + ) +else + subordinate_uid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subuid 2>/dev/null || true) + subordinate_gid=$(awk -F: -v user="$(id -un)" '$1 == user { print $2; exit }' /etc/subgid 2>/dev/null || true) + if [[ $subordinate_uid =~ ^[0-9]+$ && $subordinate_gid =~ ^[0-9]+$ ]]; then + namespace_args=( + unshare --user --mount --fork + "--map-users=0:$(id -u):1" "--map-users=1000:$subordinate_uid:2" + "--map-groups=0:$(id -g):1" "--map-groups=1000:$subordinate_gid:2" + --setuid=0 --setgid=0 + ) + fi +fi + +namespace_capable=0 +if (( ${#namespace_args[@]} > 0 )) && + "${namespace_args[@]}" /usr/bin/bash -c ' + set -e + mount -t tmpfs -o mode=1777 tmpfs /tmp + setpriv --reuid=1000 --regid=1000 --clear-groups true + setpriv --reuid=1001 --regid=1001 --clear-groups true + ' 2>"$namespace_probe_error"; then + namespace_capable=1 +fi + +if (( namespace_capable == 0 )); then + skip "cross-UID fallback probe: two-UID mount namespace unavailable" +else + # Keep the protected entrypoint, shared helper and harmless command stubs + # together, even when tmpfs hides a checkout or fixture under /tmp. + tar -C "$test_tmp" -cf "$test_tmp/namespace-fixture.tar" bin omarchy + if ! "${namespace_args[@]}" /usr/bin/bash -s -- "$test_tmp" "$test_run_id" \ + 9<"$test_tmp/namespace-fixture.tar" <<'SH' +set -euo pipefail +fixture=$1 +run_id=$2 +mount -t tmpfs -o mode=1777 tmpfs /tmp +mkdir -m 755 "$fixture" +tar -C "$fixture" -xf /proc/self/fd/9 +exec 9<&- +mkdir -m 700 /tmp/victim-home +chown 1000:1000 /tmp/victim-home + +setpriv --reuid=1000 --regid=1000 --clear-groups sleep 30 & +victim_pid=$! +trap 'kill "$victim_pid" "${legacy_sudo_pid:-$victim_pid}" 2>/dev/null || true; wait "$victim_pid" 2>/dev/null || true' EXIT +victim_start=$(awk '{ print $22 }' "/proc/$victim_pid/stat") +state=/tmp/omarchy-1000/omarchy-update-stay-awake-$run_id + +run_fallback() { + local uid=$1 home=$2 + shift 2 + setpriv --reuid="$uid" --regid="$uid" --clear-groups /usr/bin/env -i \ + HOME="$home" OMARCHY_PATH="$fixture/omarchy" OMARCHY_TEST_RUN_ID="$run_id" \ + INHIBITOR_LOG="$home/inhibitors" PATH="$fixture/bin:/usr/bin:/bin" \ + "$fixture/omarchy/bin/omarchy-update-stay-awake" "$@" +} + +setpriv --reuid=1001 --regid=1001 --clear-groups /usr/bin/bash -c ' + mkdir -m 755 /tmp/omarchy-1000 "$3" + printf "%s %s\n" "$1" "$2" >"$3/inhibit-pid" + chmod 644 "$3/inhibit-pid" +' attacker "$victim_pid" "$victim_start" "$state" + +if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then + echo "foreign fallback state was accepted" >&2 + exit 1 +fi +grep -q 'unsafe Omarchy update inhibitor state path' /tmp/refusal +kill -0 "$victim_pid" + +rm -rf /tmp/omarchy-1000 +# The old helper also left a readable fallback parent after a successful stop. +setpriv --reuid=1000 --regid=1000 --clear-groups mkdir -m 755 /tmp/omarchy-1000 +run_fallback 1000 /tmp/victim-home start +[[ $(stat -Lc '%u %a' /tmp/omarchy-1000) == "1000 700" ]] +run_fallback 1000 /tmp/victim-home stop +[[ ! -e $state ]] + +mkdir -m 700 "$state" +chown 1000:1000 "$state" +printf '1 %s %s 1000 %032d\n' "$victim_pid" "$victim_start" 0 \ + >"$state/inhibit-pid" +chown 1001:1001 "$state/inhibit-pid" +chmod 600 "$state/inhibit-pid" +if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then + echo "foreign state file was accepted" >&2 + exit 1 +fi +grep -q 'unsafe Omarchy update sleep inhibitor state' /tmp/refusal +kill -0 "$victim_pid" + +# A legacy-looking record owned by another account is still untrusted. +mkdir -m 700 "$state" +chown 1000:1000 "$state" +printf '%s %s\n' "$victim_pid" "$victim_start" >"$state/inhibit-pid" +chown 1001:1001 "$state/inhibit-pid" +chmod 644 "$state/inhibit-pid" +if run_fallback 1000 /tmp/victim-home stop 2>/tmp/refusal; then + echo "foreign legacy state file was accepted" >&2 + exit 1 +fi +kill -0 "$victim_pid" + +# The old terminal helper recorded sudo, with the caller's real UID but an +# effective root UID. Its /proc owner is root; the caller can still signal it. +setpriv --ruid=1000 --euid=0 --regid=1000 --clear-groups sleep 30 & +legacy_sudo_pid=$! +for _ in {1..50}; do + [[ $(awk '/^Uid:/ { print $2, $3 }' "/proc/$legacy_sudo_pid/status") == "1000 0" ]] && break + sleep .02 +done +[[ $(awk '/^Uid:/ { print $2, $3 }' "/proc/$legacy_sudo_pid/status") == "1000 0" ]] +mkdir -m 755 "$state" +chown 1000:1000 "$state" +printf '%s %s\n' "$legacy_sudo_pid" "$(awk '{ print $22 }' "/proc/$legacy_sudo_pid/stat")" >"$state/inhibit-pid" +chown 1000:1000 "$state/inhibit-pid" +chmod 644 "$state/inhibit-pid" +run_fallback 1000 /tmp/victim-home stop +[[ ! -e /proc/$legacy_sudo_pid || $(awk '{ print $3 }' "/proc/$legacy_sudo_pid/stat") == "Z" ]] +wait "$legacy_sudo_pid" 2>/dev/null || true + +# Root may read the record, but must not signal a live legacy target whose +# real UID differs. Preserve the retry handle instead of treating it as dead. +mkdir -m 700 /tmp/root-home +root_state=/tmp/omarchy-0/omarchy-update-stay-awake-$run_id +mkdir -p "$root_state" +printf '%s %s\n' "$victim_pid" "$victim_start" >"$root_state/inhibit-pid" +chmod 644 "$root_state/inhibit-pid" +if run_fallback 0 /tmp/root-home stop 2>/tmp/refusal; then + echo "legacy cleanup accepted another account's live process" >&2 + exit 1 +fi +grep -q 'retained its state for recovery' /tmp/refusal +[[ -s $root_state/inhibit-pid ]] +kill -0 "$victim_pid" +rm "$root_state/inhibit-pid" + +run_fallback 0 /tmp/root-home start +[[ $(stat -Lc '%u %a' /tmp/omarchy-0) == "0 700" ]] +run_fallback 0 /tmp/root-home stop +[[ ! -e /tmp/omarchy-0/omarchy-update-stay-awake-$run_id ]] +SH + then + fail "two-UID fallback probe failed after its capability check" "$(<"$namespace_probe_error")" + fi + pass "foreign UID fallback state cannot kill a victim and safe fallback works" +fi