@xynogen/pix-ssh 0.1.0 → 0.2.1
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/package.json +1 -1
- package/src/index.ts +26 -1
- package/src/lib.test.ts +32 -0
- package/src/lib.ts +27 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -47,10 +47,13 @@ import { withAgentBlock } from "@xynogen/pix-runtime";
|
|
|
47
47
|
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
48
48
|
import { Type } from "typebox";
|
|
49
49
|
import {
|
|
50
|
+
APPROVAL_TTL_MS,
|
|
51
|
+
commandEscalatesPrivilege,
|
|
50
52
|
controlPathFor,
|
|
51
53
|
detectSshFailure,
|
|
52
54
|
detectSudoFailure,
|
|
53
55
|
type HostSpec,
|
|
56
|
+
hostApproved,
|
|
54
57
|
hostTarget,
|
|
55
58
|
MAX_OUTPUT_BYTES,
|
|
56
59
|
MAX_OUTPUT_LINES,
|
|
@@ -71,6 +74,14 @@ interface HostCreds {
|
|
|
71
74
|
}
|
|
72
75
|
const credCache = new Map<string, HostCreds>();
|
|
73
76
|
|
|
77
|
+
// Per-host allow-memory (on by default): once the user approves any command on
|
|
78
|
+
// a host, later calls to that host skip the confirm overlay until the approval
|
|
79
|
+
// expires — mirroring sudo's PAM ticket window (~15 min), not the whole session.
|
|
80
|
+
// Password prompts are NOT skipped — a still-missing login or sudo password
|
|
81
|
+
// always re-prompts. Each auto-approve emits a visible notify so the decision is
|
|
82
|
+
// never silent. Map value = epoch ms when the approval lapses (see APPROVAL_TTL_MS).
|
|
83
|
+
const approvedHosts = new Map<string, number>();
|
|
84
|
+
|
|
74
85
|
function cacheKey(spec: HostSpec): string {
|
|
75
86
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
76
87
|
}
|
|
@@ -318,9 +329,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
318
329
|
// pix-sudo uses (masked input, N attempts). We can't validate remote
|
|
319
330
|
// passwords without connecting, so validatePassword just accepts a
|
|
320
331
|
// non-empty entry; a wrong password surfaces as an auth error after run.
|
|
332
|
+
// Session allow-memory: host already approved + no password missing → skip
|
|
333
|
+
// the confirm overlay entirely (visible notify below). Privileged commands
|
|
334
|
+
// (sudo:true, or a sudo/su/doas/pkexec token in the command text) are never
|
|
335
|
+
// auto-approved — they always re-confirm.
|
|
336
|
+
const privileged = sudo || commandEscalatesPrivilege(command);
|
|
337
|
+
const alreadyApproved =
|
|
338
|
+
!privileged && hostApproved(approvedHosts, key) && promptFor.length === 0;
|
|
339
|
+
if (alreadyApproved) {
|
|
340
|
+
ctx.ui.notify(`🔐 ssh_run auto-approved — ${host} allowed this session`, "warning");
|
|
341
|
+
}
|
|
342
|
+
|
|
321
343
|
const runOverlay = (): Promise<OverlayResult> =>
|
|
322
344
|
withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
|
|
323
|
-
if (yolo && promptFor.length === 0) {
|
|
345
|
+
if ((yolo || alreadyApproved) && promptFor.length === 0) {
|
|
324
346
|
return { action: "approved", password: "" } as OverlayResult;
|
|
325
347
|
}
|
|
326
348
|
// Confirm-only when no password is missing.
|
|
@@ -376,6 +398,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
376
398
|
return r;
|
|
377
399
|
}
|
|
378
400
|
|
|
401
|
+
// Remember this host's approval until the TTL lapses (refreshed each call).
|
|
402
|
+
approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
403
|
+
|
|
379
404
|
// Persist newly-entered passwords in the session cache.
|
|
380
405
|
const loginPassword = creds.loginPassword ?? collected.login;
|
|
381
406
|
const sudoPassword = creds.sudoPassword ?? collected.sudo;
|
package/src/lib.test.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
baseSshArgs,
|
|
4
|
+
commandEscalatesPrivilege,
|
|
4
5
|
controlPathFor,
|
|
5
6
|
detectSshFailure,
|
|
6
7
|
detectSudoFailure,
|
|
7
8
|
filterSudoPrompt,
|
|
9
|
+
hostApproved,
|
|
8
10
|
hostTarget,
|
|
9
11
|
isUnreachable,
|
|
10
12
|
parseHost,
|
|
@@ -142,6 +144,36 @@ describe("filterSudoPrompt", () => {
|
|
|
142
144
|
});
|
|
143
145
|
});
|
|
144
146
|
|
|
147
|
+
describe("hostApproved", () => {
|
|
148
|
+
it("is false for an unknown host", () => {
|
|
149
|
+
expect(hostApproved(new Map(), "u@h:22")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
it("is true within the TTL window", () => {
|
|
152
|
+
const m = new Map([["u@h:22", 1000]]);
|
|
153
|
+
expect(hostApproved(m, "u@h:22", 500)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
it("expires and self-prunes at/after the deadline", () => {
|
|
156
|
+
const m = new Map([["u@h:22", 1000]]);
|
|
157
|
+
expect(hostApproved(m, "u@h:22", 1000)).toBe(false);
|
|
158
|
+
expect(m.has("u@h:22")).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("commandEscalatesPrivilege", () => {
|
|
163
|
+
it("flags leading and mid-chain escalation tokens", () => {
|
|
164
|
+
expect(commandEscalatesPrivilege("sudo apt update")).toBe(true);
|
|
165
|
+
expect(commandEscalatesPrivilege("apt update && sudo apt install x")).toBe(true);
|
|
166
|
+
expect(commandEscalatesPrivilege("foo; su -c 'x'")).toBe(true);
|
|
167
|
+
expect(commandEscalatesPrivilege("doas reboot")).toBe(true);
|
|
168
|
+
expect(commandEscalatesPrivilege("pkexec whoami")).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
it("does not flag plain commands or substrings", () => {
|
|
171
|
+
expect(commandEscalatesPrivilege("ls -la")).toBe(false);
|
|
172
|
+
expect(commandEscalatesPrivilege("pseudo-tty")).toBe(false);
|
|
173
|
+
expect(commandEscalatesPrivilege("cat sudoku.txt")).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
145
177
|
describe("truncate", () => {
|
|
146
178
|
it("passes short text through", () => {
|
|
147
179
|
expect(truncate("a\nb").truncated).toBe(false);
|
package/src/lib.ts
CHANGED
|
@@ -21,6 +21,33 @@ import { join } from "node:path";
|
|
|
21
21
|
export const MAX_OUTPUT_BYTES = 50 * 1024;
|
|
22
22
|
export const MAX_OUTPUT_LINES = 2000;
|
|
23
23
|
|
|
24
|
+
/** Per-host approval TTL (ms) — mirrors sudo's PAM ticket window (~15 min). */
|
|
25
|
+
export const APPROVAL_TTL_MS = 15 * 60_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* True when `key` has a live (non-expired) approval in `map`; deletes the entry
|
|
29
|
+
* on expiry so the map self-prunes. `now` is injectable for tests.
|
|
30
|
+
*/
|
|
31
|
+
export function hostApproved(map: Map<string, number>, key: string, now = Date.now()): boolean {
|
|
32
|
+
const expiry = map.get(key);
|
|
33
|
+
if (expiry === undefined) return false;
|
|
34
|
+
if (now >= expiry) {
|
|
35
|
+
map.delete(key);
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* True when the command text itself escalates privilege (sudo/su/doas/pkexec),
|
|
43
|
+
* so per-host allow-memory must NOT auto-approve it even when `sudo:true` was
|
|
44
|
+
* not passed. Word-boundary match; catches leading and mid-chain occurrences
|
|
45
|
+
* (`… && sudo …`).
|
|
46
|
+
*/
|
|
47
|
+
export function commandEscalatesPrivilege(command: string): boolean {
|
|
48
|
+
return /(^|[\s;&|(])(sudo|su|doas|pkexec)\b/.test(command);
|
|
49
|
+
}
|
|
50
|
+
|
|
24
51
|
/** ControlPersist window (seconds) — the multiplexed connection lingers this
|
|
25
52
|
* long after the last call, so repeat commands skip re-auth. */
|
|
26
53
|
const CONTROL_PERSIST_SECONDS = 120;
|