@xynogen/pix-gate 0.1.1 → 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 CHANGED
@@ -12,6 +12,19 @@ Intercepts every `bash` tool call and classifies the command against a set of se
12
12
  pi install npm:@xynogen/pix-gate
13
13
  ```
14
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
+
15
28
  ## Configuration
16
29
 
17
30
  `~/.pi/agent/pix-gate.json`:
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.1.1",
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 };