@xynogen/pix-gate 0.2.9 → 0.2.11
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 +25 -2
- package/package.json +2 -3
- package/src/gate.test.ts +15 -30
- package/src/index.ts +4 -4
- package/src/lib.ts +9 -5
package/README.md
CHANGED
|
@@ -4,9 +4,32 @@ Pi extension — permission gate for dangerous bash commands.
|
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
|
-
Intercepts every
|
|
7
|
+
Intercepts every tool call and classifies it against severity rules before it runs. Two rule sets:
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Path rules** — protect `read`/`write`/`edit` from private keys, credential files, etc.
|
|
10
|
+
|
|
11
|
+
| Tier | Behavior |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `block` | deny-first, 15s timeout |
|
|
14
|
+
| `warn` | allow-first, 30s |
|
|
15
|
+
| `info` | blue notify, never blocks |
|
|
16
|
+
|
|
17
|
+
**Command rules** — gate `bash` invocations.
|
|
18
|
+
|
|
19
|
+
| Tier | Examples | Behavior |
|
|
20
|
+
|---|---|---|
|
|
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 |
|
|
23
|
+
| `risky` | — | 60s allow-first dialog; silently passes non-interactive |
|
|
24
|
+
|
|
25
|
+
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
|
+
|
|
27
|
+
**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:
|
|
28
|
+
|
|
29
|
+
- `/afk` — yellow gates auto-allow, red/root auto-deny, so only genuine stops surface.
|
|
30
|
+
- `/yolo` — every tier including red auto-approves (capable models only).
|
|
31
|
+
|
|
32
|
+
Bare `sudo` in bash is always redirected to `sudo_run`, regardless of mode.
|
|
10
33
|
|
|
11
34
|
**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
35
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-gate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
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",
|
|
@@ -43,8 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"@xynogen/pix-pretty": "^1.11.2",
|
|
46
|
-
"@xynogen/pix-
|
|
47
|
-
"@xynogen/pix-runtime": "^0.7.0"
|
|
46
|
+
"@xynogen/pix-runtime": "^0.8.0"
|
|
48
47
|
},
|
|
49
48
|
"peerDependencies": {
|
|
50
49
|
"@earendil-works/pi-coding-agent": "*",
|
package/src/gate.test.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEventBus } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { setUnattendedMode } from "@xynogen/pix-runtime";
|
|
2
4
|
import {
|
|
3
5
|
buildRules,
|
|
4
6
|
classify,
|
|
@@ -66,43 +68,26 @@ describe("isSudoCommand", () => {
|
|
|
66
68
|
// ── AFK behavior ──────────────────────────────────────────────────────────────
|
|
67
69
|
|
|
68
70
|
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
71
|
test("does nothing while every mode is off", () => {
|
|
76
|
-
|
|
77
|
-
expect(unattendedGateDecision(5)).toBeUndefined();
|
|
72
|
+
expect(unattendedGateDecision(createEventBus(), 5)).toBeUndefined();
|
|
78
73
|
});
|
|
79
74
|
|
|
80
75
|
test("AFK allows yellow concerns and denies red concerns", () => {
|
|
81
|
-
|
|
82
|
-
(
|
|
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();
|
|
76
|
+
const events = createEventBus();
|
|
77
|
+
setUnattendedMode(events, "afk");
|
|
78
|
+
expect(unattendedGateDecision(events, 1)).toBe("allow");
|
|
79
|
+
expect(unattendedGateDecision(events, 2)).toBe("allow");
|
|
80
|
+
expect(unattendedGateDecision(events, 3)).toBe("allow");
|
|
81
|
+
expect(unattendedGateDecision(events, 4)).toBe("deny");
|
|
82
|
+
expect(unattendedGateDecision(events, 5)).toBe("deny");
|
|
89
83
|
});
|
|
90
84
|
|
|
91
85
|
test("YOLO allows every tier including red/critical", () => {
|
|
92
|
-
|
|
93
|
-
(
|
|
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();
|
|
86
|
+
const events = createEventBus();
|
|
87
|
+
setUnattendedMode(events, "yolo");
|
|
88
|
+
expect(unattendedGateDecision(events, 1)).toBe("allow");
|
|
89
|
+
expect(unattendedGateDecision(events, 4)).toBe("allow");
|
|
90
|
+
expect(unattendedGateDecision(events, 5)).toBe("allow");
|
|
106
91
|
});
|
|
107
92
|
});
|
|
108
93
|
|
package/src/index.ts
CHANGED
|
@@ -72,7 +72,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
72
72
|
return undefined;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
const unattended = unattendedGateDecision(hit.severity === "block" ? 4 : 2);
|
|
75
|
+
const unattended = unattendedGateDecision(pi.events, hit.severity === "block" ? 4 : 2);
|
|
76
76
|
if (unattended === "allow") return undefined;
|
|
77
77
|
if (unattended === "deny") {
|
|
78
78
|
return {
|
|
@@ -160,7 +160,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
160
160
|
// sudo: hard redirect — no prompt, no bypass. Even YOLO cannot run bare
|
|
161
161
|
// `sudo` in bash (the password can't be auto-typed); it must use sudo_run.
|
|
162
162
|
if (isSudoCommand(command)) {
|
|
163
|
-
if (unattendedGateDecision(highest.tier) === "deny") {
|
|
163
|
+
if (unattendedGateDecision(pi.events, highest.tier) === "deny") {
|
|
164
164
|
return { block: true, reason: "[AFK] sudo is denied while user is away." };
|
|
165
165
|
}
|
|
166
166
|
ctx.ui.notify(
|
|
@@ -176,13 +176,13 @@ export default function (pi: ExtensionAPI): void {
|
|
|
176
176
|
// Circuit breaker: catastrophic commands never auto-approve, even under YOLO.
|
|
177
177
|
// They fall through to the interactive dialog (or the no-UI block below).
|
|
178
178
|
const breaker = isCircuitBreaker(command);
|
|
179
|
-
if (breaker && unattendedGateDecision(highest.tier) === "allow") {
|
|
179
|
+
if (breaker && unattendedGateDecision(pi.events, highest.tier) === "allow") {
|
|
180
180
|
ctx.ui.notify(
|
|
181
181
|
`⛔ ${ctx.ui.theme.fg("error", "CIRCUIT BREAKER")} — refused even under YOLO; confirm manually`,
|
|
182
182
|
"error",
|
|
183
183
|
);
|
|
184
184
|
}
|
|
185
|
-
const unattended = breaker ? undefined : unattendedGateDecision(highest.tier);
|
|
185
|
+
const unattended = breaker ? undefined : unattendedGateDecision(pi.events, highest.tier);
|
|
186
186
|
if (unattended === "allow") return undefined;
|
|
187
187
|
if (unattended === "deny") {
|
|
188
188
|
return {
|
package/src/lib.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Pure helpers for pix-gate — no Pi API deps, fully unit-testable.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import type { EventBus } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getUnattendedMode } from "@xynogen/pix-runtime";
|
|
5
7
|
import { config } from "@xynogen/pix-runtime/config";
|
|
6
8
|
import { type GateRuleConfig, gateSection } from "@xynogen/pix-runtime/sections";
|
|
7
9
|
|
|
@@ -318,11 +320,13 @@ export function isCircuitBreaker(command: string): boolean {
|
|
|
318
320
|
* yolo — auto-allow every tier, including red/critical.
|
|
319
321
|
* afk — auto-allow yellow (tier < 4), auto-deny red (tier >= 4).
|
|
320
322
|
* off — undefined (fall through to the interactive dialog).
|
|
321
|
-
* Mutually exclusive globals set by pix-commands' /afk and /yolo.
|
|
322
323
|
*/
|
|
323
|
-
export function unattendedGateDecision(
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
324
|
+
export function unattendedGateDecision(
|
|
325
|
+
events: EventBus,
|
|
326
|
+
tier: number,
|
|
327
|
+
): "allow" | "deny" | undefined {
|
|
328
|
+
const mode = getUnattendedMode(events);
|
|
329
|
+
if (mode === "yolo") return "allow";
|
|
330
|
+
if (mode === "afk") return tier >= 4 ? "deny" : "allow";
|
|
327
331
|
return undefined;
|
|
328
332
|
}
|