Draw the authentication prompt ourselves
hyprpolkitagent's dialog is compiled into its binary -- no config, no stylesheet, nothing to theme -- and it was the one window on this desktop that looked like it belonged to something else. The split between the two halves is the security design, not an implementation detail. A small agent process owns the D-Bus side: it registers with polkitd, receives the request, and hands the shell the action, the message, who may answer, and a one-time cookie. It never sees a password. The shell draws the prompt and, on submit, spawns the setuid polkit-agent-helper-1 itself and writes the password to that helper's stdin; the helper runs the PAM conversation and reports to polkitd directly. The password exists in the shell and in the helper's stdin and nowhere else -- never on a command line, never over D-Bus, never through IPC arguments. The prompt takes exclusive keyboard focus, because a password field that lets keystrokes reach the window behind it is a keylogger with extra steps. The request travels as a file created 0600 with O_EXCL inside a 0700 runtime directory: a cookie is not a password, but it is a capability, and capabilities do not belong in a process listing either. Three things cost real time. polkitd calls back on the same connection that registered, so exporting the object on the session bus while registering from the system bus failed every request as "Not authorized" with no error anywhere. XDG_SESSION_ID is absent in a systemd user unit, which runs under [email protected] and belongs to no login session, so the session comes from logind's Display property instead. And PyGObject does not accept the @ placeholder in variant format strings. hyprpolkitagent stays installed as the fallback, only one agent is started, and the comment beside the autostart says how to get the stock prompt back. Verified end to end, including a real password accepted and three cancellations refused. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -32,7 +32,12 @@ hl.on("hyprland.start", function()
|
|||||||
-- so that failure mode is "lock-session goes to nobody". Re-import
|
-- so that failure mode is "lock-session goes to nobody". Re-import
|
||||||
-- synchronously in the same shell invocation first so the Condition
|
-- synchronously in the same shell invocation first so the Condition
|
||||||
-- always sees it, regardless of how the dbus-update call above scheduled.
|
-- always sees it, regardless of how the dbus-update call above scheduled.
|
||||||
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service")
|
-- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled
|
||||||
|
-- into its binary and cannot be themed. Only one agent may register per
|
||||||
|
-- session, so they must not both start. hyprpolkitagent stays INSTALLED as
|
||||||
|
-- the fallback: `systemctl --user start hyprpolkitagent` restores the stock
|
||||||
|
-- prompt if Panama's ever fails to come up.
|
||||||
|
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service")
|
||||||
|
|
||||||
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
-- The shell: bar, dock, overview, quick settings, notifications, capture.
|
||||||
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
-- No systemd unit ships with quickshell, so it runs as a compositor child.
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// The authentication prompt.
|
||||||
|
//
|
||||||
|
// Deliberately a Panama surface rather than the compositor's stock agent: this
|
||||||
|
// is the window that asks for the password to everything, and it was the one
|
||||||
|
// window on the desktop that looked like it belonged to something else.
|
||||||
|
//
|
||||||
|
// Two things here are security, not styling. It takes EXCLUSIVE keyboard focus,
|
||||||
|
// so keystrokes cannot reach the window underneath while a password is being
|
||||||
|
// typed. And the field is cleared on every exit path, including the ones nobody
|
||||||
|
// plans for.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
import qs.modules.settings
|
||||||
|
|
||||||
|
PanelWindow {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
visible: Polkit.active
|
||||||
|
color: "transparent"
|
||||||
|
|
||||||
|
anchors { top: true; bottom: true; left: true; right: true }
|
||||||
|
exclusiveZone: 0
|
||||||
|
|
||||||
|
WlrLayershell.namespace: "qs-polkit"
|
||||||
|
WlrLayershell.layer: WlrLayer.Overlay
|
||||||
|
// Exclusive, not OnDemand: a password prompt that lets keystrokes through
|
||||||
|
// to whatever is behind it is a keylogger with extra steps.
|
||||||
|
WlrLayershell.keyboardFocus: Polkit.active
|
||||||
|
? WlrKeyboardFocus.Exclusive
|
||||||
|
: WlrKeyboardFocus.None
|
||||||
|
|
||||||
|
onVisibleChanged: {
|
||||||
|
if (root.visible) {
|
||||||
|
field.text = "";
|
||||||
|
field.forceActiveFocus();
|
||||||
|
} else {
|
||||||
|
field.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dims what is behind, and swallows clicks so nothing outside the dialog
|
||||||
|
// can be operated while it is waiting.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
// Clicking outside does nothing on purpose. Dismissing an
|
||||||
|
// authentication request by misclick, and having the thing that
|
||||||
|
// asked report a mysterious failure, is worse than an explicit
|
||||||
|
// Cancel.
|
||||||
|
hoverEnabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: dialog
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: 420
|
||||||
|
implicitHeight: layout.implicitHeight + 44
|
||||||
|
radius: Theme.popoverRadius
|
||||||
|
color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.fg, 0.1)
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: layout
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width - 44
|
||||||
|
spacing: 14
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: "Authentication required"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeLarge
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: Polkit.message
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: Polkit.users.length > 1
|
||||||
|
text: "Authenticating as " + Polkit.chosenUser
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
PasswordField {
|
||||||
|
id: field
|
||||||
|
width: parent.width
|
||||||
|
placeholder: "Password"
|
||||||
|
enabled: !Polkit.authenticating
|
||||||
|
onAccepted: {
|
||||||
|
if (field.text !== "")
|
||||||
|
Polkit.submit(field.text);
|
||||||
|
field.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: Polkit.failureText !== ""
|
||||||
|
text: Polkit.failureText
|
||||||
|
color: Theme.danger
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: "Cancel"
|
||||||
|
onClicked: Polkit.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
text: Polkit.authenticating ? "Checking…" : "Authenticate"
|
||||||
|
tone: "accent"
|
||||||
|
enabled: !Polkit.authenticating
|
||||||
|
onClicked: {
|
||||||
|
if (field.text !== "")
|
||||||
|
Polkit.submit(field.text);
|
||||||
|
field.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape cancels, which is what every other dialog on this desktop does.
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
focus: true
|
||||||
|
Keys.onEscapePressed: Polkit.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
module qs.modules.polkit
|
||||||
|
PolkitPrompt 1.0 PolkitPrompt.qml
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""Panama's polkit authentication agent -- the D-Bus half.
|
||||||
|
|
||||||
|
This process never sees a password. It is worth being explicit about why the
|
||||||
|
work is split this way, because the split IS the security design:
|
||||||
|
|
||||||
|
* This half registers with polkitd, receives an authentication request, and
|
||||||
|
hands the non-secret parts of it to the shell: which action, what message,
|
||||||
|
which identities may answer, and the one-time cookie.
|
||||||
|
* The shell half draws the prompt, and when someone types a password it
|
||||||
|
spawns the setuid `polkit-agent-helper-1` itself and writes the password to
|
||||||
|
that helper's STDIN. The helper performs the PAM conversation and reports
|
||||||
|
the result to polkitd directly.
|
||||||
|
|
||||||
|
So the password exists only inside the shell process and the helper's stdin. It
|
||||||
|
never crosses D-Bus, never crosses this process, and never appears on a command
|
||||||
|
line -- argv is world-readable through /proc, which is why "just pass it as an
|
||||||
|
argument" is not an option anywhere in this codebase.
|
||||||
|
|
||||||
|
The request itself is handed over as a file in the runtime directory, created
|
||||||
|
0600, rather than as IPC arguments. The cookie is not a password, but it is a
|
||||||
|
capability, and capabilities do not belong in a process listing either.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import gi
|
||||||
|
|
||||||
|
gi.require_version("Gio", "2.0")
|
||||||
|
gi.require_version("GLib", "2.0")
|
||||||
|
from gi.repository import Gio, GLib # noqa: E402
|
||||||
|
|
||||||
|
AGENT_PATH = "/org/panama/PolkitAgent"
|
||||||
|
AGENT_INTERFACE = "org.freedesktop.PolicyKit1.AuthenticationAgent"
|
||||||
|
AUTHORITY_NAME = "org.freedesktop.PolicyKit1"
|
||||||
|
AUTHORITY_PATH = "/org/freedesktop/PolicyKit1/Authority"
|
||||||
|
AUTHORITY_INTERFACE = "org.freedesktop.PolicyKit1.Authority"
|
||||||
|
|
||||||
|
# How long a prompt may stay on screen before this gives up and tells polkit the
|
||||||
|
# request failed. Without it, a shell that died mid-prompt would leave the
|
||||||
|
# caller (a package manager, say) waiting forever.
|
||||||
|
PROMPT_TIMEOUT_SECONDS = 300
|
||||||
|
|
||||||
|
INTROSPECTION = """
|
||||||
|
<node>
|
||||||
|
<interface name='org.freedesktop.PolicyKit1.AuthenticationAgent'>
|
||||||
|
<method name='BeginAuthentication'>
|
||||||
|
<arg type='s' name='action_id' direction='in'/>
|
||||||
|
<arg type='s' name='message' direction='in'/>
|
||||||
|
<arg type='s' name='icon_name' direction='in'/>
|
||||||
|
<arg type='a{ss}' name='details' direction='in'/>
|
||||||
|
<arg type='s' name='cookie' direction='in'/>
|
||||||
|
<arg type='a(sa{sv})' name='identities' direction='in'/>
|
||||||
|
</method>
|
||||||
|
<method name='CancelAuthentication'>
|
||||||
|
<arg type='s' name='cookie' direction='in'/>
|
||||||
|
</method>
|
||||||
|
</interface>
|
||||||
|
</node>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_dir() -> Path:
|
||||||
|
base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit"
|
||||||
|
base.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
# Enforced rather than assumed: an inherited directory with looser
|
||||||
|
# permissions would expose every request that passes through it.
|
||||||
|
os.chmod(base, 0o700)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(f"panama-polkit-agent: {message}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Agent:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
|
||||||
|
self.pending: dict[str, Path] = {}
|
||||||
|
self.loop = GLib.MainLoop()
|
||||||
|
|
||||||
|
# ── Registration ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def subject(self) -> GLib.Variant:
|
||||||
|
"""This login session, which is what the agent authenticates for.
|
||||||
|
|
||||||
|
XDG_SESSION_ID is set for a login shell but NOT in the systemd user
|
||||||
|
environment, so running as a unit has to ask logind which session this
|
||||||
|
process belongs to rather than trusting the variable to be there.
|
||||||
|
"""
|
||||||
|
session_id = os.environ.get("XDG_SESSION_ID", "")
|
||||||
|
if not session_id:
|
||||||
|
session_id = self.session_from_logind()
|
||||||
|
if not session_id:
|
||||||
|
raise RuntimeError("could not determine the login session to register for")
|
||||||
|
return GLib.Variant("(sa{sv})", ("unix-session",
|
||||||
|
{"session-id": GLib.Variant("s", session_id)}))
|
||||||
|
|
||||||
|
def session_from_logind(self) -> str:
|
||||||
|
"""This user's graphical session, asked of logind.
|
||||||
|
|
||||||
|
Not GetSessionByPID: a systemd USER unit runs under [email protected],
|
||||||
|
which belongs to no login session at all -- which is exactly why
|
||||||
|
XDG_SESSION_ID is missing when started that way. The user object's
|
||||||
|
Display property names the graphical session, which is the one whose
|
||||||
|
authentications this agent should answer.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_path = self.bus.call_sync(
|
||||||
|
"org.freedesktop.login1", "/org/freedesktop/login1",
|
||||||
|
"org.freedesktop.login1.Manager", "GetUser",
|
||||||
|
GLib.Variant("(u)", (os.getuid(),)), GLib.VariantType("(o)"),
|
||||||
|
Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
|
||||||
|
display = self.bus.call_sync(
|
||||||
|
"org.freedesktop.login1", user_path,
|
||||||
|
"org.freedesktop.DBus.Properties", "Get",
|
||||||
|
GLib.Variant("(ss)", ("org.freedesktop.login1.User", "Display")),
|
||||||
|
GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
|
||||||
|
# (session-id, object-path)
|
||||||
|
return str(display[0]) if display and display[0] else ""
|
||||||
|
except Exception: # noqa: BLE001 - no session is a real answer
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def register(self) -> None:
|
||||||
|
node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
|
||||||
|
# The object MUST be exported on the same connection that registers the
|
||||||
|
# agent. polkitd calls BeginAuthentication back on the unique name that
|
||||||
|
# called RegisterAuthenticationAgent -- so exporting on the session bus
|
||||||
|
# while registering from the system bus leaves polkitd calling a path
|
||||||
|
# that does not exist, and every request fails as "Not authorized"
|
||||||
|
# without ever prompting.
|
||||||
|
self.bus.register_object(
|
||||||
|
AGENT_PATH, node.interfaces[0], self.on_call, None, None)
|
||||||
|
self.bus.call_sync(
|
||||||
|
AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE,
|
||||||
|
"RegisterAuthenticationAgent",
|
||||||
|
GLib.Variant.new_tuple(self.subject(),
|
||||||
|
GLib.Variant("s", "en_US.UTF-8"),
|
||||||
|
GLib.Variant("s", AGENT_PATH)),
|
||||||
|
None, Gio.DBusCallFlags.NONE, 10000, None)
|
||||||
|
log("registered with polkitd")
|
||||||
|
|
||||||
|
def unregister(self) -> None:
|
||||||
|
try:
|
||||||
|
self.bus.call_sync(
|
||||||
|
AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE,
|
||||||
|
"UnregisterAuthenticationAgent",
|
||||||
|
GLib.Variant.new_tuple(self.subject(), GLib.Variant("s", AGENT_PATH)),
|
||||||
|
None, Gio.DBusCallFlags.NONE, 5000, None)
|
||||||
|
log("unregistered")
|
||||||
|
except Exception: # noqa: BLE001 - shutting down either way
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── The agent interface ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def on_call(self, connection, sender, path, interface, method, parameters, invocation):
|
||||||
|
if method == "BeginAuthentication":
|
||||||
|
action_id, message, icon_name, details, cookie, identities = parameters.unpack()
|
||||||
|
self.begin(action_id, message, icon_name, cookie, identities, invocation)
|
||||||
|
return
|
||||||
|
if method == "CancelAuthentication":
|
||||||
|
(cookie,) = parameters.unpack()
|
||||||
|
self.cancel(cookie)
|
||||||
|
invocation.return_value(None)
|
||||||
|
return
|
||||||
|
invocation.return_error_literal(Gio.dbus_error_quark(),
|
||||||
|
Gio.DBusError.UNKNOWN_METHOD, "Unknown method")
|
||||||
|
|
||||||
|
def usernames(self, identities: list) -> list[str]:
|
||||||
|
"""Who polkit will accept an answer from, as names a person recognizes."""
|
||||||
|
names = []
|
||||||
|
for kind, attributes in identities:
|
||||||
|
if kind != "unix-user":
|
||||||
|
continue
|
||||||
|
uid = attributes.get("uid")
|
||||||
|
if uid is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
|
||||||
|
names.append(pwd.getpwuid(int(uid)).pw_name)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
continue
|
||||||
|
return names
|
||||||
|
|
||||||
|
def begin(self, action_id, message, icon_name, cookie, identities, invocation) -> None:
|
||||||
|
names = self.usernames(identities)
|
||||||
|
if not names:
|
||||||
|
invocation.return_error_literal(
|
||||||
|
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
|
||||||
|
"No user is allowed to authenticate this request.")
|
||||||
|
return
|
||||||
|
|
||||||
|
request_path = runtime_dir() / f"request-{os.getpid()}-{int(time.time() * 1000)}.json"
|
||||||
|
payload = {
|
||||||
|
"actionId": action_id,
|
||||||
|
"message": message,
|
||||||
|
"iconName": icon_name,
|
||||||
|
"cookie": cookie,
|
||||||
|
"users": names,
|
||||||
|
"preferred": os.environ.get("USER", names[0]),
|
||||||
|
}
|
||||||
|
# 0600 before anything is written to it, so the cookie is never briefly
|
||||||
|
# world-readable.
|
||||||
|
handle = os.open(request_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
with os.fdopen(handle, "w", encoding="utf-8") as stream:
|
||||||
|
json.dump(payload, stream)
|
||||||
|
|
||||||
|
self.pending[cookie] = request_path
|
||||||
|
response_path = request_path.with_suffix(".response")
|
||||||
|
|
||||||
|
if not self.ask_shell(request_path):
|
||||||
|
self.finish(cookie, request_path, response_path)
|
||||||
|
invocation.return_error_literal(
|
||||||
|
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
|
||||||
|
"The desktop could not show an authentication prompt.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Poll for the shell's answer rather than blocking the main loop, so a
|
||||||
|
# Cancel from polkit is still processed while a prompt is open.
|
||||||
|
started = time.monotonic()
|
||||||
|
|
||||||
|
def check() -> bool:
|
||||||
|
if cookie not in self.pending:
|
||||||
|
# Cancelled from the polkit side.
|
||||||
|
self.finish(cookie, request_path, response_path)
|
||||||
|
invocation.return_error_literal(
|
||||||
|
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Cancelled.")
|
||||||
|
return False
|
||||||
|
if response_path.exists():
|
||||||
|
try:
|
||||||
|
answer = json.loads(response_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
answer = {"result": "failed"}
|
||||||
|
self.finish(cookie, request_path, response_path)
|
||||||
|
if answer.get("result") == "ok":
|
||||||
|
# The helper has already told polkitd. Returning normally is
|
||||||
|
# what completes the request.
|
||||||
|
invocation.return_value(None)
|
||||||
|
else:
|
||||||
|
invocation.return_error_literal(
|
||||||
|
Gio.dbus_error_quark(), Gio.DBusError.FAILED,
|
||||||
|
"Authentication was not completed.")
|
||||||
|
return False
|
||||||
|
if time.monotonic() - started > PROMPT_TIMEOUT_SECONDS:
|
||||||
|
self.finish(cookie, request_path, response_path)
|
||||||
|
invocation.return_error_literal(
|
||||||
|
Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Timed out.")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
GLib.timeout_add(150, check)
|
||||||
|
|
||||||
|
def cancel(self, cookie: str) -> None:
|
||||||
|
request_path = self.pending.pop(cookie, None)
|
||||||
|
if request_path is None:
|
||||||
|
return
|
||||||
|
# A marker rather than a deletion: the shell watches for this to close a
|
||||||
|
# prompt that polkit no longer wants an answer to.
|
||||||
|
try:
|
||||||
|
request_path.with_suffix(".cancelled").touch(mode=0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def finish(self, cookie: str, request_path: Path, response_path: Path) -> None:
|
||||||
|
self.pending.pop(cookie, None)
|
||||||
|
for path in (request_path, response_path, request_path.with_suffix(".cancelled")):
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ask_shell(self, request_path: Path) -> bool:
|
||||||
|
"""Tell the shell to prompt. Only the path travels, never the contents."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["qs", "ipc", "call", "polkit", "begin", str(request_path)],
|
||||||
|
capture_output=True, text=True, timeout=15, check=False)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return False
|
||||||
|
if result.returncode != 0:
|
||||||
|
log(f"the shell refused the prompt: {result.stderr.strip()[:120]}")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def run(self) -> int:
|
||||||
|
self.register()
|
||||||
|
for received in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, received, self.stop)
|
||||||
|
try:
|
||||||
|
self.loop.run()
|
||||||
|
finally:
|
||||||
|
self.unregister()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def stop(self) -> bool:
|
||||||
|
self.loop.quit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
try:
|
||||||
|
return Agent().run()
|
||||||
|
except Exception as error: # noqa: BLE001
|
||||||
|
log(f"could not start: {error}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
// Panama's authentication prompt -- the half that handles the password.
|
||||||
|
//
|
||||||
|
// The split with scripts/panama-polkit-agent is the security design, not an
|
||||||
|
// accident of implementation:
|
||||||
|
//
|
||||||
|
// * The agent talks to polkitd and hands this a request FILE containing the
|
||||||
|
// action, the message, who may answer, and a one-time cookie. No password
|
||||||
|
// ever reaches it.
|
||||||
|
// * This draws the prompt, and on submit spawns the setuid
|
||||||
|
// polkit-agent-helper-1 and writes the password to that helper's stdin. The
|
||||||
|
// helper performs the PAM conversation and reports to polkitd itself.
|
||||||
|
//
|
||||||
|
// So the password lives in this process and the helper's stdin, and nowhere
|
||||||
|
// else. It is never an argument -- argv is world-readable through /proc -- and
|
||||||
|
// it is cleared the moment it has been handed over.
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
readonly property string helperBinary: "/usr/lib/polkit-1/polkit-agent-helper-1"
|
||||||
|
|
||||||
|
// The request currently on screen, or null.
|
||||||
|
property var request: null
|
||||||
|
property string requestPath: ""
|
||||||
|
property string message: ""
|
||||||
|
property string actionId: ""
|
||||||
|
property var users: []
|
||||||
|
property string chosenUser: ""
|
||||||
|
|
||||||
|
property bool authenticating: false
|
||||||
|
property string failureText: ""
|
||||||
|
// How many times a wrong password has been offered for this request.
|
||||||
|
property int attempts: 0
|
||||||
|
|
||||||
|
readonly property bool active: root.request !== null
|
||||||
|
|
||||||
|
// Held only between pressing Enter and the helper accepting it on stdin.
|
||||||
|
property string pendingSecret: ""
|
||||||
|
|
||||||
|
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
|
||||||
|
// safer than stacking prompts.
|
||||||
|
if (root.active) {
|
||||||
|
console.warn("Polkit: a prompt is already open; ignoring", path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.requestPath = path;
|
||||||
|
requestFile.path = path;
|
||||||
|
requestFile.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function adopt(text: string): void {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
root.request = parsed;
|
||||||
|
root.actionId = String(parsed.actionId ?? "");
|
||||||
|
root.message = String(parsed.message ?? "Authentication is required");
|
||||||
|
root.users = Array.isArray(parsed.users) ? parsed.users : [];
|
||||||
|
root.chosenUser = root.users.includes(String(parsed.preferred ?? ""))
|
||||||
|
? String(parsed.preferred)
|
||||||
|
: (root.users.length > 0 ? String(root.users[0]) : "");
|
||||||
|
root.attempts = 0;
|
||||||
|
root.failureText = "";
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Polkit: could not read the request:", error);
|
||||||
|
root.dismiss("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(password: string): void {
|
||||||
|
if (!root.active || root.authenticating || root.chosenUser === "")
|
||||||
|
return;
|
||||||
|
root.authenticating = true;
|
||||||
|
root.failureText = "";
|
||||||
|
root.pendingSecret = password;
|
||||||
|
helper.command = [root.helperBinary, root.chosenUser];
|
||||||
|
helper.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(): void {
|
||||||
|
root.dismiss("cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes the outcome where the agent is watching for it, then forgets
|
||||||
|
// everything about the request.
|
||||||
|
function dismiss(result: string): void {
|
||||||
|
if (root.requestPath !== "") {
|
||||||
|
answer.command = ["sh", "-c",
|
||||||
|
"printf '%s' " + JSON.stringify(JSON.stringify({ result: result }))
|
||||||
|
+ " > " + JSON.stringify(root.responsePathFor(root.requestPath))];
|
||||||
|
answer.running = true;
|
||||||
|
}
|
||||||
|
root.request = null;
|
||||||
|
root.requestPath = "";
|
||||||
|
root.message = "";
|
||||||
|
root.actionId = "";
|
||||||
|
root.users = [];
|
||||||
|
root.chosenUser = "";
|
||||||
|
root.attempts = 0;
|
||||||
|
root.failureText = "";
|
||||||
|
root.pendingSecret = "";
|
||||||
|
root.authenticating = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function responsePathFor(path: string): string {
|
||||||
|
return String(path).replace(/\.json$/, "") + ".response";
|
||||||
|
}
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: requestFile
|
||||||
|
onLoaded: root.adopt(this.text())
|
||||||
|
onLoadFailed: {
|
||||||
|
console.warn("Polkit: the request file could not be read");
|
||||||
|
root.dismiss("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watches for polkit withdrawing the request while the prompt is open --
|
||||||
|
// the caller gave up, or another agent answered it.
|
||||||
|
Timer {
|
||||||
|
running: root.active
|
||||||
|
interval: 500
|
||||||
|
repeat: true
|
||||||
|
onTriggered: cancelledCheck.running = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: cancelledCheck
|
||||||
|
command: ["test", "-e", root.requestPath.replace(/\.json$/, "") + ".cancelled"]
|
||||||
|
onExited: (code) => { if (code === 0 && root.active) root.dismiss("cancelled"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: helper
|
||||||
|
stdinEnabled: true
|
||||||
|
|
||||||
|
onStarted: {
|
||||||
|
// The cookie first, then the password when the helper asks for it.
|
||||||
|
// Both go to stdin; neither is ever an argument.
|
||||||
|
helper.write(String(root.request?.cookie ?? "") + "\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout: SplitParser {
|
||||||
|
splitMarker: "\n"
|
||||||
|
onRead: line => {
|
||||||
|
const text = String(line);
|
||||||
|
if (text.startsWith("PAM_PROMPT_ECHO_OFF")
|
||||||
|
|| text.startsWith("PAM_PROMPT_ECHO_ON")) {
|
||||||
|
helper.write(root.pendingSecret + "\n");
|
||||||
|
// Gone from this process the instant it has been handed
|
||||||
|
// over; the helper owns it from here.
|
||||||
|
root.pendingSecret = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.startsWith("PAM_ERROR_MSG")) {
|
||||||
|
const detail = text.slice("PAM_ERROR_MSG".length).trim();
|
||||||
|
if (detail !== "")
|
||||||
|
root.failureText = detail;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.startsWith("SUCCESS")) {
|
||||||
|
root.dismiss("ok");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (text.startsWith("FAILURE")) {
|
||||||
|
root.attempts += 1;
|
||||||
|
if (root.failureText === "")
|
||||||
|
root.failureText = "That password was not accepted.";
|
||||||
|
// Three tries, then the request is failed rather than left
|
||||||
|
// open forever -- polkit's own agents behave the same way.
|
||||||
|
if (root.attempts >= 3)
|
||||||
|
root.dismiss("failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onExited: {
|
||||||
|
root.authenticating = false;
|
||||||
|
root.pendingSecret = "";
|
||||||
|
helper.stdinEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process { id: answer }
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import qs.modules.clipboard
|
|||||||
import qs.modules.datemenu
|
import qs.modules.datemenu
|
||||||
import qs.modules.focus
|
import qs.modules.focus
|
||||||
import qs.modules.signals
|
import qs.modules.signals
|
||||||
|
import qs.modules.polkit
|
||||||
import qs.modules.settings
|
import qs.modules.settings
|
||||||
import qs.modules.osd
|
import qs.modules.osd
|
||||||
|
|
||||||
@@ -409,6 +410,19 @@ ShellRoot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Panama's authentication prompt. The agent process calls in here with a
|
||||||
|
// path to a request file; no password ever travels the other way.
|
||||||
|
PolkitPrompt {}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "polkit"
|
||||||
|
function begin(path: string): void { Polkit.begin(path); }
|
||||||
|
function cancel(): void { Polkit.cancel(); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({ active: Polkit.active, action: Polkit.actionId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "settings"
|
target: "settings"
|
||||||
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Panama polkit authentication agent
|
||||||
|
Documentation=https://github.com/gibbyb/Panama
|
||||||
|
# Only one agent may be registered per session, so this must not run alongside
|
||||||
|
# the one it replaces.
|
||||||
|
Conflicts=hyprpolkitagent.service
|
||||||
|
After=graphical-session.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%h/.config/quickshell/scripts/panama-polkit-agent
|
||||||
|
# The prompt is drawn by the shell, so an agent that outlives a shell restart
|
||||||
|
# would be registered with nowhere to prompt. Restarting reconnects it.
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=2
|
||||||
|
# If this fails repeatedly, stop rather than flap: a session with no agent is
|
||||||
|
# recoverable from a terminal, a session with a broken one is confusing.
|
||||||
|
StartLimitBurst=5
|
||||||
|
StartLimitIntervalSec=60
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
@@ -236,3 +236,47 @@ and it is the page that makes this feel like *your* operating system.
|
|||||||
|
|
||||||
Batch 3 and 4 in either order. Firewall is the most valuable of the remainder
|
Batch 3 and 4 in either order. Firewall is the most valuable of the remainder
|
||||||
and also the one that most deserves care.
|
and also the one that most deserves care.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The authentication prompt
|
||||||
|
|
||||||
|
Not on the original list, and worth recording because of how it was built.
|
||||||
|
|
||||||
|
hyprpolkitagent's dialog is compiled into its binary -- no config, no
|
||||||
|
stylesheet, nothing to theme -- and it was the one window on the desktop that
|
||||||
|
looked like it belonged to something else. Panama now provides the agent.
|
||||||
|
|
||||||
|
**The split is the security design.** A Python process owns the D-Bus side: it
|
||||||
|
registers with polkitd, receives the request, and hands the shell the action,
|
||||||
|
the message, who may answer, and a one-time cookie. It never sees a password.
|
||||||
|
The shell draws the prompt, and on submit spawns the setuid
|
||||||
|
`polkit-agent-helper-1` itself and writes the password to that helper's stdin;
|
||||||
|
the helper performs the PAM conversation and reports to polkitd directly. So the
|
||||||
|
password exists in the shell process and the helper's stdin, and nowhere else --
|
||||||
|
never on a command line, never over D-Bus, never through IPC arguments.
|
||||||
|
|
||||||
|
The request travels as a file created 0600 with O_EXCL in a 0700 runtime
|
||||||
|
directory. A cookie is not a password, but it is a capability, and capabilities
|
||||||
|
do not belong in a process listing either.
|
||||||
|
|
||||||
|
**Three things cost real time and are worth writing down.**
|
||||||
|
|
||||||
|
polkitd calls `BeginAuthentication` back on the same connection that called
|
||||||
|
`RegisterAuthenticationAgent`. Exporting the object on the session bus while
|
||||||
|
registering from the system bus produced no error at all -- every request just
|
||||||
|
failed as "Not authorized" without ever prompting.
|
||||||
|
|
||||||
|
`XDG_SESSION_ID` is not in the systemd user environment, because a user unit
|
||||||
|
runs under `[email protected]`, which belongs to no login session. `GetSessionByPID`
|
||||||
|
fails for the same reason. The user object's `Display` property is the answer.
|
||||||
|
|
||||||
|
PyGObject does not accept the `@` placeholder in variant format strings; a tuple
|
||||||
|
has to be assembled from already-built variants.
|
||||||
|
|
||||||
|
**The fallback is deliberate.** hyprpolkitagent stays installed, and only one
|
||||||
|
agent may register per session, so the autostart starts exactly one and the
|
||||||
|
comment beside it says how to get the stock prompt back. A session with no
|
||||||
|
working agent can still authenticate from a terminal, which is the escape hatch
|
||||||
|
that made this safe to attempt at all.
|
||||||
|
|||||||
Executable
+110
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# The authentication prompt asks for the password to everything, so the rules
|
||||||
|
# here are not style, they are the reason it was safe to write at all.
|
||||||
|
#
|
||||||
|
# 1. The agent process must never handle a password. It talks to polkitd and
|
||||||
|
# hands the shell a request; the shell talks to the setuid helper.
|
||||||
|
# 2. The password reaches the helper on STDIN and nowhere else. argv is
|
||||||
|
# world-readable through /proc, so an argument is a broadcast.
|
||||||
|
# 3. The prompt takes EXCLUSIVE keyboard focus. A password field that lets
|
||||||
|
# keystrokes through to the window behind it is a keylogger with extra
|
||||||
|
# steps.
|
||||||
|
# 4. The request file carries a one-time capability and is created 0600.
|
||||||
|
# 5. The stock agent stays installed. Only one agent may register per session,
|
||||||
|
# so a broken replacement must have something to fall back to.
|
||||||
|
#
|
||||||
|
# Static, plus a live check of the runtime directory. It never authenticates.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
agent="$repo_dir/config/dot/quickshell/scripts/panama-polkit-agent"
|
||||||
|
service="$repo_dir/config/dot/quickshell/services/Polkit.qml"
|
||||||
|
prompt="$repo_dir/config/dot/quickshell/modules/polkit/PolkitPrompt.qml"
|
||||||
|
autostart="$repo_dir/config/dot/hypr/autostart.lua"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'polkit agent contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
for path in "$agent" "$service" "$prompt" "$autostart"; do
|
||||||
|
[[ -r "$path" ]] || fail "missing $path"
|
||||||
|
done
|
||||||
|
[[ -x "$agent" ]] || fail 'the agent is not executable'
|
||||||
|
|
||||||
|
# ── 1. The agent never touches a password ───────────────────────────────────
|
||||||
|
# Comments and docstrings are stripped first: this file EXPLAINS at length why
|
||||||
|
# it must not touch a password, and an earlier version of this check failed on
|
||||||
|
# the explanation rather than on any behaviour.
|
||||||
|
agent_code="$(python3 - "$agent" <<'STRIP'
|
||||||
|
import ast, sys
|
||||||
|
source = open(sys.argv[1], encoding="utf-8").read()
|
||||||
|
tree = ast.parse(source)
|
||||||
|
# Drop every docstring, then print what is left as code.
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||||
|
if (node.body and isinstance(node.body[0], ast.Expr)
|
||||||
|
and isinstance(node.body[0].value, ast.Constant)
|
||||||
|
and isinstance(node.body[0].value.value, str)):
|
||||||
|
node.body.pop(0)
|
||||||
|
print(ast.unparse(tree))
|
||||||
|
STRIP
|
||||||
|
)"
|
||||||
|
[[ -n "$agent_code" ]] || fail 'could not read the agent source'
|
||||||
|
grep -qE 'polkit-agent-helper|PAM_PROMPT|password' <<<"$agent_code" \
|
||||||
|
&& fail 'the agent process handles passwords; that belongs on the shell side only'
|
||||||
|
|
||||||
|
# ── 2. The password goes to the helper on stdin ─────────────────────────────
|
||||||
|
grep -q 'stdinEnabled: true' "$service" \
|
||||||
|
|| fail 'the helper is spawned without a writable stdin, so the password has nowhere to go'
|
||||||
|
grep -q 'helper.write(root.pendingSecret' "$service" \
|
||||||
|
|| fail 'the password is not written to the helper stdin'
|
||||||
|
# It must never appear in the argument list.
|
||||||
|
helper_command="$(grep -n 'helper.command' "$service" || true)"
|
||||||
|
grep -qE 'pendingSecret|password' <<<"$helper_command" \
|
||||||
|
&& fail 'the password appears in the helper command line'
|
||||||
|
grep -q 'root.pendingSecret = "";' "$service" \
|
||||||
|
|| fail 'the password is never cleared after being handed over'
|
||||||
|
|
||||||
|
# ── 3. The prompt owns the keyboard ─────────────────────────────────────────
|
||||||
|
grep -q 'WlrKeyboardFocus.Exclusive' "$prompt" \
|
||||||
|
|| fail 'the prompt does not take exclusive keyboard focus'
|
||||||
|
grep -q 'field.text = ""' "$prompt" \
|
||||||
|
|| fail 'the password field is not cleared when the prompt closes'
|
||||||
|
|
||||||
|
# ── 4. The request file is private ──────────────────────────────────────────
|
||||||
|
grep -q '0o600' "$agent" || fail 'the request file is not created 0600'
|
||||||
|
grep -q '0o700' "$agent" || fail 'the runtime directory is not private'
|
||||||
|
grep -qE 'O_CREAT \| os\.O_EXCL' "$agent" \
|
||||||
|
|| fail 'the request file is not created exclusively, so it could be pre-created by someone else'
|
||||||
|
|
||||||
|
# ── 5. A fallback exists ────────────────────────────────────────────────────
|
||||||
|
grep -q 'hyprpolkitagent' "$autostart" \
|
||||||
|
|| fail 'nothing records how to get the stock agent back if this one fails'
|
||||||
|
command -v rpm >/dev/null 2>&1 && {
|
||||||
|
rpm -q hyprpolkitagent >/dev/null 2>&1 \
|
||||||
|
|| fail 'the stock agent is no longer installed, so there is nothing to fall back to'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only one agent may be started by the session. Lua comments are excluded: the
|
||||||
|
# file documents how to restore the stock agent, and that sentence is not a
|
||||||
|
# command the session runs.
|
||||||
|
started="$(grep -vE '^\s*--' "$autostart" | grep -E 'systemctl --user start .*polkit' | head -1)"
|
||||||
|
[[ -n "$started" ]] || fail 'the session starts no polkit agent at all'
|
||||||
|
grep -q 'panama-polkit-agent' <<<"$started" \
|
||||||
|
|| fail 'the session does not start the Panama agent'
|
||||||
|
grep -q 'hyprpolkitagent.service' <<<"$started" \
|
||||||
|
&& fail 'the session starts both agents; the second to register will fail'
|
||||||
|
|
||||||
|
# ── Live: the runtime directory is private, if it exists ────────────────────
|
||||||
|
runtime="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama-polkit"
|
||||||
|
if [[ -d "$runtime" ]]; then
|
||||||
|
mode="$(stat -c '%a' "$runtime")"
|
||||||
|
[[ "$mode" == "700" ]] || fail "the runtime directory is mode $mode, not 700"
|
||||||
|
leaked="$(find "$runtime" -type f ! -perm 600 2>/dev/null | head -1)"
|
||||||
|
[[ -z "$leaked" ]] || fail "a request file is readable beyond its owner: $leaked"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'polkit agent contract: PASS (password never leaves the shell, prompt owns the keyboard, fallback intact)\n'
|
||||||
Reference in New Issue
Block a user