@xynogen/pix-gate 0.2.12 → 0.3.0

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.12",
3
+ "version": "0.3.0",
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);
@@ -157,19 +178,19 @@ export default function (pi: ExtensionAPI): void {
157
178
 
158
179
  const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
159
180
 
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)) {
181
+ // Privileged auth redirect — hard block, no prompt, no bypass. Even YOLO
182
+ // cannot run bare sudo/ssh in bash (password/passphrase can't be auto-typed);
183
+ // it must use the dedicated tool. Only fires when that tool is installed.
184
+ for (const redirect of AUTH_REDIRECTS) {
185
+ if (!redirect.match(command) || !isRegistered(redirect.tool)) continue;
163
186
  if (unattendedGateDecision(pi.events, highest.tier) === "deny") {
164
- return { block: true, reason: "[AFK] sudo is denied while user is away." };
187
+ return { block: true, reason: `[AFK] ${redirect.label} is denied while user is away.` };
165
188
  }
166
- ctx.ui.notify(
167
- `⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — use sudo_run tool instead (handles auth securely)`,
168
- "warning",
169
- );
189
+ // One surface only: the block reason renders as the tool-error card and
190
+ // reaches the model. A separate notify would duplicate the warning.
170
191
  return {
171
192
  block: true,
172
- reason: "Use sudo_run tool instead of sudo in bash.",
193
+ reason: `DANGEROUS — use the ${redirect.tool} tool instead of ${redirect.label} in bash (it handles auth securely).`,
173
194
  };
174
195
  }
175
196
 
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
package/src/prompt.ts CHANGED
@@ -26,6 +26,11 @@ export interface Concern {
26
26
 
27
27
  export type GatePromptUI = ExtensionContext["ui"];
28
28
 
29
+ /** Title Case a severity tier for dialog titles: "critical" → "Critical". */
30
+ function titleCase(word: string): string {
31
+ return word ? word[0]!.toUpperCase() + word.slice(1).toLowerCase() : word;
32
+ }
33
+
29
34
  const TIMEOUT_MS: Record<Rule["severity"], number> = {
30
35
  critical: 15_000,
31
36
  dangerous: 30_000,
@@ -73,7 +78,7 @@ export async function promptPathDecision(
73
78
  path: string,
74
79
  ): Promise<GateDecision> {
75
80
  const icon = PATH_SEVERITY_ICON[hit.severity];
76
- const label = hit.severity.toUpperCase();
81
+ const label = titleCase(hit.severity);
77
82
  const accent = PATH_SEVERITY_COLOR[hit.severity];
78
83
 
79
84
  const choices =
@@ -105,7 +110,8 @@ export async function promptPathDecision(
105
110
 
106
111
  const result: OverlayResult = await showOverlay(ui as Parameters<typeof showOverlay>[0], {
107
112
  mode: "confirm",
108
- title: `${icon} ${label} — ${hit.reason}`,
113
+ icon,
114
+ title: `${label} — ${hit.reason}`,
109
115
  body: [`${op.toUpperCase()} ${path}`],
110
116
  accent,
111
117
  timeoutMs: PATH_TIMEOUT_MS[hit.severity],
@@ -231,7 +237,7 @@ export async function promptGateDecision(
231
237
  command: string,
232
238
  ): Promise<GateDecision> {
233
239
  const icon = SEVERITY_ICON[hit.severity];
234
- const label = hit.severity.toUpperCase();
240
+ const label = titleCase(hit.severity);
235
241
  const accent = SEVERITY_COLOR[hit.severity];
236
242
 
237
243
  // critical: deny listed first so it's the default selected item
@@ -260,7 +266,8 @@ export async function promptGateDecision(
260
266
 
261
267
  const result: OverlayResult = await showOverlay(ui as Parameters<typeof showOverlay>[0], {
262
268
  mode: "confirm",
263
- title: `${icon} ${label} — ${hit.reason}`,
269
+ icon,
270
+ title: `${label} — ${hit.reason}`,
264
271
  body: [command],
265
272
  accent,
266
273
  timeoutMs: TIMEOUT_MS[hit.severity],