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.
This commit is contained in:
Gabriel Brown
2026-08-21 18:57:26 -04:00
parent 51ceb19480
commit f42b3cfe0e
6 changed files with 221 additions and 2 deletions
+7 -1
View File
@@ -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,
+34
View File
@@ -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 "$@"
@@ -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
+27
View File
@@ -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 {
+8 -1
View File
@@ -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
});
}
}
+117
View File
@@ -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" <<STUB
#!/usr/bin/env bash
printf '%s %s\n' "$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'