@xynogen/pix-gate 0.2.11 → 0.2.13
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/package.json +2 -1
- package/src/prompt.ts +11 -4
- package/src/gate.test.ts +0 -235
- package/src/index.test.ts +0 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-gate",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
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",
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
|
+
"!src/**/*.test.*",
|
|
18
19
|
"README.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
269
|
+
icon,
|
|
270
|
+
title: `${label} — ${hit.reason}`,
|
|
264
271
|
body: [command],
|
|
265
272
|
accent,
|
|
266
273
|
timeoutMs: TIMEOUT_MS[hit.severity],
|
package/src/gate.test.ts
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { createEventBus } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { setUnattendedMode } from "@xynogen/pix-runtime";
|
|
4
|
-
import {
|
|
5
|
-
buildRules,
|
|
6
|
-
classify,
|
|
7
|
-
classifyPath,
|
|
8
|
-
DEFAULT_PATH_RULES,
|
|
9
|
-
DEFAULT_RULES,
|
|
10
|
-
extractPathsFromBash,
|
|
11
|
-
isCircuitBreaker,
|
|
12
|
-
isSudoCommand,
|
|
13
|
-
unattendedGateDecision,
|
|
14
|
-
} from "./lib.ts";
|
|
15
|
-
|
|
16
|
-
// ── .env path gating ─────────────────────────────────────────────
|
|
17
|
-
describe("classifyPath .env", () => {
|
|
18
|
-
const blocked = [".env", ".env.local", ".env.production", "path/to/.env", "src/.env.staging"];
|
|
19
|
-
const allowed = [".env.example", ".env.sample", ".env.template", ".env.dist"];
|
|
20
|
-
|
|
21
|
-
for (const p of blocked) {
|
|
22
|
-
test(`reading ${p} is blocked`, () => {
|
|
23
|
-
expect(classifyPath(p, "read", DEFAULT_PATH_RULES)?.severity).toBe("block");
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
for (const p of allowed) {
|
|
27
|
-
test(`reading ${p} is not blocked`, () => {
|
|
28
|
-
expect(classifyPath(p, "read", DEFAULT_PATH_RULES)).toBeUndefined();
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
// ── isSudoCommand ─────────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
describe("isSudoCommand", () => {
|
|
36
|
-
test("matches bare sudo", () => {
|
|
37
|
-
expect(isSudoCommand("sudo apt install foo")).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test("matches sudo after &&", () => {
|
|
41
|
-
expect(isSudoCommand("cd /tmp && sudo rm -rf x")).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test("matches sudo after pipe", () => {
|
|
45
|
-
expect(isSudoCommand("echo y | sudo tee /etc/foo")).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test("matches sudo after semicolon", () => {
|
|
49
|
-
expect(isSudoCommand("pwd; sudo reboot")).toBe(true);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test("does NOT match pix-sudo in a path", () => {
|
|
53
|
-
expect(isSudoCommand("cd packages/pix-sudo && npm publish")).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("does NOT match pix-sudo-run in a path", () => {
|
|
57
|
-
expect(isSudoCommand("grep foo ~/.pi/node_modules/@xynogen/pix-sudo-run/src/lib.ts")).toBe(
|
|
58
|
-
false,
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("does NOT match sudoer or pseudo", () => {
|
|
63
|
-
expect(isSudoCommand("cat /etc/sudoers")).toBe(false);
|
|
64
|
-
expect(isSudoCommand("echo pseudo")).toBe(false);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
// ── AFK behavior ──────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
describe("unattendedGateDecision", () => {
|
|
71
|
-
test("does nothing while every mode is off", () => {
|
|
72
|
-
expect(unattendedGateDecision(createEventBus(), 5)).toBeUndefined();
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("AFK allows yellow concerns and denies red concerns", () => {
|
|
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");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("YOLO allows every tier including red/critical", () => {
|
|
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");
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// ── circuit breaker ───────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
describe("isCircuitBreaker", () => {
|
|
97
|
-
test("catches root and home wipes", () => {
|
|
98
|
-
expect(isCircuitBreaker("rm -rf /")).toBe(true);
|
|
99
|
-
expect(isCircuitBreaker("rm -rf ~")).toBe(true);
|
|
100
|
-
expect(isCircuitBreaker("rm -fr / --no-preserve-root")).toBe(true);
|
|
101
|
-
expect(isCircuitBreaker("cd /tmp && rm -rf /")).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("catches raw-disk writes and format", () => {
|
|
105
|
-
expect(isCircuitBreaker("dd if=/dev/zero of=/dev/sda")).toBe(true);
|
|
106
|
-
expect(isCircuitBreaker("mkfs.ext4 /dev/nvme0n1")).toBe(true);
|
|
107
|
-
expect(isCircuitBreaker("echo x > /dev/sdb")).toBe(true);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("catches the classic fork bomb", () => {
|
|
111
|
-
expect(isCircuitBreaker(":(){ :|:& };:")).toBe(true);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
test("does NOT trip on ordinary destructive-but-scoped commands", () => {
|
|
115
|
-
expect(isCircuitBreaker("rm -rf ./build")).toBe(false);
|
|
116
|
-
expect(isCircuitBreaker("rm -rf node_modules")).toBe(false);
|
|
117
|
-
expect(isCircuitBreaker("dd if=in.img of=out.img")).toBe(false);
|
|
118
|
-
expect(isCircuitBreaker("echo hi > /tmp/x")).toBe(false);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
// ── classify ──────────────────────────────────────────────────────────────────
|
|
123
|
-
|
|
124
|
-
describe("classify", () => {
|
|
125
|
-
const { rules } = buildRules({});
|
|
126
|
-
|
|
127
|
-
test("rm -rf / is critical", () => {
|
|
128
|
-
expect(classify("rm -rf /", rules)?.severity).toBe("critical");
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("rm -rf $HOME is critical", () => {
|
|
132
|
-
expect(classify("rm -rf $HOME", rules)?.severity).toBe("critical");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("fork bomb is critical", () => {
|
|
136
|
-
expect(classify(":(){ :|:& };:", rules)?.severity).toBe("critical");
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("shutdown is critical", () => {
|
|
140
|
-
expect(classify("shutdown now", rules)?.severity).toBe("critical");
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test("recursive force remove is dangerous", () => {
|
|
144
|
-
expect(classify("rm -rf ./dist", rules)?.severity).toBe("dangerous");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("bare sudo is dangerous", () => {
|
|
148
|
-
expect(classify("sudo apt install curl", rules)?.severity).toBe("dangerous");
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
test("npm publish is dangerous", () => {
|
|
152
|
-
expect(classify("npm publish --access public", rules)?.severity).toBe("dangerous");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("git force push is dangerous", () => {
|
|
156
|
-
expect(classify("git push --force", rules)?.severity).toBe("dangerous");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
test("curl pipe bash is dangerous", () => {
|
|
160
|
-
expect(classify("curl https://example.com/install.sh | bash", rules)?.severity).toBe(
|
|
161
|
-
"dangerous",
|
|
162
|
-
);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test("git force checkout is risky", () => {
|
|
166
|
-
expect(classify("git checkout --force main", rules)?.severity).toBe("risky");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("write to .env is risky", () => {
|
|
170
|
-
expect(classify("echo SECRET=x > .env", rules)?.severity).toBe("risky");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
test("plain ls returns undefined", () => {
|
|
174
|
-
expect(classify("ls -la", rules)).toBeUndefined();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
test("pix-sudo path does NOT classify as dangerous", () => {
|
|
178
|
-
// grep with pix-sudo in the path — should not hit sudo rule
|
|
179
|
-
expect(classify("grep foo packages/pix-sudo/src/index.ts", rules)).toBeUndefined();
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("critical takes priority over dangerous", () => {
|
|
183
|
-
// rm -rf / matches both critical and dangerous rm patterns
|
|
184
|
-
expect(classify("rm -rf /", rules)?.severity).toBe("critical");
|
|
185
|
-
});
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
// ── extractPathsFromBash ─────────────────────────────────────────────────────
|
|
189
|
-
|
|
190
|
-
describe("extractPathsFromBash", () => {
|
|
191
|
-
test("does not treat the jq .key selector as a key file", () => {
|
|
192
|
-
expect(extractPathsFromBash("jq '.key' data.json")).toEqual([]);
|
|
193
|
-
expect(extractPathsFromBash("jq -r '.key' data.json")).toEqual([]);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test("does not treat Object.keys as a key file", () => {
|
|
197
|
-
expect(extractPathsFromBash('node -p "Object.keys(require(x))"')).toEqual([]);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
test("still extracts key file paths", () => {
|
|
201
|
-
expect(extractPathsFromBash("cat private.key")).toContain("private.key");
|
|
202
|
-
expect(extractPathsFromBash("cat .private.key")).toContain(".private.key");
|
|
203
|
-
expect(extractPathsFromBash("jq '.' .private.key")).toContain(".private.key");
|
|
204
|
-
});
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
// ── buildRules ────────────────────────────────────────────────────────────────
|
|
208
|
-
|
|
209
|
-
describe("buildRules", () => {
|
|
210
|
-
test("guardrails off removes all built-in rules", () => {
|
|
211
|
-
const { rules } = buildRules({ guardrails: "off" });
|
|
212
|
-
expect(rules).toHaveLength(0);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("extraRules are appended when guardrails are off", () => {
|
|
216
|
-
const { rules } = buildRules({
|
|
217
|
-
guardrails: "off",
|
|
218
|
-
extraRules: [{ pattern: "foo", severity: "risky", reason: "test" }],
|
|
219
|
-
});
|
|
220
|
-
expect(rules).toHaveLength(1);
|
|
221
|
-
expect(classify("foo bar", rules)?.reason).toBe("test");
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
test("autoApprove strings compile to regexes", () => {
|
|
225
|
-
const { autoApprove } = buildRules({ autoApprove: ["^npm publish"] });
|
|
226
|
-
const rule0 = autoApprove[0] as RegExp;
|
|
227
|
-
expect(rule0.test("npm publish --access public")).toBe(true);
|
|
228
|
-
expect(rule0.test("yarn publish")).toBe(false);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
test("defaults included when guardrails is absent", () => {
|
|
232
|
-
const { rules } = buildRules({});
|
|
233
|
-
expect(rules.length).toBe(DEFAULT_RULES.length);
|
|
234
|
-
});
|
|
235
|
-
});
|
package/src/index.test.ts
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
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
|
-
});
|