@pi-archimedes/sudo 2.13.0 → 2.14.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/README.md CHANGED
@@ -29,6 +29,7 @@ After installing Pi, choose one installation command above, then `cd` into your
29
29
  ## What you get
30
30
 
31
31
  - **`sudo_exec` tool** — runs a privileged command via `sudo -S`, showing the exact command and a human-readable **reason** for confirmation before any credential prompt appears. A declined confirmation means nothing runs and no password is requested.
32
+ - **Optional quieter confirmation** — `confirmWhenCached: false` skips the per-command confirmation while a credential is already cached, so an approved credential window is not interrupted by one dialog per privileged step. Off by default, and a skipped run says so in its own output — the first line of the tool result is `[sudo] confirmation skipped …`, so the terminal, the transcript and the model all see that nobody confirmed it ([ADR 0033](https://github.com/archimedes-codes/pi-archimedes/blob/main/docs/decisions/0033-sudo-confirmation-while-cached.md)).
32
33
  - **Masked password prompt** — the password is typed into a masked UI and passed to sudo via stdin only. It is never in argv, environment variables, command logs, or the LLM context.
33
34
  - **Defensive output scrubbing** — command output lines that contain the password are redacted before they reach the tool result. That is a literal-substring scrub: it catches common cases, and it is **not** universal leak protection.
34
35
  - **Credential cache** — a single in-memory cache with a TTL (15 minutes by default, `ttlMs`); cleared on authentication failure, at `session_start`/`session_shutdown`, and by `/sudo forget`.
@@ -70,13 +71,30 @@ The guard is a **heuristic with accepted residual bypasses** documented in the [
70
71
  |---------|------|---------|-------------|
71
72
  | `ttlMs` | number | `900000` | Password cache TTL in milliseconds (default 15 minutes) |
72
73
  | `defaultTimeoutMs` | number | `120000` | `sudo_exec` default command timeout in milliseconds (default 120 seconds) |
74
+ | `confirmWhenCached` | boolean | `true` | Ask for confirmation before every `sudo_exec`, even with a cached credential. Set to `false` to confirm only when a password will be requested — that is, whenever the credential cache is empty — and skip it for a cached credential; the skipped run then starts its output with the `[sudo] confirmation skipped` marker and sets `confirmationSkipped: true` in the details. Keep `ttlMs` short if you enable this: the cache, not the session, is what bounds unattended privileged runs ([ADR 0033](https://github.com/archimedes-codes/pi-archimedes/blob/main/docs/decisions/0033-sudo-confirmation-while-cached.md)) |
75
+
76
+ ### When the confirmation is skipped
77
+
78
+ With `confirmWhenCached: false`, a privileged command whose credential is already cached runs without a confirmation dialog. What stays true:
79
+
80
+ - A **cache miss** still confirms first and then asks for the password, so the run that creates the credential always has both a human click and a human password entry.
81
+ - The **command and reason** stay in the tool call, so the transcript still records exactly what was run and why.
82
+ - The run's own output starts with a line saying the confirmation was skipped, so a quiet run is visibly different from one you approved — in the terminal, in the transcript, and to the model. `details.confirmationSkipped` is the same fact for anything reading the session file.
83
+ - `/sudo forget`, `session_shutdown`, `ttlMs`, and an authentication failure all close the cache and bring the confirmation back.
84
+ - The window is per **process**, not per session: run more than one session in the same process — or a subordinate session inside it — and they share the cached credential, so that is the case to keep `ttlMs` short for ([ADR 0033](https://github.com/archimedes-codes/pi-archimedes/blob/main/docs/decisions/0033-sudo-confirmation-while-cached.md)).
73
85
 
74
86
  ### Credential handling when sudo retains no verifiable ticket
75
87
 
76
- On sudoers policies that retain no reusable credential ticket (e.g. `timestamp_timeout=0` with strict `Defaults`), an authenticated but failed command is indistinguishable from an authentication failure unless the ticket can be verified with `sudo -n -v`.
88
+ An authenticated but failed command is indistinguishable from an authentication failure unless the credential can be verified independently, so the tool runs two probes after a command-level failure:
89
+
90
+ 1. **`sudo -n -v` (ticket probe)** — validates sudo's ticket, executes no program, needs no password.
91
+ 2. **`sudo -S -v` (password probe)** — validates the **cached password** via stdin, executes no program. It runs **only when the ticket probe is non-terminal**, which is exactly the case it answers: hosts where no ticket is ever replayable — `timestamp_timeout=0` / no-reuse `Defaults`, or a tty-keyed timestamp (`timestamp_type=tty`, Ubuntu's default) against a spawn with no controlling terminal. On those hosts the ticket probe fails **even for a correct password**, so without stage 2 every command-level failure would read as an unverifiable credential.
92
+
93
+ Then:
77
94
 
78
95
  - A **recognized wrong password** (sudo's `incorrect password` output alongside its prompt) clears the credential **immediately**, on that failure.
79
- - The **two-strike rule** applies to **ambiguous** failures where the ticket probe cannot verify the credential: the first strike keeps the cached password (with a visible one-more-attempt warning), the second consecutive strike clears it and re-prompts. Success resets the streak, and transport failures don't count as strikes.
96
+ - **Either probe succeeding** means the credential is fine and the command itself failed: the run is reported as an ordinary non-zero exit and any carried strike is reset.
97
+ - The **two-strike rule** applies to **ambiguous** failures, i.e. both probes non-terminal: the first strike keeps the cached password (with a visible one-more-attempt warning), the second consecutive strike clears it and re-prompts. Success resets the streak, and transport failures don't count as strikes. A failed password probe is never treated as proof of a wrong password — only success is terminal — so a transient hiccup cannot discard a good credential.
80
98
 
81
99
  ## Part of the suite
82
100
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/sudo",
3
- "version": "2.13.0",
3
+ "version": "2.14.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/archimedes-codes/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./guard": "./src/guard.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.13.0"
22
+ "@pi-archimedes/core": "2.14.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.99.0",
package/src/cache.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * In-memory credential cache for sudo_exec (ADR 0010).
3
3
  *
4
- * A single credential at a time, kept ONLY in memory for the active session —
5
- * never written to disk or the OS keyring. Expired entries return null from
4
+ * A single credential at a time, kept ONLY in memory for the process — never
5
+ * written to disk or the OS keyring. pi caches an extension module per process
6
+ * and re-invokes the factory per session, so the one thing separating two
7
+ * sessions in a process is the unconditional session_start/session_shutdown
8
+ * clears in index.ts (ADR 0033's Consequences spell out what that means for a
9
+ * subordinate in-process session). Expired entries return null from
6
10
  * get() (triggering a re-prompt) but are not auto-cleared; clear() is called
7
11
  * on session_shutdown, /sudo forget, and auth failure to drop the password
8
12
  * from memory entirely. No host/user keying in v1.
@@ -54,5 +58,11 @@ export class CredentialCache {
54
58
  }
55
59
  }
56
60
 
57
- /** Module-level singleton, recreated per session via clear() on session_start (Task 3 lifecycle). */
61
+ /**
62
+ * Module-level singleton, process-scoped: pi caches an extension module per
63
+ * process, so this object outlives a session and is shared by any session in
64
+ * the same process. It is cleared — never recreated — by the `session_start` /
65
+ * `session_shutdown` handlers in index.ts (every session's, not just the one
66
+ * that filled it), plus `/sudo forget` and auth failure. See ADR 0033.
67
+ */
58
68
  export const credentialCache = new CredentialCache();
@@ -27,8 +27,8 @@ describe("sudo config", () => {
27
27
  store.saveCalls.length = 0;
28
28
  });
29
29
 
30
- it("exposes the shared defaults (15 min TTL, 120 s command timeout)", () => {
31
- expect(DEFAULT_SUDO_CONFIG).toEqual({ ttlMs: 900000, defaultTimeoutMs: 120000 });
30
+ it("exposes the shared defaults (15 min TTL, 120 s command timeout, confirm while cached)", () => {
31
+ expect(DEFAULT_SUDO_CONFIG).toEqual({ ttlMs: 900000, defaultTimeoutMs: 120000, confirmWhenCached: true });
32
32
  });
33
33
 
34
34
  it("returns the defaults when no archimedes.sudo section exists", () => {
@@ -37,11 +37,18 @@ describe("sudo config", () => {
37
37
 
38
38
  it("merges a partial archimedes.sudo section over the defaults", () => {
39
39
  store.settings["archimedes.sudo"] = { ttlMs: 5000 };
40
- expect(loadSudoConfig()).toEqual({ ttlMs: 5000, defaultTimeoutMs: 120000 });
40
+ expect(loadSudoConfig()).toEqual({ ttlMs: 5000, defaultTimeoutMs: 120000, confirmWhenCached: true });
41
+ });
42
+
43
+ it("keeps confirming while cached unless the user opts out", () => {
44
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
45
+ expect(loadSudoConfig().confirmWhenCached).toBe(false);
41
46
  });
42
47
 
43
48
  it("saveSudoConfig writes to the archimedes.sudo namespace", () => {
44
- saveSudoConfig({ ttlMs: 1234, defaultTimeoutMs: 5678 });
45
- expect(store.saveCalls).toEqual([{ namespace: "archimedes.sudo", config: { ttlMs: 1234, defaultTimeoutMs: 5678 } }]);
49
+ saveSudoConfig({ ttlMs: 1234, defaultTimeoutMs: 5678, confirmWhenCached: true });
50
+ expect(store.saveCalls).toEqual([
51
+ { namespace: "archimedes.sudo", config: { ttlMs: 1234, defaultTimeoutMs: 5678, confirmWhenCached: true } },
52
+ ]);
46
53
  });
47
54
  });
package/src/config.ts CHANGED
@@ -11,11 +11,21 @@ export interface SudoConfig {
11
11
  ttlMs: number;
12
12
  /** sudo_exec default command timeout in ms. Default 120 s. */
13
13
  defaultTimeoutMs: number;
14
+ /**
15
+ * Ask for confirmation before every `sudo_exec`, even when a credential is
16
+ * already cached. Default `true` — the confirmation is the last human gate
17
+ * once the password is cached (ADR 0010, ADR 0033). Set it to `false` to
18
+ * confirm only on the runs that will ask for a password, i.e. the first one
19
+ * while the credential cache is empty, and to accept that the agent can then
20
+ * run privileged commands unattended for the rest of the cache TTL.
21
+ */
22
+ confirmWhenCached: boolean;
14
23
  }
15
24
 
16
25
  export const DEFAULT_SUDO_CONFIG: SudoConfig = {
17
26
  ttlMs: 900_000,
18
27
  defaultTimeoutMs: 120_000,
28
+ confirmWhenCached: true,
19
29
  };
20
30
 
21
31
  export function loadSudoConfig(): SudoConfig {
package/src/index.ts CHANGED
@@ -21,8 +21,10 @@ export function registerSudo(pi: ExtensionAPI, options?: { spawner?: SudoSpawner
21
21
  // direct testing; handleBashToolCall only vets — it never mutates event.input.
22
22
  pi.on("tool_call", (event) => handleBashToolCall(event));
23
23
 
24
- // Session lifecycle: the credential is a module singleton and must never
25
- // outlive a session — cleared at every session boundary (ADR 0010).
24
+ // Session lifecycle: the credential is a module singleton, so any session
25
+ // boundary clears it (ADR 0010) — every session's handlers, not just the one
26
+ // that filled it, which is what keeps an in-process subordinate session from
27
+ // inheriting it indefinitely (ADR 0033, Consequences).
26
28
  pi.on("session_start", () => {
27
29
  credentialCache.clear();
28
30
  });
@@ -13,6 +13,7 @@ import { createSudoExecTool } from "./tool.js";
13
13
 
14
14
  const tool = createSudoExecTool() as unknown as {
15
15
  name: string;
16
+ description?: string;
16
17
  annotations?: { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean };
17
18
  exposure?: string;
18
19
  };
@@ -37,3 +38,24 @@ describe("sudo_exec annotations", () => {
37
38
  expect(tool.exposure ?? "direct").toBe("direct");
38
39
  });
39
40
  });
41
+
42
+ describe("sudo_exec description", () => {
43
+ // The description is handed to the model on every call, and it now carries the
44
+ // skip contract (ADR 0033): without a pin the wording drifts silently, which is
45
+ // how bullet 2 kept saying "for the session" after the cache became
46
+ // process-scoped.
47
+ const description = tool.description ?? "";
48
+
49
+ it("names the setting that skips the gate, so the model can explain the exception", () => {
50
+ expect(description).toContain("confirmWhenCached");
51
+ });
52
+
53
+ it("scopes the cache to the process, not the session", () => {
54
+ expect(description).toContain("cached in memory for the process");
55
+ expect(description).not.toContain("for the session");
56
+ });
57
+
58
+ it("still says the password is never exposed", () => {
59
+ expect(description).toContain("never exposed");
60
+ });
61
+ });
package/src/tool.test.ts CHANGED
@@ -32,7 +32,7 @@ vi.mock("@pi-archimedes/core/bridge", () => ({
32
32
  getBridge: () => bridgeState,
33
33
  }));
34
34
 
35
- import { buildSudoArgv, createSudoExecTool, probeSudoTicket, scrubSecret } from "./tool.js";
35
+ import { buildSudoArgv, CONFIRMATION_SKIPPED_MARKER, createSudoExecTool, probeSudoPassword, probeSudoTicket, scrubSecret } from "./tool.js";
36
36
  import type { SudoChild, SudoSpawner } from "./tool.js";
37
37
  import { credentialCache } from "./cache.js";
38
38
 
@@ -333,6 +333,292 @@ describe("sudo_exec tool", () => {
333
333
  });
334
334
  });
335
335
 
336
+ describe("confirmation while a credential is cached (ADR 0033)", () => {
337
+ // This block installs process.kill spies and turns the bridge on; reset both
338
+ // so the rest of the file keeps exercising the plain TUI path.
339
+ afterEach(() => {
340
+ vi.restoreAllMocks();
341
+ bridgeState.active = false;
342
+ bridgeState.confirm.mockReset();
343
+ bridgeState.password.mockReset();
344
+ });
345
+
346
+ it("confirms on every run by default, even with a cached credential, and the text stays byte-identical", async () => {
347
+ const { spawner } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
348
+ const tool = createSudoExecTool({ spawner });
349
+ credentialCache.set("cachedpw", 60_000);
350
+ const spies = spiesNoPrompt();
351
+ const result = await run(tool, tuiCtxWithSpies(true, spies), { command: "id", reason: "whoami" });
352
+
353
+ expect(spies.confirmCalls).toHaveLength(1);
354
+ expect(spies.confirmCalls[0]?.message).toContain("id");
355
+ // the default path never carries the marker or the flag
356
+ expect((result.content[0] as { text: string }).text).toBe("ok\n");
357
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBeUndefined();
358
+ });
359
+
360
+ it("skips the confirmation for a cached credential when confirmWhenCached is false, in both channels", async () => {
361
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
362
+ const { spawner } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
363
+ const tool = createSudoExecTool({ spawner });
364
+ credentialCache.set("cachedpw", 60_000);
365
+ const spies = spiesNoPrompt();
366
+ const result = await run(tool, tuiCtxWithSpies(true, spies), { command: "id", reason: "whoami" });
367
+
368
+ expect(spies.confirmCalls).toHaveLength(0); // no human click for a cached credential
369
+ expect(result.isError).toBeFalsy();
370
+ // the text is what the terminal and the model see; details is the session-file record
371
+ expect((result.content[0] as { text: string }).text).toBe(`${CONFIRMATION_SKIPPED_MARKER}\nok\n`);
372
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
373
+ });
374
+
375
+ it("still confirms when the cache is empty, because that run is what asks for the password", async () => {
376
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
377
+ const { spawner } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
378
+ const tool = createSudoExecTool({ spawner });
379
+ const spies = spiesNoPrompt();
380
+ const ui = {
381
+ confirm: (title: string, message: string) => {
382
+ spies.confirmCalls.push({ title, message });
383
+ return Promise.resolve(true);
384
+ },
385
+ // the masked prompt resolves "" = cancelled, so nothing runs
386
+ custom: (factory: unknown): Promise<string> => {
387
+ spies.customCalls.push(factory);
388
+ return Promise.resolve("");
389
+ },
390
+ };
391
+ const ctx = { mode: "tui", hasUI: true, cwd: "/tmp", ui } as unknown as ExtensionContext;
392
+ const result = await run(tool, ctx, { command: "id", reason: "whoami" });
393
+
394
+ expect(spies.confirmCalls).toHaveLength(1); // cache miss → the gate stays
395
+ expect((result.content[0] as { text: string }).text).toMatch(/password entry cancelled/i);
396
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBeUndefined();
397
+ });
398
+
399
+ it("marks a failed run that skipped the gate, and keeps the no-output case to the marker alone", async () => {
400
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
401
+ const { spawner } = makeFakeSpawner({ stdout: "", stderr: "boom\n", code: 7 });
402
+ const { authProbe, passwordProbe } = probeSpy(true);
403
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
404
+ credentialCache.set("cachedpw", 60_000);
405
+ const result = await run(tool, tuiCtxWithSpies(true, spiesNoPrompt()), { command: "false", reason: "check" });
406
+
407
+ expect(result.isError).toBe(true);
408
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
409
+ expect((result.content[0] as { text: string }).text).toBe(CONFIRMATION_SKIPPED_MARKER);
410
+ });
411
+
412
+ it("a failing run on the default settings carries neither the marker nor the flag", async () => {
413
+ const { spawner } = makeFakeSpawner({ stdout: "", stderr: "boom\n", code: 7 });
414
+ const { authProbe, passwordProbe } = probeSpy(true);
415
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
416
+ credentialCache.set("cachedpw", 60_000);
417
+ const result = await run(tool, tuiCtxWithSpies(true, spiesNoPrompt()), { command: "false", reason: "check" });
418
+
419
+ expect(result.isError).toBe(true);
420
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBeUndefined();
421
+ expect((result.content[0] as { text: string }).text).toBe("");
422
+ });
423
+
424
+ it("marks a timed-out run that skipped the gate (kill path, not the command path)", async () => {
425
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
426
+ const { spawner, last } = makeFakeSpawner({ stderr: "", code: null, exitOn: "kill" });
427
+ vi.spyOn(process, "kill").mockImplementation((pid?: number) => {
428
+ if (pid === -4242) last.child.kill("SIGKILL");
429
+ return true;
430
+ });
431
+ const tool = createSudoExecTool({ spawner });
432
+ credentialCache.set("pw", 60_000);
433
+
434
+ const result = await run(tool, tuiCtxWithSpies(true, spiesNoPrompt()), { command: "sleep", reason: "test", timeoutMs: 10 });
435
+
436
+ expect(result.isError).toBe(true);
437
+ expect((result.details as { exitCode: number; confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
438
+ expect((result.content[0] as { text: string }).text.startsWith(CONFIRMATION_SKIPPED_MARKER)).toBe(true);
439
+ });
440
+
441
+ it("marks a transport failure that skipped the gate (spawner threw, no command ran)", async () => {
442
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
443
+ const tool = createSudoExecTool({
444
+ spawner: () => {
445
+ throw Object.assign(new Error("spawn sudo EACCES"), { code: "EACCES" });
446
+ },
447
+ });
448
+ credentialCache.set("pw", 60_000);
449
+
450
+ const result = await run(tool, tuiCtxWithSpies(true, spiesNoPrompt()), { command: "apt update", reason: "maintain" });
451
+
452
+ expect(result.isError).toBe(true);
453
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
454
+ expect((result.content[0] as { text: string }).text.startsWith(CONFIRMATION_SKIPPED_MARKER)).toBe(true);
455
+ });
456
+
457
+ it("marks the ambiguous-credential paths too: strike 1 keeps the warning after the marker, strike 2 clears", async () => {
458
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
459
+ const { spawner } = makeFakeSpawner({ stdout: "", stderr: "boom\n", code: 1 });
460
+ const { authProbe, passwordProbe } = probeSpy(false, false, false); // both probes non-terminal
461
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
462
+ credentialCache.set("pw", 60_000);
463
+ const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
464
+
465
+ const first = await run(tool, ctx, { command: "false", reason: "strike one" });
466
+ const firstText = (first.content[0] as { text: string }).text;
467
+ expect(firstText.startsWith(`${CONFIRMATION_SKIPPED_MARKER}\n`)).toBe(true);
468
+ expect(firstText).toMatch(/WARNING/); // the warning keeps its place at the end
469
+ expect((first.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
470
+
471
+ const second = await run(tool, ctx, { command: "false", reason: "strike two" });
472
+ expect(credentialCache.get()).toBeNull(); // the second strike cleared the cache
473
+ expect((second.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
474
+ expect((second.content[0] as { text: string }).text).toMatch(/authentication failed/i);
475
+ });
476
+
477
+ it("an EXPIRED credential does not authorise a gate-free run — the gate and the prompt come back", async () => {
478
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
479
+ credentialCache.set("stale", 1); // expires immediately
480
+ await new Promise((resolve) => setTimeout(resolve, 5));
481
+ const { spawner } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
482
+ const tool = createSudoExecTool({ spawner });
483
+ const spies = spiesNoPrompt();
484
+ const ui = {
485
+ confirm: (title: string, message: string) => {
486
+ spies.confirmCalls.push({ title, message });
487
+ return Promise.resolve(true);
488
+ },
489
+ custom: (factory: unknown): Promise<string> => {
490
+ spies.customCalls.push(factory);
491
+ return Promise.resolve(""); // cancel the prompt: nothing runs
492
+ },
493
+ };
494
+ const ctx = { mode: "tui", hasUI: true, cwd: "/tmp", ui } as unknown as ExtensionContext;
495
+
496
+ const result = await run(tool, ctx, { command: "id", reason: "whoami" });
497
+
498
+ // get() is expiry-aware, so an expired entry reads as "no credential"
499
+ expect(spies.confirmCalls).toHaveLength(1);
500
+ expect((result.content[0] as { text: string }).text).toMatch(/password entry cancelled/i);
501
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBeUndefined();
502
+ });
503
+
504
+ it("re-reads the cache after the dialog: a credential cleared while the human was deciding is not used", async () => {
505
+ // The hoist of the cache read above the gate is ADR 0033's whole structural
506
+ // change, but the pre-dialog read only decides whether a dialog exists. The
507
+ // read that arms the run has to happen AFTER the awaited dialog, or whatever
508
+ // lands during it (`/sudo forget`, another session's `session_start`, the TTL
509
+ // elapsing) is silently dropped and a revoked password reaches `sudo -S`.
510
+ const { spawner, last } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
511
+ const tool = createSudoExecTool({ spawner });
512
+ credentialCache.set("STALE-PASSWORD", 60_000);
513
+ const spies = spiesNoPrompt();
514
+ const ui = {
515
+ confirm: (title: string, message: string) => {
516
+ spies.confirmCalls.push({ title, message });
517
+ credentialCache.clear(); // the human runs /sudo forget while the dialog is open
518
+ return Promise.resolve(true);
519
+ },
520
+ custom: (factory: unknown): Promise<string> => {
521
+ spies.customCalls.push(factory);
522
+ return Promise.resolve("FRESH-PASSWORD");
523
+ },
524
+ };
525
+ const ctx = { mode: "tui", hasUI: true, cwd: "/tmp", ui } as unknown as ExtensionContext;
526
+
527
+ const result = await run(tool, ctx, { command: "id", reason: "whoami" });
528
+
529
+ expect(spies.confirmCalls).toHaveLength(1);
530
+ expect(spies.customCalls).toHaveLength(1); // re-prompted, exactly as before the hoist
531
+ expect(last.child.stdin.written.join("")).toBe("FRESH-PASSWORD\n");
532
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBeUndefined();
533
+ });
534
+
535
+ it("honours the same window when the entry expires during the dialog, not only before the call", async () => {
536
+ const { spawner, last } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
537
+ const tool = createSudoExecTool({ spawner });
538
+ credentialCache.set("STALE-PASSWORD", 20); // live at the pre-dialog read, dead after the wait
539
+ const spies = spiesNoPrompt();
540
+ const ui = {
541
+ confirm: async (title: string, message: string) => {
542
+ spies.confirmCalls.push({ title, message });
543
+ await new Promise((resolve) => setTimeout(resolve, 30));
544
+ return true;
545
+ },
546
+ custom: (factory: unknown): Promise<string> => {
547
+ spies.customCalls.push(factory);
548
+ return Promise.resolve("FRESH-PASSWORD");
549
+ },
550
+ };
551
+ const ctx = { mode: "tui", hasUI: true, cwd: "/tmp", ui } as unknown as ExtensionContext;
552
+
553
+ const result = await run(tool, ctx, { command: "id", reason: "whoami" });
554
+
555
+ expect(spies.customCalls).toHaveLength(1);
556
+ expect(last.child.stdin.written.join("")).toBe("FRESH-PASSWORD\n");
557
+ expect(result.isError).toBeFalsy();
558
+ });
559
+
560
+ it("marks the English auth-failure fast path too — the one gated() funnel with no skip-path coverage", async () => {
561
+ // A wrong-password run that skipped the gate is the result a reader most
562
+ // needs to see was unconfirmed, and the English signature path is the only
563
+ // gated() return the rest of the suite never reaches with the skip on.
564
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
565
+ const { spawner } = makeFakeSpawner({
566
+ stderr: "[sudo] password for daniel: \nsorry, try again.\n3 incorrect password attempts\n",
567
+ code: 1,
568
+ });
569
+ const tool = createSudoExecTool({ spawner });
570
+ credentialCache.set("cachedpw", 60_000);
571
+ const spies = spiesNoPrompt();
572
+
573
+ const result = await run(tool, tuiCtxWithSpies(true, spies), { command: "reboot", reason: "maintain" });
574
+
575
+ expect(spies.confirmCalls).toHaveLength(0); // the skip path, not the confirm path
576
+ expect(result.isError).toBe(true);
577
+ const text = (result.content[0] as { text: string }).text;
578
+ expect(text.startsWith(`${CONFIRMATION_SKIPPED_MARKER}\n`)).toBe(true);
579
+ expect(text).toMatch(/authentication failed \(incorrect password\)/i);
580
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
581
+ });
582
+
583
+ it("keeps the unverified warning AFTER stdout, not merely somewhere in the text", async () => {
584
+ // With stdout empty, marker-then-warning and warning-then-marker are
585
+ // byte-identical, so the existing strike-1 case cannot pin the order. One
586
+ // line of stdout makes the two orderings distinguishable.
587
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
588
+ const { spawner } = makeFakeSpawner({ stdout: "partial\n", stderr: "boom\n", code: 1 });
589
+ const { authProbe, passwordProbe } = probeSpy(false, false, false); // both probes non-terminal
590
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
591
+ credentialCache.set("pw", 60_000);
592
+
593
+ const result = await run(tool, tuiCtxWithSpies(true, spiesNoPrompt()), { command: "false", reason: "strike one" });
594
+
595
+ const text = (result.content[0] as { text: string }).text;
596
+ expect(text.startsWith(`${CONFIRMATION_SKIPPED_MARKER}\n`)).toBe(true); // marker first
597
+ expect(text.indexOf("partial\n")).toBeGreaterThan(text.indexOf(CONFIRMATION_SKIPPED_MARKER));
598
+ expect(text.indexOf("(WARNING:")).toBeGreaterThan(text.indexOf("partial\n")); // warning last
599
+ expect((result.details as { error?: string }).error).toMatch(/— WARNING:/);
600
+ });
601
+
602
+ it("applies the same rule in bridge mode: no Client confirm, and both channels still say so", async () => {
603
+ store.settings["archimedes.sudo"] = { confirmWhenCached: false };
604
+ bridgeState.active = true;
605
+ const { spawner } = makeFakeSpawner({ stdout: "ok\n", code: 0 });
606
+ const tool = createSudoExecTool({ spawner });
607
+ credentialCache.set("cachedpw", 60_000);
608
+ const ui = { confirm: vi.fn(), custom: vi.fn() } as unknown as ExtensionContext["ui"];
609
+
610
+ const result = await run(tool, { mode: "tui", hasUI: true, cwd: "/tmp", ui } as unknown as ExtensionContext, {
611
+ command: "id",
612
+ reason: "whoami",
613
+ });
614
+
615
+ expect(bridgeState.confirm).not.toHaveBeenCalled();
616
+ expect(result.isError).toBeFalsy();
617
+ expect((result.content[0] as { text: string }).text).toBe(`${CONFIRMATION_SKIPPED_MARKER}\nok\n`);
618
+ expect((result.details as { confirmationSkipped?: boolean }).confirmationSkipped).toBe(true);
619
+ });
620
+ });
621
+
336
622
  describe("buildSudoArgv", () => {
337
623
  it("builds sudo -S argv for a plain command", () => {
338
624
  expect(buildSudoArgv("apt install ripgrep")).toEqual(["sudo", "-S", "apt", "install", "ripgrep"]);
@@ -350,8 +636,8 @@ describe("sudo_exec tool", () => {
350
636
  describe("execution", () => {
351
637
  it("runs the command and returns stdout with scrubbed details on success", async () => {
352
638
  const { spawner, last } = makeFakeSpawner({ stdout: "hello\n", stderr: "[sudo] password for daniel: ", code: 0 });
353
- const { authProbe, calls } = probeSpy(true);
354
- const tool = createSudoExecTool({ spawner, authProbe });
639
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
640
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
355
641
  credentialCache.set("cachedpw", 60_000);
356
642
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
357
643
 
@@ -402,8 +688,8 @@ describe("sudo_exec tool", () => {
402
688
 
403
689
  it("reports non-zero exit codes as errors", async () => {
404
690
  const { spawner } = makeFakeSpawner({ stderr: "E: not found", code: 100 });
405
- const { authProbe, calls } = probeSpy(true);
406
- const tool = createSudoExecTool({ spawner, authProbe });
691
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
692
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
407
693
  credentialCache.set("pw", 60_000);
408
694
  const spies = spiesNoPrompt();
409
695
  const ctx = tuiCtxWithSpies(true, spies);
@@ -456,8 +742,8 @@ describe("sudo_exec tool", () => {
456
742
  // No automatic exit: only abort's kill ever settles the child.
457
743
  const { spawner, last } = makeFakeSpawner({ stderr: "starting…", code: null, exitOn: "kill" });
458
744
  const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); // group SIGKILL delivered, no exit echoed here — the settle-guard is the point
459
- const { authProbe, calls } = probeSpy(true);
460
- const tool = createSudoExecTool({ spawner, authProbe });
745
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
746
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
461
747
  credentialCache.set("pw", 60_000);
462
748
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
463
749
  const controller = new AbortController();
@@ -568,8 +854,8 @@ describe("sudo_exec tool", () => {
568
854
  // never produced a process) → the review's crash/hang finding.
569
855
  credentialCache.set("pw", 60_000);
570
856
  const err = Object.assign(new Error("spawn sudo ENOENT"), { code: "ENOENT" });
571
- const { authProbe, calls } = probeSpy(true);
572
- const tool = createSudoExecTool({ spawner: () => errorChild(err), authProbe });
857
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
858
+ const tool = createSudoExecTool({ spawner: () => errorChild(err), authProbe, passwordProbe });
573
859
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
574
860
 
575
861
  const result = await run(tool, ctx, { command: "apt update", reason: "maintain" });
@@ -700,8 +986,8 @@ describe("sudo_exec tool", () => {
700
986
  // false-fail this perfectly legitimate fast-exit success.
701
987
  credentialCache.set("nopw", 60_000);
702
988
  const { child, emitStdinError, exit } = streamingChild();
703
- const { authProbe, calls } = probeSpy(true);
704
- const tool = createSudoExecTool({ spawner: () => child, authProbe });
989
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
990
+ const tool = createSudoExecTool({ spawner: () => child, authProbe, passwordProbe });
705
991
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
706
992
 
707
993
  const promise = run(tool, ctx, { command: "hostname", reason: "test" });
@@ -720,8 +1006,8 @@ describe("sudo_exec tool", () => {
720
1006
  it("EPIPE on stdin followed by exit(1) settles as a normal command failure (exit 1), no crash", async () => {
721
1007
  credentialCache.set("pw", 60_000);
722
1008
  const { child, emitStdinError, exit } = streamingChild();
723
- const { authProbe, calls } = probeSpy(true); // gate probes; valid ticket → command failure, not auth
724
- const tool = createSudoExecTool({ spawner: () => child, authProbe });
1009
+ const { authProbe, passwordProbe, calls } = probeSpy(true); // gate probes; valid ticket → command failure, not auth
1010
+ const tool = createSudoExecTool({ spawner: () => child, authProbe, passwordProbe });
725
1011
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
726
1012
 
727
1013
  const promise = run(tool, ctx, { command: "systemctl stop dnsmasq", reason: "test" });
@@ -741,8 +1027,8 @@ describe("sudo_exec tool", () => {
741
1027
  it("anomalous stdin error while the child is still running (no exit) settles a clean transport failure — cache RETAINED, no probe", async () => {
742
1028
  credentialCache.set("pw", 60_000);
743
1029
  const { child, emitStdinError } = streamingChild();
744
- const { authProbe, calls } = probeSpy(true);
745
- const tool = createSudoExecTool({ spawner: () => child, authProbe });
1030
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
1031
+ const tool = createSudoExecTool({ spawner: () => child, authProbe, passwordProbe });
746
1032
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
747
1033
 
748
1034
  const promise = run(tool, ctx, { command: "apt update", reason: "maintain" });
@@ -768,8 +1054,8 @@ describe("sudo_exec tool", () => {
768
1054
  it("anomalous stdin error AFTER exit(0) already settled success is ignored — result stays success (no double-settle)", async () => {
769
1055
  credentialCache.set("pw", 60_000);
770
1056
  const { child, emitStdinError, exit } = streamingChild();
771
- const { authProbe } = probeSpy(true);
772
- const tool = createSudoExecTool({ spawner: () => child, authProbe });
1057
+ const { authProbe, passwordProbe } = probeSpy(true);
1058
+ const tool = createSudoExecTool({ spawner: () => child, authProbe, passwordProbe });
773
1059
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
774
1060
 
775
1061
  const promise = run(tool, ctx, { command: "true", reason: "test" });
@@ -842,6 +1128,67 @@ describe("sudo_exec tool", () => {
842
1128
  });
843
1129
  });
844
1130
 
1131
+ describe("probeSudoPassword (password-based second-stage credential verification)", () => {
1132
+ it("validates with ['sudo','-S','-v','-p',''] and the password on STDIN — no program, never argv", async () => {
1133
+ const calls: Array<{ command: string; args: string[] }> = [];
1134
+ const { spawner, last } = makeFakeSpawner({ stderr: "", code: 0 });
1135
+ const capturing: SudoSpawner = (command, args) => {
1136
+ calls.push({ command, args });
1137
+ return spawner(command, args);
1138
+ };
1139
+
1140
+ const valid = await probeSudoPassword("hunter2", 5000, undefined, capturing);
1141
+
1142
+ expect(calls).toHaveLength(1);
1143
+ expect(calls[0]).toEqual({ command: "sudo", args: ["-S", "-v", "-p", ""] });
1144
+ expect(calls[0]!.args).not.toContain("hunter2"); // password never in argv
1145
+ expect(last.child.stdin.written).toEqual(["hunter2\n"]); // …only on stdin
1146
+ expect(valid).toBe(true);
1147
+ });
1148
+
1149
+ it("returns false when sudo rejects the credential (non-zero exit)", async () => {
1150
+ const calls: Array<{ command: string; args: string[] }> = [];
1151
+ const { spawner } = makeFakeSpawner({ stderr: "sudo: 1 incorrect password attempt\n", code: 1 });
1152
+ const capturing: SudoSpawner = (command, args) => {
1153
+ calls.push({ command, args });
1154
+ return spawner(command, args);
1155
+ };
1156
+
1157
+ const valid = await probeSudoPassword("wrong", 5000, undefined, capturing);
1158
+
1159
+ expect(calls[0]).toEqual({ command: "sudo", args: ["-S", "-v", "-p", ""] });
1160
+ expect(valid).toBe(false);
1161
+ });
1162
+
1163
+ it("is what the tool runs by DEFAULT after a non-terminal ticket probe (real wiring, fake processes)", async () => {
1164
+ const calls: Array<{ command: string; args: string[] }> = [];
1165
+ const steps = stepSpawner([
1166
+ { stderr: NON_ENGLISH_STDERR, code: 1 }, // the command run: ambiguous failure
1167
+ { stderr: "sudo: interactive authentication is required\n", code: 1 }, // stage 1: no reusable ticket
1168
+ { stderr: "", code: 0 }, // stage 2: the password validates
1169
+ ]);
1170
+ const recording: SudoSpawner = (command, args) => {
1171
+ calls.push({ command, args });
1172
+ return steps(command, args);
1173
+ };
1174
+ const tool = createSudoExecTool({ spawner: recording });
1175
+ credentialCache.set("goodpw", 60_000);
1176
+ const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1177
+
1178
+ const result = await run(tool, ctx, { command: "systemctl restart dnsmasq", reason: "flip" });
1179
+
1180
+ expect(calls.map((c) => c.args)).toEqual([
1181
+ ["-S", "systemctl", "restart", "dnsmasq"],
1182
+ ["-n", "-v"],
1183
+ ["-S", "-v", "-p", ""],
1184
+ ]);
1185
+ expect(result.isError).toBe(true);
1186
+ const text = (result.content[0] as { text: string }).text;
1187
+ expect(text).not.toMatch(/one more attempt/i);
1188
+ expect(credentialCache.get()?.failStreak).toBe(0);
1189
+ });
1190
+ });
1191
+
845
1192
  describe("locale-immune auth-failure invalidation (credential ticket probe)", () => {
846
1193
  // The probe gate no longer requires the English prompt: it fires on any
847
1194
  // command-level failure (normal non-zero exit) whose stderr did not
@@ -861,8 +1208,8 @@ describe("sudo_exec tool", () => {
861
1208
  }
862
1209
 
863
1210
  it("keeps the cache on the FIRST ambiguous failure with a non-terminal probe (strike 1 of two) — with the one-more-attempt warning", async () => {
864
- const { authProbe, calls } = probeSpy(false);
865
- const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe });
1211
+ const { authProbe, passwordProbe, calls } = probeSpy(false);
1212
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
866
1213
  credentialCache.set("badpw-de", 60_000);
867
1214
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
868
1215
 
@@ -879,8 +1226,8 @@ describe("sudo_exec tool", () => {
879
1226
  });
880
1227
 
881
1228
  it("keeps the cache (and resets a carried streak) when the probe succeeds — valid credential ticket means the command itself failed, NOT the auth", async () => {
882
- const { authProbe, calls } = probeSpy(true);
883
- const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe });
1229
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
1230
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
884
1231
  credentialCache.set("goodpw", 60_000);
885
1232
  credentialCache.bumpFailStreak(); // carried streak from an earlier ambiguous failure
886
1233
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
@@ -899,9 +1246,76 @@ describe("sudo_exec tool", () => {
899
1246
  expect(calls()).toBe(1);
900
1247
  });
901
1248
 
1249
+ it("verifies the cached PASSWORD when the ticket probe is non-terminal — a correct password is a command failure, not an unverifiable credential", async () => {
1250
+ const { authProbe, passwordProbe, calls, passwordCalls } = probeSpy(false, false, true);
1251
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
1252
+ credentialCache.set("goodpw", 60_000);
1253
+ const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1254
+
1255
+ const result = await run(tool, ctx, { command: "sync; echo 3 > /proc/sys/vm/drop_caches", reason: "cache test" });
1256
+
1257
+ // ticket probe non-terminal, password probe terminal → plain command failure
1258
+ expect(result.isError).toBe(true);
1259
+ const text = (result.content[0] as { text: string }).text;
1260
+ expect(text).not.toMatch(/one more attempt/i);
1261
+ expect(text).not.toMatch(/could not be verified/i);
1262
+ expect((result.details as { error?: string }).error).toMatch(/command failed with exit code 1/i);
1263
+ // the credential verified — nothing thrown away, no strike carried
1264
+ expect(credentialCache.get()?.password).toBe("goodpw");
1265
+ expect(credentialCache.get()?.failStreak).toBe(0);
1266
+ expect(calls()).toBe(1);
1267
+ expect(passwordCalls()).toBe(1); // stage 2 ran exactly once
1268
+ });
1269
+
1270
+ it("does NOT run the password probe when the ticket probe is already terminal (stage 1 stays the cheap check)", async () => {
1271
+ const { authProbe, passwordProbe, calls, passwordCalls } = probeSpy(true);
1272
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
1273
+ credentialCache.set("goodpw", 60_000);
1274
+ const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1275
+
1276
+ const result = await run(tool, ctx, { command: "systemctl restart dnsmasq", reason: "flip" });
1277
+
1278
+ expect(result.isError).toBe(true);
1279
+ expect(calls()).toBe(1);
1280
+ expect(passwordCalls()).toBe(0); // no password spent on a validated ticket
1281
+ });
1282
+
1283
+ it("counts a strike only when BOTH probes are non-terminal — the warning names the credential, not the command", async () => {
1284
+ const { authProbe, passwordProbe, calls, passwordCalls } = probeSpy(false, false, false);
1285
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
1286
+ credentialCache.set("pw", 60_000);
1287
+ const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1288
+
1289
+ const result = await run(tool, ctx, { command: "wake lock", reason: "test" });
1290
+
1291
+ expect(result.isError).toBe(true);
1292
+ expect((result.content[0] as { text: string }).text).toMatch(/one more attempt/i);
1293
+ expect(credentialCache.get()?.failStreak).toBe(1);
1294
+ expect(calls()).toBe(1);
1295
+ expect(passwordCalls()).toBe(1);
1296
+ });
1297
+
1298
+ it("treats a stage-2 transport error as non-terminal — strike 1, never a clear", async () => {
1299
+ const { authProbe, calls } = probeSpy(false);
1300
+ const passwordProbe = (): Promise<boolean> => Promise.reject(new Error("probe transport error"));
1301
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
1302
+ credentialCache.set("pw", 60_000);
1303
+ const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1304
+
1305
+ const result = await run(tool, ctx, { command: "wake lock", reason: "test" });
1306
+
1307
+ expect(result.isError).toBe(true);
1308
+ const text = (result.content[0] as { text: string }).text;
1309
+ expect(text).not.toMatch(/authentication failed/i);
1310
+ expect(text).toMatch(/one more attempt/i);
1311
+ expect(credentialCache.get()?.password).toBe("pw");
1312
+ expect(credentialCache.get()?.failStreak).toBe(1);
1313
+ expect(calls()).toBe(1);
1314
+ });
1315
+
902
1316
  it("keeps the cache on the first ambiguous failure when the probe itself errors — strike 1, not an immediate clear", async () => {
903
- const { authProbe, calls } = probeSpy(false, true);
904
- const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe });
1317
+ const { authProbe, passwordProbe, calls } = probeSpy(false, true);
1318
+ const tool = createSudoExecTool({ spawner: ambiguousRunSpawner(), authProbe, passwordProbe });
905
1319
  credentialCache.set("pw", 60_000);
906
1320
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
907
1321
 
@@ -922,8 +1336,8 @@ describe("sudo_exec tool", () => {
922
1336
  stderr: "[sudo] password for daniel: \nsorry, try again.\n3 incorrect password attempts\n",
923
1337
  code: 1,
924
1338
  });
925
- const { authProbe, calls } = probeSpy(true);
926
- const tool = createSudoExecTool({ spawner, authProbe });
1339
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
1340
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
927
1341
  credentialCache.set("badpw", 60_000);
928
1342
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
929
1343
 
@@ -938,8 +1352,8 @@ describe("sudo_exec tool", () => {
938
1352
 
939
1353
  it("never probes a timed-out run, even when the prompt was seen (124/137 branches stay untouched)", async () => {
940
1354
  const { spawner } = makeFakeSpawner({ stderr: "[sudo] password for daniel: ", code: null, exitOn: "kill" });
941
- const { authProbe, calls } = probeSpy(true);
942
- const tool = createSudoExecTool({ spawner, authProbe });
1355
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
1356
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
943
1357
  credentialCache.set("pw", 60_000);
944
1358
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
945
1359
 
@@ -951,8 +1365,8 @@ describe("sudo_exec tool", () => {
951
1365
  });
952
1366
 
953
1367
  it("verifies the probe still runs on a localized-sudo failure with NO English prompt text at all (promptSeen=false — the key regression) — and keeps the cache on strike 1", async () => {
954
- const { authProbe, calls } = probeSpy(false);
955
- const tool = createSudoExecTool({ spawner: localizedOnlyRunSpawner(), authProbe });
1368
+ const { authProbe, passwordProbe, calls } = probeSpy(false);
1369
+ const tool = createSudoExecTool({ spawner: localizedOnlyRunSpawner(), authProbe, passwordProbe });
956
1370
  credentialCache.set("badpw-de", 60_000);
957
1371
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
958
1372
 
@@ -968,8 +1382,8 @@ describe("sudo_exec tool", () => {
968
1382
  });
969
1383
 
970
1384
  it("keeps the cache on a no-English-prompt failure when the ticket is valid (probe disambiguates auth from command failure)", async () => {
971
- const { authProbe, calls } = probeSpy(true);
972
- const tool = createSudoExecTool({ spawner: localizedOnlyRunSpawner(), authProbe });
1385
+ const { authProbe, passwordProbe, calls } = probeSpy(true);
1386
+ const tool = createSudoExecTool({ spawner: localizedOnlyRunSpawner(), authProbe, passwordProbe });
973
1387
  credentialCache.set("goodpw", 60_000);
974
1388
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
975
1389
 
@@ -989,8 +1403,8 @@ describe("sudo_exec tool", () => {
989
1403
  { stderr: NON_ENGLISH_STDERR, code: 1 }, // strike 1
990
1404
  { stderr: NON_ENGLISH_STDERR, code: 1 }, // strike 2
991
1405
  ]);
992
- const { authProbe, calls } = probeSpy(false);
993
- const tool = createSudoExecTool({ spawner, authProbe });
1406
+ const { authProbe, passwordProbe, calls } = probeSpy(false);
1407
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
994
1408
  credentialCache.set("badpw-de", 60_000);
995
1409
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
996
1410
 
@@ -1017,8 +1431,8 @@ describe("sudo_exec tool", () => {
1017
1431
  { stdout: "ok\n", code: 0 }, // success → reset
1018
1432
  { stderr: NON_ENGLISH_STDERR, code: 1 }, // ambiguous again → must be strike 1, not a clear
1019
1433
  ]);
1020
- const { authProbe, calls } = probeSpy(false);
1021
- const tool = createSudoExecTool({ spawner, authProbe });
1434
+ const { authProbe, passwordProbe, calls } = probeSpy(false);
1435
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
1022
1436
  credentialCache.set("pw", 60_000);
1023
1437
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1024
1438
 
@@ -1043,8 +1457,8 @@ describe("sudo_exec tool", () => {
1043
1457
  { stderr: NON_ENGLISH_STDERR, code: 1 }, // ambiguous → streak 1
1044
1458
  { stderr: "[sudo] password for daniel: \nsorry, try again.\n3 incorrect password attempts\n", code: 1 }, // fast path
1045
1459
  ]);
1046
- const { authProbe, calls } = probeSpy(false); // non-terminal probe → strike 1, not a ticket-valid reset
1047
- const tool = createSudoExecTool({ spawner, authProbe });
1460
+ const { authProbe, passwordProbe, calls } = probeSpy(false); // non-terminal probe → strike 1, not a ticket-valid reset
1461
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
1048
1462
  credentialCache.set("badpw", 60_000);
1049
1463
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1050
1464
 
@@ -1067,8 +1481,8 @@ describe("sudo_exec tool", () => {
1067
1481
  if (i === 2) return erroredChild(); // transport failure says nothing about the credential
1068
1482
  return ambient(command, args);
1069
1483
  };
1070
- const { authProbe } = probeSpy(false);
1071
- const tool = createSudoExecTool({ spawner, authProbe });
1484
+ const { authProbe, passwordProbe } = probeSpy(false);
1485
+ const tool = createSudoExecTool({ spawner, authProbe, passwordProbe });
1072
1486
  credentialCache.set("pw", 60_000);
1073
1487
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1074
1488
 
@@ -1342,9 +1756,9 @@ describe("sudo_exec tool", () => {
1342
1756
  if (killAttempts === 1) throw Object.assign(new Error("operation not permitted"), { code: "EACCES" });
1343
1757
  return true;
1344
1758
  });
1345
- const { authProbe, calls } = probeSpy(false); // non-terminal probe — any bad entry would bump the streak, VIEWABLE
1759
+ const { authProbe, passwordProbe, calls } = probeSpy(false); // non-terminal probe — any bad entry would bump the streak, VIEWABLE
1346
1760
  const racing = racingChild();
1347
- const tool = createSudoExecTool({ spawner: racing.spawner, authProbe });
1761
+ const tool = createSudoExecTool({ spawner: racing.spawner, authProbe, passwordProbe });
1348
1762
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1349
1763
  const controller = new AbortController();
1350
1764
 
@@ -1378,10 +1792,10 @@ describe("sudo_exec tool", () => {
1378
1792
  if (killAttempts % 2 === 1) throw Object.assign(new Error("operation not permitted"), { code: "EACCES" });
1379
1793
  return true; // every odd attempt opens the 50ms retry window; the even one confirms
1380
1794
  });
1381
- const { authProbe, calls } = probeSpy(false);
1795
+ const { authProbe, passwordProbe, calls } = probeSpy(false);
1382
1796
  const races = [racingChild(), racingChild()]; // fresh child per run
1383
1797
  let next = 0;
1384
- const tool = createSudoExecTool({ spawner: () => races[next++]!.child, authProbe });
1798
+ const tool = createSudoExecTool({ spawner: () => races[next++]!.child, authProbe, passwordProbe });
1385
1799
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1386
1800
 
1387
1801
  const abortRun = async (reason: string) => {
@@ -1407,9 +1821,9 @@ describe("sudo_exec tool", () => {
1407
1821
 
1408
1822
  it("a normal (non-aborted) 1–123 exit still probes and bumps exactly as before — the swallow only applies post-abort", async () => {
1409
1823
  credentialCache.set("pw", 60_000);
1410
- const { authProbe, calls } = probeSpy(false); // non-terminal probe → strike 1
1824
+ const { authProbe, passwordProbe, calls } = probeSpy(false); // non-terminal probe → strike 1
1411
1825
  const racing = racingChild();
1412
- const tool = createSudoExecTool({ spawner: racing.spawner, authProbe });
1826
+ const tool = createSudoExecTool({ spawner: racing.spawner, authProbe, passwordProbe });
1413
1827
  const ctx = tuiCtxWithSpies(true, spiesNoPrompt());
1414
1828
 
1415
1829
  const promise = run(tool, ctx, { command: "apt update", reason: "maintain" });
@@ -1436,14 +1850,27 @@ function spiesNoPrompt() {
1436
1850
  };
1437
1851
  }
1438
1852
 
1439
- function probeSpy(ok: boolean, throws = false) {
1853
+ /**
1854
+ * Both credential probes, controllable per test.
1855
+ *
1856
+ * `ok`/`throws` drive stage 1 (the `sudo -n -v` ticket probe). `passwordOk`
1857
+ * drives stage 2 (the `sudo -S -v` password probe), which the tool runs ONLY
1858
+ * when stage 1 is non-terminal — the default there is non-terminal (false),
1859
+ * which keeps the pre-existing two-strike tests meaning what they say.
1860
+ */
1861
+ function probeSpy(ok: boolean, throws = false, passwordOk = false) {
1440
1862
  let calls = 0;
1863
+ let pwCalls = 0;
1441
1864
  const authProbe: (timeoutMs: number, signal: AbortSignal | undefined) => Promise<boolean> = () => {
1442
1865
  calls += 1;
1443
1866
  if (throws) return Promise.reject(new Error("probe transport error"));
1444
1867
  return Promise.resolve(ok);
1445
1868
  };
1446
- return { authProbe, calls: () => calls };
1869
+ const passwordProbe: (password: string, timeoutMs: number, signal: AbortSignal | undefined) => Promise<boolean> = () => {
1870
+ pwCalls += 1;
1871
+ return Promise.resolve(passwordOk);
1872
+ };
1873
+ return { authProbe, passwordProbe, calls: () => calls, passwordCalls: () => pwCalls };
1447
1874
  }
1448
1875
 
1449
1876
  function tuiCtxWithSpies(confirmAnswer: boolean, spies: { confirmCalls: Array<{ title: string; message: string }>; customCalls: unknown[] }): ExtensionContext {
package/src/tool.ts CHANGED
@@ -30,8 +30,29 @@ export interface SudoExecDetails {
30
30
  stdout: string;
31
31
  stderr: string;
32
32
  error?: string;
33
+ /**
34
+ * Present and `true` only when the confirmation gate was skipped because a
35
+ * credential was already cached (`archimedes.sudo.confirmWhenCached: false`,
36
+ * ADR 0033). This is the machine-readable record of the skipped gate in the
37
+ * session file; `CONFIRMATION_SKIPPED_MARKER` is the same fact in the result
38
+ * text, which is the half the terminal and the model actually see.
39
+ */
40
+ confirmationSkipped?: boolean;
33
41
  }
34
42
 
43
+ /**
44
+ * First line of a tool result whose confirmation gate was skipped (ADR 0033).
45
+ *
46
+ * It belongs in the result **text**, not only in `details`: this tool registers
47
+ * no result renderer, so the TUI fallback prints `content[].text` alone, and
48
+ * both provider transforms rebuild a tool result from `content`, dropping
49
+ * `details`. Without the line, a run nobody approved is word-for-word what an
50
+ * approved one looks like — to the human reading the transcript, and to the
51
+ * model deciding whether to issue the next privileged command.
52
+ */
53
+ export const CONFIRMATION_SKIPPED_MARKER =
54
+ "[sudo] confirmation skipped — cached credential, archimedes.sudo.confirmWhenCached: false (no human confirmed this command)";
55
+
35
56
  // ── process plumbing ─────────────────────────────────────────────────────────
36
57
 
37
58
  /** Structural subset of ChildProcess used by runSudo — injectable for tests. */
@@ -284,16 +305,20 @@ export function runSudo(
284
305
  });
285
306
  }
286
307
 
287
- /** Timeout for the credential ticket probe (see probeSudoTicket). */
308
+ /** Timeout for the credential probes (see probeSudoTicket / probeSudoPassword). */
288
309
  export const AUTH_PROBE_TIMEOUT_MS = 5000;
289
310
 
290
311
  /**
291
312
  * Honest warning surfaced (content + details) on the FIRST ambiguous failure
292
- * — a non-zero exit without a recognizable failure signature whose
293
- * credential probe came back non-terminal: the credential is unverified but
294
- * NOT yet invalidated (a no-reuse sudoers policy makes `sudo -n -v` fail even
295
- * for a CORRECT password). Kept for one more attempt; a second consecutive
313
+ * — a non-zero exit without a recognizable failure signature whose BOTH
314
+ * credential probes came back non-terminal: the credential is unverified but
315
+ * NOT yet invalidated. Kept for one more attempt; a second consecutive
296
316
  * ambiguous failure on the same cached entry clears it and re-prompts.
317
+ *
318
+ * Both probes must fail before this warning appears: the ticket probe
319
+ * (`sudo -n -v`) cannot see a ticket under a no-reuse sudoers policy or a
320
+ * tty-keyed timestamp with no controlling terminal, and the password probe
321
+ * (`sudo -S -v`, second stage) covers exactly that case.
297
322
  */
298
323
  const AMBIGUOUS_CREDENTIAL_WARNING =
299
324
  "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.";
@@ -318,6 +343,40 @@ export async function probeSudoTicket(
318
343
  return !run.timedOut && run.error === undefined && run.exitCode === 0;
319
344
  }
320
345
 
346
+ /**
347
+ * Second-stage credential check: validate the CACHED PASSWORD itself by
348
+ * running `sudo -S -v`, which executes no program and returns 0 iff sudo
349
+ * accepts the credential supplied on stdin. That is what the ticket probe
350
+ * cannot answer, and the difference matters on hosts where a ticket is never
351
+ * reusable:
352
+ *
353
+ * - `timestamp_type=tty` (Ubuntu's compile-time default) against a spawn
354
+ * with no controlling terminal produces a ticket no later invocation can
355
+ * replay, so `sudo -n -v` fails for a CORRECT password.
356
+ * - `timestamp_timeout=0` / `!timestamp` sudoers policies have the same
357
+ * effect by design (the ticket is gone the moment the command ends).
358
+ *
359
+ * Without this stage every command-level failure on such a host reads as an
360
+ * unverifiable credential: a misleading warning on the first, a cache wipe
361
+ * and a fresh password prompt on the second.
362
+ *
363
+ * No `-k`: when a ticket IS reusable this returns immediately and reports an
364
+ * otherwise-usable credential, and revoking the user's own ticket as a side
365
+ * effect of a diagnostic would be rude. The password reaches sudo over stdin
366
+ * only, never argv or env (ADR 0010), and a failed validation is NOT treated
367
+ * as evidence of a wrong password here — only success is terminal, so the
368
+ * two-strike rule keeps its existing margin.
369
+ */
370
+ export async function probeSudoPassword(
371
+ password: string,
372
+ timeoutMs: number,
373
+ signal: AbortSignal | undefined,
374
+ spawner: SudoSpawner,
375
+ ): Promise<boolean> {
376
+ const run = await runSudo(["sudo", "-S", "-v", "-p", ""], password, timeoutMs, signal, spawner);
377
+ return !run.timedOut && run.error === undefined && run.exitCode === 0;
378
+ }
379
+
321
380
  /**
322
381
  * Belt-and-braces: drop any line that contains the raw password so it can
323
382
  * never appear in tool details/result content (normally stderr is clean —
@@ -346,15 +405,15 @@ export function buildSudoArgv(command: string): string[] {
346
405
 
347
406
  const SUDO_EXEC_DESCRIPTION = `Run a command with elevated privileges using sudo.
348
407
 
349
- - BEFORE any credential is requested, the exact command and its reason are shown to the user for confirmation.
350
- - The password is entered only through a masked UI, passed to sudo via stdin, cached in memory for the session, and never exposed.
408
+ - BEFORE any credential is requested, the exact command and its reason are shown to the user for confirmation. The one exception is user configuration: with \`archimedes.sudo.confirmWhenCached: false\`, a command whose credential is already cached runs without that confirmation, and the result text starts with a line saying so.
409
+ - The password is entered only through a masked UI, passed to sudo via stdin, cached in memory for the process, and never exposed.
351
410
  - Use this instead of sudo in bash — interactive sudo in bash is blocked.
352
411
  - The command is executed as argv, NOT through a shell: no pipes, redirects, &&, ;, or env assignments; pass args space-separated, quote only literal args.`;
353
412
 
354
413
  /**
355
414
  * Create the sudo_exec tool definition. Options are a test seam: pass a
356
- * custom spawner to exercise execution paths without real processes, and a
357
- * custom authProbe to control the ticket probe without spawning `sudo -n`.
415
+ * custom spawner to exercise execution paths without real processes, and
416
+ * custom probes to control the credential checks without spawning `sudo -v`.
358
417
  */
359
418
  export function createSudoExecTool(options: {
360
419
  spawner?: SudoSpawner;
@@ -367,12 +426,26 @@ export function createSudoExecTool(options: {
367
426
  * password-free credential validation that executes no program)
368
427
  * is safe to run after any command-level failure. Default: a
369
428
  * spawner-based `sudo -n -v` ticket probe (probeSudoTicket). It
370
- * never receives the password, by design.
429
+ * never receives the password, by design — the second stage below
430
+ * is what asks the credential itself.
371
431
  */
372
432
  authProbe?: (timeoutMs: number, signal: AbortSignal | undefined) => Promise<boolean>;
433
+ /**
434
+ * Second-stage credential check, run only when the ticket probe is
435
+ * non-terminal: validate the CACHED PASSWORD with `sudo -S -v` (no
436
+ * program executed, password over stdin). This is the check that
437
+ * separates "the password is bad" from "this host never has a
438
+ * reusable ticket" (tty-keyed timestamps without a controlling
439
+ * terminal, `timestamp_timeout=0`, no-reuse sudoers). Default:
440
+ * probeSudoPassword.
441
+ */
442
+ passwordProbe?: (password: string, timeoutMs: number, signal: AbortSignal | undefined) => Promise<boolean>;
373
443
  } = {}) {
374
444
  const spawner = options.spawner ?? defaultSpawner;
375
445
  const authProbe = options.authProbe ?? ((timeoutMs: number, signal: AbortSignal | undefined) => probeSudoTicket(timeoutMs, signal, spawner));
446
+ const passwordProbe =
447
+ options.passwordProbe ??
448
+ ((password: string, timeoutMs: number, signal: AbortSignal | undefined) => probeSudoPassword(password, timeoutMs, signal, spawner));
376
449
 
377
450
  const fail = (text: string, details: SudoExecDetails) => ({
378
451
  content: [{ type: "text" as const, text }],
@@ -436,20 +509,54 @@ export function createSudoExecTool(options: {
436
509
  }
437
510
 
438
511
  // Display the exact command + reason and require confirmation
439
- // BEFORE any credential is acquired.
440
- const confirmed = await confirmCommand(ctx, command, reason);
441
- if (!confirmed) {
442
- return fail("Command not confirmed — not executed, and no password was requested.", {
443
- command,
444
- reason,
445
- exitCode: -1,
446
- stdout: "",
447
- stderr: "",
448
- error: "command not confirmed",
449
- });
450
- }
451
-
512
+ // BEFORE any credential is acquired (ADR 0010). The one exception is
513
+ // opt-in and narrow — `confirmWhenCached: false` with a credential
514
+ // already cached: the password prompt already showed this user the
515
+ // command and reason it was cached for, and the run is recorded with
516
+ // `confirmationSkipped` (ADR 0033). Nothing about the credential is
517
+ // changed here, and a cache miss still confirms and prompts.
452
518
  let credential = credentialCache.get();
519
+ const confirmationSkipped = credential !== null && config.confirmWhenCached === false;
520
+ /**
521
+ * Attach the gate provenance to a result produced after the gate: the
522
+ * flag for the session file, and the marker line in front of the text —
523
+ * which is the half a human in the terminal and the model actually see.
524
+ * Prefixing keeps every existing suffix (the unverified-credential
525
+ * warning) exactly where it read before.
526
+ */
527
+ const gated = <T extends { content: Array<{ type: "text"; text: string }>; details: SudoExecDetails }>(result: T): T => {
528
+ if (!confirmationSkipped) return result;
529
+ const text = result.content[0]?.text ?? "";
530
+ return {
531
+ ...result,
532
+ // A failed command with no stdout gets the marker alone — not a
533
+ // marker plus an empty line the terminal then renders as a gap.
534
+ content: [
535
+ { type: "text" as const, text: text ? `${CONFIRMATION_SKIPPED_MARKER}\n${text}` : CONFIRMATION_SKIPPED_MARKER },
536
+ ],
537
+ details: { ...result.details, confirmationSkipped: true },
538
+ };
539
+ };
540
+ if (!confirmationSkipped) {
541
+ const confirmed = await confirmCommand(ctx, command, reason);
542
+ if (!confirmed) {
543
+ return fail("Command not confirmed — not executed, and no password was requested.", {
544
+ command,
545
+ reason,
546
+ exitCode: -1,
547
+ stdout: "",
548
+ stderr: "",
549
+ error: "command not confirmed",
550
+ });
551
+ }
552
+ // The dialog is awaited, so the entry above may have been cleared or have
553
+ // expired while the human decided (`/sudo forget`, another session's
554
+ // `session_start`, the TTL elapsing). Re-read it rather than running on
555
+ // the retained object: a revoked credential must be re-prompted for.
556
+ // The read above stays where it is — `confirmationSkipped` is computed
557
+ // from it and decides whether a dialog exists at all.
558
+ credential = credentialCache.get();
559
+ }
453
560
  if (!credential) {
454
561
  const password = await promptForPassword(ctx, command, reason);
455
562
  if (!password) {
@@ -489,26 +596,26 @@ export function createSudoExecTool(options: {
489
596
  // auth failure, and never drop the password: a transport glitch is
490
597
  // transient relative to the credential itself.
491
598
  if (run.error) {
492
- return fail(`failed to run privileged command: ${run.error}`, {
599
+ return gated(fail(`failed to run privileged command: ${run.error}`, {
493
600
  command,
494
601
  reason,
495
602
  exitCode: run.exitCode,
496
603
  stdout,
497
604
  stderr,
498
605
  error: run.error,
499
- });
606
+ }));
500
607
  }
501
608
 
502
609
  if (run.authFailed) {
503
610
  credentialCache.clear();
504
- return fail(
611
+ return gated(fail(
505
612
  `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.`,
506
613
  { command, reason, exitCode: run.exitCode, stdout, stderr, error: "authentication failed" },
507
- );
614
+ ));
508
615
  }
509
616
 
510
617
  if (run.timedOut) {
511
- return {
618
+ return gated({
512
619
  content: [{ type: "text" as const, text: stdout }],
513
620
  details: {
514
621
  command,
@@ -519,28 +626,39 @@ export function createSudoExecTool(options: {
519
626
  error: `timed out after ${timeoutMs}ms — command was killed`,
520
627
  } satisfies SudoExecDetails,
521
628
  isError: true,
522
- };
629
+ });
523
630
  }
524
631
 
525
632
  // Locale-immune auth-failure disambiguation: the run exited
526
633
  // non-zero WITHOUT the English "incorrect password" fast-path
527
634
  // signature — a wrong password is indistinguishable from a
528
635
  // localized message (or a genuine command failure) on the
529
- // signature path alone. Probe the credential with `sudo -n -v`
530
- // (validates the ticket, executes no program, no password):
531
- // the probe succeeding means the credential is fine and the
532
- // command itself failed → normal result; the probe failing means
533
- // the credential is unverified. Two-strike rule on the AMBIGUOUS
534
- // path only: a no-reuse sudoers policy makes `sudo -n -v` fail
535
- // even for a CORRECT password (no reusable ticket), so the first
536
- // such failure keeps the credential with a visible one-more-attempt
636
+ // signature path alone. Two credential probes, cheapest first:
637
+ //
638
+ // 1. `sudo -n -v` (probeSudoTicket) validates the TICKET,
639
+ // executes no program, needs no password.
640
+ // 2. `sudo -S -v` (probeSudoPassword) validates the CACHED
641
+ // PASSWORD, executes no program, password over stdin only.
642
+ // Runs only when stage 1 is non-terminal, because stage 1
643
+ // failing is exactly the case stage 2 answers: a no-reuse
644
+ // sudoers policy, or `timestamp_type=tty` against a spawn
645
+ // with no controlling terminal (Ubuntu's default), leaves
646
+ // no replayable ticket EVEN FOR A CORRECT PASSWORD. With
647
+ // stage 1 alone, every command-level failure on such a host
648
+ // reads as an unverifiable credential.
649
+ //
650
+ // Either probe succeeding means the credential is fine and the
651
+ // command itself failed → normal result. Two-strike rule on the
652
+ // AMBIGUOUS path only (both probes non-terminal): the first such
653
+ // failure keeps the credential with a visible one-more-attempt
537
654
  // warning; the SECOND consecutive ambiguous failure on the same
538
655
  // cached entry clears it and re-prompts. The English fast path
539
656
  // (above) still clears immediately, and success / run / /timeout
540
657
  // /abort outcomes reset (or leave untouched) the streak.
541
658
  // NOT gated on the prompt text — localized sudo emits a prompt in
542
- // the user's language, and after ANY command-level failure the
543
- // probe is safe (valid ticket → keep, no ticket → re-prompt).
659
+ // the user's language, and after ANY command-level failure both
660
+ // probes are safe (the password one validates only, and a wrong
661
+ // password still costs nothing beyond a failed `sudo -v`).
544
662
  // Control/kill exits are excluded: -1 process error (handled
545
663
  // above), 124 timeout (handled above), 130 abort, 137 SIGKILL —
546
664
  // those aren't auth evidence and must not count as strikes.
@@ -551,20 +669,34 @@ export function createSudoExecTool(options: {
551
669
  } catch {
552
670
  ticketValid = false; // probe transport error → non-terminal probe
553
671
  }
554
- if (ticketValid) {
555
- // Ticket terminal: the credential verified — drop any carried
672
+
673
+ // Stage 2. Only success is terminal: a failed `sudo -v` keeps the
674
+ // existing margin (no immediate clear on a single ambiguous
675
+ // failure), so a transport hiccup or an over-restrictive sudoers
676
+ // cannot throw away a good credential.
677
+ let passwordValid = false;
678
+ if (!ticketValid) {
679
+ try {
680
+ passwordValid = await passwordProbe(credential.password, AUTH_PROBE_TIMEOUT_MS, signal);
681
+ } catch {
682
+ passwordValid = false; // probe transport error → non-terminal probe
683
+ }
684
+ }
685
+
686
+ if (ticketValid || passwordValid) {
687
+ // Terminal: the credential verified — drop any carried
556
688
  // streak; this is a straightforward command failure.
557
689
  credentialCache.resetFailStreak();
558
690
  } else {
559
- // Non-terminal probe: unverifiable credential — count a strike
691
+ // Non-terminal probes: unverifiable credential — count a strike
560
692
  // on the CURRENT cached entry.
561
693
  credentialCache.bumpFailStreak();
562
694
  if ((credentialCache.get()?.failStreak ?? 0) >= 2) {
563
695
  credentialCache.clear();
564
- return fail(
696
+ return gated(fail(
565
697
  `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.`,
566
698
  { command, reason, exitCode: run.exitCode, stdout, stderr, error: "authentication failed" },
567
- );
699
+ ));
568
700
  }
569
701
  unverifiedCredentialWarning = true; // strike 1: keep, warn, and fall through to the normal failure result
570
702
  }
@@ -573,12 +705,12 @@ export function createSudoExecTool(options: {
573
705
  const isError = run.exitCode !== 0;
574
706
  if (!isError) {
575
707
  credentialCache.resetFailStreak();
576
- return {
708
+ return gated({
577
709
  content: [{ type: "text" as const, text: stdout }],
578
710
  details: { command, reason, exitCode: run.exitCode, stdout, stderr } satisfies SudoExecDetails,
579
- };
711
+ });
580
712
  }
581
- return {
713
+ return gated({
582
714
  content: [{ type: "text" as const, text: unverifiedCredentialWarning ? `${stdout}\n(WARNING: ${AMBIGUOUS_CREDENTIAL_WARNING})` : stdout }],
583
715
  details: {
584
716
  command,
@@ -591,7 +723,7 @@ export function createSudoExecTool(options: {
591
723
  : `command failed with exit code ${run.exitCode}`,
592
724
  } satisfies SudoExecDetails,
593
725
  isError: true,
594
- };
726
+ });
595
727
  },
596
728
  };
597
729
  }