@xynogen/pix-gate 0.1.7 → 0.1.8

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.ts +87 -47
  3. package/src/prompt.ts +109 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-gate",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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",
package/src/index.ts CHANGED
@@ -27,8 +27,11 @@ import {
27
27
  loadUserConfig,
28
28
  } from "./lib.ts";
29
29
  import {
30
+ type Concern,
31
+ type GateDecision,
30
32
  PATH_SEVERITY_ICON,
31
33
  promptGateDecision,
34
+ promptMergedGateDecision,
32
35
  promptPathDecision,
33
36
  SEVERITY_ICON,
34
37
  } from "./prompt.ts";
@@ -36,10 +39,14 @@ import {
36
39
  export default function (pi: ExtensionAPI): void {
37
40
  const { rules, autoApprove, pathRules } = buildRules(loadUserConfig());
38
41
 
39
- // ── Path protection ──────────────────────────────────────────────────────
42
+ // ── Path protection (read/write/edit tools only — bash handled below) ───
40
43
  pi.on("tool_call", async (event, ctx) => {
41
44
  const tool = String(event.toolName);
42
45
  const input = event.input as Record<string, unknown>;
46
+
47
+ // bash is handled by the unified gate below
48
+ if (tool === "bash") return undefined;
49
+
43
50
  let path: string | undefined;
44
51
  let op: "read" | "write";
45
52
 
@@ -49,32 +56,6 @@ export default function (pi: ExtensionAPI): void {
49
56
  } else if (tool === "write" || tool === "edit") {
50
57
  path = String(input.path ?? "");
51
58
  op = "write";
52
- } else if (tool === "bash") {
53
- const cmd = String(input.command ?? "");
54
- const candidates = extractPathsFromBash(cmd);
55
- for (const p of candidates) {
56
- const hit = classifyPath(p, "read", pathRules);
57
- if (!hit) continue;
58
- if (hit.severity === "info") {
59
- ctx.ui.notify(
60
- `${PATH_SEVERITY_ICON.info} ${hit.reason}: ${p}`,
61
- "info",
62
- );
63
- continue;
64
- }
65
- if (!ctx.hasUI)
66
- return {
67
- block: true,
68
- reason: `[PATH:${hit.severity.toUpperCase()}] ${hit.reason} (no UI)`,
69
- };
70
- const decision = await promptPathDecision(ctx.ui, hit, "bash read", p);
71
- if (!decision.approved)
72
- return {
73
- block: true,
74
- reason: `[PATH] ${decision.reason}: ${hit.reason}`,
75
- };
76
- }
77
- return undefined;
78
59
  } else {
79
60
  return undefined;
80
61
  }
@@ -106,6 +87,15 @@ export default function (pi: ExtensionAPI): void {
106
87
  return undefined;
107
88
  });
108
89
 
90
+ // ── Unified bash gate (path + command concerns in ONE dialog) ───────────
91
+ const SEVERITY_TIER: Record<string, number> = {
92
+ critical: 5,
93
+ block: 4,
94
+ dangerous: 3,
95
+ warn: 2,
96
+ risky: 1,
97
+ };
98
+
109
99
  pi.on("tool_call", async (event, ctx) => {
110
100
  if (event.toolName !== "bash") return undefined;
111
101
 
@@ -114,22 +104,42 @@ export default function (pi: ExtensionAPI): void {
114
104
 
115
105
  if (autoApprove.some((re) => re.test(command))) return undefined;
116
106
 
117
- const hit = classify(command, rules);
118
- if (!hit) return undefined;
119
-
120
- const icon = SEVERITY_ICON[hit.severity];
121
- const label = hit.severity.toUpperCase();
107
+ // Collect all concerns: path hits + command hit
108
+ const concerns: Concern[] = [];
109
+
110
+ // Path concerns
111
+ const candidates = extractPathsFromBash(command);
112
+ for (const p of candidates) {
113
+ const hit = classifyPath(p, "read", pathRules);
114
+ if (!hit) continue;
115
+ if (hit.severity === "info") {
116
+ ctx.ui.notify(`${PATH_SEVERITY_ICON.info} ${hit.reason}: ${p}`, "info");
117
+ continue;
118
+ }
119
+ concerns.push({
120
+ icon: PATH_SEVERITY_ICON[hit.severity],
121
+ label: hit.severity.toUpperCase(),
122
+ detail: `${hit.reason} — ${p}`,
123
+ tier: SEVERITY_TIER[hit.severity] ?? 0,
124
+ });
125
+ }
122
126
 
123
- // No UI: can't show dialog — auto-block critical/dangerous, pass risky.
124
- if (!ctx.hasUI) {
125
- if (hit.severity === "risky") return undefined;
126
- return {
127
- block: true,
128
- reason: `[${label}] ${hit.reason} (no UI, auto-blocked)`,
129
- };
127
+ // Command concern
128
+ const cmdHit = classify(command, rules);
129
+ if (cmdHit) {
130
+ concerns.push({
131
+ icon: SEVERITY_ICON[cmdHit.severity],
132
+ label: cmdHit.severity.toUpperCase(),
133
+ detail: cmdHit.reason,
134
+ tier: SEVERITY_TIER[cmdHit.severity] ?? 0,
135
+ });
130
136
  }
131
137
 
132
- // sudo: hard redirect to sudo_run — no prompt, no bypass.
138
+ if (concerns.length === 0) return undefined;
139
+
140
+ const highest = concerns.reduce((a, b) => (a.tier > b.tier ? a : b));
141
+
142
+ // sudo: hard redirect — no prompt, no bypass.
133
143
  if (isSudoCommand(command)) {
134
144
  ctx.ui.notify(
135
145
  `⚠️ ${ctx.ui.theme.fg("warning", "DANGEROUS")} — use sudo_run tool instead (handles auth securely)`,
@@ -141,24 +151,54 @@ export default function (pi: ExtensionAPI): void {
141
151
  };
142
152
  }
143
153
 
144
- const decision = await promptGateDecision(ctx.ui, hit, command);
154
+ // No UI: auto-block block+ severity, pass anything lower.
155
+ if (!ctx.hasUI) {
156
+ if (highest.tier >= SEVERITY_TIER.block) {
157
+ return {
158
+ block: true,
159
+ reason: `[${highest.label}] ${highest.detail} (no UI, auto-blocked)`,
160
+ };
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ // Single concern → use existing targeted dialog. Multiple → merged.
166
+ let decision: GateDecision;
167
+ if (concerns.length === 1 && cmdHit) {
168
+ decision = await promptGateDecision(ctx.ui, cmdHit, command);
169
+ } else if (concerns.length === 1 && !cmdHit) {
170
+ // Single path hit — reuse path dialog
171
+ const ph = candidates
172
+ .map((p) => ({ p, h: classifyPath(p, "read", pathRules) }))
173
+ .find((x) => x.h?.severity !== "info" && x.h);
174
+ if (ph?.h) {
175
+ decision = await promptPathDecision(ctx.ui, ph.h, "bash read", ph.p);
176
+ } else {
177
+ return undefined;
178
+ }
179
+ } else {
180
+ decision = await promptMergedGateDecision(ctx.ui, concerns, command);
181
+ }
145
182
 
146
183
  if (!decision.approved) {
147
- ctx.ui.notify(`${icon} ${decision.reason}: ${hit.reason}`, "warning");
148
- return { block: true, reason: `[${label}] ${decision.reason}` };
184
+ ctx.ui.notify(
185
+ `${highest.icon} ${decision.reason}: ${highest.detail}`,
186
+ "warning",
187
+ );
188
+ return { block: true, reason: `[${highest.label}] ${decision.reason}` };
149
189
  }
150
190
 
151
191
  const severityColor =
152
- hit.severity === "critical"
192
+ highest.tier >= SEVERITY_TIER.block
153
193
  ? "error"
154
- : hit.severity === "dangerous"
194
+ : highest.tier >= SEVERITY_TIER.dangerous
155
195
  ? "warning"
156
196
  : "accent";
157
197
  ctx.ui.notify(
158
- `${icon} ` +
198
+ `${highest.icon} ` +
159
199
  ctx.ui.theme.fg(
160
200
  severityColor,
161
- `Approved ${label.toLowerCase()} command`,
201
+ `Approved ${highest.label.toLowerCase()} command`,
162
202
  ),
163
203
  "info",
164
204
  );
package/src/prompt.ts CHANGED
@@ -18,6 +18,15 @@ export interface GateDecision {
18
18
  reason: string;
19
19
  }
20
20
 
21
+ /** Single concern — path hit or command hit — shown in the merged dialog body. */
22
+ export interface Concern {
23
+ icon: string;
24
+ label: string;
25
+ detail: string;
26
+ /** Highest severity tier (for dialog behavior). "critical" > "block" > "dangerous" > "warn" > "risky". */
27
+ tier: number;
28
+ }
29
+
21
30
  export type GatePromptUI = ExtensionContext["ui"];
22
31
 
23
32
  const TIMEOUT_MS: Record<Rule["severity"], number> = {
@@ -116,6 +125,106 @@ export async function promptPathDecision(
116
125
  return { approved: false, reason: "Blocked by user" };
117
126
  }
118
127
 
128
+ /** Tier for dialog behavior — higher = more restrictive (deny-first, shorter timeout). */
129
+ const SEVERITY_TIER: Record<string, number> = {
130
+ critical: 5,
131
+ block: 4,
132
+ dangerous: 3,
133
+ warn: 2,
134
+ risky: 1,
135
+ };
136
+
137
+ /**
138
+ * Show a single merged dialog for a bash command that hit BOTH path rules AND
139
+ * command-gate rules. Combines all concerns under one title, one confirm/deny.
140
+ */
141
+ export async function promptMergedGateDecision(
142
+ ui: GatePromptUI,
143
+ concerns: Concern[],
144
+ command: string,
145
+ ): Promise<GateDecision> {
146
+ // Sort by tier desc so the highest-severity concern drives dialog behavior.
147
+ const sorted = [...concerns].sort((a, b) => b.tier - a.tier);
148
+ const highest = sorted[0];
149
+ const isRestrictive = highest.tier >= SEVERITY_TIER.block; // block or critical
150
+
151
+ // Title: unique severity types, joined with " + "
152
+ const seenLabels = new Set<string>();
153
+ const uniqueLabels: string[] = [];
154
+ for (const c of sorted) {
155
+ const key = `${c.icon} ${c.label}`;
156
+ if (!seenLabels.has(key)) {
157
+ seenLabels.add(key);
158
+ uniqueLabels.push(key);
159
+ }
160
+ }
161
+ const titleSuffix =
162
+ command.length > 55 ? `${command.slice(0, 52)}…` : command;
163
+ const title = `${uniqueLabels.join(" + ")} — ${titleSuffix}`;
164
+
165
+ // Body: command + concern list
166
+ const body = [
167
+ command,
168
+ "",
169
+ ...sorted.map((c) => `${c.icon} ${c.label} — ${c.detail}`),
170
+ ];
171
+
172
+ const accent =
173
+ highest.tier >= SEVERITY_TIER.critical
174
+ ? "error"
175
+ : highest.tier >= SEVERITY_TIER.block
176
+ ? "error"
177
+ : highest.tier >= SEVERITY_TIER.dangerous
178
+ ? "warning"
179
+ : "accent";
180
+
181
+ const timeoutMs =
182
+ highest.tier >= SEVERITY_TIER.block
183
+ ? TIMEOUT_BY_TIER.block
184
+ : highest.tier >= SEVERITY_TIER.dangerous
185
+ ? TIMEOUT_BY_TIER.dangerous
186
+ : TIMEOUT_BY_TIER.risky;
187
+
188
+ const choices = isRestrictive
189
+ ? [
190
+ {
191
+ value: "no",
192
+ label: "No, block it",
193
+ description: "Prevent this command from running",
194
+ },
195
+ {
196
+ value: "yes",
197
+ label: "Yes, I understand the risk",
198
+ description: "Allow once",
199
+ },
200
+ ]
201
+ : [
202
+ { value: "yes", label: "Yes, allow", description: "Run the command" },
203
+ {
204
+ value: "no",
205
+ label: "No, block it",
206
+ description: "Prevent this command from running",
207
+ },
208
+ ];
209
+
210
+ const result: OverlayResult = await showOverlay(
211
+ ui as Parameters<typeof showOverlay>[0],
212
+ { mode: "confirm", title, body, accent, timeoutMs, choices },
213
+ );
214
+
215
+ if (result.action === "approved")
216
+ return { approved: true, reason: "Approved" };
217
+ if (result.action === "timeout")
218
+ return { approved: false, reason: "Timed out" };
219
+ return { approved: false, reason: "Blocked by user" };
220
+ }
221
+
222
+ const TIMEOUT_BY_TIER: Record<string, number> = {
223
+ block: 15_000,
224
+ dangerous: 30_000,
225
+ risky: 60_000,
226
+ };
227
+
119
228
  /**
120
229
  * Show the confirm/deny dialog for a matched command.
121
230
  * Critical is deny-first; dangerous/risky are allow-first.