@xynogen/pix-gate 0.2.13 → 0.3.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/README.md CHANGED
@@ -19,9 +19,11 @@ Intercepts every tool call and classifies it against severity rules before it ru
19
19
  | Tier | Examples | Behavior |
20
20
  |---|---|---|
21
21
  | `critical` | force-push to main, recursive delete, `dd` to disk | hard-block (non-interactive) / 15s auto-deny dialog (TUI) |
22
- | `dangerous` | any `sudo` (hard-redirected to `sudo_run`, no bypass) | 30s auto-deny confirmation |
22
+ | `dangerous` | destructive git, `chmod 777`, `curl … \| sh` | 30s auto-deny confirmation |
23
23
  | `risky` | — | 60s allow-first dialog; silently passes non-interactive |
24
24
 
25
+ **Privileged auth redirect** — bash `sudo`/`ssh` can't have their password/passphrase auto-typed, so they are hard-blocked and redirected to the dedicated tool (`sudo_run`/`ssh_run`) with no bypass — but *only when that tool is installed* (pix-sudo/pix-ssh are opt-in; without them the bash form is the only path and passes through). This is **guarding** (stop + redirect); the softer **steering** of non-privileged stand-ins (`cat`→`read`, `grep`→`grep`, …) lives in [`pix-nudge`](../pix-nudge).
26
+
25
27
  Auto-approve patterns and extra rules go in the `gate` section of `~/.pi/agent/pix.json`; set `guardrails: "off"` to disable built-in rules entirely.
26
28
 
