@pi-archimedes/sudo 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,81 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { credentialCache } from "./cache.js";
4
+ import { splitCommandIntoArgv } from "./argv-split.js";
5
+ import { DEFAULT_SUDO_CONFIG, loadSudoConfig, saveSudoConfig, type SudoConfig } from "./config.js";
6
+ import { handleBashToolCall, isInteractiveSudoAttempt, type GuardResult } from "./guard.js";
7
+ import { confirmCommand, maskLine, promptForPassword } from "./prompt.js";
8
+ import { buildSudoArgv, createSudoExecTool, registerSudoTool, scrubSecret, type SudoSpawner } from "./tool.js";
9
+
10
+ /**
11
+ * Registers `sudo_exec` (Task 1), the bash-sudo guard (Task 2) — a
12
+ * `pi.on("tool_call")` veto that blocks interactive `sudo` issued through
13
+ * the built-in bash tool — and the session lifecycle + `/sudo` command
14
+ * (Task 3). ALL registrations happen at the top level of this function,
15
+ * never inside a session handler (nested registration accumulates on
16
+ * `/reload`).
17
+ */
18
+ export function registerSudo(pi: ExtensionAPI, options?: { spawner?: SudoSpawner }): void {
19
+ registerSudoTool(pi, options);
20
+ // Active veto: the guard is pure (isInteractiveSudoAttempt) and exported for
21
+ // direct testing; handleBashToolCall only vets — it never mutates event.input.
22
+ pi.on("tool_call", (event) => handleBashToolCall(event));
23
+
24
+ // Session lifecycle: the credential is a module singleton and must never
25
+ // outlive a session — cleared at every session boundary (ADR 0010).
26
+ pi.on("session_start", () => {
27
+ credentialCache.clear();
28
+ });
29
+ pi.on("session_shutdown", () => {
30
+ credentialCache.clear();
31
+ });
32
+
33
+ // /sudo — manage the credential cache (v1: state + forget, JSON-only config).
34
+ pi.registerCommand("sudo", {
35
+ description: "Manage the sudo credential cache",
36
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
37
+ const args = _args.trim();
38
+ if (args === "forget") {
39
+ credentialCache.clear();
40
+ ctx.ui.notify("Sudo credential cleared.", "info");
41
+ return;
42
+ }
43
+ if (args !== "") {
44
+ ctx.ui.notify("Unknown /sudo subcommand. Usage: /sudo, /sudo forget", "info");
45
+ return;
46
+ }
47
+ // Bare: report state. get() is expiry-aware, so a stale entry reads
48
+ // as "not cached" (it re-prompts on next use) without being dropped.
49
+ ctx.ui.notify(
50
+ credentialCache.get() !== null
51
+ ? "Sudo credential cached."
52
+ : "No sudo credential cached.",
53
+ "info",
54
+ );
55
+ },
56
+ });
57
+ }
58
+
59
+ // Exports for Tasks 2–3 (guard wiring, /sudo command + session lifecycle)
60
+ export {
61
+ credentialCache,
62
+ splitCommandIntoArgv,
63
+ buildSudoArgv,
64
+ scrubSecret,
65
+ confirmCommand,
66
+ promptForPassword,
67
+ maskLine,
68
+ loadSudoConfig,
69
+ saveSudoConfig,
70
+ DEFAULT_SUDO_CONFIG,
71
+ createSudoExecTool,
72
+ handleBashToolCall,
73
+ isInteractiveSudoAttempt,
74
+ };
75
+ export type { SudoConfig, SudoSpawner, GuardResult };
76
+
77
+ // Standalone entry — pi's loader requires a default export (see the
78
+ // image-paste fix precedent). Under meta, the named exports are used.
79
+ export default function (pi: ExtensionAPI): void {
80
+ registerSudo(pi);
81
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Session lifecycle + /sudo command tests.
3
+ *
4
+ * The credential cache is a module singleton that must NEVER outlive a
5
+ * session: session_shutdown drops it from memory, session_start resets it
6
+ * for a fresh session, and `/sudo forget` clears it explicitly (ADR 0010).
7
+ * Handlers must be registered at the TOP level of registerSudo — these
8
+ * fake-`pi` tests double-check that, because a wrapper-building handler
9
+ * (nested registration) would not show up under the expected event key.
10
+ *
11
+ * I/O is mocked out: fake `pi` records `on()`/`registerCommand()` calls,
12
+ * fake ctx carries a `ui.notify` spy.
13
+ */
14
+ import { afterEach, describe, expect, it } from "vitest";
15
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
+ import { credentialCache } from "./cache.js";
17
+ import { registerSudo } from "./index.js";
18
+
19
+ interface FakePi {
20
+ pi: ExtensionAPI;
21
+ /** event name → handlers in registration order */
22
+ handlers: Map<string, Function[]>;
23
+ commands: Map<string, { description: string; handler: Function }>;
24
+ tools: unknown[];
25
+ }
26
+
27
+ function fakePi(): FakePi {
28
+ const handlers = new Map<string, Function[]>();
29
+ const commands = new Map<string, { description: string; handler: Function }>();
30
+ const tools: unknown[] = [];
31
+ const pi = {
32
+ on(event: string, fn: Function) {
33
+ const list = handlers.get(event) ?? [];
34
+ list.push(fn);
35
+ handlers.set(event, list);
36
+ },
37
+ registerTool(tool: unknown) {
38
+ tools.push(tool);
39
+ },
40
+ registerCommand(name: string, def: { description: string; handler: Function }) {
41
+ commands.set(name, def);
42
+ },
43
+ } as unknown as ExtensionAPI;
44
+ return { pi, handlers, commands, tools };
45
+ }
46
+
47
+ function fakeCtx() {
48
+ const notified: string[] = [];
49
+ const ctx = {
50
+ ui: {
51
+ notify(message: string, _kind?: string) {
52
+ notified.push(message);
53
+ },
54
+ },
55
+ } as unknown as ExtensionCommandContext;
56
+ return { ctx, notified };
57
+ }
58
+
59
+ function lastHandler(fake: FakePi, event: string): Function {
60
+ const list = fake.handlers.get(event);
61
+ expect(list, `no handler registered for "${event}"`).toBeDefined();
62
+ const fn = list![list!.length - 1];
63
+ expect(fn, `last handler for "${event}" must be a function`).toBeTypeOf("function");
64
+ if (typeof fn !== "function") throw new Error(`missing handler for "${event}"`);
65
+ return fn;
66
+ }
67
+
68
+ describe("registerSudo — session lifecycle + /sudo command", () => {
69
+ afterEach(() => {
70
+ credentialCache.clear();
71
+ });
72
+
73
+ it("registers session_shutdown and session_start at the top level, both clearing the credential cache", () => {
74
+ const fake = fakePi();
75
+ registerSudo(fake.pi);
76
+
77
+ // Top-level registration (no accumulation on /reload).
78
+ expect(lastHandler(fake, "session_shutdown")).toBeTypeOf("function");
79
+ expect(lastHandler(fake, "session_start")).toBeTypeOf("function");
80
+
81
+ // Sentinel entry survives until either handler runs.
82
+ credentialCache.set("hunter2", 900_000);
83
+ expect(credentialCache.get()).not.toBeNull();
84
+
85
+ lastHandler(fake, "session_shutdown")(undefined, undefined);
86
+ expect(credentialCache.get()).toBeNull();
87
+
88
+ // A fresh session must not inherit a credential either.
89
+ credentialCache.set("hunter2", 900_000);
90
+ expect(credentialCache.get()).not.toBeNull();
91
+
92
+ lastHandler(fake, "session_start")(undefined, undefined);
93
+ expect(credentialCache.get()).toBeNull();
94
+ });
95
+
96
+ it("registers the sudo_exec tool whose parameters are exactly {command, reason, timeoutMs}", () => {
97
+ const fake = fakePi();
98
+ registerSudo(fake.pi);
99
+
100
+ expect(fake.tools).toHaveLength(1);
101
+ const tool = fake.tools[0] as {
102
+ name: string;
103
+ parameters: { properties: Record<string, unknown>; required?: string[] };
104
+ };
105
+ expect(tool.name).toBe("sudo_exec");
106
+ expect(Object.keys(tool.parameters.properties).sort()).toEqual(["command", "reason", "timeoutMs"]);
107
+ expect(tool.parameters.required?.sort()).toEqual(["command", "reason"]);
108
+ });
109
+
110
+ it("registers a /sudo command whose forget subcommand clears the credential cache", async () => {
111
+ const fake = fakePi();
112
+ registerSudo(fake.pi);
113
+
114
+ const def = fake.commands.get("sudo");
115
+ expect(def).toBeDefined();
116
+ expect(def!.description).toBe("Manage the sudo credential cache");
117
+
118
+ credentialCache.set("hunter2", 900_000);
119
+ expect(credentialCache.get()).not.toBeNull();
120
+
121
+ const { ctx, notified } = fakeCtx();
122
+ await def!.handler("forget", ctx);
123
+
124
+ expect(credentialCache.get()).toBeNull();
125
+ expect(notified).toEqual(["Sudo credential cleared."]);
126
+ });
127
+
128
+ it("responds to an unknown /sudo subcommand with usage info — no exception, cache unchanged", async () => {
129
+ const fake = fakePi();
130
+ registerSudo(fake.pi);
131
+ const def = fake.commands.get("sudo")!;
132
+
133
+ credentialCache.set("hunter2", 900_000);
134
+ const { ctx, notified } = fakeCtx();
135
+ await expect(def.handler("noop", ctx)).resolves.toBeUndefined(); // must not throw
136
+
137
+ expect(notified).toEqual(["Unknown /sudo subcommand. Usage: /sudo, /sudo forget"]);
138
+ expect(credentialCache.get()).not.toBeNull(); // live credential untouched
139
+ });
140
+
141
+ it("bare /sudo reports the cache state (expiry-aware) and does not clear a live credential", async () => {
142
+ const fake = fakePi();
143
+ registerSudo(fake.pi);
144
+ const def = fake.commands.get("sudo")!;
145
+
146
+ // Not cached.
147
+ const notCached = fakeCtx();
148
+ await def.handler("", notCached.ctx);
149
+ expect(notCached.notified).toEqual(["No sudo credential cached."]);
150
+
151
+ // Cached (live entry).
152
+ credentialCache.set("hunter2", 900_000);
153
+ const cached = fakeCtx();
154
+ await def.handler("", cached.ctx);
155
+ expect(cached.notified).toEqual(["Sudo credential cached."]);
156
+ expect(credentialCache.get()).not.toBeNull();
157
+
158
+ // Expired entry reads as not-cached (get() is expiry-aware).
159
+ credentialCache.set("hunter2", 1);
160
+ await new Promise((r) => setTimeout(r, 5));
161
+ const expired = fakeCtx();
162
+ await def.handler("", expired.ctx);
163
+ expect(expired.notified).toEqual(["No sudo credential cached."]);
164
+ });
165
+ });
@@ -0,0 +1,149 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { confirmCommand, maskLine, promptForPassword } from "./prompt.js";
4
+
5
+ // ── minimal ui.custom fakes ──────────────────────────────────────────────────
6
+
7
+ interface MaskedComponent {
8
+ handleInput(data: string): void;
9
+ render(width: number): string[];
10
+ }
11
+
12
+ interface UnmaskedFactory {
13
+ (
14
+ tui: { requestRender(): void },
15
+ theme: { fg: (token: string, text: string) => string },
16
+ keybindings: unknown,
17
+ done: (value: string) => void,
18
+ ): { handleInput(data: string): void; render(width: number): string[]; focused: boolean };
19
+ }
20
+
21
+ function maskedPromptUi(): { ctx: ExtensionContext; component(): MaskedComponent } {
22
+ let active: MaskedComponent | undefined;
23
+ const ui = {
24
+ custom: (factory: UnmaskedFactory): Promise<string> =>
25
+ new Promise<string>((resolve) => {
26
+ active = factory({ requestRender: () => {} }, { fg: (_token, text) => text }, {}, resolve);
27
+ }),
28
+ } as unknown as ExtensionContext["ui"];
29
+ const ctx = { mode: "tui" as const, hasUI: true, ui } as unknown as ExtensionContext;
30
+ return { ctx, component: () => active as MaskedComponent };
31
+ }
32
+
33
+ const headlessCtx = (mode: string): ExtensionContext =>
34
+ ({ mode, hasUI: mode === "rpc", ui: {} }) as unknown as ExtensionContext;
35
+
36
+ // ── maskLine ─────────────────────────────────────────────────────────────────
37
+
38
+ describe("maskLine", () => {
39
+ it("returns an empty string for an empty buffer", () => {
40
+ expect(maskLine("")).toBe("");
41
+ });
42
+
43
+ it("renders one • per character and never leaks the raw value", () => {
44
+ for (const secret of ["s", "supersecret", "pässword123"]) {
45
+ expect(maskLine(secret)).toBe("•".repeat(secret.length));
46
+ expect(maskLine(secret)).not.toContain(secret);
47
+ }
48
+ });
49
+ });
50
+
51
+ // ── headless block ────────────────────────────────────────────────────────────
52
+
53
+ describe("promptForPassword — headless block", () => {
54
+ it("rejects when ctx.mode is json", async () => {
55
+ await expect(promptForPassword(headlessCtx("json"))).rejects.toThrow();
56
+ });
57
+
58
+ it("rejects in print mode", async () => {
59
+ await expect(promptForPassword(headlessCtx("print"))).rejects.toThrow();
60
+ });
61
+
62
+ it("rejects in rpc mode (hasUI is true there, but custom components need a TUI)", async () => {
63
+ const ctx = headlessCtx("rpc");
64
+ expect(ctx.hasUI).toBe(true);
65
+ await expect(promptForPassword(ctx)).rejects.toThrow();
66
+ });
67
+ });
68
+
69
+ // ── confirmCommand ─────────────────────────────────────────────────────────
70
+
71
+ describe("confirmCommand", () => {
72
+ function confirmCtx(answer: boolean) {
73
+ const confirm = vi.fn(async (_title: string, _message: string) => answer);
74
+ const ctx = { mode: "tui" as const, hasUI: true, ui: { confirm } } as unknown as ExtensionContext;
75
+ return { ctx, confirm };
76
+ }
77
+
78
+ it("resolves true when the user confirms", async () => {
79
+ const { ctx, confirm } = confirmCtx(true);
80
+ await expect(confirmCommand(ctx, "apt install ripgrep", "install ripgrep")).resolves.toBe(true);
81
+ expect(confirm).toHaveBeenCalledTimes(1);
82
+ });
83
+
84
+ it("resolves false when the user declines", async () => {
85
+ const { ctx, confirm } = confirmCtx(false);
86
+ await expect(confirmCommand(ctx, "reboot", "retry service")).resolves.toBe(false);
87
+ expect(confirm).toHaveBeenCalledTimes(1);
88
+ });
89
+
90
+ it("shows the exact command and reason in the confirmation message", async () => {
91
+ const { ctx, confirm } = confirmCtx(true);
92
+ await confirmCommand(ctx, "systemctl restart 'open rest api'", "service keeps wedging");
93
+
94
+ expect(confirm).toHaveBeenCalledTimes(1);
95
+ const [title, message] = confirm.mock.calls[0] as [string, string];
96
+ expect(title).toMatch(/confirm privileged command/i);
97
+ expect(message).toContain(`$ systemctl restart 'open rest api'`);
98
+ expect(message).toContain("service keeps wedging");
99
+ expect(message).toMatch(/elevated privileges/i);
100
+ });
101
+ });
102
+
103
+ describe("promptForPassword — masked component", () => {
104
+ it("buffers printable chars, applies Backspace, resolves on Enter, and never renders raw chars", async () => {
105
+ const { ctx, component } = maskedPromptUi();
106
+ const promise = promptForPassword(ctx);
107
+
108
+ const c = component();
109
+ for (const ch of ["s", "e", "c", "r", "e", "t"]) c.handleInput(ch);
110
+ c.handleInput("\x7f"); // backspace drops the last char
111
+
112
+ const renderedText = c.render(120).join("\n");
113
+ expect(renderedText).toContain("•••••"); // 5 chars typed after backspace
114
+ expect(renderedText).not.toContain("secre");
115
+ expect(renderedText).not.toContain("secret");
116
+
117
+ c.handleInput("\r"); // enter confirms
118
+ await expect(promise).resolves.toBe("secre");
119
+ });
120
+
121
+ it("ignores non-printable control characters", async () => {
122
+ const { ctx, component } = maskedPromptUi();
123
+ const promise = promptForPassword(ctx);
124
+
125
+ const c = component();
126
+ c.handleInput("\x01"); // control char — not a printable
127
+ c.handleInput("a");
128
+ c.handleInput("\r");
129
+ await expect(promise).resolves.toBe("a");
130
+ });
131
+
132
+ it("resolves with an empty string on Esc (cancellation)", async () => {
133
+ const { ctx, component } = maskedPromptUi();
134
+ const promise = promptForPassword(ctx);
135
+
136
+ const c = component();
137
+ c.handleInput("x");
138
+ c.handleInput("y");
139
+ c.handleInput("\x1b"); // escape
140
+ await expect(promise).resolves.toBe("");
141
+ });
142
+
143
+ it("resolves with an empty string when Enter is pressed with no input", async () => {
144
+ const { ctx, component } = maskedPromptUi();
145
+ const promise = promptForPassword(ctx);
146
+ component().handleInput("\r");
147
+ await expect(promise).resolves.toBe("");
148
+ });
149
+ });
package/src/prompt.ts ADDED
@@ -0,0 +1,99 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+
4
+ /**
5
+ * Render one `•` per typed character — pure, so the masking itself is
6
+ * unit-testable without a TUI. The raw buffer must never appear in
7
+ * rendered output.
8
+ */
9
+ export function maskLine(buffer: string): string {
10
+ return "•".repeat(buffer.length);
11
+ }
12
+
13
+ /**
14
+ * Prompt for the sudo password through a masked `ui.custom` component
15
+ * (ask's picker.ts pattern, extended for a text field).
16
+ *
17
+ * Hard requirement (ADR 0010): the password ONLY ever comes from this
18
+ * masked interactive UI — never from a tool parameter. Headless sessions
19
+ * (json / print, RPC, subagent children) have no TUI surface, so we reject
20
+ * with a clear error rather than prompt. `ctx.mode === "tui"` is the
21
+ * precise gate: `hasUI` is also true in RPC mode, where custom components
22
+ * cannot run.
23
+ *
24
+ * Resolves to the typed password, or `""` when the user cancels (Esc)
25
+ * or confirms with an empty buffer — callers treat `""` as cancellation.
26
+ */
27
+ export function promptForPassword(ctx: ExtensionContext, command?: string, reason?: string): Promise<string> {
28
+ if (ctx.mode !== "tui") {
29
+ return Promise.reject(
30
+ new Error(
31
+ "sudo_exec can only prompt for a password in an interactive session (TUI). Headless/RPC/subagent sessions have no masked input surface.",
32
+ ),
33
+ );
34
+ }
35
+
36
+ return ctx.ui.custom<string>((tui, theme, _keybindings, done) => {
37
+ let buffer = "";
38
+
39
+ const isPrintable = (data: string): boolean => {
40
+ if (data.length !== 1) return false;
41
+ const code = data.codePointAt(0);
42
+ return code !== undefined && code >= 0x20 && code !== 0x7f;
43
+ };
44
+
45
+ const render = (width: number): string[] => {
46
+ const lines: string[] = [];
47
+ lines.push(truncateToWidth(theme.fg("warning", " ⚠ sudo — enter your password (input is masked)"), width));
48
+ if (command) lines.push(truncateToWidth(theme.fg("text", ` $ ${command}`), width));
49
+ if (reason) lines.push(truncateToWidth(theme.fg("muted", ` ${reason}`), width));
50
+ lines.push("");
51
+ const masked = maskLine(buffer);
52
+ lines.push(
53
+ truncateToWidth(` password: ${theme.fg("accent", masked)}${masked.length > 0 ? "" : theme.fg("dim", "(hidden)")}`, width),
54
+ );
55
+ lines.push(theme.fg("dim", " Enter confirm · Esc cancel · Backspace delete"));
56
+ return lines;
57
+ };
58
+
59
+ const handleInput = (data: string): void => {
60
+ if (matchesKey(data, Key.ctrl("c")) || matchesKey(data, Key.escape)) {
61
+ done("");
62
+ return;
63
+ }
64
+ if (matchesKey(data, Key.enter)) {
65
+ done(buffer);
66
+ return;
67
+ }
68
+ if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
69
+ buffer = buffer.slice(0, -1);
70
+ tui.requestRender();
71
+ return;
72
+ }
73
+ if (isPrintable(data)) {
74
+ buffer += data;
75
+ tui.requestRender();
76
+ return;
77
+ }
78
+ // everything else (alt-sequences, other control chars): ignore
79
+ };
80
+
81
+ return {
82
+ focused: true,
83
+ render,
84
+ invalidate: () => {},
85
+ handleInput,
86
+ };
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Display the exact privileged command and the human-readable reason to
92
+ * the user and require an explicit confirmation BEFORE any credential is
93
+ * requested. Declining returns false — the tool must fail without
94
+ * prompting for a password.
95
+ */
96
+ export async function confirmCommand(ctx: ExtensionContext, command: string, reason: string): Promise<boolean> {
97
+ const message = ["This will run with elevated privileges (password required until cached):", "", `$ ${command}`, "", `Reason: ${reason}`].join("\n");
98
+ return ctx.ui.confirm("Confirm privileged command", message);
99
+ }