#!/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'