@xynogen/pix-gate 0.2.6 → 0.2.7
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 +2 -0
- package/package.json +2 -2
- package/src/gate.test.ts +27 -1
- package/src/index.test.ts +38 -0
- package/src/index.ts +49 -6
- package/src/lib.ts +5 -0
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ 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.
|
|
10
|
+
|
|
9
11
|
## Install
|
|
10
12
|
|
|
11
13
|
```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.7",
|
|
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,12 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
afkGateDecision,
|
|
4
|
+
buildRules,
|
|
5
|
+
classify,
|
|
6
|
+
DEFAULT_RULES,
|
|
7
|
+
extractPathsFromBash,
|
|
8
|
+
isSudoCommand,
|
|
9
|
+
} from "./lib.ts";
|
|
3
10
|
|
|
4
11
|
// ── isSudoCommand ─────────────────────────────────────────────────────────────
|
|
5
12
|
|
|
@@ -36,6 +43,25 @@ describe("isSudoCommand", () => {
|
|
|
36
43
|
});
|
|
37
44
|
});
|
|
38
45
|
|
|
46
|
+
// ── AFK behavior ──────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
describe("afkGateDecision", () => {
|
|
49
|
+
test("does nothing while AFK mode is off", () => {
|
|
50
|
+
delete (globalThis as { __pixAfk?: boolean }).__pixAfk;
|
|
51
|
+
expect(afkGateDecision(5)).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
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;
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
39
65
|
// ── classify ──────────────────────────────────────────────────────────────────
|
|
40
66
|
|
|
41
67
|
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,7 +18,9 @@
|
|
|
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 {
|
|
23
|
+
afkGateDecision,
|
|
22
24
|
buildRules,
|
|
23
25
|
classify,
|
|
24
26
|
classifyPath,
|
|
@@ -69,13 +71,27 @@ export default function (pi: ExtensionAPI): void {
|
|
|
69
71
|
return undefined;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
const afkDecision = afkGateDecision(hit.severity === "block" ? 4 : 2);
|
|
75
|
+
if (afkDecision === "allow") return undefined;
|
|
76
|
+
if (afkDecision === "deny") {
|
|
77
|
+
return {
|
|
78
|
+
block: true,
|
|
79
|
+
reason: `[AFK][PATH:${hit.severity.toUpperCase()}] ${hit.reason}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
if (!ctx.hasUI)
|
|
73
84
|
return {
|
|
74
85
|
block: true,
|
|
75
86
|
reason: `[PATH:${hit.severity.toUpperCase()}] ${hit.reason} (no UI)`,
|
|
76
87
|
};
|
|
77
88
|
|
|
78
|
-
const decision = await
|
|
89
|
+
const decision = await withAgentBlock(
|
|
90
|
+
pi.events,
|
|
91
|
+
"gate",
|
|
92
|
+
`${hit.severity.toUpperCase()} path approval required`,
|
|
93
|
+
() => promptPathDecision(ctx.ui, hit, op, path),
|
|
94
|
+
);
|
|
79
95
|
if (!decision.approved)
|
|
80
96
|
return {
|
|
81
97
|
block: true,
|
|
@@ -105,8 +121,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
105
121
|
const command = String(event.input.command ?? "");
|
|
106
122
|
if (!command.trim()) return undefined;
|
|
107
123
|
|
|
108
|
-
if (autoApprove.some((re) => re.test(command))) return undefined;
|
|
109
|
-
|
|
110
124
|
// Collect all concerns: path hits + command hit
|
|
111
125
|
const concerns: Concern[] = [];
|
|
112
126
|
|
|
@@ -144,6 +158,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
144
158
|
|
|
145
159
|
// sudo: hard redirect — no prompt, no bypass.
|
|
146
160
|
if (isSudoCommand(command)) {
|
|
161
|
+
if (afkGateDecision(highest.tier)) {
|
|
162
|
+
return { block: true, reason: "[AFK] sudo is denied while user is away." };
|
|
163
|
+
}
|
|
147
164
|
ctx.ui.notify(
|
|
148
165
|
`⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — use sudo_run tool instead (handles auth securely)`,
|
|
149
166
|
"warning",
|
|
@@ -154,6 +171,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
154
171
|
};
|
|
155
172
|
}
|
|
156
173
|
|
|
174
|
+
const afkDecision = afkGateDecision(highest.tier);
|
|
175
|
+
if (afkDecision === "allow") return undefined;
|
|
176
|
+
if (afkDecision === "deny") {
|
|
177
|
+
return {
|
|
178
|
+
block: true,
|
|
179
|
+
reason: `[AFK][${highest.label}] ${highest.detail}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (autoApprove.some((re) => re.test(command))) return undefined;
|
|
184
|
+
|
|
157
185
|
// No UI: auto-block block+ severity, pass anything lower.
|
|
158
186
|
if (!ctx.hasUI) {
|
|
159
187
|
if (highest.tier >= SEVERITY_TIER.block) {
|
|
@@ -168,19 +196,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
168
196
|
// Single concern → use existing targeted dialog. Multiple → merged.
|
|
169
197
|
let decision: GateDecision;
|
|
170
198
|
if (concerns.length === 1 && cmdHit) {
|
|
171
|
-
decision = await
|
|
199
|
+
decision = await withAgentBlock(
|
|
200
|
+
pi.events,
|
|
201
|
+
"gate",
|
|
202
|
+
`${highest.label} command approval required`,
|
|
203
|
+
() => promptGateDecision(ctx.ui, cmdHit, command),
|
|
204
|
+
);
|
|
172
205
|
} else if (concerns.length === 1 && !cmdHit) {
|
|
173
206
|
// Single path hit — reuse path dialog
|
|
174
207
|
const ph = candidates
|
|
175
208
|
.map((p) => ({ p, h: classifyPath(p, "read", pathRules) }))
|
|
176
209
|
.find((x) => x.h?.severity !== "info" && x.h);
|
|
177
210
|
if (ph?.h) {
|
|
178
|
-
decision = await
|
|
211
|
+
decision = await withAgentBlock(
|
|
212
|
+
pi.events,
|
|
213
|
+
"gate",
|
|
214
|
+
`${highest.label} path approval required`,
|
|
215
|
+
() => promptPathDecision(ctx.ui, ph.h as NonNullable<typeof ph.h>, "bash read", ph.p),
|
|
216
|
+
);
|
|
179
217
|
} else {
|
|
180
218
|
return undefined;
|
|
181
219
|
}
|
|
182
220
|
} else {
|
|
183
|
-
decision = await
|
|
221
|
+
decision = await withAgentBlock(
|
|
222
|
+
pi.events,
|
|
223
|
+
"gate",
|
|
224
|
+
`${highest.label} command approval required`,
|
|
225
|
+
() => promptMergedGateDecision(ctx.ui, concerns, command),
|
|
226
|
+
);
|
|
184
227
|
}
|
|
185
228
|
|
|
186
229
|
if (!decision.approved) {
|
package/src/lib.ts
CHANGED
|
@@ -289,3 +289,8 @@ 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
|
+
export function afkGateDecision(tier: number): "allow" | "deny" | undefined {
|
|
294
|
+
if ((globalThis as { __pixAfk?: boolean }).__pixAfk !== true) return undefined;
|
|
295
|
+
return tier >= 4 ? "deny" : "allow";
|
|
296
|
+
}
|