From 7b68fee056847e4be9debc5df7da3bac37194470 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:15:00 +0200 Subject: [PATCH 1/4] Add Slack to Install > Service (#14434) Slack is one of the most popular apps people install from the AUR. It now ships from the Omarchy package repository as slack-desktop, with native x86_64 and aarch64 builds. Install it the same way as Bitwarden: one menu entry that installs the package and then launches Slack. The commercial apps page of the manual gets a matching section. Co-authored-by: Claude Opus 5.5 (1M context) --- default/omarchy/omarchy-menu.jsonc | 1 + manual/24-commercial-apps-services.md | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 736cba1a..0b9ced63 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -227,6 +227,7 @@ "install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"}, "install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"}, "install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"}, + "install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"}, "install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"}, "install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"}, "install.service.once": {"icon":"󰏖","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"}, diff --git a/manual/24-commercial-apps-services.md b/manual/24-commercial-apps-services.md index 462c67ee..275c91a4 100644 --- a/manual/24-commercial-apps-services.md +++ b/manual/24-commercial-apps-services.md @@ -18,6 +18,10 @@ You start 1Password with `Super + Shift + /`. If it isn't installed yet, that ho You start Spotify using `Super + Shift + M`. Like 1Password, the hotkey kicks off the installation first if Spotify isn't installed yet (or use _Install > Service > Spotify_ from the Omarchy menu). +## Slack + +[Slack](https://slack.com/) is where a lot of teams do their chatting, both inside the company and with outside collaborators. Install it with _Install > Service > Slack_ from the Omarchy menu, and it'll open as soon as it's ready. + ## Dropbox [Dropbox](https://www.dropbox.com/) is a great way to sync files between machines while keeping a backup in the cloud. To set it up, select _Install > Service > Dropbox_ from the Omarchy menu. Once it's running, hover the tray in the top right of the bar and right-click the Dropbox icon to finish the setup. From b6f2c1cef7bbf3c3560b0d9e039d6f4c742b70bf Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:15:38 +0200 Subject: [PATCH 2/4] Describe the agents panel's limit rows and sign-in link as they are (#14430) The manual still said the panel shows the percentage used and that an idle account keeps its last known limits, and the README that sign-in trouble is only text. Rows show the time to reset with the percentage on hover, and a lapsed sign-in is a Sign-in required link that signs the account in again. Co-authored-by: Claude Opus 5.5 --- manual/17-ai.md | 4 ++-- shell/plugins/agents/README.md | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/manual/17-ai.md b/manual/17-ai.md index f740622a..aa661fff 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -38,7 +38,7 @@ There are terminal shortcuts too: `a` runs the default agent inline in the curre ### The agents panel -The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan and the percentage used of its 5-hour session and weekly limits (or the remaining prepaid balance), under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box. +The top bar has an agents icon. Before you've set up an agent, its panel walks you through signing in to Claude, Codex, or Grok, and the first one becomes your default agent. After that, the panel puts every subscription on one page: each agent and account with its plan, a meter for each of its 5-hour session and weekly limits with the time until it resets (hover for the exact percentage), or the remaining prepaid balance, under a line that rotates through what you've used across all of them, like tokens this week and your most used model. Under _Make something_ it offers starter prompts for a new theme, plugin, or app that hand the task to your default agent. Apps are built the way Omarchy's own Hype, Monologue, and Omacut are, in C++ and Qt Quick, following the omarchy-app agent skill, and the compiler and Qt pieces they need come installed. Claude Code, Codex, Grok, and Fireworks are covered out of the box. Left-click the bar icon for the panel, right-click to launch your default agent. The usage records behind it are regenerated every 15 minutes by `omarchy agent usage-update`, and the panel can even merge usage from your other machines via a synced folder. See the README under `$OMARCHY_PATH/shell/plugins/agents/` for the full settings. @@ -50,7 +50,7 @@ One account per provider is active, and every new session starts as it: `cx`, `c With more than one account, the panel shows each account's limits side by side, with the active one marked. By default Omarchy notifies you when the active account passes 95% of a limit, and clicking the notification switches to the account with the most headroom. Hover an account's line in the panel and click the Autoswitch that appears beside Use (or press `m`), or run `omarchy agent account mode auto`, to have it switch on its own instead; while it's on, Autoswitch takes Use's place, and clicking it again goes back to notifying. Add a number to change the threshold, as in `omarchy agent account mode auto 90`. If every account is over the threshold, you'll hear about it once, along with which account frees up first. While an active account is within 15 points of its threshold (80% at the default), its limits are checked every three minutes rather than every 15. -`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours shows its last known limits until a session runs in it again. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account. +`omarchy agent account list` shows every account and its latest limits, `omarchy agent account remove work` forgets one, and `omarchy agent account rename primary Hey` renames one (or click an account's name in the panel). These commands work on your default agent's accounts; to reach the other provider, name it first, as in `omarchy agent account use codex side`. The login you already had in `~/.claude` or `~/.codex` is the _Main_ account and stays where it is. An account left unused for more than about eight hours can show _Sign-in required_; click it to sign that account in again right in the panel, or start a session in it, which refreshes the sign-in on its own. MCP servers you had when an account was added come along to it, but ones you add later with `claude mcp add` only reach the account you added them in. Claude Desktop and tools configured with an absolute path to a CLI binary do not follow the switch. An explicit `CLAUDE_CONFIG_DIR`, `CODEX_HOME`, or `GROK_HOME` takes precedence over the selected account. ### Crash diagnosis diff --git a/shell/plugins/agents/README.md b/shell/plugins/agents/README.md index d1081862..26af2f72 100644 --- a/shell/plugins/agents/README.md +++ b/shell/plugins/agents/README.md @@ -19,8 +19,9 @@ Every subscription on one page, limits first. per limit window: its meter and the time until it resets (the exact percentage on hover). A model-scoped allowance on the same clock (Claude's Fable weekly limit) is a tick on that window's meter rather than a line of its own; the - row's tooltip names it. Sign-in and endpoint trouble shows under the name in the urgent - color. Limits kept from an earlier check after a failed one dim, and their + row's tooltip names it. A lapsed or missing sign-in shows a _Sign-in required_ link that signs + that account in again from the panel; other endpoint trouble shows under the + name in the urgent color. Limits kept from an earlier check after a failed one dim, and their tooltip says how old they are. - **Accounts** — an agent with more than one subscription account (see `omarchy agent account`) lists each: name and plan on one line (the email on hover), and its own limit From 982290fa42aa45510b25c10127588d9eded8fcef Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 13:41:30 +0200 Subject: [PATCH 3/4] Nicer order --- default/omarchy/omarchy-menu.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 0b9ced63..6cd399f3 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -185,8 +185,8 @@ "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"}, - "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, "setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"}, + "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, "setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""}, "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"}, From d9b970dd626da8fdf8174dada67c5e564664e2f8 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 14:41:27 +0200 Subject: [PATCH 4/4] 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