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:
@@ -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,
|
||||
|
||||
Executable
+34
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Executable
+117
@@ -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'
|
||||
Reference in New Issue
Block a user