diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index 5164d71e..badf65ea 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -462,7 +462,34 @@ root_dispatch() { esac } +# The bar indicator only probes on demand, so every grant change pushes a +# refresh. Root's expiry timer can't reach the user's shell, so a user timer +# refreshes once the deadline has passed. Both are best effort: a stale red +# indicator still revokes cleanly when clicked. +refresh_indicator() { + /usr/bin/omarchy-shell -q omarchy.indicators refresh >/dev/null 2>&1 || true +} + +# A failed enable can still change the grant (a renewal that rolls back removes +# the previous policy), so refresh whatever the privileged action returned. +change_grant() { + local status=0 + /usr/bin/sudo "$@" || status=$? + refresh_indicator + return "$status" +} + +# A wall-clock deadline, like the sudoers rule and root's timer, so expiry +# during suspend still refreshes on resume. +schedule_expiry_refresh() { + [[ $minutes == "permanent" ]] && return 0 + /usr/bin/systemd-run --user --quiet --collect --on-calendar="@$((EPOCHSECONDS + 10#$minutes * 60 + 2))" \ + --timer-property=AccuracySec=1s \ + -- /usr/bin/omarchy-shell -q omarchy.indicators refresh >/dev/null 2>&1 || true +} + show_enabled() { + schedule_expiry_refresh if [[ $minutes == "permanent" ]]; then echo "Passwordless sudo has been ENABLED permanently. Run this command again to disable it." else @@ -542,12 +569,12 @@ else fi fi if (( status == 0 )) && { (($# == 0)) || [[ $minutes == "--disable" ]]; }; then - /usr/bin/sudo -n -N -- "$INSTALLED_SELF" __disable "$uid" + change_grant -n -N -- "$INSTALLED_SELF" __disable "$uid" echo "Passwordless sudo has been DISABLED. Sudo will require a password again." elif (( status == STATUS_INACTIVE )) && [[ $minutes == "--disable" ]]; then echo "Passwordless sudo is already DISABLED." elif (( status == 0 )) && [[ $minutes != "permanent" ]]; then - /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + change_grant -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" show_enabled else if (( status != 0 && status != STATUS_INACTIVE )); then @@ -587,7 +614,7 @@ else echo "" if /usr/bin/gum confirm "Enable passwordless sudo $duration? This is a significant security risk!"; then - /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" + change_grant -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" echo "" show_enabled else diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 8ff7c48a..d1c80d32 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -2,7 +2,7 @@ `omarchy-sudo-passwordless` publishes a timed or permanent grant for the numeric UID authenticated by sudo. Its user interface inspects sudo policy without prompting and authenticates only the enable action when access is off, without a reusable sudo timestamp; fixed installed internal actions run as root and serialize on `/run/lock/omarchy-sudo-passwordless.lock`. -The menu bar's `PasswordlessSudo` indicator polls `--active` every five seconds and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed `__status` action and checks its `!authenticate` tag. If listing requires authentication, it falls back to a noninteractive `__status` call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs `--disable` noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow. +The menu bar's `PasswordlessSudo` indicator runs `--active` when the shell starts and whenever `omarchy.indicators` is refreshed, and shows the passwordless sudo icon in red while access is active. The probe normally reads sudo's noninteractive long policy listing for the installed `__status` action and checks its `!authenticate` tag. If listing requires authentication, it falls back to a noninteractive `__status` call that ignores cached credentials. Neither path prompts or updates the credential cache. Interactive setup likewise falls back to noninteractive status and, when authentication is unavailable, leaves validation to the single confirmed enable action. Clicking the active indicator runs `--disable` noninteractively without opening a terminal, so a grant expiring between display and click cannot start the enable flow. The indicator never polls: every enable or disable attempt refreshes it directly, failed ones included since a rolled-back renewal can remove the previous grant, and enabling a timed grant also starts a transient user timer on the same wall-clock deadline that refreshes it two seconds later, so expiry during suspend shows on resume. The root expiry timer cannot reach the user's shell. If that refresh is missed, the indicator stays red past expiry, and clicking it reports access is already disabled and clears it. ## Grant lifecycle diff --git a/shell/plugins/bar/indicators/PasswordlessSudo.qml b/shell/plugins/bar/indicators/PasswordlessSudo.qml index bdc75434..f9b1cb9c 100644 --- a/shell/plugins/bar/indicators/PasswordlessSudo.qml +++ b/shell/plugins/bar/indicators/PasswordlessSudo.qml @@ -31,19 +31,15 @@ BarIndicator { onBarChanged: refresh() Component.onCompleted: refresh() + // No polling: each probe is a hardened script plus sudo calls that can log. + // omarchy-sudo-passwordless refreshes omarchy.indicators on enable, disable + // and expiry instead. Connections { target: root.indicatorHost ignoreUnknownSignals: true function onRefreshRequested() { root.refresh() } } - Timer { - interval: 5000 - repeat: true - running: !!root.bar - onTriggered: root.refresh() - } - Process { id: statusProc command: ["omarchy-sudo-passwordless", "--active"] diff --git a/test/shell.d/fixtures/passwordless-sudo-test.sh b/test/shell.d/fixtures/passwordless-sudo-test.sh index 54a90948..ec06a39f 100644 --- a/test/shell.d/fixtures/passwordless-sudo-test.sh +++ b/test/shell.d/fixtures/passwordless-sudo-test.sh @@ -58,6 +58,8 @@ case "$name" in [[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing" ;; systemd-run) + # The indicator's user refresh timer is not the root expiry timer. + [[ ${1:-} != --user ]] || exit 0 [[ ${TEST_TIMER_FAIL:-0} != 1 ]] || exit 1 if [[ ${TEST_CANCEL_ENABLE:-0} == 1 ]]; then kill -TERM "$PPID"; fi ;; @@ -67,6 +69,7 @@ case "$name" in date) if [[ ${TEST_EXPIRED:-0} == 1 && $* == '-u +%Y%m%d%H%M%SZ' ]]; then echo 99991231235959Z; else /usr/bin/date "$@"; fi ;; + omarchy-shell) ;; 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 @@ -86,6 +89,7 @@ case "$name" in TEST_EUID=0 /usr/bin/bash -p "$@" else [[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}" + [[ ${2:-} != __enable || ${TEST_ENABLE_FAIL:-0} != 1 ]] || exit 1 fi ;; gum) @@ -100,7 +104,7 @@ case "$name" in esac STUB chmod +x "$test_tmp/bin/mock" -for name in stat chown install rm mv systemd-run systemctl date getent sudo gum; do +for name in stat chown install rm mv systemd-run systemctl date getent sudo gum omarchy-shell; do ln -s mock "$test_tmp/bin/$name" done @@ -114,7 +118,7 @@ for name in ('omarchy-sudo-passwordless', 'omarchy-security-functions'): 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'): + for command in ('stat', 'chown', 'install', 'rm', 'mv', 'systemd-run', 'systemctl', 'date', 'getent', 'sudo', 'gum', 'omarchy-shell'): text = text.replace('/usr/bin/' + command, str(temp/'bin'/command)) (temp/name).write_text(text) (temp/name).chmod(0o755) diff --git a/test/shell.d/passwordless-sudo-indicator-test.sh b/test/shell.d/passwordless-sudo-indicator-test.sh index f9ef1226..033be545 100644 --- a/test/shell.d/passwordless-sudo-indicator-test.sh +++ b/test/shell.d/passwordless-sudo-indicator-test.sh @@ -17,7 +17,8 @@ assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), ' assert(!indicator.includes('visible:'), 'sudo uses the shared indicator visibility and hover behavior') assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--active"]'), 'indicator uses the noninteractive grant probe') assert(indicator.includes('root.granted = exitCode === 0 && exitStatus === 0'), 'failed or interrupted probes do not claim an active grant') -assert(indicator.includes('interval: 5000') && indicator.includes('onTriggered: root.refresh()'), 'indicator refreshes after activation, revocation, and expiry') +assert(!indicator.includes('Timer'), 'indicator does not poll the sudo probe') +assert(indicator.includes('function onRefreshRequested() { root.refresh() }'), 'indicator probes when the indicator host asks for a refresh') assert(indicator.includes('command: ["omarchy-sudo-passwordless", "--disable"]'), 'active indicator revokes access through a background process') assert(indicator.includes('if (root.granted) disableProc.running = true'), 'active click bypasses the terminal launcher') assert(indicator.includes('root.indicatorHost.refresh()'), 'disabling access refreshes all indicator instances immediately') @@ -34,3 +35,57 @@ revoke.running = false press(button, revoke) assertDeepEqual(launched, ['omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless'], 'inactive click still opens interactive setup') JS + +# Run the public grant flows against the fixture's stand-ins and check the +# refreshes they push, without touching the host shell or user timers. +source "$SHELL_TEST_DIR/fixtures/passwordless-sudo-test.sh" +refresh="omarchy-shell -q omarchy.indicators refresh" +run_grant_flow() { + : >"$test_tmp/commands" + env "$@" /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" "${grant_args[@]}" >"$test_tmp/public.log" 2>&1 || true +} +refresh_follows() { + grep -A1 -- "$1" "$test_tmp/commands" | grep -qxF -- "$refresh" +} +user_timers() { + grep -c '^systemd-run --user ' "$test_tmp/commands" || true +} + +grant_args=(15) +before=$EPOCHSECONDS +run_grant_flow TEST_STATUS=3 TEST_CONFIRM_STATUS=0 +after=$EPOCHSECONDS +refresh_follows ' __enable [0-9]* 15$' || fail "timed enable must refresh the indicator after the grant changes" +timer=$(grep '^systemd-run --user ' "$test_tmp/commands") || fail "timed enable must schedule an expiry refresh" +timer_pattern="^systemd-run --user --quiet --collect --on-calendar=@([0-9]+) --timer-property=AccuracySec=1s -- $test_tmp/bin/omarchy-shell -q omarchy.indicators refresh$" +[[ $timer =~ $timer_pattern ]] || fail "unexpected expiry refresh timer: $timer" +deadline=${BASH_REMATCH[1]} +(( deadline >= before + 15 * 60 + 2 && deadline <= after + 15 * 60 + 2 )) || fail "expiry refresh must fire just after the wall-clock deadline" +pass "timed enable refreshes now and on a wall-clock timer just after expiry" + +run_grant_flow TEST_STATUS=0 +refresh_follows ' __enable [0-9]* 15$' || fail "renewal must refresh the indicator" +(( $(user_timers) == 1 )) || fail "renewal must schedule an expiry refresh" +pass "renewing a timed grant refreshes and reschedules the expiry refresh" + +run_grant_flow TEST_STATUS=0 TEST_ENABLE_FAIL=1 +refresh_follows ' __enable [0-9]* 15$' || fail "failed renewal must still refresh the indicator" +(( $(user_timers) == 0 )) || fail "failed renewal must not schedule an expiry refresh" +pass "a failed grant change still refreshes the indicator" + +grant_args=(permanent) +run_grant_flow TEST_STATUS=3 TEST_CONFIRM_STATUS=0 +refresh_follows ' __enable [0-9]* permanent$' || fail "permanent enable must refresh the indicator" +(( $(user_timers) == 0 )) || fail "permanent enable must not schedule an expiry refresh" +pass "permanent enable refreshes without scheduling an expiry refresh" + +assert_disable_refreshes() { + run_grant_flow TEST_STATUS=0 + refresh_follows ' __disable [0-9]*$' || fail "disable ${grant_args[*]} must refresh the indicator" + (( $(user_timers) == 0 )) || fail "disable must not schedule an expiry refresh" +} +grant_args=() +assert_disable_refreshes +grant_args=(--disable) +assert_disable_refreshes +pass "disabling access refreshes the indicator"