Files
Bolt-Pet/bolt_pet/sudo_askpass.py
T
2026-07-23 07:55:43 -06:00

146 lines
5.7 KiB
Python

"""Graphical password prompts for server-relayed `sudo` commands.
The pet has no terminal. When the server relays something like
`sudo apt update`, sudo tries to read a password from a tty, finds none (or
finds the terminal the pet was launched from, which you're not looking at),
and the command fails with no way to answer it.
sudo's own answer to this is SUDO_ASKPASS: with `-A`, it runs a helper
program and reads the password from the helper's stdout instead of a tty.
Any GUI prompt that prints what was typed works, so this module finds a real
askpass binary if one is installed and otherwise generates a one-line wrapper
around zenity/kdialog, which every desktop has one of.
Worth being clear about what this changes: the prompt is a *feature*, not
just plumbing. Server-relayed commands already run as your desktop user (see
server_client.run_local_command); this lets them ask to run as root, and the
dialog is the only thing standing between "Bolt decided to run sudo" and it
happening. Leave SUDO_ASKPASS_PROMPT on, and read the dialogs.
The parts that decide *what* to run are pure functions so they're tested
without a display, a password, or a working sudo.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
from pathlib import Path
from typing import Callable, Optional
from . import config
# Real askpass binaries, in preference order. These are purpose-built for
# this (they grab the keyboard, hide the input, and don't leave the password
# in a process argument), so they win over a generated wrapper.
_KNOWN_HELPERS = (
"/usr/bin/ssh-askpass",
"/usr/lib/ssh/ssh-askpass",
"/usr/lib/openssh/gnome-ssh-askpass3",
"/usr/lib/openssh/gnome-ssh-askpass",
"/usr/libexec/openssh/ssh-askpass",
"/usr/bin/ksshaskpass",
"/usr/bin/lxqt-openssh-askpass",
)
# Dialog tools we can wrap when no askpass binary exists. Each must print the
# typed password to stdout and nothing else.
_WRAPPABLE = {
"zenity": '{tool} --password --title="Bolt" --text="$1" 2>/dev/null',
"kdialog": '{tool} --password "$1" --title "Bolt" 2>/dev/null',
}
# `sudo` at the start of the command or right after a shell separator, not
# already carrying a flag. Deliberately conservative: a `sudo` inside a
# quoted string or a heredoc is left alone, because rewriting it could change
# what the command means.
_SUDO = re.compile(r"(^|[;&|]\s*|\n\s*)(sudo)(\s+)(?!-)")
def add_askpass_flag(command: str) -> str:
"""Insert `-A` after each bare `sudo`, so it prompts through the helper
instead of a tty. Commands that already pass a flag (`sudo -n`, `sudo -A`,
`sudo -u bob`) are left exactly as they are — the caller was explicit."""
return _SUDO.sub(r"\1\2 -A\3", command or "")
def needs_password_prompt(command: str) -> bool:
"""True if *command* has a `sudo` that might sit waiting on a dialog.
Used to give those commands a longer timeout — 30 seconds is fine for a
shell command and nowhere near enough for a human to notice a window,
read it, and type a password."""
return bool(_SUDO.search(command or ""))
def helper_script(tool_path: str) -> str:
"""The wrapper script for a dialog *tool_path*. sudo passes its prompt
("[sudo] password for maji:") as $1, which is worth showing — it names
the user the password is for."""
name = Path(tool_path).name
body = _WRAPPABLE[name].format(tool=tool_path)
return f"#!/bin/sh\n# Generated by bolt-pet. Prints the typed password on stdout for sudo -A.\n{body}\n"
def _default_cache_dir() -> Path:
base = os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")
return Path(base) / "bolt-pet"
def find_helper(
configured: str = None,
is_executable: Callable[[str], bool] = None,
which: Callable[[str], Optional[str]] = None,
cache_dir: Path = None,
write: bool = True,
) -> Optional[str]:
"""Path to an askpass helper, or None if the desktop has nothing we can
use. Order: whatever SUDO_ASKPASS_HELPER names, then a real askpass
binary, then a generated wrapper around zenity/kdialog.
The lookups are injectable so the resolution order is testable on a box
with a different set of these installed than yours."""
configured = config.SUDO_ASKPASS_HELPER if configured is None else configured
is_executable = is_executable or (lambda path: os.path.isfile(path) and os.access(path, os.X_OK))
which = which or shutil.which
if configured:
return configured if is_executable(configured) else None
for candidate in _KNOWN_HELPERS:
if is_executable(candidate):
return candidate
for tool in _WRAPPABLE:
tool_path = which(tool)
if not tool_path:
continue
if not write:
return tool_path
return _write_wrapper(tool_path, cache_dir or _default_cache_dir())
return None
def _write_wrapper(tool_path: str, cache_dir: Path) -> Optional[str]:
"""Drop the wrapper script somewhere sudo can execute it. Mode 0700: it
isn't secret, but it's a thing that pops up a password box, so nobody
else on the machine gets to edit it."""
try:
cache_dir.mkdir(parents=True, exist_ok=True)
script = cache_dir / "askpass.sh"
source = helper_script(tool_path)
if not script.exists() or script.read_text(encoding="utf-8") != source:
script.write_text(source, encoding="utf-8")
script.chmod(stat.S_IRWXU)
return str(script)
except Exception:
return None # no prompt is better than a crashed command relay
def environment(helper: str, base: dict = None) -> dict:
"""The subprocess environment with SUDO_ASKPASS pointed at *helper*."""
env = dict(os.environ if base is None else base)
env["SUDO_ASKPASS"] = helper
return env