From c8697407cb54213f971652e1a89d9757882ca823 Mon Sep 17 00:00:00 2001 From: Afonso Oliveira Date: Mon, 7 Sep 2026 17:33:33 +0100 Subject: [PATCH] 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