@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/tool.ts ADDED
@@ -0,0 +1,587 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type, type Static } from "typebox";
4
+
5
+ import { credentialCache } from "./cache.js";
6
+ import { splitCommandIntoArgv } from "./argv-split.js";
7
+ import { loadSudoConfig } from "./config.js";
8
+ import { confirmCommand, promptForPassword } from "./prompt.js";
9
+
10
+ // ── Schemas ──────────────────────────────────────────────────────────────────
11
+
12
+ const SudoExecParamsSchema = Type.Object({
13
+ command: Type.String({
14
+ description:
15
+ 'Exact command and arguments to run with elevated privileges, e.g. "apt install ripgrep". Do NOT include a leading \'sudo\'. Executed directly via argv — no shell: avoid pipes, redirects, &&, env assignments, or quotes-as-syntax; pass multiple args space-separated, quote only literal args.',
16
+ }),
17
+ reason: Type.String({
18
+ description: "Human-readable explanation of why this privileged command is needed, shown to the user before execution.",
19
+ }),
20
+ timeoutMs: Type.Optional(Type.Number({ description: "Optional timeout in milliseconds (default from config)." })),
21
+ });
22
+
23
+ export type SudoExecInput = Static<typeof SudoExecParamsSchema>;
24
+
25
+ export interface SudoExecDetails {
26
+ command: string;
27
+ reason: string;
28
+ exitCode: number;
29
+ stdout: string;
30
+ stderr: string;
31
+ error?: string;
32
+ }
33
+
34
+ // ── process plumbing ─────────────────────────────────────────────────────────
35
+
36
+ /** Structural subset of ChildProcess used by runSudo — injectable for tests. */
37
+ export interface SudoChild {
38
+ readonly pid?: number | undefined;
39
+ readonly stdin?: {
40
+ write(chunk: string): void;
41
+ end(): void;
42
+ /** Stream error registration — present on real child stdin; optional so fakes can omit it. */
43
+ on?(evt: "error", cb: (err: Error) => void): unknown;
44
+ } | null;
45
+ readonly stdout: { on(evt: string, cb: (data: Buffer) => void): unknown };
46
+ readonly stderr: { on(evt: string, cb: (data: Buffer) => void): unknown };
47
+ kill(signal?: NodeJS.Signals): boolean;
48
+ on(evt: "exit" | "close", cb: (code: number | null, signal?: NodeJS.Signals | null) => void): unknown;
49
+ on(evt: "error", cb: (err: Error) => void): unknown;
50
+ }
51
+
52
+ export type SudoSpawner = (command: string, args: string[], options?: { detached: boolean }) => SudoChild;
53
+
54
+ const defaultSpawner: SudoSpawner = (command, args, options) => spawn(command, args, options);
55
+
56
+ /** Short delay before the ONE retry of a transient group-kill failure. */
57
+ const GROUP_KILL_RETRY_DELAY_MS = 50;
58
+
59
+ function sleepMs(ms: number): Promise<void> {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+
63
+ /**
64
+ * Kill the command's ENTIRE process group — the child is spawned detached
65
+ * (own group), so a root process that forked further survives
66
+ * `child.kill()` alone. Group first: a DEFINITIVE ESRCH means the group no
67
+ * longer exists — the whole group is already dead, so that is complete
68
+ * success (no retry, no fallback). Only a non-ESRCH throw (transient /
69
+ * state-uncertain) waits one short tick and retries the group signal ONCE
70
+ * before falling back to the single-process kill; that last-ditch kill is
71
+ * swallowed if it throws too — the child is gone in that case.
72
+ */
73
+ async function killProcessTree(child: SudoChild): Promise<void> {
74
+ if (!child.pid) return;
75
+ try {
76
+ process.kill(-child.pid, "SIGKILL");
77
+ return;
78
+ } catch (err) {
79
+ if ((err as NodeJS.ErrnoException | undefined)?.code === "ESRCH") return; // group definitively dead — done
80
+ await sleepMs(GROUP_KILL_RETRY_DELAY_MS);
81
+ try {
82
+ process.kill(-child.pid, "SIGKILL");
83
+ } catch (err) {
84
+ if ((err as NodeJS.ErrnoException | undefined)?.code === "ESRCH") return; // retry confirmed the group is dead
85
+ try {
86
+ child.kill("SIGKILL");
87
+ } catch {
88
+ // even the single-process signal failed — the child is gone; nothing left to do
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ export interface SudoRunResult {
95
+ exitCode: number;
96
+ stdout: string;
97
+ stderr: string;
98
+ /** sudo printed its password prompt on stderr (normal for `sudo -S`). */
99
+ authPromptSeen: boolean;
100
+ /** stderr indicated the password was wrong — the credential is stale. */
101
+ authFailed: boolean;
102
+ /** the timeout fired and the child was SIGKILLed. */
103
+ timedOut: boolean;
104
+ /**
105
+ * Process-level failure under the hood (spawn `error` event, spawner
106
+ * threw, fatal stdin write). When set, `exitCode` is -1 and `authFailed`
107
+ * / `timedOut` are false — surface as a tool failure, NOT an auth
108
+ * failure, and keep the credential cache.
109
+ */
110
+ error?: string;
111
+ }
112
+
113
+ /** "ENOENT" et al. when the errno code is present, the error message otherwise. */
114
+ function errDetail(err: unknown): string {
115
+ if (err instanceof Error) {
116
+ const code = (err as NodeJS.ErrnoException).code;
117
+ return code ?? err.message;
118
+ }
119
+ return String(err);
120
+ }
121
+
122
+ /**
123
+ * Run `sudo -S <argv>`, writing the password to stdin only. `argv` is the
124
+ * FULL argv (command first, e.g. ["sudo", "-S", "apt", "update"] from
125
+ * buildSudoArgv); this splits the leading command off for the spawner. The
126
+ * password never appears in argv or env. Uses spawn (never exec) with
127
+ * `detached: true` so the command owns its process group; the timeout and
128
+ * the honoured externally-supplied abort signal kill the WHOLE group with
129
+ * SIGKILL (privileged descendants cannot survive).
130
+ */
131
+ export function runSudo(
132
+ commandArgv: string[],
133
+ password: string,
134
+ timeoutMs: number,
135
+ signal: AbortSignal | undefined,
136
+ spawner: SudoSpawner,
137
+ ): Promise<SudoRunResult> {
138
+ return new Promise<SudoRunResult>((resolve) => {
139
+ const [command, ...args] = commandArgv;
140
+ if (!command) {
141
+ resolve({ exitCode: -1, stdout: "", stderr: "no command in argv", authPromptSeen: false, authFailed: false, timedOut: false, error: "no command in argv" });
142
+ return;
143
+ }
144
+
145
+ let child: SudoChild;
146
+ try {
147
+ child = spawner(command, args, { detached: true });
148
+ } catch (err) {
149
+ // the spawner itself threw (platform/transport) — resolving the
150
+ // failed shape instead of letting the throw escape the promise
151
+ // executor, which would hand the tool an unhandled rejection.
152
+ resolve({ exitCode: -1, stdout: "", stderr: "", authPromptSeen: false, authFailed: false, timedOut: false, error: `failed to spawn ${command}: ${errDetail(err)}` });
153
+ return;
154
+ }
155
+
156
+ let stdout = "";
157
+ let stderr = "";
158
+ let timedOut = false;
159
+ let settled = false;
160
+ // An abort claims the settlement SYNCHRONOUSLY, before its (async,
161
+ // retrying) group kill. While that kill is in flight, the child's
162
+ // real 'exit'/'close' can still land with an ordinary 1–123 code —
163
+ // without the flag the settled-once guard would let that exit win,
164
+ // settling a user CANCELLATION as a command failure and dragging it
165
+ // into the ticket-probe/two-strike path. With the flag, post-abort
166
+ // exits/closes are swallowed: a cancellation is ALWAYS the 130 abort
167
+ // outcome and never touches the credential bookkeeping.
168
+ let abortSettled = false;
169
+ let exitFallback: ReturnType<typeof setTimeout> | undefined;
170
+
171
+ const onStdout = (data: Buffer): void => {
172
+ stdout += data.toString();
173
+ };
174
+ const onStderr = (data: Buffer): void => {
175
+ stderr += data.toString();
176
+ };
177
+ child.stdout.on("data", onStdout);
178
+ child.stderr.on("data", onStderr);
179
+
180
+ // A child 'error' with no listener (ENOENT for a missing sudo,
181
+ // EACCES/EPERM, …) crashes the process. Always attach and route it
182
+ // through the normal settle path; the settled guard swallows any
183
+ // 'exit'/'close' that follows.
184
+ child.on("error", (err) => {
185
+ finish(-1, `failed to spawn ${command}: ${errDetail(err)}`);
186
+ });
187
+
188
+ let stdinReadSideClosed = false;
189
+ // Asynchronous stdin-stream 'error' events are NOT caught by the
190
+ // synchronous write/end try/catch below: a dead child's EPIPE can land
191
+ // on a later tick after the write already completed. With no 'error'
192
+ // listener on the stream, that event takes the whole host process
193
+ // down. Policy (never false-fail a legitimate fast-exit success):
194
+ // • EPIPE / ECONNRESET — the child's read side is gone (e.g. a
195
+ // NOPASSWD sudo that finishes without ever reading the password).
196
+ // DECIDE NOTHING here: the 'exit' handler settles the run with the
197
+ // child's real code; if no exit ever comes, the dead process's own
198
+ // 'error' path — or the timeout + exit fallback — settles it.
199
+ // • anything else — the child is alive but the channel went bad and
200
+ // the outcome is still undecided: settle a clean transport failure
201
+ // (credential cache retained); once settled, 'exit' has already won
202
+ // and this is ignored.
203
+ // Both branches run under `finish`'s settled guard, so 'exit'/'close'
204
+ // double events and out-of-order stream errors can't double-settle.
205
+ child.stdin?.on?.("error", (err) => {
206
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
207
+ if (code === "EPIPE" || code === "ECONNRESET") {
208
+ stdinReadSideClosed = true; // record only — 'exit' settles with the real code
209
+ return;
210
+ }
211
+ if (!settled) finish(-1, `stdin failed: ${errDetail(err)}`);
212
+ });
213
+
214
+ const finish = (exitCode: number, error?: string): void => {
215
+ if (settled) return;
216
+ settled = true;
217
+ clearTimeout(timer);
218
+ if (exitFallback) clearTimeout(exitFallback);
219
+ if (signal) signal.removeEventListener("abort", onAbort);
220
+ const result: SudoRunResult = {
221
+ exitCode,
222
+ stdout,
223
+ stderr,
224
+ authPromptSeen: /\[sudo\] password for/i.test(stderr),
225
+ // "3 incorrect password attempts" / "incorrect password" — only
226
+ // meaningful when sudo actually asked for a password.
227
+ authFailed: /\[sudo\] password for/i.test(stderr) && /incorrect password/i.test(stderr),
228
+ timedOut,
229
+ };
230
+ // exactOptionalPropertyTypes: attach `error` only when present.
231
+ if (error !== undefined) result.error = error;
232
+ resolve(result);
233
+ };
234
+
235
+ // The kill may transiently need its one retry (short real delay), so
236
+ // it runs async: finish() stays EXACTLY ONCE via the settled guard,
237
+ // called only AFTER the kill — same ordering as before. The
238
+ // abortSettled flag is set FIRST (synchronously) so any exit/close
239
+ // event landing while the kill awaits its retry tick cannot win the
240
+ // settled-once race against the 130 settle.
241
+ const onAbort = (): void => {
242
+ abortSettled = true;
243
+ void killProcessTree(child).then(() => finish(130));
244
+ };
245
+
246
+ const timer = setTimeout(() => {
247
+ timedOut = true;
248
+ void killProcessTree(child); // fire-and-forget: the 2s exit fallback below settles if no exit comes
249
+ // belt-and-braces: resolve even if no exit/close ever fires
250
+ exitFallback = setTimeout(() => finish(124), 2000);
251
+ }, timeoutMs);
252
+
253
+ const onExit = (code: number | null, sig: NodeJS.Signals | null | undefined): void => {
254
+ // Post-abort exit/close (the child finally died while the abort's
255
+ // async group kill was still retrying): swallow — the abort already
256
+ // claimed the settlement and finish(130) will land when the bounded
257
+ // kill completes.
258
+ if (abortSettled) return;
259
+ finish(code === null ? (sig === "SIGKILL" ? 137 : timedOut ? 124 : -1) : code);
260
+ };
261
+ child.on("exit", onExit);
262
+ child.on("close", onExit);
263
+
264
+ if (signal && signal.aborted) {
265
+ abortSettled = true;
266
+ void killProcessTree(child).then(() => finish(130));
267
+ return;
268
+ }
269
+ if (signal) {
270
+ signal.addEventListener("abort", onAbort);
271
+ }
272
+
273
+ // The password travels via stdin ONLY — sudo -S reads it from there.
274
+ // A fatal EPIPE / ERR_STREAM_DESTROYED (child died before reading it)
275
+ // is NOT an auth failure: route it through the failed process shape
276
+ // instead of throwing out of the async function.
277
+ try {
278
+ child.stdin?.write(`${password}\n`);
279
+ child.stdin?.end();
280
+ } catch (err) {
281
+ finish(-1, `failed to write to child stdin: ${errDetail(err)}`);
282
+ }
283
+ });
284
+ }
285
+
286
+ /** Timeout for the credential ticket probe (see probeSudoTicket). */
287
+ export const AUTH_PROBE_TIMEOUT_MS = 5000;
288
+
289
+ /**
290
+ * Honest warning surfaced (content + details) on the FIRST ambiguous failure
291
+ * — a non-zero exit without a recognizable failure signature whose
292
+ * credential probe came back non-terminal: the credential is unverified but
293
+ * NOT yet invalidated (a no-reuse sudoers policy makes `sudo -n -v` fail even
294
+ * for a CORRECT password). Kept for one more attempt; a second consecutive
295
+ * ambiguous failure on the same cached entry clears it and re-prompts.
296
+ */
297
+ const AMBIGUOUS_CREDENTIAL_WARNING =
298
+ "the command failed and the cached credential could not be verified (no recognizable authentication failure, and the credential check did not confirm it) — the cached password is kept for one more attempt; if the password itself was wrong it will be cleared and re-prompted after one more such failure.";
299
+
300
+ /**
301
+ * Locale-immune check that the cached credential is still usable: run
302
+ * `sudo -n -v`. `-v` VALIDATES the credential timestamp without executing
303
+ * any program — its success needs no program entry in the target sudoers,
304
+ * unlike `sudo -n true` (a restricted sudoers can permit the requested
305
+ * command while denying `true` (or not listing it), and a ticketless/no-reuse
306
+ * policy can hold a freshly valid credential with no replayable ticket
307
+ * for `-n true` to find). `-n` refuses to prompt, so nothing is written
308
+ * to its stdin and it exits 0 iff validation succeeded. Takes no password
309
+ * — by design.
310
+ */
311
+ export async function probeSudoTicket(
312
+ timeoutMs: number,
313
+ signal: AbortSignal | undefined,
314
+ spawner: SudoSpawner,
315
+ ): Promise<boolean> {
316
+ const run = await runSudo(["sudo", "-n", "-v"], "", timeoutMs, signal, spawner);
317
+ return !run.timedOut && run.error === undefined && run.exitCode === 0;
318
+ }
319
+
320
+ /**
321
+ * Belt-and-braces: drop any line that contains the raw password so it can
322
+ * never appear in tool details/result content (normally stderr is clean —
323
+ * the password never appears in argv — but scrub defensively).
324
+ */
325
+ export function scrubSecret(text: string, secret: string): string {
326
+ if (!secret) return text;
327
+ return text
328
+ .split("\n")
329
+ .map((line) => (line.includes(secret) ? "[redacted]" : line))
330
+ .join("\n");
331
+ }
332
+
333
+ /**
334
+ * Build the full argv for `sudo -S <command>`. A proper POSIX-ish word
335
+ * split (quoted args stay single argv entries) — never a shell string.
336
+ * A stray leading `sudo` is stripped so `sudo -S` is applied exactly once.
337
+ */
338
+ export function buildSudoArgv(command: string): string[] {
339
+ let words = splitCommandIntoArgv(command.trim());
340
+ if (words[0] === "sudo") words = words.slice(1);
341
+ return ["sudo", "-S", ...words];
342
+ }
343
+
344
+ // ── Tool ─────────────────────────────────────────────────────────────────────
345
+
346
+ const SUDO_EXEC_DESCRIPTION = `Run a command with elevated privileges using sudo.
347
+
348
+ - BEFORE any credential is requested, the exact command and its reason are shown to the user for confirmation.
349
+ - The password is entered only through a masked UI, passed to sudo via stdin, cached in memory for the session, and never exposed.
350
+ - Use this instead of sudo in bash — interactive sudo in bash is blocked.
351
+ - The command is executed as argv, NOT through a shell: no pipes, redirects, &&, ;, or env assignments; pass args space-separated, quote only literal args.`;
352
+
353
+ /**
354
+ * Create the sudo_exec tool definition. Options are a test seam: pass a
355
+ * custom spawner to exercise execution paths without real processes, and a
356
+ * custom authProbe to control the ticket probe without spawning `sudo -n`.
357
+ */
358
+ export function createSudoExecTool(options: {
359
+ spawner?: SudoSpawner;
360
+ /**
361
+ * Locale-immune credential check used to disambiguate a run that
362
+ * failed at the command level (normal non-zero exit) WITHOUT the
363
+ * English "incorrect password" fast-path signature matching — e.g.
364
+ * a non-English sudo. It is not gated on the prompt text: a
365
+ * localized prompt leaves no recognizer, and `sudo -n -v` (a
366
+ * password-free credential validation that executes no program)
367
+ * is safe to run after any command-level failure. Default: a
368
+ * spawner-based `sudo -n -v` ticket probe (probeSudoTicket). It
369
+ * never receives the password, by design.
370
+ */
371
+ authProbe?: (timeoutMs: number, signal: AbortSignal | undefined) => Promise<boolean>;
372
+ } = {}) {
373
+ const spawner = options.spawner ?? defaultSpawner;
374
+ const authProbe = options.authProbe ?? ((timeoutMs: number, signal: AbortSignal | undefined) => probeSudoTicket(timeoutMs, signal, spawner));
375
+
376
+ const fail = (text: string, details: SudoExecDetails) => ({
377
+ content: [{ type: "text" as const, text }],
378
+ details,
379
+ isError: true,
380
+ });
381
+
382
+ return {
383
+ name: "sudo_exec",
384
+ label: "sudo",
385
+ description: SUDO_EXEC_DESCRIPTION,
386
+ parameters: SudoExecParamsSchema,
387
+
388
+ // NOTE: no return-type annotation on execute — pi reads `isError` at
389
+ // runtime though AgentToolResult omits it; the inferred union escapes
390
+ // fresh-literal excess-property checking (same convention as todo/mcp).
391
+ async execute(
392
+ _toolCallId: string,
393
+ params: SudoExecInput,
394
+ signal: AbortSignal | undefined,
395
+ _onUpdate: undefined,
396
+ ctx: ExtensionContext,
397
+ ) {
398
+ const config = loadSudoConfig();
399
+ const command = params.command.trim();
400
+ const reason = params.reason.trim();
401
+
402
+ if (!command) {
403
+ return fail("The command is empty — provide the exact command to run with elevated privileges.", {
404
+ command,
405
+ reason,
406
+ exitCode: -1,
407
+ stdout: "",
408
+ stderr: "",
409
+ error: "empty command",
410
+ });
411
+ }
412
+
413
+ if (ctx.mode !== "tui") {
414
+ return fail("sudo_exec requires an interactive session — run privileged commands from the main session.", {
415
+ command,
416
+ reason,
417
+ exitCode: -1,
418
+ stdout: "",
419
+ stderr: "",
420
+ error: "headless session",
421
+ });
422
+ }
423
+
424
+ // Display the exact command + reason and require confirmation
425
+ // BEFORE any credential is acquired.
426
+ const confirmed = await confirmCommand(ctx, command, reason);
427
+ if (!confirmed) {
428
+ return fail("Command not confirmed — not executed, and no password was requested.", {
429
+ command,
430
+ reason,
431
+ exitCode: -1,
432
+ stdout: "",
433
+ stderr: "",
434
+ error: "command not confirmed",
435
+ });
436
+ }
437
+
438
+ let credential = credentialCache.get();
439
+ if (!credential) {
440
+ const password = await promptForPassword(ctx, command, reason);
441
+ if (!password) {
442
+ return fail("Password entry cancelled — not executed, and nothing was cached.", {
443
+ command,
444
+ reason,
445
+ exitCode: -1,
446
+ stdout: "",
447
+ stderr: "",
448
+ error: "password entry cancelled",
449
+ });
450
+ }
451
+ credentialCache.set(password, config.ttlMs);
452
+ credential = credentialCache.get();
453
+ if (!credential) {
454
+ return fail("Password was not cached — aborting without executing.", {
455
+ command,
456
+ reason,
457
+ exitCode: -1,
458
+ stdout: "",
459
+ stderr: "",
460
+ error: "cache unavailable",
461
+ });
462
+ }
463
+ }
464
+
465
+ const timeoutMs = params.timeoutMs && params.timeoutMs > 0 ? params.timeoutMs : config.defaultTimeoutMs;
466
+ const run = await runSudo(buildSudoArgv(command), credential.password, timeoutMs, signal, spawner);
467
+
468
+ const stdout = scrubSecret(run.stdout, credential.password);
469
+ const stderr = scrubSecret(run.stderr, credential.password);
470
+ /** Set when the ambiguous-failure hit its first strike and the credential is kept unverified. */
471
+ let unverifiedCredentialWarning = false;
472
+
473
+ // Process-level failure (spawn 'error' event, spawner throw, fatal
474
+ // stdin write) — surface it as a clean tool failure, never as an
475
+ // auth failure, and never drop the password: a transport glitch is
476
+ // transient relative to the credential itself.
477
+ if (run.error) {
478
+ return fail(`failed to run privileged command: ${run.error}`, {
479
+ command,
480
+ reason,
481
+ exitCode: run.exitCode,
482
+ stdout,
483
+ stderr,
484
+ error: run.error,
485
+ });
486
+ }
487
+
488
+ if (run.authFailed) {
489
+ credentialCache.clear();
490
+ return fail(
491
+ `sudo authentication failed (incorrect password) — exit code ${run.exitCode}. The in-memory credential cache was cleared; the user will be re-prompted on the next attempt.`,
492
+ { command, reason, exitCode: run.exitCode, stdout, stderr, error: "authentication failed" },
493
+ );
494
+ }
495
+
496
+ if (run.timedOut) {
497
+ return {
498
+ content: [{ type: "text" as const, text: stdout }],
499
+ details: {
500
+ command,
501
+ reason,
502
+ exitCode: run.exitCode,
503
+ stdout,
504
+ stderr,
505
+ error: `timed out after ${timeoutMs}ms — command was killed`,
506
+ } satisfies SudoExecDetails,
507
+ isError: true,
508
+ };
509
+ }
510
+
511
+ // Locale-immune auth-failure disambiguation: the run exited
512
+ // non-zero WITHOUT the English "incorrect password" fast-path
513
+ // signature — a wrong password is indistinguishable from a
514
+ // localized message (or a genuine command failure) on the
515
+ // signature path alone. Probe the credential with `sudo -n -v`
516
+ // (validates the ticket, executes no program, no password):
517
+ // the probe succeeding means the credential is fine and the
518
+ // command itself failed → normal result; the probe failing means
519
+ // the credential is unverified. Two-strike rule on the AMBIGUOUS
520
+ // path only: a no-reuse sudoers policy makes `sudo -n -v` fail
521
+ // even for a CORRECT password (no reusable ticket), so the first
522
+ // such failure keeps the credential with a visible one-more-attempt
523
+ // warning; the SECOND consecutive ambiguous failure on the same
524
+ // cached entry clears it and re-prompts. The English fast path
525
+ // (above) still clears immediately, and success / run / /timeout
526
+ // /abort outcomes reset (or leave untouched) the streak.
527
+ // NOT gated on the prompt text — localized sudo emits a prompt in
528
+ // the user's language, and after ANY command-level failure the
529
+ // probe is safe (valid ticket → keep, no ticket → re-prompt).
530
+ // Control/kill exits are excluded: -1 process error (handled
531
+ // above), 124 timeout (handled above), 130 abort, 137 SIGKILL —
532
+ // those aren't auth evidence and must not count as strikes.
533
+ if (run.exitCode >= 1 && run.exitCode < 124) {
534
+ let ticketValid = false;
535
+ try {
536
+ ticketValid = await authProbe(AUTH_PROBE_TIMEOUT_MS, signal);
537
+ } catch {
538
+ ticketValid = false; // probe transport error → non-terminal probe
539
+ }
540
+ if (ticketValid) {
541
+ // Ticket terminal: the credential verified — drop any carried
542
+ // streak; this is a straightforward command failure.
543
+ credentialCache.resetFailStreak();
544
+ } else {
545
+ // Non-terminal probe: unverifiable credential — count a strike
546
+ // on the CURRENT cached entry.
547
+ credentialCache.bumpFailStreak();
548
+ if ((credentialCache.get()?.failStreak ?? 0) >= 2) {
549
+ credentialCache.clear();
550
+ return fail(
551
+ `sudo authentication failed — exit code ${run.exitCode}. The cached password produced two consecutive failures without a verifiable credential — the credential cache was cleared; you will be re-prompted for the password on the next sudo_exec.`,
552
+ { command, reason, exitCode: run.exitCode, stdout, stderr, error: "authentication failed" },
553
+ );
554
+ }
555
+ unverifiedCredentialWarning = true; // strike 1: keep, warn, and fall through to the normal failure result
556
+ }
557
+ }
558
+
559
+ const isError = run.exitCode !== 0;
560
+ if (!isError) {
561
+ credentialCache.resetFailStreak();
562
+ return {
563
+ content: [{ type: "text" as const, text: stdout }],
564
+ details: { command, reason, exitCode: run.exitCode, stdout, stderr } satisfies SudoExecDetails,
565
+ };
566
+ }
567
+ return {
568
+ content: [{ type: "text" as const, text: unverifiedCredentialWarning ? `${stdout}\n(WARNING: ${AMBIGUOUS_CREDENTIAL_WARNING})` : stdout }],
569
+ details: {
570
+ command,
571
+ reason,
572
+ exitCode: run.exitCode,
573
+ stdout,
574
+ stderr,
575
+ error: unverifiedCredentialWarning
576
+ ? `command failed with exit code ${run.exitCode} — WARNING: ${AMBIGUOUS_CREDENTIAL_WARNING}`
577
+ : `command failed with exit code ${run.exitCode}`,
578
+ } satisfies SudoExecDetails,
579
+ isError: true,
580
+ };
581
+ },
582
+ };
583
+ }
584
+
585
+ export function registerSudoTool(pi: ExtensionAPI, options?: { spawner?: SudoSpawner }): void {
586
+ pi.registerTool(createSudoExecTool(options));
587
+ }