@xynogen/pix-gate 0.2.6 → 0.2.8
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 +4 -0
- package/package.json +2 -2
- package/src/gate.test.ts +80 -1
- package/src/index.test.ts +38 -0
- package/src/index.ts +61 -7
- package/src/lib.ts +31 -0
package/README.md
CHANGED
|
@@ -6,6 +6,10 @@ 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 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.
|
|
12
|
+
|
|
9
13
|
## Install
|
|
10
14
|
|
|
11
15
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-gate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
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.
|
|
47
|
+
"@xynogen/pix-runtime": "^0.6.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/gate.test.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildRules,
|
|
4
|
+
classify,
|
|
5
|
+
DEFAULT_RULES,
|
|
6
|
+
extractPathsFromBash,
|
|
7
|
+
isCircuitBreaker,
|
|
8
|
+
isSudoCommand,
|
|
9
|
+
unattendedGateDecision,
|
|
10
|
+
} from "./lib.ts";
|
|
3
11
|
|
|
4
12
|
// ── isSudoCommand ─────────────────────────────────────────────────────────────
|
|
5
13
|
|
|
@@ -36,6 +44,77 @@ describe("isSudoCommand", () => {
|
|
|
36
44
|
});
|
|
37
45
|
});
|
|
38
46
|
|
|
47
|
+
// ── AFK behavior ──────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("unattendedGateDecision", () => {
|
|
50
|
+
type G = { __pixAfk?: boolean; __pixYolo?: boolean };
|
|
51
|
+
const reset = () => {
|
|
52
|
+
delete (globalThis as G).__pixAfk;
|
|
53
|
+
delete (globalThis as G).__pixYolo;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
test("does nothing while every mode is off", () => {
|
|
57
|
+
reset();
|
|
58
|
+
expect(unattendedGateDecision(5)).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("AFK allows yellow concerns and denies red concerns", () => {
|
|
62
|
+
reset();
|
|
63
|
+
(globalThis as G).__pixAfk = true;
|
|
64
|
+
expect(unattendedGateDecision(1)).toBe("allow");
|
|
65
|
+
expect(unattendedGateDecision(2)).toBe("allow");
|
|
66
|
+
expect(unattendedGateDecision(3)).toBe("allow");
|
|
67
|
+
expect(unattendedGateDecision(4)).toBe("deny");
|
|
68
|
+
expect(unattendedGateDecision(5)).toBe("deny");
|
|
69
|
+
reset();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("YOLO allows every tier including red/critical", () => {
|
|
73
|
+
reset();
|
|
74
|
+
(globalThis as G).__pixYolo = true;
|
|
75
|
+
expect(unattendedGateDecision(1)).toBe("allow");
|
|
76
|
+
expect(unattendedGateDecision(4)).toBe("allow");
|
|
77
|
+
expect(unattendedGateDecision(5)).toBe("allow");
|
|
78
|
+
reset();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("YOLO wins when both flags are somehow set", () => {
|
|
82
|
+
reset();
|
|
83
|
+
(globalThis as G).__pixAfk = true;
|
|
84
|
+
(globalThis as G).__pixYolo = true;
|
|
85
|
+
expect(unattendedGateDecision(5)).toBe("allow");
|
|
86
|
+
reset();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── circuit breaker ───────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
describe("isCircuitBreaker", () => {
|
|
93
|
+
test("catches root and home wipes", () => {
|
|
94
|
+
expect(isCircuitBreaker("rm -rf /")).toBe(true);
|
|
95
|
+
expect(isCircuitBreaker("rm -rf ~")).toBe(true);
|
|
96
|
+
expect(isCircuitBreaker("rm -fr / --no-preserve-root")).toBe(true);
|
|
97
|
+
expect(isCircuitBreaker("cd /tmp && rm -rf /")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("catches raw-disk writes and format", () => {
|
|
101
|
+
expect(isCircuitBreaker("dd if=/dev/zero of=/dev/sda")).toBe(true);
|
|
102
|
+
expect(isCircuitBreaker("mkfs.ext4 /dev/nvme0n1")).toBe(true);
|
|
103
|
+
expect(isCircuitBreaker("echo x > /dev/sdb")).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("catches the classic fork bomb", () => {
|
|
107
|
+
expect(isCircuitBreaker(":(){ :|:& };:")).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("does NOT trip on ordinary destructive-but-scoped commands", () => {
|
|
111
|
+
expect(isCircuitBreaker("rm -rf ./build")).toBe(false);
|
|
112
|
+
expect(isCircuitBreaker("rm -rf node_modules")).toBe(false);
|
|
113
|
+
expect(isCircuitBreaker("dd if=in.img of=out.img")).toBe(false);
|
|
114
|
+
expect(isCircuitBreaker("echo hi > /tmp/x")).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
39
118
|
// ── classify ──────────────────────────────────────────────────────────────────
|
|
40
119
|
|
|
41
120
|
describe("classify", () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEventBus } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import registerGate from "./index.ts";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
delete (globalThis as { __pixAgentState?: WeakMap<object, unknown> }).__pixAgentState;
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("gate agent state", () => {
|
|
10
|
+
test("reports blocked while waiting for command approval", async () => {
|
|
11
|
+
const events = createEventBus();
|
|
12
|
+
const states: string[] = [];
|
|
13
|
+
events.on("pix:agent-state", (event) => states.push((event as { state: string }).state));
|
|
14
|
+
const handlers: Array<(event: any, ctx: any) => Promise<unknown>> = [];
|
|
15
|
+
const pi = {
|
|
16
|
+
events,
|
|
17
|
+
on(event: string, handler: (event: any, ctx: any) => Promise<unknown>) {
|
|
18
|
+
if (event === "tool_call") handlers.push(handler);
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
registerGate(pi as never);
|
|
22
|
+
|
|
23
|
+
const ctx = {
|
|
24
|
+
hasUI: true,
|
|
25
|
+
ui: {
|
|
26
|
+
custom: async () => ({ action: "denied" }),
|
|
27
|
+
notify() {},
|
|
28
|
+
theme: { fg: (_color: string, text: string) => text },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
for (const handler of handlers) {
|
|
32
|
+
await handler({ toolName: "bash", input: { command: "git push --force" } }, ctx);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
expect(states).toContain("blocked");
|
|
36
|
+
expect(states.at(-1)).toBe("idle");
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -18,13 +18,16 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { withAgentBlock } from "@xynogen/pix-runtime";
|
|
21
22
|
import {
|
|
22
23
|
buildRules,
|
|
23
24
|
classify,
|
|
24
25
|
classifyPath,
|
|
25
26
|
extractPathsFromBash,
|
|
27
|
+
isCircuitBreaker,
|
|
26
28
|
isSudoCommand,
|
|
27
29
|
loadUserConfig,
|
|
30
|
+
unattendedGateDecision,
|
|
28
31
|
} from "./lib.ts";
|
|
29
32
|
import {
|
|
30
33
|
type Concern,
|
|
@@ -69,13 +72,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
69
72
|
return undefined;
|
|
70
73
|
}
|
|
71
74
|
|
|
75
|
+
const unattended = unattendedGateDecision(hit.severity === "block" ? 4 : 2);
|
|
76
|
+
if (unattended === "allow") return undefined;
|
|
77
|
+
if (unattended === "deny") {
|
|
78
|
+
return {
|
|
79
|
+
block: true,
|
|
80
|
+
reason: `[AFK][PATH:${hit.severity.toUpperCase()}] ${hit.reason}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
if (!ctx.hasUI)
|
|
73
85
|
return {
|
|
74
86
|
block: true,
|
|
75
87
|
reason: `[PATH:${hit.severity.toUpperCase()}] ${hit.reason} (no UI)`,
|
|
76
88
|
};
|
|
77
89
|
|
|
78
|
-
const decision = await
|
|
90
|
+
const decision = await withAgentBlock(
|
|
91
|
+
pi.events,
|
|
92
|
+
"gate",
|
|
93
|
+
`${hit.severity.toUpperCase()} path approval required`,
|
|
94
|
+
() => promptPathDecision(ctx.ui, hit, op, path),
|
|
95
|
+
);
|
|
79
96
|
if (!decision.approved)
|
|
80
97
|
return {
|
|
81
98
|
block: true,
|
|
@@ -105,8 +122,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
105
122
|
const command = String(event.input.command ?? "");
|
|
106
123
|
if (!command.trim()) return undefined;
|
|
107
124
|
|
|
108
|
-
if (autoApprove.some((re) => re.test(command))) return undefined;
|
|
109
|
-
|
|
110
125
|
// Collect all concerns: path hits + command hit
|
|
111
126
|
const concerns: Concern[] = [];
|
|
112
127
|
|
|
@@ -142,8 +157,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
142
157
|
|
|
143
158
|
const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
|
|
144
159
|
|
|
145
|
-
// 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.
|
|
146
162
|
if (isSudoCommand(command)) {
|
|
163
|
+
if (unattendedGateDecision(highest.tier) === "deny") {
|
|
164
|
+
return { block: true, reason: "[AFK] sudo is denied while user is away." };
|
|
165
|
+
}
|
|
147
166
|
ctx.ui.notify(
|
|
148
167
|
`⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — use sudo_run tool instead (handles auth securely)`,
|
|
149
168
|
"warning",
|
|
@@ -154,6 +173,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
173
|
};
|
|
155
174
|
}
|
|
156
175
|
|
|
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") {
|
|
188
|
+
return {
|
|
189
|
+
block: true,
|
|
190
|
+
reason: `[AFK][${highest.label}] ${highest.detail}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (autoApprove.some((re) => re.test(command))) return undefined;
|
|
195
|
+
|
|
157
196
|
// No UI: auto-block block+ severity, pass anything lower.
|
|
158
197
|
if (!ctx.hasUI) {
|
|
159
198
|
if (highest.tier >= SEVERITY_TIER.block) {
|
|
@@ -168,19 +207,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
168
207
|
// Single concern → use existing targeted dialog. Multiple → merged.
|
|
169
208
|
let decision: GateDecision;
|
|
170
209
|
if (concerns.length === 1 && cmdHit) {
|
|
171
|
-
decision = await
|
|
210
|
+
decision = await withAgentBlock(
|
|
211
|
+
pi.events,
|
|
212
|
+
"gate",
|
|
213
|
+
`${highest.label} command approval required`,
|
|
214
|
+
() => promptGateDecision(ctx.ui, cmdHit, command),
|
|
215
|
+
);
|
|
172
216
|
} else if (concerns.length === 1 && !cmdHit) {
|
|
173
217
|
// Single path hit — reuse path dialog
|
|
174
218
|
const ph = candidates
|
|
175
219
|
.map((p) => ({ p, h: classifyPath(p, "read", pathRules) }))
|
|
176
220
|
.find((x) => x.h?.severity !== "info" && x.h);
|
|
177
221
|
if (ph?.h) {
|
|
178
|
-
decision = await
|
|
222
|
+
decision = await withAgentBlock(
|
|
223
|
+
pi.events,
|
|
224
|
+
"gate",
|
|
225
|
+
`${highest.label} path approval required`,
|
|
226
|
+
() => promptPathDecision(ctx.ui, ph.h as NonNullable<typeof ph.h>, "bash read", ph.p),
|
|
227
|
+
);
|
|
179
228
|
} else {
|
|
180
229
|
return undefined;
|
|
181
230
|
}
|
|
182
231
|
} else {
|
|
183
|
-
decision = await
|
|
232
|
+
decision = await withAgentBlock(
|
|
233
|
+
pi.events,
|
|
234
|
+
"gate",
|
|
235
|
+
`${highest.label} command approval required`,
|
|
236
|
+
() => promptMergedGateDecision(ctx.ui, concerns, command),
|
|
237
|
+
);
|
|
184
238
|
}
|
|
185
239
|
|
|
186
240
|
if (!decision.approved) {
|
package/src/lib.ts
CHANGED
|
@@ -289,3 +289,34 @@ export function extractPathsFromBash(cmd: string): string[] {
|
|
|
289
289
|
export function isSudoCommand(command: string): boolean {
|
|
290
290
|
return /(^|[\s;|&])sudo\b/i.test(command);
|
|
291
291
|
}
|
|
292
|
+
|
|
293
|
+
// Non-lifting circuit breaker: catastrophic, unrecoverable commands that NO
|
|
294
|
+
// mode (not even YOLO) may auto-approve. Mirrors Claude Code's bypassPermissions
|
|
295
|
+
// floor, which still prompts on root/home wipes. These always fall through to
|
|
296
|
+
// the interactive dialog (or a no-UI block).
|
|
297
|
+
const CIRCUIT_BREAKER: RegExp[] = [
|
|
298
|
+
/\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(?:--\s+)?[~/]\s*(?:$|[\s;|&])/i, // rm -rf / or rm -rf ~
|
|
299
|
+
/\bdd\b[^\n]*\bof=\/dev\/(?:sd|nvme|disk|hd|vd)/i, // dd onto a raw disk
|
|
300
|
+
/\bmkfs\.\w+\s+\/dev\//i, // format a device
|
|
301
|
+
/:\s*\(\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;/, // classic fork bomb
|
|
302
|
+
/>\s*\/dev\/(?:sd|nvme|disk|hd|vd)[a-z0-9]*\b/i, // redirect over a raw disk
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
/** True when a command is catastrophic enough that no unattended mode may auto-approve it. */
|
|
306
|
+
export function isCircuitBreaker(command: string): boolean {
|
|
307
|
+
return CIRCUIT_BREAKER.some((re) => re.test(command));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Unattended-mode gate decision for a concern tier (1 risky … 5 critical).
|
|
312
|
+
* yolo — auto-allow every tier, including red/critical.
|
|
313
|
+
* afk — auto-allow yellow (tier < 4), auto-deny red (tier >= 4).
|
|
314
|
+
* off — undefined (fall through to the interactive dialog).
|
|
315
|
+
* Mutually exclusive globals set by pix-commands' /afk and /yolo.
|
|
316
|
+
*/
|
|
317
|
+
export function unattendedGateDecision(tier: number): "allow" | "deny" | undefined {
|
|
318
|
+
const g = globalThis as { __pixAfk?: boolean; __pixYolo?: boolean };
|
|
319
|
+
if (g.__pixYolo === true) return "allow";
|
|
320
|
+
if (g.__pixAfk === true) return tier >= 4 ? "deny" : "allow";
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|