From f42b3cfe0e6b9a33b21b841db7db145bd5668b51 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Fri, 21 Aug 2026 18:57:26 -0400 Subject: [PATCH] Let the password prompt say why panama-sudo is pkexec with a stated reason: the reason travels to the shell over the existing polkit IPC target, and the prompt renders it labeled "Stated reason (unverified)" beside polkitd's real action message -- beside, never instead of, because any process can claim any reason and the action text is the trust anchor. Reasons are single-shot and expire in ten seconds, so a stale one cannot dress up an unrelated prompt; without a reason, a running shell, or qs the wrapper is exactly pkexec. Built for agents, so the person typing their password learns what for. Verified live end to end -- reason shown, consumed once, expired when stale, cleared on dismissal -- and pinned by the polkit reason contract. --- README.md | 8 +- bin/panama-sudo | 34 +++++ .../modules/polkit/PolkitPrompt.qml | 28 +++++ config/dot/quickshell/services/Polkit.qml | 27 ++++ config/dot/quickshell/shell.qml | 9 +- tests/quickshell/polkit-reason-contract | 117 ++++++++++++++++++ 6 files changed, 221 insertions(+), 2 deletions(-) create mode 100755 bin/panama-sudo create mode 100755 tests/quickshell/polkit-reason-contract diff --git a/README.md b/README.md index 6b25ab7..d415e8e 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -132 of them, under `tests/`. Run the lot, or a subset by pattern: +133 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything @@ -171,6 +171,12 @@ package, a `flatpak:` line is a Flathub id, `| Name` gives the menu something readable, and an indented line belongs to the entry above it — which is how OBS carries its sixteen plugin extensions as one thing to tick. +`panama-sudo` is pkexec with a stated reason: `panama-sudo --reason "why" -- +command` shows the reason on Panama's password prompt, clearly labeled as an +unverified claim beside polkitd's own action text — meant for agents and +scripts, so the person typing the password learns why before they do. Without +a reason, a running shell, or `qs` it behaves exactly like pkexec. + `panama app` is deliberately not part of `./install`. Everything else Panama installs comes from dnf or Flathub; these are built from source because no packaged form exists, and a source build is slow, wants the network throughout, diff --git a/bin/panama-sudo b/bin/panama-sudo new file mode 100755 index 0000000..634de80 --- /dev/null +++ b/bin/panama-sudo @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +# pkexec, with a stated reason on the password prompt. +# +# panama-sudo --reason "Installing gamemode hooks" -- dnf install gamemode +# +# The reason travels to the shell over Quickshell IPC before pkexec runs, and +# Panama's prompt shows it clearly labeled beside polkitd's own action message +# -- beside, never instead of: anything can claim any reason, so the real +# action text stays the trust anchor. Meant for agents and scripts, so the +# person at the keyboard learns WHY before typing their password. +# +# Degrades to plain pkexec: no --reason, no running shell, or no qs on PATH +# all behave identically to calling pkexec yourself. + +set -euo pipefail + +reason="" +if [[ "${1:-}" == "--reason" ]]; then + reason="${2:?panama-sudo: --reason needs a value}" + shift 2 +fi +[[ "${1:-}" == "--" ]] && shift + +if (( $# == 0 )); then + echo 'usage: panama-sudo [--reason "why"] -- command [args...]' >&2 + exit 2 +fi + +if [[ -n "$reason" ]] && command -v qs >/dev/null 2>&1; then + qs ipc call polkit reason "$reason" >/dev/null 2>&1 || true +fi + +exec pkexec "$@" diff --git a/config/dot/quickshell/modules/polkit/PolkitPrompt.qml b/config/dot/quickshell/modules/polkit/PolkitPrompt.qml index f6317ec..ebf8e71 100644 --- a/config/dot/quickshell/modules/polkit/PolkitPrompt.qml +++ b/config/dot/quickshell/modules/polkit/PolkitPrompt.qml @@ -94,6 +94,34 @@ PanelWindow { wrapMode: Text.WordWrap } + // The caller's stated reason, when panama-sudo passed one. + // Untrusted commentary from an unprivileged process, so it is + // labeled as a claim and drawn beside polkitd's message above -- + // never in place of it. The real action text is the trust anchor. + Column { + width: parent.width + visible: Polkit.statedReason !== "" + spacing: 2 + + Text { + text: "Stated reason (unverified)" + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + } + + Text { + width: parent.width + text: Polkit.statedReason + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.italic: true + wrapMode: Text.WordWrap + } + } + Text { width: parent.width visible: Polkit.users.length > 1 diff --git a/config/dot/quickshell/services/Polkit.qml b/config/dot/quickshell/services/Polkit.qml index 83dc901..f709973 100644 --- a/config/dot/quickshell/services/Polkit.qml +++ b/config/dot/quickshell/services/Polkit.qml @@ -38,12 +38,31 @@ Singleton { // How many times a wrong password has been offered for this request. property int attempts: 0 + // A caller's stated reason for the NEXT request, and the one attached to + // the request on screen. Untrusted by design -- any process can state one + // -- so the prompt shows it clearly labeled beside polkitd's real action + // message, never in place of it. See stateReason(). + property var pendingReason: null + property string statedReason: "" + readonly property bool active: root.request !== null // Held only between pressing Enter and the helper accepting it on stdin. property string pendingSecret: "" + // panama-sudo's side channel: state WHY the authentication request about + // to arrive is being made, so the prompt can say more than the generic + // action text. Single-shot and short-lived -- it attaches only to the next + // request, and only if that request arrives within ten seconds -- so a + // stale reason can never dress up an unrelated prompt. + function stateReason(text: string): void { + const trimmed = String(text).trim().slice(0, 200); + if (trimmed === "") + return; + root.pendingReason = { text: trimmed, at: Date.now() }; + } + function begin(path: string): void { // A second request while one is open would leave the first // unanswerable; polkit serializes these in practice, and refusing is @@ -69,6 +88,13 @@ Singleton { : (root.users.length > 0 ? String(root.users[0]) : ""); root.attempts = 0; root.failureText = ""; + // Consume the stated reason whether or not it is still fresh: + // either way it must not survive to a later request. + const pending = root.pendingReason; + root.pendingReason = null; + root.statedReason = (pending !== null && Date.now() - pending.at <= 10000) + ? pending.text + : ""; } catch (error) { console.warn("Polkit: could not read the request:", error); root.dismiss("failed"); @@ -114,6 +140,7 @@ Singleton { root.failureText = ""; root.pendingSecret = ""; root.authenticating = false; + root.statedReason = ""; } function responsePathFor(path: string): string { diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index bb2d881..124f759 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -424,8 +424,15 @@ ShellRoot { target: "polkit" function begin(path: string): void { Polkit.begin(path); } function cancel(): void { Polkit.cancel(); } + // panama-sudo's side channel: the reason a privileged command is about + // to run, shown labeled on the prompt beside polkitd's own message. + function reason(text: string): void { Polkit.stateReason(text); } function status(): string { - return JSON.stringify({ active: Polkit.active, action: Polkit.actionId }); + return JSON.stringify({ + active: Polkit.active, + action: Polkit.actionId, + statedReason: Polkit.statedReason + }); } } diff --git a/tests/quickshell/polkit-reason-contract b/tests/quickshell/polkit-reason-contract new file mode 100755 index 0000000..53a6779 --- /dev/null +++ b/tests/quickshell/polkit-reason-contract @@ -0,0 +1,117 @@ +#!/usr/bin/env bash + +# The stated reason on the authentication prompt. +# +# panama-sudo lets a caller say WHY it is about to trigger a password prompt. +# The reason is untrusted text from an unprivileged process, so the properties +# worth pinning are the ones that keep it honest: +# +# 1. The prompt renders the reason BESIDE polkitd's real action message, +# labeled as unverified -- never in place of it. Any process can claim +# "Updating your system" while requesting something else; the action text +# is the trust anchor and must survive. +# 2. Single-shot and short-lived: a reason attaches to the next request +# only, is consumed whether or not it was fresh, and expires rather than +# dressing up an unrelated prompt minutes later. +# 3. panama-sudo degrades to plain pkexec: no --reason, no qs, or a dead +# shell must all still run the command. +# +# The wrapper is exercised for real against stub qs and pkexec; the QML side +# is pinned statically, the way the polkit-agent contract pins its rules. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +wrapper="$repo_dir/bin/panama-sudo" +service="$repo_dir/config/dot/quickshell/services/Polkit.qml" +prompt="$repo_dir/config/dot/quickshell/modules/polkit/PolkitPrompt.qml" +shell_qml="$repo_dir/config/dot/quickshell/shell.qml" + +findings=() +note() { findings+=("$1"); } + +[[ -x "$wrapper" ]] || { printf 'polkit reason contract: %s is not executable\n' "$wrapper" >&2; exit 1; } + +# ── The wrapper, for real ──────────────────────────────────────────────────── + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +calls="$work/calls" +stub_dir="$work/bin" +mkdir -p "$stub_dir" + +for command in qs pkexec; do + cat >"$stub_dir/$command" <>"$calls" +STUB + chmod +x "$stub_dir/$command" +done + +run_wrapper() { PATH="$stub_dir:$PATH" "$wrapper" "$@" >/dev/null 2>&1; } + +# A reason reaches the shell first, then the command runs unchanged. +: >"$calls" +run_wrapper --reason "Test reason" -- some-command --with args \ + || note 'the wrapper failed with a reason and a command' +grep -q 'qs ipc call polkit reason Test reason' "$calls" \ + || note 'the reason never reaches the shell over IPC' +grep -q 'pkexec some-command --with args' "$calls" \ + || note 'the command does not reach pkexec unchanged' +[[ "$(head -1 "$calls")" == qs* ]] \ + || note 'the reason is sent after pkexec instead of before the prompt can appear' + +# No reason means no IPC chatter, and still pkexec. +: >"$calls" +run_wrapper -- some-command || note 'the wrapper failed without a reason' +grep -q 'qs' "$calls" && note 'the wrapper calls qs even when no reason was given' +grep -q 'pkexec some-command' "$calls" || note 'a reasonless call does not reach pkexec' + +# A dead shell must not cost the command: qs failing is stepped over. +cat >"$stub_dir/qs" <<'STUB' +#!/usr/bin/env bash +exit 1 +STUB +chmod +x "$stub_dir/qs" +: >"$calls" +run_wrapper --reason "Doomed" -- some-command \ + || note 'a failing qs stops the command instead of degrading to plain pkexec' +grep -q 'pkexec some-command' "$calls" \ + || note 'the command is lost when the shell is not answering' + +# No command is a usage error, not a bare pkexec prompt for nothing. +run_wrapper --reason "Aimless" -- && note 'the wrapper accepts a reason with no command' + +# ── The QML side, statically ───────────────────────────────────────────────── + +# The IPC door exists and feeds the service. +rg -Fq 'target: "polkit"' "$shell_qml" \ + || note 'shell.qml has no polkit IPC target' +rg -Fq 'Polkit.stateReason(text)' "$shell_qml" \ + || note 'the polkit IPC target does not feed Polkit.stateReason' + +# Single-shot, bounded, and cleared: consumed on adopt even when stale, aged +# against a ten-second window, and wiped with the rest of the request state. +rg -Fq 'root.pendingReason = null' "$service" \ + || note 'a stated reason is not consumed when a request arrives' +rg -Fq 'pending.at <= 10000' "$service" \ + || note 'a stated reason never expires, so it can dress up a later prompt' +rg -Fq 'root.statedReason = ""' "$service" \ + || note 'the stated reason survives dismissal' + +# The prompt shows the real message AND the labeled reason -- both, in that +# trust order. +rg -Fq 'text: Polkit.message' "$prompt" \ + || note "polkitd's own action message is no longer rendered" +rg -Fq 'text: Polkit.statedReason' "$prompt" \ + || note 'the stated reason is never rendered' +rg -Fq 'Stated reason (unverified)' "$prompt" \ + || note 'the stated reason is not labeled as an unverified claim' + +if (( ${#findings[@]} > 0 )); then + printf 'polkit reason contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'polkit reason contract: PASS\n'