@xynogen/pix-gate 0.1.0 → 0.1.2

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 ADDED
@@ -0,0 +1,52 @@
1
+ # pix-gate
2
+
3
+ Pi extension — permission gate for dangerous bash commands.
4
+
5
+ ## What it does
6
+
7
+ Intercepts every `bash` tool call and classifies the command against a set of severity rules before it runs. Three tiers apply: `critical` commands are blocked outright in non-interactive mode and hard-denied via dialog in TUI mode; `dangerous` commands (including any `sudo` invocation, which is redirected to `sudo_run`) 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 `~/.pi/agent/pix-gate.json`. Built-in rules can be replaced entirely by setting `disableDefaults: true` in the config file.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@xynogen/pix-gate
13
+ ```
14
+
15
+ ## Reusable exports
16
+
17
+ The gate is split into a pure rule engine and the interactive prompt, so the
18
+ classification logic can be reused without the TUI:
19
+
20
+ - `@xynogen/pix-gate/lib` — pure rules: `DEFAULT_RULES`, `buildRules`,
21
+ `classify`, `loadUserConfig`, `isSudoCommand`. No Pi/TUI dependency.
22
+ - `@xynogen/pix-gate/prompt` — `promptGateDecision()`, the confirm/deny dialog
23
+ (depends on `pi-tui`).
24
+
25
+ `pix-skills` imports `./lib` to gate skill `` !`cmd` `` directives with the same
26
+ rules as the bash tool (auto-deny on match, no prompt).
27
+
28
+ ## Configuration
29
+
30
+ `~/.pi/agent/pix-gate.json`:
31
+
32
+ ```json
33
+ {
34
+ "disableDefaults": false,
35
+ "extraRules": [
36
+ { "pattern": "rm -rf /my-dir", "severity": "critical", "reason": "Deletes project root" }
37
+ ],
38
+ "autoApprove": ["^echo "]
39
+ }
40
+ ```
41
+
42
+ ## Full distro
43
+
44
+ To install the complete pix suite (all packages + Pi itself):
45
+
46
+ ```bash
47
+ curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
48
+ ```
49
+
50
+ ## License
51
+
52
+ MIT
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./lib": "./src/lib.ts",
10
+ "./prompt": "./src/prompt.ts",
11
+ "./src/*": "./src/*"
12
+ },
7
13
  "scripts": {
8
14
  "test": "bun test"
9
15
  },
package/src/index.ts CHANGED
@@ -16,9 +16,8 @@
16
16
  */
17
17
 
18
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
19
  import { buildRules, classify, isSudoCommand, loadUserConfig } from "./lib.ts";
20
+ import { promptGateDecision, SEVERITY_ICON } from "./prompt.ts";
22
21
 
23
22
  export default function (pi: ExtensionAPI): void {
24
23
  const { rules, autoApprove } = buildRules(loadUserConfig());
@@ -49,12 +48,7 @@ export default function (pi: ExtensionAPI): void {
49
48
  };
50
49
  }
51
50
 
52
- const icon =
53
- hit.severity === "critical"
54
- ? "🛑"
55
- : hit.severity === "dangerous"
56
- ? "⚠️ "
57
- : "❓";
51
+ const icon = SEVERITY_ICON[hit.severity];
58
52
  const label = hit.severity.toUpperCase();
59
53
 
60
54
  // Non-interactive: block critical + dangerous outright; allow risky silently.
@@ -74,12 +68,12 @@ export default function (pi: ExtensionAPI): void {
74
68
  return undefined;
75
69
  }
76
70
 
77
- const timeoutMs =
78
- hit.severity === "critical"
79
- ? 15_000
80
- : hit.severity === "dangerous"
81
- ? 30_000
82
- : 60_000;
71
+ const decision = await promptGateDecision(ctx.ui, hit, command);
72
+
73
+ if (!decision.approved) {
74
+ ctx.ui.notify(`${icon} ${decision.reason}: ${hit.reason}`, "warning");
75
+ return { block: true, reason: `[${label}] ${decision.reason}` };
76
+ }
83
77
 
84
78
  const severityColor =
85
79
  hit.severity === "critical"
@@ -87,133 +81,6 @@ export default function (pi: ExtensionAPI): void {
87
81
  : hit.severity === "dangerous"
88
82
  ? "warning"
89
83
  : "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
84
  ctx.ui.notify(
218
85
  `${icon} ` +
219
86
  ctx.ui.theme.fg(
package/src/prompt.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * pix-gate — Part 2: the prompt.
3
+ *
4
+ * The interactive confirm/deny dialog, decoupled from the rule engine (lib.ts).
5
+ * Returns a pure decision; the caller maps it to a tool-call result and emits
6
+ * any notifications. This lets the rule engine be reused without the TUI dep.
7
+ */
8
+
9
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
11
+ import { Box, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
12
+ import type { Rule } from "./lib.ts";
13
+
14
+ export interface GateDecision {
15
+ approved: boolean;
16
+ /** "Approved" | "Blocked by user" | "Timed out" */
17
+ reason: string;
18
+ }
19
+
20
+ /** The UI surface the dialog needs — the extension context's `ui`. */
21
+ export type GatePromptUI = ExtensionContext["ui"];
22
+
23
+ const TIMEOUT_MS: Record<Rule["severity"], number> = {
24
+ critical: 15_000,
25
+ dangerous: 30_000,
26
+ risky: 60_000,
27
+ };
28
+
29
+ const SEVERITY_COLOR = {
30
+ critical: "error",
31
+ dangerous: "warning",
32
+ risky: "accent",
33
+ } as const satisfies Record<Rule["severity"], string>;
34
+
35
+ const SEVERITY_ICON: Record<Rule["severity"], string> = {
36
+ critical: "🛑",
37
+ dangerous: "⚠️ ",
38
+ risky: "❓",
39
+ };
40
+
41
+ /**
42
+ * Show the confirm/deny dialog for a matched command and resolve the decision.
43
+ * Critical defaults to deny-first; dangerous/risky default to allow-first.
44
+ * Times out to a denial after a severity-scaled deadline.
45
+ */
46
+ export async function promptGateDecision(
47
+ ui: GatePromptUI,
48
+ hit: Rule,
49
+ command: string,
50
+ ): Promise<GateDecision> {
51
+ const timeoutMs = TIMEOUT_MS[hit.severity];
52
+ const severityColor = SEVERITY_COLOR[hit.severity];
53
+ const icon = SEVERITY_ICON[hit.severity];
54
+ const label = hit.severity.toUpperCase();
55
+
56
+ const choices: SelectItem[] =
57
+ hit.severity === "critical"
58
+ ? [
59
+ {
60
+ value: "no",
61
+ label: "No, block it",
62
+ description: "Prevent this command from running",
63
+ },
64
+ {
65
+ value: "yes",
66
+ label: "Yes, I understand the risk",
67
+ description: "Allow once",
68
+ },
69
+ ]
70
+ : [
71
+ {
72
+ value: "yes",
73
+ label: "Yes, allow",
74
+ description: "Run the command",
75
+ },
76
+ {
77
+ value: "no",
78
+ label: "No, block it",
79
+ description: "Prevent this command from running",
80
+ },
81
+ ];
82
+
83
+ const controller = new AbortController();
84
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
85
+
86
+ const choice = await ui.custom<string | null>(
87
+ (tui, theme, _kb, done) => {
88
+ const container = new Box(0, 0, (s) => theme.bg("customMessageBg", s));
89
+
90
+ container.addChild(
91
+ new DynamicBorder((s: string) => theme.fg(severityColor, s)),
92
+ );
93
+
94
+ container.addChild(
95
+ new Text(
96
+ `${icon} ` +
97
+ theme.fg(severityColor, theme.bold(label)) +
98
+ theme.fg("muted", ` — ${hit.reason}`),
99
+ 1,
100
+ 0,
101
+ ),
102
+ );
103
+
104
+ container.addChild(new Text(theme.fg("toolOutput", command), 2, 0));
105
+
106
+ const deadlineMs = Date.now() + timeoutMs;
107
+ const countdownText = new Text("", 1, 0);
108
+ const updateCountdown = () => {
109
+ const remaining = Math.max(
110
+ 0,
111
+ Math.ceil((deadlineMs - Date.now()) / 1000),
112
+ );
113
+ countdownText.setText(
114
+ theme.fg("dim", "Auto-deny in ") +
115
+ theme.fg(remaining <= 5 ? severityColor : "muted", `${remaining}s`),
116
+ );
117
+ };
118
+ updateCountdown();
119
+ const ticker = setInterval(() => {
120
+ updateCountdown();
121
+ tui.requestRender();
122
+ }, 1000);
123
+ container.addChild(countdownText);
124
+
125
+ const list = new SelectList(choices, choices.length, {
126
+ selectedPrefix: (t) => theme.fg(severityColor, t),
127
+ selectedText: (t) => theme.fg(severityColor, t),
128
+ description: (t) => theme.fg("muted", t),
129
+ scrollInfo: (t) => theme.fg("dim", t),
130
+ noMatch: (t) => theme.fg("warning", t),
131
+ });
132
+ const finish = (v: string | null) => {
133
+ clearInterval(ticker);
134
+ done(v);
135
+ };
136
+ list.onSelect = (item) => finish(item.value);
137
+ list.onCancel = () => finish(null);
138
+ container.addChild(list);
139
+
140
+ container.addChild(
141
+ new Text(
142
+ theme.fg("dim", "↑↓ navigate • enter select • esc cancel"),
143
+ 1,
144
+ 0,
145
+ ),
146
+ );
147
+
148
+ container.addChild(
149
+ new DynamicBorder((s: string) => theme.fg(severityColor, s)),
150
+ );
151
+
152
+ controller.signal.addEventListener("abort", () => finish(null));
153
+
154
+ return {
155
+ render: (w: number) => container.render(w),
156
+ invalidate: () => container.invalidate(),
157
+ handleInput: (data: string) => {
158
+ list.handleInput(data);
159
+ tui.requestRender();
160
+ },
161
+ };
162
+ },
163
+ { overlay: true },
164
+ );
165
+
166
+ clearTimeout(timeoutId);
167
+
168
+ const approved = choice === "yes";
169
+ if (approved) return { approved: true, reason: "Approved" };
170
+ return {
171
+ approved: false,
172
+ reason: controller.signal.aborted ? "Timed out" : "Blocked by user",
173
+ };
174
+ }
175
+
176
+ export { SEVERITY_ICON };