From d9b970dd626da8fdf8174dada67c5e564664e2f8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 14:41:27 +0200 Subject: [PATCH] Let users choose passwordless sudo duration (#14435) * Let users choose passwordless sudo duration * Warn in the menu bar when passwordless sudo is active * Fix sudo indicator hover behavior and repeated authentication * Disable passwordless sudo from the bar without a terminal * Shorten passwordless sudo indicator tooltip * Recover interrupted passwordless sudo duration switches * Allow sudo grant changes when listings require authentication * Start passwordless sudo setup with the duration question --- bin/omarchy-sudo-passwordless | 241 ++++++++++++++---- docs/passwordless-sudo.md | 16 +- manual/48-security.md | 4 +- .../bar/indicators/PasswordlessSudo.qml | 71 ++++++ .../bar/widgets/Indicators.manifest.json | 5 + shell/plugins/bar/widgets/Indicators.qml | 2 +- .../fixtures/passwordless-sudo-test.sh | 23 +- test/shell.d/nopasswd-sudo-expiry-test.sh | 213 +++++++++++++++- .../passwordless-sudo-indicator-test.sh | 36 +++ 9 files changed, 552 insertions(+), 59 deletions(-) create mode 100644 shell/plugins/bar/indicators/PasswordlessSudo.qml create mode 100644 test/shell.d/passwordless-sudo-indicator-test.sh diff --git a/bin/omarchy-sudo-passwordless b/bin/omarchy-sudo-passwordless index b5f88a94..5164d71e 100755 --- a/bin/omarchy-sudo-passwordless +++ b/bin/omarchy-sudo-passwordless @@ -1,7 +1,7 @@ #!/bin/bash -p # omarchy:summary=Toggle passwordless sudo for the current user. -# omarchy:args=[MINUTES] +# omarchy:args=[MINUTES|permanent|--active|--disable] # omarchy:requires-sudo=true if [[ $- != *p* && ${BASH_SOURCE[0]} == "$0" ]]; then @@ -22,7 +22,6 @@ fi set -euo pipefail -readonly DEFAULT_MINUTES=15 readonly MAX_MINUTES=1440 readonly LOCK_FILE=/run/lock/omarchy-sudo-passwordless.lock readonly BOOT_CLEANUP_FILE=/etc/tmpfiles.d/omarchy-nopasswd-sudo.conf @@ -34,7 +33,7 @@ readonly INSTALLED_SELF=/usr/bin/omarchy-sudo-passwordless readonly STATUS_INACTIVE=3 usage() { - echo "Usage: omarchy-sudo-passwordless [MINUTES]" >&2 + echo "Usage: omarchy-sudo-passwordless [MINUTES|permanent|--active|--disable]" >&2 echo "MINUTES must be between 1 and $MAX_MINUTES." >&2 exit 1 } @@ -43,6 +42,10 @@ valid_minutes() { [[ $1 =~ ^0*[1-9][0-9]{0,3}$ ]] && ((10#$1 <= MAX_MINUTES)) } +valid_duration() { + [[ $1 == "permanent" ]] || valid_minutes "$1" +} + valid_uid() { [[ $1 =~ ^0*[1-9][0-9]{0,9}$ ]] && ((10#$1 <= 4294967294)) } @@ -96,14 +99,39 @@ with_root_lock() { } rule_file() { - printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$1" + local uid=$((10#$1)) + if [[ ${2:-} == "permanent" ]]; then + printf '/etc/sudoers.d/99-omarchy-permanent-nopasswd-%s' "$uid" + else + printf '/etc/sudoers.d/99-omarchy-nopasswd-%s' "$uid" + fi } # 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 + local file permanent contents file=$(rule_file "$1") + permanent=$(rule_file "$1" permanent) + if [[ -e $permanent || -L $permanent ]]; then + verify_root_path "$permanent" && [[ -f $permanent ]] || return 2 + contents=$(/usr/bin/cat -- "$permanent") || return 2 + [[ $contents =~ ^([a-z_][a-z0-9_-]*\$?)\ ALL=\(ALL\)\ NOPASSWD:\ ALL$ ]] || return 2 + GRANT_NAME=${BASH_REMATCH[1]} + GRANT_DEADLINE="" + valid_account_name "$GRANT_NAME" || return 2 + if [[ -e $file || -L $file ]]; then + # A SIGKILL between publication and removal can leave both policies. + # Only accept a matching generated timed rule. Permanent access already + # exists, so old expiry callbacks must not revoke it; disable removes + # both files, and the next duration change finishes their replacement. + 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 + [[ ${BASH_REMATCH[1]} == "$GRANT_NAME" ]] || return 2 + fi + return 0 + fi [[ -e $file || -L $file ]] || return "$STATUS_INACTIVE" verify_root_path "$file" && [[ -f $file ]] || return 2 contents=$(/usr/bin/cat -- "$file") || return 2 @@ -121,6 +149,9 @@ classify_generated_rule() { [[ -f $file && ! -L $file ]] || return 1 contents=$(/usr/bin/cat -- "$file") || return 2 suffix=${file##*/99-omarchy-nopasswd-} + if [[ $file == */99-omarchy-permanent-nopasswd-* ]]; then + suffix=${file##*/99-omarchy-permanent-nopasswd-} + fi # The legacy command wrote the caller's unvalidated name into both the # filename and the rule. That exact relationship is its fingerprint, so an @@ -138,14 +169,21 @@ classify_generated_rule() { valid_account_name "$name" && [[ $contents =~ ^[a-z_][a-z0-9_-]*\$?\ ALL=\(ALL\)\ NOTAFTER=[0-9]{14}Z\ NOPASSWD:\ ALL$ ]] } -cleanup_uid_locked() { - local file - file=$(rule_file "$1") +cleanup_rule_file() { + local 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_uid_locked() { + local file duration + for duration in temporary permanent; do + file=$(rule_file "$1" "$duration") + cleanup_rule_file "$file" || return 1 + done +} + # 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 @@ -263,6 +301,7 @@ migrate_locked() { expire_locked() { local status now if read_grant "$1"; then + [[ -n $GRANT_DEADLINE ]] || return 0 now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 [[ $now < $GRANT_DEADLINE ]] && return 0 cleanup_uid_locked "$1" @@ -281,6 +320,7 @@ status_locked() { resolve_account "$1" || return 2 if read_grant "$1"; then [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 2 + [[ -n $GRANT_DEADLINE ]] || return 0 now=$(/usr/bin/date -u +%Y%m%d%H%M%SZ) || return 2 if [[ $now < $GRANT_DEADLINE ]]; then return 0 @@ -300,7 +340,11 @@ finish_enable() { 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 + if [[ -n $timer ]]; then + echo "Could not revoke passwordless sudo; expiry remains armed. Administrator cleanup is required." >&2 + else + echo "Could not revoke passwordless sudo. Administrator cleanup is required." >&2 + fi fi fi [[ -z $pending ]] || /usr/bin/rm -f -- "$pending" @@ -309,9 +353,15 @@ finish_enable() { 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") + resolve_account "$uid" && valid_duration "$minutes" || return 1 + verify_root_path /etc/sudoers.d || return 1 + if [[ $minutes != "permanent" ]]; then + verify_boot_cleanup || { + echo "Temporary sudo cleanup is unavailable or a settings package transaction is incomplete. Check the omarchy-settings installation." >&2 + return 1 + } + fi + file=$(rule_file "$uid" "$minutes") if read_grant "$uid"; then [[ $GRANT_NAME == "$ACCOUNT_NAME" ]] || return 1 else @@ -320,39 +370,69 @@ enable_locked() ( 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 + if [[ $minutes == "permanent" ]]; then + /usr/bin/printf '%s ALL=(ALL) NOPASSWD: ALL\n' "$ACCOUNT_NAME" >"$pending" || return 1 + else + 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 + /usr/bin/printf '%s ALL=(ALL) NOTAFTER=%s NOPASSWD: ALL\n' "$ACCOUNT_NAME" "$deadline" >"$pending" || return 1 + fi /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 '-' &2 + return "$status" + fi ;; __disable) (($# == 1)) && verify_sudo_caller "$1" || return 1 @@ -382,6 +462,14 @@ root_dispatch() { esac } +show_enabled() { + if [[ $minutes == "permanent" ]]; then + echo "Passwordless sudo has been ENABLED permanently. Run this command again to disable it." + else + echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." + fi +} + case "${1:-}" in __status|__enable|__disable|__expire|__cleanup-all|__package-removing|__migrate|__migration-complete) action=$1 @@ -391,9 +479,30 @@ case "${1:-}" in ;; esac +# Prefer matched policy tags to executing a privileged command, avoiding +# authentication/session logs under the default listing policy. Restrictive +# listpw settings require the root-status fallback. Neither path prompts or +# trusts cached credentials as evidence of passwordless access. +if [[ ${1:-} == "--active" ]]; then + (($# == 1)) || usage + uid=$(/usr/bin/id -u) + if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then + /usr/bin/grep -q '!authenticate' <<<"$policy" + else + # listpw=always can require authentication to list a passwordless grant. + # With a command, -k ignores cached credentials without invalidating them. + status=0 + /usr/bin/sudo -kn -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$? + # An internal inspection error still proves that sudo ran the helper + # without authentication. Keep the warning visible for unsafe policy. + (( status == 0 || status == 2 )) || exit 1 + fi + exit +fi + (($# <= 1)) || usage -minutes=${1:-$DEFAULT_MINUTES} -valid_minutes "$minutes" || usage +minutes=${1:-} +(($# == 0)) || [[ $minutes == "--disable" ]] || valid_duration "$minutes" || usage uid=$(/usr/bin/id -u) valid_uid "$uid" || { echo "omarchy-sudo-passwordless: cannot grant passwordless sudo to this account" >&2 @@ -411,38 +520,76 @@ omarchy_security_install_sudo_cleanup_traps exit 1 } -echo "Toggle passwordless sudo..." -if /usr/bin/sudo -N -- "$INSTALLED_SELF" __status "$uid"; then - if (($# == 0)); then - /usr/bin/sudo -N -- "$INSTALLED_SELF" __disable "$uid" - echo "Passwordless sudo has been DISABLED. Sudo will require a password again." - else - /usr/bin/sudo -N -- "$INSTALLED_SELF" __enable "$uid" "$minutes" - echo "Passwordless sudo expiry updated. It will automatically disable in ${minutes} minutes." +# Inspect policy without authenticating. Only inspect the root-owned grant +# when policy already permits it without a password; otherwise the eventual +# enable action is the sole authentication boundary. enable_locked validates +# any existing policy before publishing, including expired or modified rules. +status=$STATUS_INACTIVE +if policy=$(/usr/bin/sudo -n -N -l -l -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null); then + if /usr/bin/grep -q '!authenticate' <<<"$policy"; then + status=0 + /usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" || status=$? fi else - status=$? - if (( status != STATUS_INACTIVE )); then + # A listing password is independent of command authorization. An active + # grant can still be inspected/revoked without prompting under listpw=always. + status=0 + /usr/bin/sudo -n -N -- "$INSTALLED_SELF" __status "$uid" 2>/dev/null || status=$? + if (( status == 1 )) && [[ $minutes != "--disable" ]]; then + # No noninteractive inspection is available. Offer the confirmed enable + # flow; its single authenticated action validates existing policy itself. + status=$STATUS_INACTIVE + fi +fi +if (( status == 0 )) && { (($# == 0)) || [[ $minutes == "--disable" ]]; }; then + /usr/bin/sudo -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" + show_enabled +else + if (( status != 0 && 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 + if [[ -z $minutes ]]; then + choice=$(/usr/bin/gum choose --header "How long should passwordless sudo stay enabled?" "15 minutes" "1 Hour" "1 Day" "Permanently") || exit 130 + case "$choice" in + "15 minutes") minutes=15 ;; + "1 Hour") minutes=60 ;; + "1 Day") minutes=1440 ;; + "Permanently") minutes=permanent ;; + *) exit 130 ;; + esac + fi + if [[ $minutes == "permanent" ]]; then + duration="permanently" + else + duration="for ${minutes} minutes" + fi echo "" echo "⚠️ WARNING: This will allow ANY process running as your user to" - echo "execute ANY command as root WITHOUT a password for ${minutes} minutes." + echo "execute ANY command as root WITHOUT a password $duration." 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 "including if the machine reboots before the deadline." - echo "Run this command again to disable it early." + if [[ $minutes == "permanent" ]]; then + echo "Passwordless sudo will remain enabled across reboots until you disable it." + else + echo "Passwordless sudo will automatically disable after ${minutes} minutes," + echo "including if the machine reboots before the deadline." + fi + echo "Run this command again to disable it." echo "" - if /usr/bin/gum confirm "Enable passwordless sudo for ${minutes} minutes? This is a significant security risk!"; then + 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" echo "" - echo "Passwordless sudo has been ENABLED. It will automatically disable in ${minutes} minutes." + show_enabled else echo "Aborted. No changes made." fi diff --git a/docs/passwordless-sudo.md b/docs/passwordless-sudo.md index 4aa6e31d..8ff7c48a 100644 --- a/docs/passwordless-sudo.md +++ b/docs/passwordless-sudo.md @@ -1,24 +1,28 @@ -# Temporary passwordless sudo +# 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`. +`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. ## Grant lifecycle -The sudoers rule is the only grant record: it contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed. +The sudoers rule is the only grant record. A timed grant contains the resolved account name and a UTC `NOTAFTER` deadline enforced by sudo itself, including after suspend. Publication validates a dot-prefixed temporary file with `visudo`, arms a calendar cleanup timer, then atomically renames the complete rule into place. There is no separate per-user state file to publish, parse, or reconcile. Failure after renewal starts removes the old grant; failed revocation remains an error and leaves the cleanup timer armed. -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. +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 offers a duration picker after result `3`. Result `0` toggles access off when no argument is given, or changes its duration when an argument is given. Permanent access always requires confirmation, including when replacing an active timed grant. It must not turn an inspection failure into a claim that no grant exists. 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. 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. +Permanent grants live under `/etc/sudoers.d/99-omarchy-permanent-nopasswd-`, outside the temporary cleanup namespace. They have no `NOTAFTER` deadline or timer. Status and disable handle both forms; switching duration publishes the replacement before removing the prior policy under the same lock. Old expiry callbacks leave permanent grants intact. If an untrappable interruption leaves both rules, a pair of trusted generated rules for the same account remains inspectable and revocable. Permanent policy continues to govern until an explicit disable or completed duration change; callbacks never remove it merely because the timed companion has expired. Mismatched accounts, symlinks, and administrator-modified companion rules remain errors. Permanent policy survives reboot and package upgrade or removal; disable it before uninstalling the command or downgrading to a version without permanent-grant support, or remove its sudoers file as an administrator afterward. + ## Package ownership The packaging companion must put the publication/expiry command, `omarchy-security-functions`, `omarchy-nopasswd-sudo.conf`, and the pre-transaction revocation hook in the settings package together. Removing the desktop runtime alone must leave a working expiry command behind. Stable and development package pairs must transfer ownership in one transaction without duplicate files. -Before settings removal or upgrade, the installed ALPM `PreTransaction` hook invokes the fixed `__package-removing` action, acquires the same grant lock, sets `/run/omarchy-sudo-passwordless-package-removing` and revokes existing policy. The marker prevents a waiting publisher from creating a new grant while package files change. A successful installation clears the marker only after boot cleanup exists. The hook uses `AbortOnFail` because a scriptlet failure alone does not abort pacman. The scriptlets repeat cleanup as a fallback for upgrades from older packages that have no installed hook. New grants require both the boot rule and hook before publication. Failed or interrupted transactions leave the marker set; retry the package transaction successfully before requesting another grant. +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 temporary policy. The marker prevents a waiting publisher from creating a new timed 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 timed 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 timed 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. +The runtime marker need not survive reboot: pre-removal revokes the old timed grants before package files disappear, and a new timed grant independently verifies boot cleanup. Both root operations use fixed machine paths. The marker is not a user-controlled mode switch. ## Validation diff --git a/manual/48-security.md b/manual/48-security.md index 6db0dff5..60d04745 100644 --- a/manual/48-security.md +++ b/manual/48-security.md @@ -20,7 +20,9 @@ It works by restoring the baseline snapshot the installer takes, so it's only av ## Passwordless sudo -Sometimes you want `sudo` to stop asking, most often when an AI agent is doing a long stretch of system work for you. _Setup > Security > Passwordless Sudo_ turns that off for 15 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. +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_ asks how long to allow access: **15 minutes**, **1 Hour**, **1 Day**, or **Permanently**. A red warning icon appears beside the other menu bar indicators while access is active. Click it or run the command again to turn access off. You can also pass your own number of minutes (from 1 to 1440) with `omarchy-sudo-passwordless 30`, or use `omarchy-sudo-passwordless permanent`. + +Timed access expires automatically, including immediately after resuming from a suspend that crossed the deadline. Restarting the computer ends it early. Permanent access survives reboots and stays enabled until you disable it. 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. diff --git a/shell/plugins/bar/indicators/PasswordlessSudo.qml b/shell/plugins/bar/indicators/PasswordlessSudo.qml new file mode 100644 index 00000000..e91b1b53 --- /dev/null +++ b/shell/plugins/bar/indicators/PasswordlessSudo.qml @@ -0,0 +1,71 @@ +import QtQuick +import Quickshell.Io +import qs.Commons +import qs.Ui + +BarIndicator { + id: root + + property bool granted: false + property bool refreshPending: false + + active: granted + activeText: "󰟵" + inactiveText: "󰟵" + activeTooltipText: "Disable Passwordless Sudo" + inactiveTooltipText: "Passwordless Sudo" + useActiveColor: true + activeColor: Color.urgent + + function refresh() { + if (!root.bar) return + if (statusProc.running) { + root.refreshPending = true + return + } + root.refreshPending = false + statusProc.running = true + } + + onBarChanged: refresh() + Component.onCompleted: refresh() + + 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"] + onExited: function(exitCode, exitStatus) { + root.granted = exitCode === 0 && exitStatus === 0 + if (root.refreshPending) Qt.callLater(root.refresh) + } + } + + Process { + id: disableProc + command: ["omarchy-sudo-passwordless", "--disable"] + onExited: function(exitCode, exitStatus) { + if ((exitCode !== 0 || exitStatus !== 0) && root.bar) + root.bar.run('omarchy-notification-send "Could not disable passwordless sudo" "Check the sudo configuration and try again."') + if (root.indicatorHost) root.indicatorHost.refresh() + else root.refresh() + } + } + + onPressed: function() { + if (!root.bar || disableProc.running) return + if (root.granted) disableProc.running = true + else root.bar.run("omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless") + } +} diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 4d8f7420..4296c7f3 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -26,6 +26,11 @@ "placeholderText": "Search indicators...", "emptyText": "No indicators", "options": [ + { + "value": "PasswordlessSudo", + "label": "Passwordless sudo", + "description": "Warning while passwordless root access is enabled" + }, { "value": "Dictation", "label": "Dictation", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index c4fd9224..c2e05cb2 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/test/shell.d/fixtures/passwordless-sudo-test.sh b/test/shell.d/fixtures/passwordless-sudo-test.sh index 3f4f0090..54a90948 100644 --- a/test/shell.d/fixtures/passwordless-sudo-test.sh +++ b/test/shell.d/fixtures/passwordless-sudo-test.sh @@ -52,7 +52,9 @@ case "$name" in ;; mv) [[ ${TEST_PUBLISH_FAIL:-0} != 1 ]] || exit 1 + [[ ${TEST_REQUIRE_EXISTING_TARGET:-0} != 1 || -f ${@: -1} ]] || exit 1 /usr/bin/mv "$@" + [[ ${TEST_KILL_AFTER_PUBLISH:-0} != 1 ]] || /usr/bin/kill -KILL "$PPID" [[ ${TEST_POST_PUBLISH_FAIL:-0} != 1 ]] || : >"$TEST_GRANT_ROOT/run/omarchy-sudo-passwordless-package-removing" ;; systemd-run) @@ -69,7 +71,15 @@ case "$name" in 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:-} == -n && ${3:-} == -l ]]; then + [[ ${TEST_POLICY_FAILURE:-0} != 1 ]] || exit 1 + default_policy=' Options: authenticate' + [[ ${TEST_STATUS:-3} == 3 ]] || default_policy=' Options: !authenticate' + printf '%s\n' "${TEST_POLICY:-$default_policy}" + exit 0 + fi + if [[ ${1:-} == -n ]]; then shift; fi + if [[ ${1:-} == -N || ${1:-} == -kn ]]; then shift; fi if [[ ${1:-} == -- ]]; then shift; fi if [[ ${TEST_MIGRATION:-0} == 1 ]]; then [[ ${TEST_NO_SUDO:-0} != 1 ]] || exit 1 @@ -78,7 +88,14 @@ case "$name" in [[ ${2:-} != __status ]] || exit "${TEST_STATUS:-3}" fi ;; - gum) exit 1 ;; + gum) + if [[ $1 == choose ]]; then + [[ ${TEST_CHOICE_CANCEL:-0} != 1 ]] || exit 130 + printf '%s\n' "${TEST_CHOICE:-15 minutes}" + else + exit "${TEST_CONFIRM_STATUS:-1}" + fi + ;; *) exit 99 ;; esac STUB @@ -120,6 +137,6 @@ assert_status() { (( 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" + rm -f "$test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-1000" "$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 d6b1a505..bc123a65 100755 --- a/test/shell.d/nopasswd-sudo-expiry-test.sh +++ b/test/shell.d/nopasswd-sudo-expiry-test.sh @@ -32,7 +32,7 @@ for status in 1 2 3; do else (( result != 0 )) && ! grep -q '^gum ' "$test_tmp/commands" || fail "inspection errors must not offer enablement" fi - grep -q '^sudo -N -- .* __status ' "$test_tmp/commands" || fail "status must not publish reusable authorization" + grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" || fail "status must inspect policy without authentication" [[ $(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" @@ -164,3 +164,214 @@ cp "$ROOT/default/libalpm/hooks/05-omarchy-passwordless-revoke.hook" "$test_tmp/ [[ ! -e $(rule_file 1000) ]] ) pass "trailing-dollar accounts publish valid native policy and alias-shaped names never publish" + +reset_grant +( + source "$library" + valid_duration permanent + ! valid_duration forever || fail "invalid duration was accepted" + enable_locked 1000 permanent + read_grant 1000 + [[ $GRANT_NAME == audituser && -z $GRANT_DEADLINE ]] + [[ $(stat -c '%a' "$(rule_file 1000 permanent)") == 440 ]] + /usr/sbin/visudo -cf "$(rule_file 1000 permanent)" >/dev/null + ! grep -q '^systemd-run ' "$test_tmp/commands" || fail "permanent grant started a timer" + TEST_EXPIRED=1 status_locked 1000 + TEST_EXPIRED=1 expire_locked 1000 + cleanup_all_locked + /usr/bin/systemd-tmpfiles --root="$test_tmp" --remove --boot --inline 'r! /etc/sudoers.d/99-omarchy-nopasswd-*' + [[ -f $(rule_file 1000 permanent) ]] + cleanup_uid_locked 1000 + assert_status 3 status_locked 1000 +) +pass "permanent access has no timer, survives expiry and temporary cleanup, and can be disabled" + +reset_grant +( + source "$library" + enable_locked 1000 15 + enable_locked 1000 permanent + [[ ! -e $(rule_file 1000) && -e $(rule_file 1000 permanent) ]] + TEST_EXPIRED=1 expire_locked 1000 + [[ -e $(rule_file 1000 permanent) ]] + enable_locked 1000 60 + [[ -e $(rule_file 1000) && ! -e $(rule_file 1000 permanent) ]] + TEST_EXPIRED=1 expire_locked 1000 + assert_status 3 status_locked 1000 + TEST_PUBLISH_FAIL=1 assert_status 1 enable_locked 1000 permanent + [[ ! -e $(rule_file 1000 permanent) ]] +) +pass "switching duration replaces the prior policy and old callbacks preserve permanent access" + +reset_grant +( + source "$library" + permanent=$(rule_file 1000 permanent) + printf 'audituser ALL=(ALL) NOPASSWD: /usr/bin/true\n' >"$permanent" + assert_status 2 status_locked 1000 + assert_status 1 enable_locked 1000 permanent + assert_status 1 cleanup_uid_locked 1000 + [[ -e $permanent ]] + rm "$permanent" + enable_locked 1000 permanent + TEST_BAD_PATH="$permanent" assert_status 2 status_locked 1000 +) +pass "permanent policy rejects unsafe ownership and preserves administrator edits" + +for choice in '15 minutes' '1 Hour' '1 Day' Permanently; do + case "$choice" in + '15 minutes') expected=15 ;; + '1 Hour') expected=60 ;; + '1 Day') expected=1440 ;; + Permanently) expected=permanent ;; + esac + : >"$test_tmp/commands" + TEST_CHOICE="$choice" TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 + grep -q '^gum choose .*15 minutes 1 Hour 1 Day Permanently$' "$test_tmp/commands" + grep -q "^sudo -N -- .* __enable .* $expected$" "$test_tmp/commands" + [[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] + if [[ $expected == permanent ]]; then + grep -q 'remain enabled across reboots until you disable it' "$test_tmp/public.log" + ! grep -q 'automatically disable' "$test_tmp/public.log" || fail "permanent grant claims automatic expiry" + fi +done +pass "each duration choice dispatches its exact grant duration with permanent-specific copy" + +: >"$test_tmp/commands" +assert_status 130 env TEST_CHOICE_CANCEL=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 +! grep -q '__enable\|^gum confirm ' "$test_tmp/commands" || fail "cancelled picker continued enablement" +[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] +: >"$test_tmp/commands" +TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 +! grep -q '^gum ' "$test_tmp/commands" || fail "disabling access offered a picker" +grep -q '__disable ' "$test_tmp/commands" +pass "cancelling the picker grants nothing and active access still toggles off" + +for confirm_status in 0 1; do + : >"$test_tmp/commands" + TEST_STATUS=0 TEST_CONFIRM_STATUS=$confirm_status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" permanent >"$test_tmp/public.log" 2>&1 + grep -q '^gum confirm Enable passwordless sudo permanently?' "$test_tmp/commands" + if (( confirm_status == 0 )); then + grep -q '__enable .* permanent$' "$test_tmp/commands" + else + ! grep -q '__enable ' "$test_tmp/commands" || fail "declining permanent access still enabled it" + fi +done +pass "changing active access to permanent always requires confirmation" + +reset_grant +( + source "$library" + enable_locked 1000 15 + TEST_REQUIRE_EXISTING_TARGET=1 enable_locked 1000 60 + cleanup_uid_locked 1000 + enable_locked 01000 permanent + status_locked 1000 + [[ -e $(rule_file 1000 permanent) ]] + [[ ! -e $test_tmp/etc/sudoers.d/99-omarchy-permanent-nopasswd-01000 ]] + cleanup_uid_locked 1000 + assert_status 3 status_locked 01000 +) +pass "renewal replaces the existing rule atomically and zero-padded UIDs share one policy" + + +for policy in ' Options: !authenticate' ' Options: authenticate'; do + : >"$test_tmp/commands" + expected=1 + [[ $policy != *'!authenticate'* ]] || expected=0 + assert_status "$expected" env TEST_POLICY="$policy" /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active + [[ $(wc -l <"$test_tmp/commands") == 1 ]] + grep -q '^sudo -n -N -l -l -- .* __status ' "$test_tmp/commands" +done +assert_status 1 env TEST_POLICY_FAILURE=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +pass "menu probe reads policy tags without executing a privileged action or changing the timestamp" + +for status in 0 3; do + : >"$test_tmp/commands" + TEST_STATUS=$status /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1 + ! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "explicit disable offered enablement" + if (( status == 0 )); then + grep -q '__disable ' "$test_tmp/commands" + fi +done +pass "explicit disable never enables access even when a displayed grant has expired" + +: >"$test_tmp/commands" +TEST_STATUS=3 TEST_CHOICE='15 minutes' TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" >"$test_tmp/public.log" 2>&1 +[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "enable must authenticate exactly one sudo call" +grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands" +! grep -q '^sudo -n -N -- .* __status ' "$test_tmp/commands" || fail "inactive flow attempted root status" +[[ $(tail -1 "$test_tmp/commands") == 'sudo -k' ]] +pass "enabling from inactive policy has exactly one password-capable sudo invocation" + +: >"$test_tmp/commands" +TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1 +grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" || fail "disable must refuse interactive authentication" +! grep -q '^sudo -N -- ' "$test_tmp/commands" || fail "disable attempted an interactive sudo command" +pass "disabling active access is fully noninteractive" + +for duration in permanent 15; do + reset_grant + ( + source "$library" + if [[ $duration == permanent ]]; then + enable_locked 1000 15 + else + enable_locked 1000 permanent + fi + assert_status 137 env TEST_KILL_AFTER_PUBLISH=1 TEST_EUID=0 SUDO_UID=1000 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" __enable 1000 "$duration" + [[ -f $(rule_file 1000) && -f $(rule_file 1000 permanent) ]] + status_locked 1000 + TEST_EXPIRED=1 expire_locked 1000 + [[ -f $(rule_file 1000 permanent) ]] + cleanup_uid_locked 1000 + assert_status 3 status_locked 1000 + ) +done +pass "SIGKILL during either duration switch leaves access inspectable and revocable without expiring permanent policy" + +reset_grant +( + source "$library" + enable_locked 1000 permanent + timed=$(rule_file 1000) + printf 'otheruser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed" + assert_status 2 read_grant 1000 + assert_status 1 enable_locked 1000 15 + printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed" + TEST_BAD_PATH="$timed" assert_status 2 read_grant 1000 + rm "$timed" + ln -s "$(rule_file 1000 permanent)" "$timed" + assert_status 2 read_grant 1000 + rm "$timed" + printf 'audituser ALL=(ALL) NOTAFTER=99991231235959Z NOPASSWD: ALL\n' >"$timed" + enable_locked 1000 15 + [[ -f $timed && ! -e $(rule_file 1000 permanent) ]] +) +pass "paired policy recovery rejects mismatched or unsafe rules and allows a complete duration change" + +: >"$test_tmp/commands" +TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --disable >"$test_tmp/public.log" 2>&1 +grep -q '^sudo -n -N -- .* __disable ' "$test_tmp/commands" +! grep -q '^gum\|^sudo -N -- ' "$test_tmp/commands" || fail "listpw=always disable prompted" +TEST_POLICY_FAILURE=1 TEST_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=1 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +: >"$test_tmp/commands" +TEST_POLICY_FAILURE=1 TEST_STATUS=1 TEST_CONFIRM_STATUS=0 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 +[[ $(grep -c '^sudo -N -- ' "$test_tmp/commands") == 1 ]] || fail "listing failure must leave one enable authentication" +grep -q '^sudo -N -- .* __enable .* 15$' "$test_tmp/commands" +: >"$test_tmp/commands" +assert_status 1 env TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" 15 >"$test_tmp/public.log" 2>&1 +! grep -q '^gum\|__enable ' "$test_tmp/commands" || fail "unsafe grant was offered enablement" +pass "password-required listings do not block enabling, disabling, or active detection" + +: >"$test_tmp/commands" +TEST_POLICY_FAILURE=1 TEST_STATUS=2 /usr/bin/bash -p "$test_tmp/omarchy-sudo-passwordless" --active +grep -q '^sudo -kn -- .* __status ' "$test_tmp/commands" || fail "fallback probe must ignore cached credentials" +( + source "$library" + with_root_lock() { return 1; } + verify_sudo_caller() { return 0; } + assert_status 2 root_dispatch __status 1000 +) +pass "status lock failures remain inspection errors and an authenticated unsafe grant keeps its warning" diff --git a/test/shell.d/passwordless-sudo-indicator-test.sh b/test/shell.d/passwordless-sudo-indicator-test.sh new file mode 100644 index 00000000..129b3b82 --- /dev/null +++ b/test/shell.d/passwordless-sudo-indicator-test.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const read = name => fs.readFileSync(path.join(root, name), 'utf8') +const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml') +const widget = read('shell/plugins/bar/widgets/Indicators.qml') +const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json')) +assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray') +assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light') +assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Color.urgent'), 'active passwordless sudo uses the theme danger color') +assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states') +assert(!widget.includes('sudoHorizontal') && !widget.includes('sudoVertical'), 'sudo participates in the normal indicator blocks') +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('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') +const press = new Function('root', 'disableProc', indicator.match(/onPressed: function\(\) \{([\s\S]*?)\n \}/)[1]) +const launched = [] +const button = { granted: true, bar: { run: command => launched.push(command) } } +const revoke = { running: false } +press(button, revoke) +assert(revoke.running && launched.length === 0, 'clicking active sudo starts background revocation without a terminal') +button.granted = false +press(button, revoke) +assert(launched.length === 0, 'repeat clicks during revocation cannot open the enable flow') +revoke.running = false +press(button, revoke) +assertDeepEqual(launched, ['omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless'], 'inactive click still opens interactive setup') +JS