UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

210 lines (209 loc) • 9.21 kB
import { type IncomingMessage, type ServerResponse } from "node:http"; import { type Json } from "./protocol.js"; /** * sudo with no terminal, answered from the phone. * * `sudo -A` runs `SUDO_ASKPASS` (`<bridge>/askpass`, a two-line script the * Hook installs) with its prompt as the argument and reads the password from * its stdout. The script runs the bundle's `askpass`, which asks the Hook over * agent.sock and holds the connection. The password must reach sudo and no * one else, so before asking the phone the Hook checks the whole chain * (`verifyAsker`): the asker is this Hook's node running its bundle with no * flags, its parent is the installed script, that script's parent is a sudo * running as root (euid 0, which no process of the owner's can fake), and the * asker's stdout is a pipe no other process of the owner's reads. The * connection is bound to that process too: on macOS its other end must be * held by the asker alone; on Linux the Hook writes the password straight into * the asker's stdout (`/proc/<pid>/fd/1`), so a connection that names another * process's pid gets nothing. It reads * that sudo's command line itself and pushes the request to the phone; `GET /v1/sudo` and the overview socket's `sudo` frame * list it. `POST /v1/sudo/answer` writes the password into the held * connection and forgets the request: the password is never logged, stored, * or kept past that write. Deny, a timeout, no reachable phone, or the asker * going away ends the request, and askpass exits non-zero, so sudo fails. */ export declare const SUDO_HOLD_MS: number; /** Held askpass requests at once; more are refused. */ export declare const MAX_SUDO_PENDING = 8; /** How long after a password is handed over the Hook waits to see whether * sudo asks again, before calling it accepted. */ export declare const SUDO_OUTCOME_MS: number; export type SudoOutcome = "accepted" | "rejected" | "unknown"; export declare const ASKPASS_FILE = "askpass"; export declare const askpassPath: () => string; export declare const askpassInstalled: () => boolean; /** SUDO_ASKPASS for a launch, when this computer's Hook installed its helper. */ export declare const askpassEnv: () => Record<string, string>; /** sudo runs SUDO_ASKPASS with no arguments of its own, so the helper is a * script: the node that installed the Hook, the current bundle, `askpass`. * It stays node's parent (no exec) so the Hook can see it in the chain, and * drops what would load other code into node. */ export declare function askpassScript(node: string, bundle: string): string; export declare function installAskpass(node: string, bundle: string): Promise<void>; export declare function removeAskpass(): Promise<void>; export interface SudoSession { source?: string; label?: string; server?: string; workspace?: string; tab?: string; pane?: string; } /** What the phone sees. Never carries a password. */ export interface SudoRequestView { /** `account` is whose password sudo asks for (the invoking user); `user` is who the command runs as. */ id: string; computer: string; command: string; account?: string; user?: string; cwd?: string; session?: SudoSession; askedAt: string; expiresAt: string; } /** What askpass gets: the password to print, word that the Hook wrote it into * askpass's stdout itself (Linux), or why not. */ export type SudoReply = { password: string; } | { delivered: true; } | { error: string; status: number; }; /** The command a sudo process will run, without sudo's own options, and the * user it runs as. Falls back to the whole line when there is no command * (`sudo -v`, `sudo -s`). */ export declare function sudoCommand(argv: string[]): { command: string; user?: string; }; export interface ProcessRecord { ppid: number; /** Effective user id. */ euid: number; /** Executable name (`comm`). */ name: string; /** Arguments, split on NULs on Linux and on spaces on macOS. */ argv: string[]; /** The whole command line as ps prints it. */ line: string; /** Environment names, where the platform shows them (Linux). */ env?: string[]; /** When it started (`ps -o lstart`), to tell it from a later process with its pid. */ started?: string; } /** One process's parent, effective uid, name and arguments. */ export declare function readProcess(pid: number): Promise<ProcessRecord | undefined>; /** Whether `pid`'s stdout is a pipe that no process of the owner's other than * `allowed` holds. sudo, running as root, is never visible here. */ export declare function stdoutReaders(pid: number, allowed: number[]): Promise<"sudo" | "other" | "unknown">; /** macOS: whether the other end of the Hook's connection `hookFd` is held by * `pid` and no other process of the owner's. */ export declare function connectionHolders(pid: number, hookFd: number | undefined): Promise<"asker" | "other" | "unknown">; /** Linux: a process's start time, to tell it from a later one with its pid. */ export declare function processStart(pid: number): Promise<string | undefined>; export interface AskerCheck { processes: (pid: number) => Promise<ProcessRecord | undefined>; stdout: (pid: number, allowed: number[]) => Promise<"sudo" | "other" | "unknown">; /** Whether the connection that asked belongs to the asker alone. */ connection: (pid: number) => Promise<"asker" | "other" | "unknown">; /** This Hook's node and the bundle paths askpass may run from. */ node: string; bundles: string[]; script: string; unprivilegedSudo?: boolean; } /** The chain from askpass up to sudo, or why it is refused. */ export declare function verifyAsker(pid: number, check: AskerCheck): Promise<{ sudo: ProcessRecord; sudoPid: number; } | { refused: string; }>; export interface SudoBrokerOptions { computer: () => string; push?: { readonly available: boolean; notifySudo(value: SudoRequestView): Promise<boolean>; }; /** Label and agent for the pane that asked, when the Hook can place it. */ describe?: (place: { server: string; workspace: string; tab: string; pane: string; }) => Promise<Pick<SudoSession, "source" | "label"> | undefined>; label?: (dispatchId: string) => Promise<string | undefined>; /** Reads a process, to see whether sudo is still running; tests replace it. */ processes?: (pid: number) => Promise<ProcessRecord | undefined>; /** The askpass chain check and how the password then reaches it; tests replace it. */ verify?: (pid: number, hookFd: number | undefined) => Promise<Verified | { refused: string; }>; holdMs?: number; outcomeMs?: number; /** Whose password sudo asks for; this Hook's user. */ account?: string; now?: () => number; } export interface Verified { sudo: ProcessRecord; sudoPid: number; deliver: (password: string) => Promise<SudoReply>; } export declare class SudoBroker { private options; private pending; private events; private watchers; private tries; constructor(options: SudoBrokerOptions); private get now(); list(): SudoRequestView[]; /** Overview sockets that asked for sudo frames. Each counts as a phone * that can answer while it is open. */ subscribe(listener: (requests: SudoRequestView[]) => void): () => void; private changed; /** An askpass call: checks the caller, builds the request, holds it until * an answer, a timeout, or `cancel` (the caller went away). */ ask(body: Json, respond: (reply: SudoReply) => void, hookFd?: number): Promise<{ cancel: () => void; }>; /** The phone's answer. Single use: the request is gone once answered. * `outcome` resolves to whether sudo took the password: rejected when the * same sudo asks again, accepted when it has not within `outcomeMs` (or * is still running then), unknown when it gave up or the Hook cannot tell. */ answer(id: string, answer: { password: string; } | { deny: true; }): false | { outcome: Promise<SudoOutcome | undefined>; }; private watchOutcome; close(): void; /** The agent.sock `/sudo` route. */ handle(req: IncomingMessage, res: ServerResponse): Promise<void>; } /** A password as sudo reads it from askpass: one line, nothing it would cut. */ export declare function sudoAnswer(data: Json): { id: string; answer: { password: string; } | { deny: true; }; outcome: boolean; }; export declare class SudoAnswerError extends Error { constructor(); } /** The bundle's `askpass` command, run by sudo through `<bridge>/askpass`. * Prints the password and exits 0, or explains on stderr and exits 1. */ export declare function askpass(prompt: string | undefined, env?: NodeJS.ProcessEnv): Promise<number>; /** `phren sudo <command...>`: `sudo -A` with this Hook's askpass, so the * password comes from the phone even with no terminal (a `!` command, an * agent's shell). Exits with sudo's status. */ export declare function runSudo(args: string[]): Promise<number>;