@xynogen/pix-gate 0.1.0

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 ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@xynogen/pix-gate",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension — permission gate for dangerous bash commands (confirm/block with TUI dialog)",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "test": "bun test"
9
+ },
10
+ "files": [
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "src/index.ts"
18
+ ]
19
+ },
20
+ "keywords": [
21
+ "pi",
22
+ "pi-package",
23
+ "pi-extension",
24
+ "security",
25
+ "permission",
26
+ "gate"
27
+ ],
28
+ "author": "xynogen",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/xynogen/pix-mono.git",
33
+ "directory": "packages/pix-gate"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "peerDependencies": {
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*"
41
+ }
42
+ }
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildRules, classify, DEFAULT_RULES, isSudoCommand } from "./lib.ts";
3
+
4
+ // ── isSudoCommand ─────────────────────────────────────────────────────────────
5
+
6
+ describe("isSudoCommand", () => {
7
+ test("matches bare sudo", () => {
8
+ expect(isSudoCommand("sudo apt install foo")).toBe(true);
9
+ });
10
+
11
+ test("matches sudo after &&", () => {
12
+ expect(isSudoCommand("cd /tmp && sudo rm -rf x")).toBe(true);
13
+ });
14
+
15
+ test("matches sudo after pipe", () => {
16
+ expect(isSudoCommand("echo y | sudo tee /etc/foo")).toBe(true);
17
+ });
18
+
19
+ test("matches sudo after semicolon", () => {
20
+ expect(isSudoCommand("pwd; sudo reboot")).toBe(true);
21
+ });
22
+
23
+ test("does NOT match pix-sudo in a path", () => {
24
+ expect(isSudoCommand("cd packages/pix-sudo && npm publish")).toBe(false);
25
+ });
26
+
27
+ test("does NOT match pix-sudo-run in a path", () => {
28
+ expect(
29
+ isSudoCommand(
30
+ "grep foo ~/.pi/node_modules/@xynogen/pix-sudo-run/src/lib.ts",
31
+ ),
32
+ ).toBe(false);
33
+ });
34
+
35
+ test("does NOT match sudoer or pseudo", () => {
36
+ expect(isSudoCommand("cat /etc/sudoers")).toBe(false);
37
+ expect(isSudoCommand("echo pseudo")).toBe(false);
38
+ });
39
+ });
40
+
41
+ // ── classify ──────────────────────────────────────────────────────────────────
42
+
43
+ describe("classify", () => {
44
+ const { rules } = buildRules({});
45
+
46
+ test("rm -rf / is critical", () => {
47
+ expect(classify("rm -rf /", rules)?.severity).toBe("critical");
48
+ });
49
+
50
+ test("rm -rf $HOME is critical", () => {
51
+ expect(classify("rm -rf $HOME", rules)?.severity).toBe("critical");
52
+ });
53
+
54
+ test("fork bomb is critical", () => {
55
+ expect(classify(":(){ :|:& };:", rules)?.severity).toBe("critical");
56
+ });
57
+
58
+ test("shutdown is critical", () => {
59
+ expect(classify("shutdown now", rules)?.severity).toBe("critical");
60
+ });
61
+
62
+ test("recursive force remove is dangerous", () => {
63
+ expect(classify("rm -rf ./dist", rules)?.severity).toBe("dangerous");
64
+ });
65
+
66
+ test("bare sudo is dangerous", () => {
67
+ expect(classify("sudo apt install curl", rules)?.severity).toBe(
68
+ "dangerous",
69
+ );
70
+ });
71
+
72
+ test("npm publish is dangerous", () => {
73
+ expect(classify("npm publish --access public", rules)?.severity).toBe(
74
+ "dangerous",
75
+ );
76
+ });
77
+
78
+ test("git force push is dangerous", () => {
79
+ expect(classify("git push --force", rules)?.severity).toBe("dangerous");
80
+ });
81
+
82
+ test("curl pipe bash is dangerous", () => {
83
+ expect(
84
+ classify("curl https://example.com/install.sh | bash", rules)?.severity,
85
+ ).toBe("dangerous");
86
+ });
87
+
88
+ test("git force checkout is risky", () => {
89
+ expect(classify("git checkout --force main", rules)?.severity).toBe(
90
+ "risky",
91
+ );
92
+ });
93
+
94
+ test("write to .env is risky", () => {
95
+ expect(classify("echo SECRET=x > .env", rules)?.severity).toBe("risky");
96
+ });
97
+
98
+ test("plain ls returns undefined", () => {
99
+ expect(classify("ls -la", rules)).toBeUndefined();
100
+ });
101
+
102
+ test("pix-sudo path does NOT classify as dangerous", () => {
103
+ // grep with pix-sudo in the path — should not hit sudo rule
104
+ expect(
105
+ classify("grep foo packages/pix-sudo/src/index.ts", rules),
106
+ ).toBeUndefined();
107
+ });
108
+
109
+ test("critical takes priority over dangerous", () => {
110
+ // rm -rf / matches both critical and dangerous rm patterns
111
+ expect(classify("rm -rf /", rules)?.severity).toBe("critical");
112
+ });
113
+ });
114
+
115
+ // ── buildRules ────────────────────────────────────────────────────────────────
116
+
117
+ describe("buildRules", () => {
118
+ test("disableDefaults removes all built-in rules", () => {
119
+ const { rules } = buildRules({ disableDefaults: true });
120
+ expect(rules).toHaveLength(0);
121
+ });
122
+
123
+ test("extraRules are appended", () => {
124
+ const { rules } = buildRules({
125
+ disableDefaults: true,
126
+ extraRules: [{ pattern: "foo", severity: "risky", reason: "test" }],
127
+ });
128
+ expect(rules).toHaveLength(1);
129
+ expect(classify("foo bar", rules)?.reason).toBe("test");
130
+ });
131
+
132
+ test("autoApprove strings compile to regexes", () => {
133
+ const { autoApprove } = buildRules({ autoApprove: ["^npm publish"] });
134
+ expect(autoApprove[0].test("npm publish --access public")).toBe(true);
135
+ expect(autoApprove[0].test("yarn publish")).toBe(false);
136
+ });
137
+
138
+ test("defaults included when disableDefaults absent", () => {
139
+ const { rules } = buildRules({});
140
+ expect(rules.length).toBe(DEFAULT_RULES.length);
141
+ });
142
+ });
package/src/index.ts ADDED
@@ -0,0 +1,227 @@
1
+ /**
2
+ * pix-gate — Pi extension
3
+ *
4
+ * Intercepts bash `tool_call` events and gates dangerous commands behind a
5
+ * TUI confirmation dialog before they run.
6
+ *
7
+ * Severity tiers:
8
+ * critical — blocked outright in non-interactive mode; hard deny-first in UI
9
+ * dangerous — 30 s auto-deny dialog; sudo fast-blocked with redirect to sudo_run
10
+ * risky — 60 s allow-first dialog; silently passes in non-interactive mode
11
+ *
12
+ * Config: ~/.pi/agent/pix-gate.json
13
+ * disableDefaults: true — replace built-in rules entirely
14
+ * extraRules: [{ pattern, flags?, severity?, reason? }] — append extra rules
15
+ * autoApprove: ["regex"] — bypass gate for matching commands
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
20
+ import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
21
+ import { buildRules, classify, isSudoCommand, loadUserConfig } from "./lib.ts";
22
+
23
+ export default function (pi: ExtensionAPI): void {
24
+ const { rules, autoApprove } = buildRules(loadUserConfig());
25
+
26
+ pi.on("tool_call", async (event, ctx) => {
27
+ if (event.toolName !== "bash") return undefined;
28
+
29
+ const command = String(event.input.command ?? "");
30
+ if (!command.trim()) return undefined;
31
+
32
+ if (autoApprove.some((re) => re.test(command))) return undefined;
33
+
34
+ const hit = classify(command, rules);
35
+ if (!hit) return undefined;
36
+
37
+ // sudo: fast-block with redirect to sudo_run — no dialog needed.
38
+ if (isSudoCommand(command)) {
39
+ ctx.ui.notify(
40
+ `⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — privilege escalation blocked\n` +
41
+ `${ctx.ui.theme.fg("dim", "Use")} ${ctx.ui.theme.fg("accent", "sudo_run")} ` +
42
+ `${ctx.ui.theme.fg("dim", "tool instead — handles auth securely")}`,
43
+ "warning",
44
+ );
45
+ return {
46
+ block: true,
47
+ reason:
48
+ "[DANGEROUS] privilege escalation — use sudo_run tool instead, it handles authentication securely.",
49
+ };
50
+ }
51
+
52
+ const icon =
53
+ hit.severity === "critical"
54
+ ? "🛑"
55
+ : hit.severity === "dangerous"
56
+ ? "⚠️ "
57
+ : "❓";
58
+ const label = hit.severity.toUpperCase();
59
+
60
+ // Non-interactive: block critical + dangerous outright; allow risky silently.
61
+ if (!ctx.hasUI) {
62
+ if (hit.severity === "critical") {
63
+ return {
64
+ block: true,
65
+ reason: `[${label}] ${hit.reason} (no UI, auto-blocked)`,
66
+ };
67
+ }
68
+ if (hit.severity === "dangerous") {
69
+ return {
70
+ block: true,
71
+ reason: `[${label}] ${hit.reason} (no UI for confirmation)`,
72
+ };
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ const timeoutMs =
78
+ hit.severity === "critical"
79
+ ? 15_000
80
+ : hit.severity === "dangerous"
81
+ ? 30_000
82
+ : 60_000;
83
+
84
+ const severityColor =
85
+ hit.severity === "critical"
86
+ ? "error"
87
+ : hit.severity === "dangerous"
88
+ ? "warning"
89
+ : "accent";
90
+
91
+ // Critical: deny-first; dangerous/risky: allow-first.
92
+ const choices: SelectItem[] =
93
+ hit.severity === "critical"
94
+ ? [
95
+ {
96
+ value: "no",
97
+ label: "No, block it",
98
+ description: "Prevent this command from running",
99
+ },
100
+ {
101
+ value: "yes",
102
+ label: "Yes, I understand the risk",
103
+ description: "Allow once",
104
+ },
105
+ ]
106
+ : [
107
+ {
108
+ value: "yes",
109
+ label: "Yes, allow",
110
+ description: "Run the command",
111
+ },
112
+ {
113
+ value: "no",
114
+ label: "No, block it",
115
+ description: "Prevent this command from running",
116
+ },
117
+ ];
118
+
119
+ const controller = new AbortController();
120
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
121
+
122
+ const choice = await ctx.ui.custom<string | null>(
123
+ (tui, theme, _kb, done) => {
124
+ const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
125
+
126
+ container.addChild(
127
+ new DynamicBorder((s: string) => theme.fg(severityColor, s)),
128
+ );
129
+
130
+ container.addChild(
131
+ new Text(
132
+ `${icon} ` +
133
+ theme.fg(severityColor, theme.bold(label)) +
134
+ theme.fg("muted", ` — ${hit.reason}`),
135
+ 1,
136
+ 0,
137
+ ),
138
+ );
139
+
140
+ container.addChild(new Text(theme.fg("toolOutput", command), 2, 0));
141
+
142
+ // Live countdown
143
+ const deadlineMs = Date.now() + timeoutMs;
144
+ const countdownText = new Text("", 1, 0);
145
+ const updateCountdown = () => {
146
+ const remaining = Math.max(
147
+ 0,
148
+ Math.ceil((deadlineMs - Date.now()) / 1000),
149
+ );
150
+ countdownText.setText(
151
+ theme.fg("dim", "Auto-deny in ") +
152
+ theme.fg(
153
+ remaining <= 5 ? severityColor : "muted",
154
+ `${remaining}s`,
155
+ ),
156
+ );
157
+ };
158
+ updateCountdown();
159
+ const ticker = setInterval(() => {
160
+ updateCountdown();
161
+ tui.requestRender();
162
+ }, 1000);
163
+ container.addChild(countdownText);
164
+
165
+ const list = new SelectList(choices, choices.length, {
166
+ selectedPrefix: (t) => theme.fg(severityColor, t),
167
+ selectedText: (t) => theme.fg(severityColor, t),
168
+ description: (t) => theme.fg("muted", t),
169
+ scrollInfo: (t) => theme.fg("dim", t),
170
+ noMatch: (t) => theme.fg("warning", t),
171
+ });
172
+ const finish = (v: string | null) => {
173
+ clearInterval(ticker);
174
+ done(v);
175
+ };
176
+ list.onSelect = (item) => finish(item.value);
177
+ list.onCancel = () => finish(null);
178
+ container.addChild(list);
179
+
180
+ container.addChild(
181
+ new Text(
182
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
183
+ 1,
184
+ 0,
185
+ ),
186
+ );
187
+
188
+ container.addChild(
189
+ new DynamicBorder((s: string) => theme.fg(severityColor, s)),
190
+ );
191
+
192
+ controller.signal.addEventListener("abort", () => finish(null));
193
+
194
+ return {
195
+ render: (w: number) => container.render(w),
196
+ invalidate: () => container.invalidate(),
197
+ handleInput: (data: string) => {
198
+ list.handleInput(data);
199
+ tui.requestRender();
200
+ },
201
+ };
202
+ },
203
+ { overlay: true },
204
+ );
205
+
206
+ clearTimeout(timeoutId);
207
+
208
+ const approved = choice === "yes";
209
+ if (!approved) {
210
+ const reason = controller.signal.aborted
211
+ ? "Timed out"
212
+ : "Blocked by user";
213
+ ctx.ui.notify(`${icon} ${reason}: ${hit.reason}`, "warning");
214
+ return { block: true, reason: `[${label}] ${reason}` };
215
+ }
216
+
217
+ ctx.ui.notify(
218
+ `${icon} ` +
219
+ ctx.ui.theme.fg(
220
+ severityColor,
221
+ `Approved ${label.toLowerCase()} command`,
222
+ ),
223
+ "info",
224
+ );
225
+ return undefined;
226
+ });
227
+ }
package/src/lib.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Pure helpers for pix-gate — no Pi API deps, fully unit-testable.
3
+ */
4
+
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ export type Severity = "critical" | "dangerous" | "risky";
10
+
11
+ export interface Rule {
12
+ pattern: RegExp;
13
+ severity: Severity;
14
+ reason: string;
15
+ }
16
+
17
+ export interface UserConfig {
18
+ extraRules?: {
19
+ pattern: string;
20
+ flags?: string;
21
+ severity?: Severity;
22
+ reason?: string;
23
+ }[];
24
+ disableDefaults?: boolean;
25
+ /** Regex strings — commands matching any are passed through without prompting. */
26
+ autoApprove?: string[];
27
+ }
28
+
29
+ export const DEFAULT_RULES: Rule[] = [
30
+ // CRITICAL — destructive, irreversible, or system-wide
31
+ {
32
+ pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+\/(\s|$)/i,
33
+ severity: "critical",
34
+ reason: "rm -rf on /",
35
+ },
36
+ {
37
+ pattern:
38
+ /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\s+(~|\$HOME)(\s|$|\/\s*$)/i,
39
+ severity: "critical",
40
+ reason: "rm -rf on $HOME",
41
+ },
42
+ {
43
+ pattern: /\bmkfs(\.\w+)?\b/i,
44
+ severity: "critical",
45
+ reason: "filesystem formatting",
46
+ },
47
+ {
48
+ pattern: /\bdd\s+.*\bof=\/dev\/(sd[a-z]|nvme|disk)/i,
49
+ severity: "critical",
50
+ reason: "dd to raw block device",
51
+ },
52
+ {
53
+ pattern: />\s*\/dev\/(sd[a-z]|nvme|disk)/i,
54
+ severity: "critical",
55
+ reason: "writing to raw block device",
56
+ },
57
+ {
58
+ pattern: /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
59
+ severity: "critical",
60
+ reason: "fork bomb",
61
+ },
62
+ {
63
+ pattern: /\bshutdown\b|\breboot\b|\bhalt\b|\bpoweroff\b/i,
64
+ severity: "critical",
65
+ reason: "system power command",
66
+ },
67
+
68
+ // DANGEROUS — destructive or privileged but recoverable in scope
69
+ {
70
+ pattern: /\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r)\b/i,
71
+ severity: "dangerous",
72
+ reason: "recursive force remove",
73
+ },
74
+ // Match sudo as a command/operator token, not as a substring of path components like pix-sudo
75
+ {
76
+ pattern: /(^|[\s;|&])sudo\b/i,
77
+ severity: "dangerous",
78
+ reason: "privilege escalation",
79
+ },
80
+ {
81
+ pattern: /\b(chmod|chown)\b[^|;&]*\b(777|-R\s+777)/i,
82
+ severity: "dangerous",
83
+ reason: "world-writable permissions",
84
+ },
85
+ {
86
+ pattern: /\bchmod\s+-R\b/i,
87
+ severity: "dangerous",
88
+ reason: "recursive chmod",
89
+ },
90
+ {
91
+ pattern: /\b(curl|wget)\b[^|;&]*\|\s*(sudo\s+)?(sh|bash|zsh)\b/i,
92
+ severity: "dangerous",
93
+ reason: "remote script execution (curl|sh)",
94
+ },
95
+ {
96
+ pattern: /\bgit\s+(push\s+(-f|--force)|reset\s+--hard|clean\s+-[a-z]*f)/i,
97
+ severity: "dangerous",
98
+ reason: "destructive git operation",
99
+ },
100
+ {
101
+ pattern: /\bnpm\s+publish\b/i,
102
+ severity: "dangerous",
103
+ reason: "package publish",
104
+ },
105
+ {
106
+ pattern: /\bdocker\s+(system\s+prune|rm\s+-f|volume\s+rm)/i,
107
+ severity: "dangerous",
108
+ reason: "destructive docker operation",
109
+ },
110
+ {
111
+ pattern: /\bkill\s+-9\s+-1\b/i,
112
+ severity: "dangerous",
113
+ reason: "kill all processes",
114
+ },
115
+
116
+ // RISKY — worth a glance but usually fine
117
+ {
118
+ pattern: /\bgit\s+checkout\s+(-f|--force)/i,
119
+ severity: "risky",
120
+ reason: "force checkout (overwrites local changes)",
121
+ },
122
+ {
123
+ pattern: /\bgit\s+stash\s+drop\b/i,
124
+ severity: "risky",
125
+ reason: "stash drop",
126
+ },
127
+ {
128
+ pattern: />\s*[^|&;]*\.env\b/i,
129
+ severity: "risky",
130
+ reason: "writing to .env",
131
+ },
132
+ ];
133
+
134
+ export function loadUserConfig(): UserConfig {
135
+ const path = join(homedir(), ".pi", "agent", "pix-gate.json");
136
+ if (!existsSync(path)) return {};
137
+ try {
138
+ return JSON.parse(readFileSync(path, "utf-8")) as UserConfig;
139
+ } catch {
140
+ return {};
141
+ }
142
+ }
143
+
144
+ export function buildRules(cfg: UserConfig): {
145
+ rules: Rule[];
146
+ autoApprove: RegExp[];
147
+ } {
148
+ const base = cfg.disableDefaults ? [] : DEFAULT_RULES.slice();
149
+ const extra = (cfg.extraRules ?? []).map((r) => ({
150
+ pattern: new RegExp(r.pattern, r.flags ?? "i"),
151
+ severity: (r.severity ?? "dangerous") as Severity,
152
+ reason: r.reason ?? "user-defined rule",
153
+ }));
154
+ const autoApprove = (cfg.autoApprove ?? []).map((s) => new RegExp(s));
155
+ return { rules: [...base, ...extra], autoApprove };
156
+ }
157
+
158
+ /**
159
+ * Return the highest-severity rule that matches `command`, or undefined if none.
160
+ * Checks critical → dangerous → risky in order, returning on first match.
161
+ */
162
+ export function classify(command: string, rules: Rule[]): Rule | undefined {
163
+ const order: Severity[] = ["critical", "dangerous", "risky"];
164
+ for (const sev of order) {
165
+ const hit = rules.find(
166
+ (r) => r.severity === sev && r.pattern.test(command),
167
+ );
168
+ if (hit) return hit;
169
+ }
170
+ return undefined;
171
+ }
172
+
173
+ /** True when command contains a real sudo invocation (not a path like pix-sudo). */
174
+ export function isSudoCommand(command: string): boolean {
175
+ return /(^|[\s;|&])sudo\b/i.test(command);
176
+ }