27
29
  **Away notifications** — while a dialog is open, the gate holds the shared **agent-state** coordinator `blocked` (via `withAgentBlock` from [`@xynogen/pix-runtime`](https://www.npmjs.com/package/@xynogen/pix-runtime)), so a herdr pane pings an away user. Pairs with pix-commands unattended modes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.2.13",
3
+ "version": "0.3.2",
4
4
  "description": "Pi extension — permission gate for dangerous bash commands (confirm/block with TUI dialog)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  classifyPath,
26
26
  extractPathsFromBash,
27
27
  isCircuitBreaker,
28
+ isSshCommand,
28
29
  isSudoCommand,
29
30
  loadUserConfig,
30
31
  unattendedGateDecision,
@@ -42,6 +43,26 @@ import {
42
43
  export default function (pi: ExtensionAPI): void {
43
44
  const { rules, autoApprove, pathRules } = buildRules(loadUserConfig());
44
45
 
46
+ // Is a tool registered (active or gated)? ssh_run/sudo_run are opt-in and
47
+ // absent when pix-ssh/pix-sudo aren't installed — only redirect toward a
48
+ // tool that actually exists, else the bash form is the only path.
49
+ const isRegistered = (name: string): boolean => {
50
+ try {
51
+ return (pi.getAllTools?.() ?? []).some((t) => t.name === name);
52
+ } catch {
53
+ return false;
54
+ }
55
+ };
56
+
57
+ // Privileged auth tools that must not run raw in bash (interactive password /
58
+ // passphrase can't be auto-typed there). Each redirects to its dedicated tool
59
+ // ONLY when that tool is installed. pix-nudge handles the non-privileged
60
+ // read/ls/grep/find/edit stand-ins.
61
+ const AUTH_REDIRECTS = [
62
+ { match: isSudoCommand, tool: "sudo_run", label: "sudo" },
63
+ { match: isSshCommand, tool: "ssh_run", label: "ssh" },
64
+ ] as const;
65
+
45
66
  // ── Path protection (read/write/edit tools only — bash handled below) ───
46
67
  pi.on("tool_call", async (event, ctx) => {
47
68
  const tool = String(event.toolName);
@@ -116,10 +137,15 @@ export default function (pi: ExtensionAPI): void {
116
137
  risky: 1,
117
138
  };
118
139
 
140
+ // The unified command gate also covers pix-proc's `proc` tool: its `start`
141
+ // action carries a shell command in `event.input.command`, identical to bash.
142
+ // proc's other actions (list/logs/stop/rm) carry no command and pass straight
143
+ // through. Install pix-proc without pix-gate = ungated, same as bash.
144
+ const GATED_COMMAND_TOOLS = new Set(["bash", "proc"]);
119
145
  pi.on("tool_call", async (event, ctx) => {
120
- if (event.toolName !== "bash") return undefined;
146
+ if (!GATED_COMMAND_TOOLS.has(String(event.toolName))) return undefined;
121
147
 
122
- const command = String(event.input.command ?? "");
148
+ const command = String((event.input as Record<string, unknown>).command ?? "");
123
149
  if (!command.trim()) return undefined;
124
150
 
125
151
  // Collect all concerns: path hits + command hit
@@ -127,6 +153,16 @@ export default function (pi: ExtensionAPI): void {
127
153
 
128
154
  // Path concerns
129
155
  const candidates = extractPathsFromBash(command);
156
+
157
+ // Steer a blocked ssh-config read toward the dedicated `ssh info` tool
158
+ // (ssh_run action:"info"), which reads the effective config without touching
159
+ // ~/.ssh directly. Only when ssh_run is installed. Appended to the block
160
+ // reason so the model reads it and stops retrying with awk/grep/raw ssh.
161
+ const readsSshConfig = candidates.some((p) => /(^|\/)\.ssh\/config\b/i.test(p));
162
+ const sshInfoSteer =
163
+ readsSshConfig && isRegistered("ssh_run")
164
+ ? ' Use the ssh_run tool with action:"info" to read the effective SSH config instead of reading ~/.ssh/config in bash.'
165
+ : "";
130
166
  for (const p of candidates) {
131
167
  const hit = classifyPath(p, "read", pathRules);
132
168
  if (!hit) continue;
@@ -157,19 +193,19 @@ export default function (pi: ExtensionAPI): void {
157
193
 
158
194
  const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
159
195
 
160
- // sudo: hard redirect — no prompt, no bypass. Even YOLO cannot run bare
161
- // `sudo` in bash (the password can't be auto-typed); it must use sudo_run.
162
- if (isSudoCommand(command)) {
196
+ // Privileged auth redirect — hard block, no prompt, no bypass. Even YOLO
197
+ // cannot run bare sudo/ssh in bash (password/passphrase can't be auto-typed);
198
+ // it must use the dedicated tool. Only fires when that tool is installed.
199
+ for (const redirect of AUTH_REDIRECTS) {
200
+ if (!redirect.match(command) || !isRegistered(redirect.tool)) continue;
163
201
  if (unattendedGateDecision(pi.events, highest.tier) === "deny") {
164
- return { block: true, reason: "[AFK] sudo is denied while user is away." };
202
+ return { block: true, reason: `[AFK] ${redirect.label} is denied while user is away.` };
165
203
  }
166
- ctx.ui.notify(
167
- `⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — use sudo_run tool instead (handles auth securely)`,
168
- "warning",
169
- );
204
+ // One surface only: the block reason renders as the tool-error card and
205
+ // reaches the model. A separate notify would duplicate the warning.
170
206
  return {
171
207
  block: true,
172
- reason: "Use sudo_run tool instead of sudo in bash.",
208
+ reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in bash (it handles auth securely).`,
173
209
  };
174
210
  }
175
211
 
@@ -187,7 +223,7 @@ export default function (pi: ExtensionAPI): void {
187
223
  if (unattended === "deny") {
188
224
  return {
189
225
  block: true,
190
- reason: `[AFK][${highest.label}] ${highest.detail}`,
226
+ reason: `[AFK][${highest.label}] ${highest.detail}${sshInfoSteer}`,
191
227
  };
192
228
  }
193
229
 
@@ -198,7 +234,7 @@ export default function (pi: ExtensionAPI): void {
198
234
  if (highest.tier >= SEVERITY_TIER.block) {
199
235
  return {
200
236
  block: true,
201
- reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)`,
237
+ reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)${sshInfoSteer}`,
202
238
  };
203
239
  }
204
240
  return undefined;
@@ -239,7 +275,7 @@ export default function (pi: ExtensionAPI): void {
239
275
 
240
276
  if (!decision.approved) {
241
277
  ctx.ui.notify(`${highest.icon} ${decision.reason}: ${highest.detail}`, "warning");
242
- return { block: true, reason: `[${highest.label}] ${decision.reason}` };
278
+ return { block: true, reason: `[${highest.label}] ${decision.reason}${sshInfoSteer}` };
243
279
  }
244
280
 
245
281
  const severityColor =
package/src/lib.ts CHANGED
@@ -298,6 +298,16 @@ export function isSudoCommand(command: string): boolean {
298
298
  return /(^|[\s;|&])sudo\b/i.test(command);
299
299
  }
300
300
 
301
+ /**
302
+ * True when a command invokes the `ssh` remote shell as a token. Requires
303
+ * trailing whitespace/end so it does NOT match `ssh-keygen`, `ssh-add`,
304
+ * `ssh-copy-id`, or `sshpass` (a later real ` ssh ` in the same line still
305
+ * matches, e.g. `sshpass -p x ssh host`).
306
+ */
307
+ export function isSshCommand(command: string): boolean {
308
+ return /(^|[\s;|&])ssh(\s|$)/i.test(command);
309
+ }
310
+
301
311
  // Non-lifting circuit breaker: catastrophic, unrecoverable commands that NO
302
312
  // mode (not even YOLO) may auto-approve. Mirrors Claude Code's bypassPermissions
303
313
  // floor, which still prompts on root/home wipes. These always fall through to