@xynogen/pix-ssh 0.1.0 → 0.2.2
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 +35 -4
- package/src/lib.test.ts +47 -0
- package/src/lib.ts +60 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -47,15 +47,19 @@ 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,
|
|
57
60
|
parseHost,
|
|
58
61
|
probeKeyAuth,
|
|
62
|
+
resolveSshHost,
|
|
59
63
|
runSsh,
|
|
60
64
|
truncate,
|
|
61
65
|
} from "./lib.ts";
|
|
@@ -71,6 +75,14 @@ interface HostCreds {
|
|
|
71
75
|
}
|
|
72
76
|
const credCache = new Map<string, HostCreds>();
|
|
73
77
|
|
|
78
|
+
// Per-host allow-memory (on by default): once the user approves any command on
|
|
79
|
+
// a host, later calls to that host skip the confirm overlay until the approval
|
|
80
|
+
// expires — mirroring sudo's PAM ticket window (~15 min), not the whole session.
|
|
81
|
+
// Password prompts are NOT skipped — a still-missing login or sudo password
|
|
82
|
+
// always re-prompts. Each auto-approve emits a visible notify so the decision is
|
|
83
|
+
// never silent. Map value = epoch ms when the approval lapses (see APPROVAL_TTL_MS).
|
|
84
|
+
const approvedHosts = new Map<string, number>();
|
|
85
|
+
|
|
74
86
|
function cacheKey(spec: HostSpec): string {
|
|
75
87
|
return `${spec.user ?? ""}@${spec.host}:${spec.port ?? 22}`;
|
|
76
88
|
}
|
|
@@ -257,9 +269,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
257
269
|
isError: true,
|
|
258
270
|
};
|
|
259
271
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const
|
|
272
|
+
// ponytail: let OpenSSH own config parsing; `ssh -G` handles aliases,
|
|
273
|
+
// Include, and Match rules without duplicating its config grammar.
|
|
274
|
+
const effectiveSpec = await resolveSshHost(spec, sig);
|
|
275
|
+
const host = hostTarget(effectiveSpec);
|
|
276
|
+
// Keep original target for execution so alias-specific IdentityFile,
|
|
277
|
+
// ProxyJump, and other SSH config options still apply.
|
|
278
|
+
const controlPath = controlPathFor(effectiveSpec);
|
|
279
|
+
const key = cacheKey(effectiveSpec);
|
|
263
280
|
const creds = credCache.get(key) ?? {};
|
|
264
281
|
|
|
265
282
|
const g = globalThis as { __pixAfk?: boolean; __pixYolo?: boolean };
|
|
@@ -318,9 +335,20 @@ export default function (pi: ExtensionAPI): void {
|
|
|
318
335
|
// pix-sudo uses (masked input, N attempts). We can't validate remote
|
|
319
336
|
// passwords without connecting, so validatePassword just accepts a
|
|
320
337
|
// non-empty entry; a wrong password surfaces as an auth error after run.
|
|
338
|
+
// Session allow-memory: host already approved + no password missing → skip
|
|
339
|
+
// the confirm overlay entirely (visible notify below). Privileged commands
|
|
340
|
+
// (sudo:true, or a sudo/su/doas/pkexec token in the command text) are never
|
|
341
|
+
// auto-approved — they always re-confirm.
|
|
342
|
+
const privileged = sudo || commandEscalatesPrivilege(command);
|
|
343
|
+
const alreadyApproved =
|
|
344
|
+
!privileged && hostApproved(approvedHosts, key) && promptFor.length === 0;
|
|
345
|
+
if (alreadyApproved) {
|
|
346
|
+
ctx.ui.notify(`🔐 ssh_run auto-approved — ${host} allowed this session`, "warning");
|
|
347
|
+
}
|
|
348
|
+
|
|
321
349
|
const runOverlay = (): Promise<OverlayResult> =>
|
|
322
350
|
withAgentBlock(pi.events, "ssh_run", "SSH approval required", async () => {
|
|
323
|
-
if (yolo && promptFor.length === 0) {
|
|
351
|
+
if ((yolo || alreadyApproved) && promptFor.length === 0) {
|
|
324
352
|
return { action: "approved", password: "" } as OverlayResult;
|
|
325
353
|
}
|
|
326
354
|
// Confirm-only when no password is missing.
|
|
@@ -376,6 +404,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
376
404
|
return r;
|
|
377
405
|
}
|
|
378
406
|
|
|
407
|
+
// Remember this host's approval until the TTL lapses (refreshed each call).
|
|
408
|
+
approvedHosts.set(key, Date.now() + APPROVAL_TTL_MS);
|
|
409
|
+
|
|
379
410
|
// Persist newly-entered passwords in the session cache.
|
|
380
411
|
const loginPassword = creds.loginPassword ?? collected.login;
|
|
381
412
|
const sudoPassword = creds.sudoPassword ?? collected.sudo;
|
package/src/lib.test.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
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,
|
|
13
|
+
parseSshConfig,
|
|
11
14
|
remoteCommand,
|
|
12
15
|
shellQuote,
|
|
13
16
|
truncate,
|
|
@@ -44,6 +47,20 @@ describe("parseHost", () => {
|
|
|
44
47
|
});
|
|
45
48
|
});
|
|
46
49
|
|
|
50
|
+
describe("parseSshConfig", () => {
|
|
51
|
+
it("reads effective user, hostname, and port from ssh -G output", () => {
|
|
52
|
+
expect(parseSshConfig("host orin\nuser jetson\nhostname 10.10.21.251\nport 2222\n")).toEqual({
|
|
53
|
+
user: "jetson",
|
|
54
|
+
host: "10.10.21.251",
|
|
55
|
+
port: 2222,
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("rejects incomplete ssh -G output", () => {
|
|
60
|
+
expect(parseSshConfig("hostname 10.10.21.251\n")).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
47
64
|
describe("hostTarget", () => {
|
|
48
65
|
it("joins user and host", () => {
|
|
49
66
|
expect(hostTarget({ user: "a", host: "b" })).toBe("a@b");
|
|
@@ -142,6 +159,36 @@ describe("filterSudoPrompt", () => {
|
|
|
142
159
|
});
|
|
143
160
|
});
|
|
144
161
|
|
|
162
|
+
describe("hostApproved", () => {
|
|
163
|
+
it("is false for an unknown host", () => {
|
|
164
|
+
expect(hostApproved(new Map(), "u@h:22")).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
it("is true within the TTL window", () => {
|
|
167
|
+
const m = new Map([["u@h:22", 1000]]);
|
|
168
|
+
expect(hostApproved(m, "u@h:22", 500)).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
it("expires and self-prunes at/after the deadline", () => {
|
|
171
|
+
const m = new Map([["u@h:22", 1000]]);
|
|
172
|
+
expect(hostApproved(m, "u@h:22", 1000)).toBe(false);
|
|
173
|
+
expect(m.has("u@h:22")).toBe(false);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("commandEscalatesPrivilege", () => {
|
|
178
|
+
it("flags leading and mid-chain escalation tokens", () => {
|
|
179
|
+
expect(commandEscalatesPrivilege("sudo apt update")).toBe(true);
|
|
180
|
+
expect(commandEscalatesPrivilege("apt update && sudo apt install x")).toBe(true);
|
|
181
|
+
expect(commandEscalatesPrivilege("foo; su -c 'x'")).toBe(true);
|
|
182
|
+
expect(commandEscalatesPrivilege("doas reboot")).toBe(true);
|
|
183
|
+
expect(commandEscalatesPrivilege("pkexec whoami")).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
it("does not flag plain commands or substrings", () => {
|
|
186
|
+
expect(commandEscalatesPrivilege("ls -la")).toBe(false);
|
|
187
|
+
expect(commandEscalatesPrivilege("pseudo-tty")).toBe(false);
|
|
188
|
+
expect(commandEscalatesPrivilege("cat sudoku.txt")).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
145
192
|
describe("truncate", () => {
|
|
146
193
|
it("passes short text through", () => {
|
|
147
194
|
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;
|
|
@@ -81,6 +108,39 @@ function parsePort(value: string): number {
|
|
|
81
108
|
return n;
|
|
82
109
|
}
|
|
83
110
|
|
|
111
|
+
/** Parse effective destination fields emitted by OpenSSH's `ssh -G`. */
|
|
112
|
+
export function parseSshConfig(output: string): HostSpec | undefined {
|
|
113
|
+
const values = new Map(
|
|
114
|
+
output
|
|
115
|
+
.split("\n")
|
|
116
|
+
.map((line) => line.trim().split(/\s+/, 2))
|
|
117
|
+
.filter((parts): parts is [string, string] => parts.length === 2),
|
|
118
|
+
);
|
|
119
|
+
const user = values.get("user");
|
|
120
|
+
const host = values.get("hostname");
|
|
121
|
+
const port = values.get("port");
|
|
122
|
+
if (!user || !host || !port) return undefined;
|
|
123
|
+
return { user, host, port: parsePort(port) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Resolve aliases through OpenSSH config, including Include and Match rules. */
|
|
127
|
+
export function resolveSshHost(spec: HostSpec, signal?: AbortSignal): Promise<HostSpec> {
|
|
128
|
+
const args = ["-G"];
|
|
129
|
+
if (spec.port !== undefined) args.push("-p", String(spec.port));
|
|
130
|
+
args.push(hostTarget(spec));
|
|
131
|
+
|
|
132
|
+
return new Promise((resolve) => {
|
|
133
|
+
let stdout = "";
|
|
134
|
+
const proc = spawn("ssh", args, { stdio: ["ignore", "pipe", "ignore"] });
|
|
135
|
+
proc.stdout.on("data", (chunk: Buffer) => {
|
|
136
|
+
stdout += chunk.toString();
|
|
137
|
+
});
|
|
138
|
+
proc.on("error", () => resolve(spec));
|
|
139
|
+
proc.on("close", (code) => resolve(code === 0 ? (parseSshConfig(stdout) ?? spec) : spec));
|
|
140
|
+
signal?.addEventListener("abort", () => proc.kill("SIGTERM"), { once: true });
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
84
144
|
/** Canonical `[user@]host` target string for ssh argv. */
|
|
85
145
|
export function hostTarget(spec: HostSpec): string {
|
|
86
146
|
return spec.user ? `${spec.user}@${spec.host}` : spec.host;
|