@pi-archimedes/sudo 2.12.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 +25 -7
- package/package.json +3 -3
- package/src/cache.ts +13 -3
- package/src/config.test.ts +12 -5
- package/src/config.ts +10 -0
- package/src/index.ts +4 -2
- package/src/tool-declarations.test.ts +22 -0
- package/src/tool.test.ts +474 -47
- package/src/tool.ts +180 -48
package/README.md
CHANGED
|
@@ -24,17 +24,18 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0:
|
|
|
24
24
|
npm install -g --ignore-scripts @earendil-works/pi-coding-agent
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
After installing Pi, choose one installation command above, then `cd` into your project and run `pi`. Inside the session, `/login` signs you in and `/model` picks a model — the [setup section](https://github.com/
|
|
27
|
+
After installing Pi, choose one installation command above, then `cd` into your project and run `pi`. Inside the session, `/login` signs you in and `/model` picks a model — the [setup section](https://github.com/archimedes-codes/pi-archimedes#setup) covers the first run. `/reload` picks the extension up in a running session.
|
|
28
28
|
|
|
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`.
|
|
35
36
|
- **Timeout/abort cleanup** — on timeout or abort, the tool attempts to kill the command's entire process group, falling back to the direct sudo process, so root children, not just the direct sudo process, are the target of the cleanup. Deliberately detached descendants (`setsid`, daemonising) leave the process group and are outside its scope — use a flag to keep the child a process in the group (e.g. `--foreground`).
|
|
36
37
|
- **Headless sessions refused** — `sudo_exec` requires an interactive (TUI) session; subagent and headless sessions get a clear error instead of a prompt. The masked prompt only ever appears in front of a human.
|
|
37
|
-
- **Active bash guard** — a `tool_call` veto on the built-in `bash` tool (per [ADR 0010](https://github.com/
|
|
38
|
+
- **Active bash guard** — a `tool_call` veto on the built-in `bash` tool (per [ADR 0010](https://github.com/archimedes-codes/pi-archimedes/blob/main/docs/decisions/0010-archimedes-sudo-security.md)) blocks interactive `sudo`, funneling privileged execution toward `sudo_exec`.
|
|
38
39
|
|
|
39
40
|
## Usage
|
|
40
41
|
|
|
@@ -55,7 +56,7 @@ The scanned `bash` commands:
|
|
|
55
56
|
- **Blocked:** `sudo` in command position without a no-prompt flag — including through runner wrappers (`env`, `nohup`, `timeout`, `xargs`, …), nested shells (`bash -c`, `su -c`), `eval`, compound keywords, and heredoc bodies.
|
|
56
57
|
- **Allowed:** sudo occurrences that carry a no-prompt flag (`-n`, `-l`, `-v`, `-K`, `-k`, `--non-interactive`, or merged short flags composed solely of those) — the scanner's allow-list exception, mirroring the typical non-interactive usage.
|
|
57
58
|
|
|
58
|
-
The guard is a **heuristic with accepted residual bypasses** documented in the [ADR 0010 design notes](https://github.com/
|
|
59
|
+
The guard is a **heuristic with accepted residual bypasses** documented in the [ADR 0010 design notes](https://github.com/archimedes-codes/pi-archimedes/blob/main/docs/decisions/0010-archimedes-sudo-security.md) — for example cross-token variable indirection, and `sudo` inside `$(...)`/backtick interpolation the word-position model cannot see. Over-blocking is the safe direction; the tested no-prompt flag set is a stable contract of the scanner, **not** a guarantee that no prompt can occur.
|
|
59
60
|
|
|
60
61
|
## Commands
|
|
61
62
|
|
|
@@ -70,16 +71,33 @@ 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
|
-
|
|
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
|
-
-
|
|
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
|
|
|
83
|
-
In [pi-archimedes](https://github.com/
|
|
101
|
+
In [pi-archimedes](https://github.com/archimedes-codes/pi-archimedes), the package is registered by the plugin manifest; the bash guard and `sudo_exec` are loaded in the main session and in subagent children — the guard still vetoes there, while `sudo_exec` itself refuses to run in headless mode. Standalone works independently. On/off is managed by the suite: toggle via `/plugins` (`archimedes.sudo.enabled`, default on).
|
|
84
102
|
|
|
85
|
-
← [Back to pi-archimedes](https://github.com/
|
|
103
|
+
← [Back to pi-archimedes](https://github.com/archimedes-codes/pi-archimedes)
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/sudo",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
|
-
"url": "https://github.com/
|
|
6
|
+
"url": "https://github.com/archimedes-codes/pi-archimedes.git"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"keywords": [
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"./guard": "./src/guard.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
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
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
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();
|
package/src/config.test.ts
CHANGED
|
@@ -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([
|
|
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
|
|
25
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
294
|
-
* NOT yet invalidated
|
|
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
|
|
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
|
|
357
|
-
* custom
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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.
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
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
|
|
543
|
-
//
|
|
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
|
-
|
|
555
|
-
|
|
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
|
|
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
|
}
|