@xynogen/pix-gate 0.2.7 → 0.2.9

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
@@ -6,7 +6,9 @@ Pi extension — permission gate for dangerous bash commands.
6
6
 
7
7
  Intercepts every `bash` tool call and classifies the command against a set of severity rules before it runs. Two rule sets: **path rules** (block / warn / info) protect the `read` / `write` / `edit` tools from touching private keys, credential files, etc. — `block` is deny-first (15s timeout), `warn` is allow-first (30s), `info` is a blue notify that never blocks. **Command rules** (critical / dangerous / risky) gate `bash` invocations: `critical` (force pushes to main, recursive deletes, `dd` to disks, etc.) is hard-blocked in non-interactive mode and hard-denied via a 15-second auto-deny dialog in TUI mode; `dangerous` commands (including any `sudo` invocation, which is hard-redirected to the `sudo_run` tool — no bypass) show a 30-second auto-deny confirmation dialog; `risky` commands show a 60-second allow-first dialog and silently pass in non-interactive mode. Auto-approve patterns and extra rules can be configured in the `gate` section of `~/.pi/agent/pix.json`. Built-in rules can be turned off entirely by setting `guardrails: "off"` in that section.
8
8
 
9
- While an approval dialog is open, the gate holds the shared **agent-state** coordinator in the `blocked` state (via `withAgentBlock` from [`@xynogen/pix-runtime`](https://www.npmjs.com/package/@xynogen/pix-runtime)). Inside a herdr pane that fires a "needs attention" notification, so an away user is pinged when a command is waiting on approval. Pairs with `/afk` (pix-commands): with AFK on, yellow gates auto-allow and red/sudo auto-deny, so only genuine stops surface.
9
+ While an approval dialog is open, the gate holds the shared **agent-state** coordinator in the `blocked` state (via `withAgentBlock` from [`@xynogen/pix-runtime`](https://www.npmjs.com/package/@xynogen/pix-runtime)). Inside a herdr pane that fires a "needs attention" notification, so an away user is pinged when a command is waiting on approval. Pairs with the unattended modes in pix-commands: with `/afk` on, yellow gates auto-allow and red/root auto-deny, so only genuine stops surface; with `/yolo` on, every tier including red auto-approves (capable models only). Bare `sudo` in bash is always redirected to `sudo_run` regardless of mode.
10
+
11
+ **Circuit breaker.** A small set of catastrophic, unrecoverable commands — `rm -rf /` or `~`, `dd`/redirect onto a raw disk, `mkfs` on a device, a fork bomb — can never be auto-approved by any mode, including YOLO. They always fall through to the interactive dialog (or a no-UI block). This mirrors Claude Code's `bypassPermissions` floor, which still prompts on root/home wipes.
10
12
 
11
13
  ## Install
12
14
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
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",
@@ -44,7 +44,7 @@
44
44
  "dependencies": {
45
45
  "@xynogen/pix-pretty": "^1.11.2",
46
46
  "@xynogen/pix-data": "^0.4.3",
47
- "@xynogen/pix-runtime": "^0.6.0"
47
+ "@xynogen/pix-runtime": "^0.7.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "@earendil-works/pi-coding-agent": "*",
package/src/gate.test.ts CHANGED
@@ -1,13 +1,33 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
- afkGateDecision,
4
3
  buildRules,
5
4
  classify,
5
+ classifyPath,
6
+ DEFAULT_PATH_RULES,
6
7
  DEFAULT_RULES,
7
8
  extractPathsFromBash,
9
+ isCircuitBreaker,
8
10
  isSudoCommand,
11
+ unattendedGateDecision,
9
12
  } from "./lib.ts";
10
13
 
14
+ // ── .env path gating ─────────────────────────────────────────────
15
+ describe("classifyPath .env", () => {
16
+ const blocked = [".env", ".env.local", ".env.production", "path/to/.env", "src/.env.staging"];
17
+ const allowed = [".env.example", ".env.sample", ".env.template", ".env.dist"];
18
+
19
+ for (const p of blocked) {
20
+ test(`reading ${p} is blocked`, () => {
21
+ expect(classifyPath(p, "read", DEFAULT_PATH_RULES)?.severity).toBe("block");
22
+ });
23
+ }
24
+ for (const p of allowed) {
25
+ test(`reading ${p} is not blocked`, () => {
26
+ expect(classifyPath(p, "read", DEFAULT_PATH_RULES)).toBeUndefined();
27
+ });
28
+ }
29
+ });
30
+
11
31
  // ── isSudoCommand ─────────────────────────────────────────────────────────────
12
32
 
13
33
  describe("isSudoCommand", () => {
@@ -45,20 +65,72 @@ describe("isSudoCommand", () => {
45
65
 
46
66
  // ── AFK behavior ──────────────────────────────────────────────────────────────
47
67
 
48
- describe("afkGateDecision", () => {
49
- test("does nothing while AFK mode is off", () => {
50
- delete (globalThis as { __pixAfk?: boolean }).__pixAfk;
51
- expect(afkGateDecision(5)).toBeUndefined();
68
+ describe("unattendedGateDecision", () => {
69
+ type G = { __pixAfk?: boolean; __pixYolo?: boolean };
70
+ const reset = () => {
71
+ delete (globalThis as G).__pixAfk;
72
+ delete (globalThis as G).__pixYolo;
73
+ };
74
+
75
+ test("does nothing while every mode is off", () => {
76
+ reset();
77
+ expect(unattendedGateDecision(5)).toBeUndefined();
78
+ });
79
+
80
+ test("AFK allows yellow concerns and denies red concerns", () => {
81
+ reset();
82
+ (globalThis as G).__pixAfk = true;
83
+ expect(unattendedGateDecision(1)).toBe("allow");
84
+ expect(unattendedGateDecision(2)).toBe("allow");
85
+ expect(unattendedGateDecision(3)).toBe("allow");
86
+ expect(unattendedGateDecision(4)).toBe("deny");
87
+ expect(unattendedGateDecision(5)).toBe("deny");
88
+ reset();
89
+ });
90
+
91
+ test("YOLO allows every tier including red/critical", () => {
92
+ reset();
93
+ (globalThis as G).__pixYolo = true;
94
+ expect(unattendedGateDecision(1)).toBe("allow");
95
+ expect(unattendedGateDecision(4)).toBe("allow");
96
+ expect(unattendedGateDecision(5)).toBe("allow");
97
+ reset();
98
+ });
99
+
100
+ test("YOLO wins when both flags are somehow set", () => {
101
+ reset();
102
+ (globalThis as G).__pixAfk = true;
103
+ (globalThis as G).__pixYolo = true;
104
+ expect(unattendedGateDecision(5)).toBe("allow");
105
+ reset();
106
+ });
107
+ });
108
+
109
+ // ── circuit breaker ───────────────────────────────────────────────────────────
110
+
111
+ describe("isCircuitBreaker", () => {
112
+ test("catches root and home wipes", () => {
113
+ expect(isCircuitBreaker("rm -rf /")).toBe(true);
114
+ expect(isCircuitBreaker("rm -rf ~")).toBe(true);
115
+ expect(isCircuitBreaker("rm -fr / --no-preserve-root")).toBe(true);
116
+ expect(isCircuitBreaker("cd /tmp && rm -rf /")).toBe(true);
117
+ });
118
+
119
+ test("catches raw-disk writes and format", () => {
120
+ expect(isCircuitBreaker("dd if=/dev/zero of=/dev/sda")).toBe(true);
121
+ expect(isCircuitBreaker("mkfs.ext4 /dev/nvme0n1")).toBe(true);
122
+ expect(isCircuitBreaker("echo x > /dev/sdb")).toBe(true);
123
+ });
124
+
125
+ test("catches the classic fork bomb", () => {
126
+ expect(isCircuitBreaker(":(){ :|:& };:")).toBe(true);
52
127
  });
53
128
 
54
- test("allows yellow concerns and denies red concerns while AFK", () => {
55
- (globalThis as { __pixAfk?: boolean }).__pixAfk = true;
56
- expect(afkGateDecision(1)).toBe("allow");
57
- expect(afkGateDecision(2)).toBe("allow");
58
- expect(afkGateDecision(3)).toBe("allow");
59
- expect(afkGateDecision(4)).toBe("deny");
60
- expect(afkGateDecision(5)).toBe("deny");
61
- delete (globalThis as { __pixAfk?: boolean }).__pixAfk;
129
+ test("does NOT trip on ordinary destructive-but-scoped commands", () => {
130
+ expect(isCircuitBreaker("rm -rf ./build")).toBe(false);
131
+ expect(isCircuitBreaker("rm -rf node_modules")).toBe(false);
132
+ expect(isCircuitBreaker("dd if=in.img of=out.img")).toBe(false);
133
+ expect(isCircuitBreaker("echo hi > /tmp/x")).toBe(false);
62
134
  });
63
135
  });
64
136
 
package/src/index.ts CHANGED
@@ -20,13 +20,14 @@
20
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
21
  import { withAgentBlock } from "@xynogen/pix-runtime";
22
22
  import {
23
- afkGateDecision,
24
23
  buildRules,
25
24
  classify,
26
25
  classifyPath,
27
26
  extractPathsFromBash,
27
+ isCircuitBreaker,
28
28
  isSudoCommand,
29
29
  loadUserConfig,
30
+ unattendedGateDecision,
30
31
  } from "./lib.ts";
31
32
  import {
32
33
  type Concern,
@@ -71,9 +72,9 @@ export default function (pi: ExtensionAPI): void {
71
72
  return undefined;
72
73
  }
73
74
 
74
- const afkDecision = afkGateDecision(hit.severity === "block" ? 4 : 2);
75
- if (afkDecision === "allow") return undefined;
76
- if (afkDecision === "deny") {
75
+ const unattended = unattendedGateDecision(hit.severity === "block" ? 4 : 2);
76
+ if (unattended === "allow") return undefined;
77
+ if (unattended === "deny") {
77
78
  return {
78
79
  block: true,
79
80
  reason: `[AFK][PATH:${hit.severity.toUpperCase()}] ${hit.reason}`,
@@ -156,9 +157,10 @@ export default function (pi: ExtensionAPI): void {
156
157
 
157
158
  const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
158
159
 
159
- // sudo: hard redirect — no prompt, no bypass.
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.
160
162
  if (isSudoCommand(command)) {
161
- if (afkGateDecision(highest.tier)) {
163
+ if (unattendedGateDecision(highest.tier) === "deny") {
162
164
  return { block: true, reason: "[AFK] sudo is denied while user is away." };
163
165
  }
164
166
  ctx.ui.notify(
@@ -171,9 +173,18 @@ export default function (pi: ExtensionAPI): void {
171
173
  };
172
174
  }
173
175
 
174
- const afkDecision = afkGateDecision(highest.tier);
175
- if (afkDecision === "allow") return undefined;
176
- if (afkDecision === "deny") {
176
+ // Circuit breaker: catastrophic commands never auto-approve, even under YOLO.
177
+ // They fall through to the interactive dialog (or the no-UI block below).
178
+ const breaker = isCircuitBreaker(command);
179
+ if (breaker && unattendedGateDecision(highest.tier) === "allow") {
180
+ ctx.ui.notify(
181
+ `⛔ ${ctx.ui.theme.fg("error", "CIRCUIT BREAKER")} — refused even under YOLO; confirm manually`,
182
+ "error",
183
+ );
184
+ }
185
+ const unattended = breaker ? undefined : unattendedGateDecision(highest.tier);
186
+ if (unattended === "allow") return undefined;
187
+ if (unattended === "deny") {
177
188
  return {
178
189
  block: true,
179
190
  reason: `[AFK][${highest.label}] ${highest.detail}`,
package/src/lib.ts CHANGED
@@ -222,9 +222,15 @@ export const DEFAULT_PATH_RULES: PathRule[] = [
222
222
  severity: "block",
223
223
  reason: "credentials file",
224
224
  },
225
+ // Real .env files hold live secrets — block. Placeholder variants
226
+ // (.env.example/.sample/.template/.dist) carry no secrets, so exclude them.
227
+ {
228
+ pattern: /(^|\/)\.env(?:$|\.(?!example|sample|template|dist)[A-Za-z0-9_-]+)/i,
229
+ severity: "block",
230
+ reason: ".env file (live secrets)",
231
+ },
225
232
 
226
233
  // warn — yellow allow-first
227
- { pattern: /(^|\/)\.env(\.|$)/i, severity: "warn", reason: ".env file" },
228
234
  { pattern: /(^|\/)\.envrc$/i, severity: "warn", reason: "direnv file" },
229
235
  {
230
236
  pattern: /(^|\/)\.npmrc$/i,
@@ -290,7 +296,33 @@ export function isSudoCommand(command: string): boolean {
290
296
  return /(^|[\s;|&])sudo\b/i.test(command);
291
297
  }
292
298
 
293
- export function afkGateDecision(tier: number): "allow" | "deny" | undefined {
294
- if ((globalThis as { __pixAfk?: boolean }).__pixAfk !== true) return undefined;
295
- return tier >= 4 ? "deny" : "allow";
299
+ // Non-lifting circuit breaker: catastrophic, unrecoverable commands that NO
300
+ // mode (not even YOLO) may auto-approve. Mirrors Claude Code's bypassPermissions
301
+ // floor, which still prompts on root/home wipes. These always fall through to
302
+ // the interactive dialog (or a no-UI block).
303
+ const CIRCUIT_BREAKER: RegExp[] = [
304
+ /\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(?:--\s+)?[~/]\s*(?:$|[\s;|&])/i, // rm -rf / or rm -rf ~
305
+ /\bdd\b[^\n]*\bof=\/dev\/(?:sd|nvme|disk|hd|vd)/i, // dd onto a raw disk
306
+ /\bmkfs\.\w+\s+\/dev\//i, // format a device
307
+ /:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, // classic fork bomb
308
+ />\s*\/dev\/(?:sd|nvme|disk|hd|vd)[a-z0-9]*\b/i, // redirect over a raw disk
309
+ ];
310
+
311
+ /** True when a command is catastrophic enough that no unattended mode may auto-approve it. */
312
+ export function isCircuitBreaker(command: string): boolean {
313
+ return CIRCUIT_BREAKER.some((re) => re.test(command));
314
+ }
315
+
316
+ /**
317
+ * Unattended-mode gate decision for a concern tier (1 risky … 5 critical).
318
+ * yolo — auto-allow every tier, including red/critical.
319
+ * afk — auto-allow yellow (tier < 4), auto-deny red (tier >= 4).
320
+ * off — undefined (fall through to the interactive dialog).
321
+ * Mutually exclusive globals set by pix-commands' /afk and /yolo.
322
+ */
323
+ export function unattendedGateDecision(tier: number): "allow" | "deny" | undefined {
324
+ const g = globalThis as { __pixAfk?: boolean; __pixYolo?: boolean };
325
+ if (g.__pixYolo === true) return "allow";
326
+ if (g.__pixAfk === true) return tier >= 4 ? "deny" : "allow";
327
+ return undefined;
296
328
  }