@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Pi tool — ssh_run: run remote commands over SSH with password/key auth and remote sudo",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
- const host = hostTarget(spec);
261
- const controlPath = controlPathFor(spec);
262
- const key = cacheKey(spec);
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;