@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.
- package/package.json +1 -1
- package/src/index.ts +87 -47
- package/src/prompt.ts +109 -0
package/package.json
CHANGED
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
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
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
148
|
-
|
|
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
|
-
|
|
192
|
+
highest.tier >= SEVERITY_TIER.block
|
|
153
193
|
? "error"
|
|
154
|
-
:
|
|
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.
|