// pi side of the secret request (ADR 0002): the `pi-secret-run` launcher, the check that a // request really comes from the root broker, and the text of the secret request modal. // Wire format, over the same private socket and HMAC as the sudo flow (protocol.ts): // broker -> pi : { type: "secret_request", id, pid, version, argv, label, ticket, ts, signature } // pi -> broker : { type: "secret_response", id, secret, signature } // | { type: "secret_denied", id, reason, signature } // The broker side lives in broker/pi-secret-broker (Python, root-owned). // test/secret-interop.test.mjs keeps both sides in lockstep. // // Must stay a .ts module without pi imports (ADR 0001; tests load it straight from node). import { createHash } from "node:crypto"; import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; import { dirname } from "node:path"; import { resolveConsumerArgv, type SecretClassification } from "./allowlist.ts"; import { canonical, MAX_SKEW_MS, type Message, procInfo, readCmdline, shellQuote } from "./protocol.ts"; /** Bump together with PROTOCOL_VERSION in broker/pi-secret-broker. */ export const PROTOCOL_VERSION = 2; export const BROKER_PATH = "/usr/local/libexec/pi-sudo-prompt/pi-secret-broker"; export const BROKER_INTERPRETER = "/usr/bin/python3"; /** Root-owned directory where the broker proves a request is its own (see validateSecretRequest). */ export const TICKET_DIR = "/run/pi-sudo-prompt"; /** * The `pi-secret-run` command put on the PATH of the agent's bash and of `!`/`!!`. * It only refuses early with a clear reason and hands the call to the broker through sudo * (NOPASSWD rule for the broker only). Never falls back to anything without root. */ export function secretRunScript(options: { brokerPath?: string; installHint: string }): string { const broker = shellQuote(options.brokerPath ?? BROKER_PATH); const hint = shellQuote(options.installHint); return `#!/bin/sh # pi-secret-run — generated by pi-sudo-prompt (ADR 0002). BROKER=${broker} HINT=${hint} fail() { printf 'pi-secret-run: %s\\n' "$1" >&2; exit 1; } usage() { fail 'usage: pi-secret-run --label "" -- [args...]'; } [ "$(uname -s)" = Linux ] || fail "refusing: only Linux is supported (the broker relies on /proc)" [ -n "$PI_ASKPASS_SOCKET" ] && [ -n "$PI_ASKPASS_SECRET" ] || fail "refusing: no interactive pi TUI session to show the secret prompt (print/json/RPC modes cannot ask)" label= has_label= while [ $# -gt 0 ]; do case "$1" in --label) [ $# -ge 2 ] || usage; label=$2; has_label=1; shift 2 ;; --label=*) label=\${1#--label=}; has_label=1; shift ;; --) shift; break ;; *) usage ;; esac done [ -n "$has_label" ] && [ $# -gt 0 ] || usage [ -f "$BROKER" ] || fail "refusing: the root broker is not installed ($BROKER). Install it once with: $HINT" cmd=$(command -v -- "$1") || fail "refusing: command not found: $1" case "$cmd" in /*) ;; *) fail "refusing: $1 is not an executable file" ;; esac shift sudo -n -l -- "$BROKER" >/dev/null 2>&1 || fail "refusing: sudo cannot start the broker without a password (sudoers rule missing). Reinstall with: $HINT" exec sudo -n -- "$BROKER" --label "$label" -- "$cmd" "$@" `; } /** sha256 of the signed fields: what the broker writes in its ticket file. */ export function requestDigest(message: Message): string { return createHash("sha256").update(canonical(message)).digest("hex"); } /** File (and every directory above it) owned by root and not writable by group/others. */ function rootOwned(path: string): boolean { try { let current = realpathSync(path); for (;;) { const info = statSync(current); if (info.uid !== 0 || (info.mode & 0o022) !== 0) return false; if (current === "/") return true; current = dirname(current); } } catch { return false; } } /** * The broker proves the request is its own: before connecting it writes, as root, a file named * after the request's random ticket in TICKET_DIR (root-owned, not listable) holding the * request digest. A process of the user can neither create such a file nor learn the ticket of * a real broker, so it cannot reuse a running broker's pid to receive the secret. */ function ticketMatches(message: Message & { ticket: string }): boolean { try { const dir = lstatSync(TICKET_DIR); if (!dir.isDirectory() || dir.uid !== 0 || (dir.mode & 0o022) !== 0) return false; const path = `${TICKET_DIR}/${message.ticket}`; const file = lstatSync(path); if (!file.isFile() || file.uid !== 0 || file.nlink !== 1 || (file.mode & 0o022) !== 0) return false; return readFileSync(path, "utf8") === requestDigest(message); } catch { return false; } } export interface SecretRequest extends Message { id: string; pid: number; version: number; argv: string[]; label: string; ticket: string; ts: number; } /** * Server-side check of a secret request (signature already verified by socket.ts). Returns * undefined when it comes from the genuine root broker, otherwise the reason to deny. * Accepted tickets are added to `usedTickets` so a request is never honored twice. */ export function validateSecretRequest( message: Message, options: { expectedVersion: number; usedTickets: Set; reinstallHint?: string; now?: number }, ): string | undefined { const { expectedVersion, usedTickets } = options; if (Math.abs((options.now ?? Date.now()) - Number(message.ts)) > MAX_SKEW_MS) return "stale request"; if (message.version !== expectedVersion) { const hint = options.reinstallHint ? `: ${options.reinstallHint}` : ""; return `broker protocol version ${String(message.version)} does not match the extension (${expectedVersion}); reinstall the broker${hint}`; } const { pid, argv, label, ticket } = message; if ( !Number.isInteger(pid) || !Array.isArray(argv) || argv.length === 0 || !argv.every((arg) => typeof arg === "string") || typeof label !== "string" || typeof ticket !== "string" || !/^[0-9a-f]{64}$/.test(ticket) ) { return "malformed secret request"; } const info = procInfo(pid as number); if (!info || info.realUid !== 0 || info.effectiveUid !== 0) return "requester is not running as root (not the broker)"; const expected = [BROKER_INTERPRETER, "-I", BROKER_PATH, "--label", label, "--", ...(argv as string[])]; const cmdline = readCmdline(pid as number); // The broker resolves argv[0] before requesting consent and executes that same path. try { if (cmdline[5] === "--" && cmdline[6]) cmdline[6] = resolveConsumerArgv(cmdline.slice(6))[0]; } catch { return "cannot resolve the consumer executable"; } if (cmdline.length !== expected.length || cmdline.some((arg, i) => arg !== expected[i])) { return "requester is not the installed broker"; } if (!rootOwned(BROKER_PATH) || !rootOwned(BROKER_INTERPRETER)) return "broker or its interpreter is not root-owned"; if (usedTickets.has(ticket) || !ticketMatches(message as SecretRequest)) return "request not proven by the broker"; usedTickets.add(ticket); return undefined; } export const SECRET_MODAL_TITLE = "Pedido de segredo (pi-secret-run)"; export const UNVERIFIED_WARNING = "⚠ este comando pode mostrar ao agente o que você digitar"; /** Escapes C0/C1 control characters so text from the agent cannot drive the terminal. */ function visible(text: string): string { return text.replace(/[\x00-\x1f\x7f-\x9f]/g, (ch) => `\\x${ch.charCodeAt(0).toString(16).padStart(2, "0")}`); } export function displayArgv(argv: string[]): string { return visible(argv.map((arg) => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : shellQuote(arg))).join(" ")); } export interface ModalLine { text: string; style: "text" | "dim" | "error" | "label"; } /** Text of the secret request modal. Classification never verifies the agent's description. */ export function secretModalContent( request: { argv: string[]; label: string }, classification: SecretClassification = "unverified", ): { title: string; lines: ModalLine[] } { return { title: SECRET_MODAL_TITLE, lines: [ { text: "Comando (lido pelo broker root):", style: "dim" }, { text: displayArgv(request.argv), style: "text" }, { text: "", style: "text" }, { text: "Descrição do agente (não verificada):", style: "dim" }, { text: visible(request.label) || "(vazia)", style: "label" }, { text: "", style: "text" }, ...(classification === "allowed" ? [] : [{ text: UNVERIFIED_WARNING, style: "error" as const }]), ], }; }