@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-ssh",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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,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;