Keep channel transitions inside command-scoped sudo

This commit is contained in:
Afonso Oliveira committed 2026-09-07 17:33:33 +01:00
1 parent 5bc41b2585
commit c8697407cb
6 files changed
+188 -22

No files matched your search

+20 -4
View File
@@ -1,10 +1,23 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Set the Omarchy package channel.
# omarchy:args=<stable|rc|edge|dev>
# 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
+1
View File
@@ -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
+131
View File
@@ -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
+34 -16
View File
@@ -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"
@@ -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"
+1 -1
View File
@@ -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