@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/LICENSE +21 -0
- package/README.md +66 -0
- package/package.json +36 -0
- package/src/argv-split.test.ts +59 -0
- package/src/argv-split.ts +82 -0
- package/src/cache.test.ts +91 -0
- package/src/cache.ts +58 -0
- package/src/config.test.ts +47 -0
- package/src/config.ts +27 -0
- package/src/guard.test.ts +427 -0
- package/src/guard.ts +759 -0
- package/src/index.ts +81 -0
- package/src/lifecycle.test.ts +165 -0
- package/src/prompt.test.ts +149 -0
- package/src/prompt.ts +99 -0
- package/src/tool.test.ts +1364 -0
- package/src/tool.ts +587 -0
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
|
+
}
|