pi-guard 1.0.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/README.md +193 -0
- package/package.json +50 -0
- package/src/config.ts +310 -0
- package/src/extract.ts +424 -0
- package/src/format.ts +206 -0
- package/src/index.ts +426 -0
- package/src/matchers.ts +72 -0
- package/src/matching.ts +133 -0
- package/src/prompt.ts +47 -0
- package/src/resolve.ts +9 -0
- package/src/types.ts +52 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { parse as parseBash } from "unbash";
|
|
3
|
+
import { extractAllCommandsFromAST } from "./extract.ts";
|
|
4
|
+
import { getCommandName, getCommandArgs } from "./resolve.ts";
|
|
5
|
+
import { formatCommand } from "./format.ts";
|
|
6
|
+
import { buildApprovalPrompt, buildFileApprovalPrompt, buildCustomApprovalPrompt } from "./prompt.ts";
|
|
7
|
+
import {
|
|
8
|
+
loadConfig,
|
|
9
|
+
saveConfig,
|
|
10
|
+
loadProjectConfig,
|
|
11
|
+
buildEffectiveRules,
|
|
12
|
+
} from "./config.ts";
|
|
13
|
+
import { resolveBashAction, resolveGlobAction, resolveExactAction } from "./matching.ts";
|
|
14
|
+
import type { Action, ToolRules, CommandRef, ToolCallInput } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export function parseGuardArgs(args: string): { action: string; target: string } {
|
|
17
|
+
const trimmed = args.trim();
|
|
18
|
+
if (!trimmed) return { action: "", target: "" };
|
|
19
|
+
|
|
20
|
+
const [action = "", ...targetParts] = trimmed.split(/\s+/);
|
|
21
|
+
const target = targetParts.join(" ").trim();
|
|
22
|
+
|
|
23
|
+
return { action, target };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export default function (pi: ExtensionAPI) {
|
|
27
|
+
const loaded = loadConfig();
|
|
28
|
+
let config = loaded.config;
|
|
29
|
+
let configWarning = loaded.warning;
|
|
30
|
+
// Session rules are stored per-tool
|
|
31
|
+
const sessionRules: Record<string, Record<string, Action>> = {};
|
|
32
|
+
|
|
33
|
+
if (configWarning) {
|
|
34
|
+
console.warn(`[pi-guard] ${configWarning}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Settings Management Command
|
|
38
|
+
pi.registerCommand("guard", {
|
|
39
|
+
description: "Manage pi-guard security settings",
|
|
40
|
+
handler: async (args, ctx) => {
|
|
41
|
+
if (configWarning && ctx.hasUI) {
|
|
42
|
+
ctx.ui.notify(`[pi-guard] ${configWarning}`, "warning");
|
|
43
|
+
configWarning = undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const { action, target } = parseGuardArgs(args);
|
|
47
|
+
|
|
48
|
+
if (action === "toggle") {
|
|
49
|
+
config.enabled = !config.enabled;
|
|
50
|
+
saveConfig(config);
|
|
51
|
+
ctx.ui.notify(`pi-guard is now ${config.enabled ? "ENABLED" : "DISABLED"}`, "info");
|
|
52
|
+
} else if (action === "list") {
|
|
53
|
+
const enabled = config.enabled ? "ENABLED" : "DISABLED";
|
|
54
|
+
|
|
55
|
+
let output = `pi-guard: ${enabled}\n\n`;
|
|
56
|
+
|
|
57
|
+
if (typeof config.rules === "string") {
|
|
58
|
+
output += `Global rule: ${config.rules}\n`;
|
|
59
|
+
} else {
|
|
60
|
+
for (const [tool, rules] of Object.entries(config.rules)) {
|
|
61
|
+
output += `${tool}:\n`;
|
|
62
|
+
if (typeof rules === "string") {
|
|
63
|
+
output += ` ${rules}\n`;
|
|
64
|
+
} else {
|
|
65
|
+
for (const [pattern, action] of Object.entries(rules)) {
|
|
66
|
+
output += ` ${pattern}: ${action}\n`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
output += "\n";
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Object.keys(sessionRules).length > 0) {
|
|
74
|
+
output += "Session rules:\n";
|
|
75
|
+
for (const [tool, rules] of Object.entries(sessionRules)) {
|
|
76
|
+
output += ` ${tool}:\n`;
|
|
77
|
+
for (const [pattern, action] of Object.entries(rules)) {
|
|
78
|
+
output += ` ${pattern}: ${action}\n`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
ctx.ui.notify(output, "info");
|
|
84
|
+
} else {
|
|
85
|
+
ctx.ui.notify("Usage: /guard <toggle|list>", "warning");
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// The core interception hook
|
|
91
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
92
|
+
if (configWarning && ctx.hasUI) {
|
|
93
|
+
ctx.ui.notify(`[pi-guard] ${configWarning}`, "warning");
|
|
94
|
+
configWarning = undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!config.enabled) return;
|
|
98
|
+
|
|
99
|
+
const tool = event.toolName;
|
|
100
|
+
const input = event.input as ToolCallInput;
|
|
101
|
+
|
|
102
|
+
// Get the effective rules (user + project + session + env)
|
|
103
|
+
const projectResult = loadProjectConfig(ctx.cwd);
|
|
104
|
+
const projectRules = projectResult?.config.rules ?? {};
|
|
105
|
+
if (projectResult?.warning && ctx.hasUI) {
|
|
106
|
+
ctx.ui.notify(`[pi-guard] ${projectResult.warning}`, "warning");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const envRules = process.env.PI_GUARD ? JSON.parse(process.env.PI_GUARD) : undefined;
|
|
110
|
+
const effectiveRules = buildEffectiveRules(
|
|
111
|
+
config.rules,
|
|
112
|
+
projectRules,
|
|
113
|
+
sessionRules,
|
|
114
|
+
envRules,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
// Check for global rule (single action for all tools)
|
|
118
|
+
if (typeof effectiveRules === "string") {
|
|
119
|
+
const action = effectiveRules;
|
|
120
|
+
if (action === "allow") return;
|
|
121
|
+
if (action === "deny") {
|
|
122
|
+
return {
|
|
123
|
+
block: true,
|
|
124
|
+
reason: `[Blocked by pi-guard: Security policy]`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
// "ask" - fall through to interactive handling
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Get tool-specific rules
|
|
131
|
+
const toolRules = typeof effectiveRules === "object" ? effectiveRules[tool] : undefined;
|
|
132
|
+
|
|
133
|
+
if (!toolRules) {
|
|
134
|
+
// No rules for this tool - use default action ("ask")
|
|
135
|
+
if (!ctx.hasUI) {
|
|
136
|
+
return {
|
|
137
|
+
block: true,
|
|
138
|
+
reason: `[Blocked by pi-guard: No interactive session available]`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// Interactive mode - prompt for approval
|
|
142
|
+
return handleInteractiveApproval(pi, tool, input, ctx, sessionRules);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Handle whole-tool action (no pattern matching needed)
|
|
146
|
+
if (typeof toolRules === "string") {
|
|
147
|
+
const action = toolRules;
|
|
148
|
+
if (action === "allow") return;
|
|
149
|
+
if (action === "deny") {
|
|
150
|
+
return {
|
|
151
|
+
block: true,
|
|
152
|
+
reason: `[Blocked by pi-guard: Security policy]`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// "ask" - prompt for approval
|
|
156
|
+
if (!ctx.hasUI) {
|
|
157
|
+
return {
|
|
158
|
+
block: true,
|
|
159
|
+
reason: `[Blocked by pi-guard: No interactive session available]`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return handleInteractiveApproval(pi, tool, input, ctx, sessionRules);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Get matcher for this tool
|
|
166
|
+
const matchers = config.matchers ?? {};
|
|
167
|
+
const matcher = matchers[tool];
|
|
168
|
+
|
|
169
|
+
// If no matcher, use whole-tool logic (already handled above if action is allow/deny)
|
|
170
|
+
if (!matcher) {
|
|
171
|
+
// For tools without matchers, check for catch-all "*"
|
|
172
|
+
const defaultAction = toolRules["*"];
|
|
173
|
+
if (defaultAction === "allow") return;
|
|
174
|
+
if (defaultAction === "deny") {
|
|
175
|
+
return {
|
|
176
|
+
block: true,
|
|
177
|
+
reason: `[Blocked by pi-guard: Security policy]`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (!ctx.hasUI) {
|
|
181
|
+
return {
|
|
182
|
+
block: true,
|
|
183
|
+
reason: `[Blocked by pi-guard: No interactive session available]`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return handleInteractiveApproval(pi, tool, input, ctx, sessionRules);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Extract input based on matcher param
|
|
190
|
+
const value = input[matcher.param];
|
|
191
|
+
if (typeof value !== "string" || value.trim() === "") return;
|
|
192
|
+
|
|
193
|
+
// Handle bash tool specially (needs AST parsing)
|
|
194
|
+
if (matcher.type === "bash") {
|
|
195
|
+
return handleBashTool(pi, tool, value, toolRules, ctx, sessionRules);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Handle glob-based tools (read, edit, write)
|
|
199
|
+
if (matcher.type === "glob") {
|
|
200
|
+
return handleGlobTool(pi, tool, value, toolRules, ctx, sessionRules);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle exact-match tools
|
|
204
|
+
if (matcher.type === "exact") {
|
|
205
|
+
return handleExactTool(pi, tool, value, toolRules, ctx, sessionRules);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function handleInteractiveApproval(
|
|
211
|
+
pi: ExtensionAPI,
|
|
212
|
+
tool: string,
|
|
213
|
+
input: ToolCallInput,
|
|
214
|
+
ctx: ExtensionContext,
|
|
215
|
+
sessionRules: Record<string, Record<string, Action>>,
|
|
216
|
+
): Promise<{ block: true; reason: string } | void> {
|
|
217
|
+
// Build appropriate prompt based on tool
|
|
218
|
+
const value = String(input[tool === "bash" ? "command" : tool === "read" || tool === "edit" || tool === "write" ? "path" : Object.keys(input)[0] ?? "input"]);
|
|
219
|
+
const prompt = buildCustomApprovalPrompt(tool, value);
|
|
220
|
+
|
|
221
|
+
pi.events.emit("nudge", { body: `${tool} needs approval` });
|
|
222
|
+
|
|
223
|
+
const alwaysLabel = `Always allow ${tool} (this session)`;
|
|
224
|
+
const choice = await ctx.ui.select(prompt, ["Allow", alwaysLabel, "Reject"]);
|
|
225
|
+
|
|
226
|
+
if (choice === alwaysLabel) {
|
|
227
|
+
sessionRules[tool] = { ...sessionRules[tool], "*": "allow" };
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (choice !== "Allow") {
|
|
232
|
+
return { block: true, reason: `[Blocked by pi-guard: User rejected this invocation]` };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function handleBashTool(
|
|
237
|
+
pi: ExtensionAPI,
|
|
238
|
+
tool: string,
|
|
239
|
+
rawCmd: string,
|
|
240
|
+
toolRules: Record<string, Action>,
|
|
241
|
+
ctx: ExtensionContext,
|
|
242
|
+
sessionRules: Record<string, Record<string, Action>>,
|
|
243
|
+
): Promise<{ block: true; reason: string } | void> {
|
|
244
|
+
let ast;
|
|
245
|
+
try {
|
|
246
|
+
ast = parseBash(rawCmd);
|
|
247
|
+
} catch {
|
|
248
|
+
if (!ctx.hasUI) {
|
|
249
|
+
return { block: true, reason: `[Blocked by pi-guard: Failed to parse command safely]` };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pi.events.emit("nudge", { body: "Command needs approval" });
|
|
253
|
+
const confirmed = await ctx.ui.confirm(
|
|
254
|
+
"⚠️ Could Not Parse Command Safely",
|
|
255
|
+
"\nAllow anyway?",
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (!confirmed) {
|
|
259
|
+
return { block: true, reason: `[Blocked by pi-guard: User rejected this invocation]` };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const allCommands = extractAllCommandsFromAST(ast, rawCmd);
|
|
266
|
+
if (allCommands.length === 0) return;
|
|
267
|
+
|
|
268
|
+
// Merge session rules with config rules
|
|
269
|
+
const mergedRules: Record<string, Action> = { ...toolRules };
|
|
270
|
+
if (sessionRules[tool]) {
|
|
271
|
+
Object.assign(mergedRules, sessionRules[tool]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const unauthorizedCommands: CommandRef[] = [];
|
|
275
|
+
|
|
276
|
+
for (const cmd of allCommands) {
|
|
277
|
+
const name = getCommandName(cmd);
|
|
278
|
+
const args = getCommandArgs(cmd);
|
|
279
|
+
const action = resolveBashAction(name, args, mergedRules);
|
|
280
|
+
if (action !== "allow") {
|
|
281
|
+
unauthorizedCommands.push(cmd);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (unauthorizedCommands.length === 0) return;
|
|
286
|
+
|
|
287
|
+
if (!ctx.hasUI) {
|
|
288
|
+
// Non-interactive: check first unauthorized command's action
|
|
289
|
+
const firstCmd = unauthorizedCommands[0]!;
|
|
290
|
+
const name = getCommandName(firstCmd);
|
|
291
|
+
const args = getCommandArgs(firstCmd);
|
|
292
|
+
const action = resolveBashAction(name, args, mergedRules);
|
|
293
|
+
|
|
294
|
+
if (action === "deny") {
|
|
295
|
+
return {
|
|
296
|
+
block: true,
|
|
297
|
+
reason: `[Blocked by pi-guard: Security policy]`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
block: true,
|
|
303
|
+
reason: `[Blocked by pi-guard: No interactive session available]`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Interactive: prompt user
|
|
308
|
+
const uniqueBaseNames = Array.from(new Set(unauthorizedCommands.map(getCommandName)));
|
|
309
|
+
const alwaysLabel = `Always allow ${uniqueBaseNames.join(", ")} (this session)`;
|
|
310
|
+
|
|
311
|
+
pi.events.emit("nudge", { body: "Command needs approval" });
|
|
312
|
+
const choice = await ctx.ui.select(
|
|
313
|
+
buildApprovalPrompt(allCommands, unauthorizedCommands),
|
|
314
|
+
["Allow", alwaysLabel, "Reject"],
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
if (choice === alwaysLabel) {
|
|
318
|
+
sessionRules[tool] = sessionRules[tool] ?? {};
|
|
319
|
+
for (const name of uniqueBaseNames) {
|
|
320
|
+
sessionRules[tool]![name] = "allow";
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (choice !== "Allow") {
|
|
326
|
+
return { block: true, reason: `[Blocked by pi-guard: User rejected this invocation]` };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function handleGlobTool(
|
|
331
|
+
pi: ExtensionAPI,
|
|
332
|
+
tool: string,
|
|
333
|
+
path: string,
|
|
334
|
+
toolRules: Record<string, Action>,
|
|
335
|
+
ctx: ExtensionContext,
|
|
336
|
+
sessionRules: Record<string, Record<string, Action>>,
|
|
337
|
+
): Promise<{ block: true; reason: string } | void> {
|
|
338
|
+
// Merge session rules with config rules
|
|
339
|
+
const mergedRules: Record<string, Action> = { ...toolRules };
|
|
340
|
+
if (sessionRules[tool]) {
|
|
341
|
+
Object.assign(mergedRules, sessionRules[tool]);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const action = resolveGlobAction(path, mergedRules);
|
|
345
|
+
|
|
346
|
+
if (action === "allow") return;
|
|
347
|
+
|
|
348
|
+
if (action === "deny") {
|
|
349
|
+
return {
|
|
350
|
+
block: true,
|
|
351
|
+
reason: `[Blocked by pi-guard: Security policy]`,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!ctx.hasUI) {
|
|
356
|
+
return {
|
|
357
|
+
block: true,
|
|
358
|
+
reason: `[Blocked by pi-guard: No interactive session available]`,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Interactive: prompt user
|
|
363
|
+
const prompt = buildFileApprovalPrompt(tool, path);
|
|
364
|
+
const alwaysLabel = `Always allow ${tool} (this session)`;
|
|
365
|
+
|
|
366
|
+
pi.events.emit("nudge", { body: `${tool} needs approval` });
|
|
367
|
+
const choice = await ctx.ui.select(prompt, ["Allow", alwaysLabel, "Reject"]);
|
|
368
|
+
|
|
369
|
+
if (choice === alwaysLabel) {
|
|
370
|
+
sessionRules[tool] = { ...sessionRules[tool], "*": "allow" };
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (choice !== "Allow") {
|
|
375
|
+
return { block: true, reason: `[Blocked by pi-guard: User rejected this invocation]` };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function handleExactTool(
|
|
380
|
+
pi: ExtensionAPI,
|
|
381
|
+
tool: string,
|
|
382
|
+
value: string,
|
|
383
|
+
toolRules: Record<string, Action>,
|
|
384
|
+
ctx: ExtensionContext,
|
|
385
|
+
sessionRules: Record<string, Record<string, Action>>,
|
|
386
|
+
): Promise<{ block: true; reason: string } | void> {
|
|
387
|
+
// Merge session rules with config rules
|
|
388
|
+
const mergedRules: Record<string, Action> = { ...toolRules };
|
|
389
|
+
if (sessionRules[tool]) {
|
|
390
|
+
Object.assign(mergedRules, sessionRules[tool]);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const action = resolveExactAction(value, mergedRules);
|
|
394
|
+
|
|
395
|
+
if (action === "allow") return;
|
|
396
|
+
|
|
397
|
+
if (action === "deny") {
|
|
398
|
+
return {
|
|
399
|
+
block: true,
|
|
400
|
+
reason: `[Blocked by pi-guard: Security policy]`,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!ctx.hasUI) {
|
|
405
|
+
return {
|
|
406
|
+
block: true,
|
|
407
|
+
reason: `[Blocked by pi-guard: No interactive session available]`,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Interactive: prompt user
|
|
412
|
+
const prompt = buildCustomApprovalPrompt(tool, value);
|
|
413
|
+
const alwaysLabel = `Always allow ${tool} (this session)`;
|
|
414
|
+
|
|
415
|
+
pi.events.emit("nudge", { body: `${tool} needs approval` });
|
|
416
|
+
const choice = await ctx.ui.select(prompt, ["Allow", alwaysLabel, "Reject"]);
|
|
417
|
+
|
|
418
|
+
if (choice === alwaysLabel) {
|
|
419
|
+
sessionRules[tool] = { ...sessionRules[tool], "*": "allow" };
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (choice !== "Allow") {
|
|
424
|
+
return { block: true, reason: `[Blocked by pi-guard: User rejected this invocation]` };
|
|
425
|
+
}
|
|
426
|
+
}
|
package/src/matchers.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { parse as parseBash } from "unbash";
|
|
2
|
+
import type { Script } from "unbash";
|
|
3
|
+
import type { Matcher, MatcherType, Action, ToolCallInput } from "./types.ts";
|
|
4
|
+
import { resolveBashAction, resolveGlobAction, resolveExactAction } from "./matching.ts";
|
|
5
|
+
import { extractAllCommandsFromAST } from "./extract.ts";
|
|
6
|
+
import { getCommandName, getCommandArgs } from "./resolve.ts";
|
|
7
|
+
|
|
8
|
+
/** Extract the value to match from a tool call based on the matcher's param. */
|
|
9
|
+
export function extractInput(toolCall: ToolCallInput, matcher: Matcher): string | undefined {
|
|
10
|
+
const value = toolCall[matcher.param];
|
|
11
|
+
if (typeof value === "string") return value;
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Match a tool call against rules using the specified matcher type. */
|
|
16
|
+
export function matchWithMatcher(
|
|
17
|
+
input: string,
|
|
18
|
+
matcherType: MatcherType,
|
|
19
|
+
rules: Record<string, Action>,
|
|
20
|
+
): Action | undefined {
|
|
21
|
+
switch (matcherType) {
|
|
22
|
+
case "bash":
|
|
23
|
+
// For bash matching, we need to parse the command first
|
|
24
|
+
// This is handled separately in the main hook due to complexity
|
|
25
|
+
throw new Error("Bash matching requires parsed commands - use matchBashCall instead");
|
|
26
|
+
case "glob":
|
|
27
|
+
return resolveGlobAction(input, rules);
|
|
28
|
+
case "exact":
|
|
29
|
+
return resolveExactAction(input, rules);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bash-specific matching that parses the command and checks all extracted commands. */
|
|
34
|
+
export function matchBashCall(
|
|
35
|
+
rawCmd: string,
|
|
36
|
+
rules: Record<string, Action>,
|
|
37
|
+
): { action: Action | undefined; unauthorizedCommands: CommandInfo[] } {
|
|
38
|
+
let ast: Script;
|
|
39
|
+
try {
|
|
40
|
+
ast = parseBash(rawCmd);
|
|
41
|
+
} catch {
|
|
42
|
+
return { action: undefined, unauthorizedCommands: [{ raw: rawCmd, name: "", args: [] }] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const allCommands = extractAllCommandsFromAST(ast, rawCmd);
|
|
46
|
+
if (allCommands.length === 0) {
|
|
47
|
+
return { action: "allow", unauthorizedCommands: [] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const unauthorizedCommands: CommandInfo[] = [];
|
|
51
|
+
|
|
52
|
+
for (const cmd of allCommands) {
|
|
53
|
+
const name = getCommandName(cmd);
|
|
54
|
+
const args = getCommandArgs(cmd);
|
|
55
|
+
const action = resolveBashAction(name, args, rules);
|
|
56
|
+
if (action !== "allow") {
|
|
57
|
+
unauthorizedCommands.push({ raw: cmd.source.slice(cmd.node.pos, cmd.node.end), name, args });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (unauthorizedCommands.length === 0) {
|
|
62
|
+
return { action: "allow", unauthorizedCommands: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { action: undefined, unauthorizedCommands };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CommandInfo {
|
|
69
|
+
raw: string;
|
|
70
|
+
name: string;
|
|
71
|
+
args: string[];
|
|
72
|
+
}
|
package/src/matching.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { minimatch } from "minimatch";
|
|
2
|
+
import type { Action } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if `needle` tokens appear in order within `haystack`.
|
|
6
|
+
* Used for bash command matching where rule tokens must appear in order,
|
|
7
|
+
* but extra flags or positional args anywhere in the sequence are permitted.
|
|
8
|
+
*/
|
|
9
|
+
export function isSubsequence(needle: string[], haystack: string[]): boolean {
|
|
10
|
+
let ni = 0;
|
|
11
|
+
for (let hi = 0; hi < haystack.length && ni < needle.length; hi++) {
|
|
12
|
+
if (haystack[hi] === needle[ni]) ni++;
|
|
13
|
+
}
|
|
14
|
+
return ni === needle.length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Match a glob pattern against a string using minimatch.
|
|
19
|
+
* - `*` matches anything except `/`
|
|
20
|
+
* - `**` matches anything including `/`
|
|
21
|
+
* - `?` matches single character
|
|
22
|
+
* - `~` at start expands to home directory
|
|
23
|
+
*/
|
|
24
|
+
export function globMatch(pattern: string, input: string): boolean {
|
|
25
|
+
// Expand ~ at the start
|
|
26
|
+
if (pattern.startsWith("~")) {
|
|
27
|
+
const home = process.env.HOME ?? "";
|
|
28
|
+
pattern = home + pattern.slice(1);
|
|
29
|
+
}
|
|
30
|
+
if (input.startsWith("~")) {
|
|
31
|
+
const home = process.env.HOME ?? "";
|
|
32
|
+
input = home + input.slice(1);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return minimatch(input, pattern);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the action for a bash command against a rules map.
|
|
40
|
+
*
|
|
41
|
+
* Rules are evaluated in insertion order; last match wins.
|
|
42
|
+
* The special pattern "*" matches any command.
|
|
43
|
+
*
|
|
44
|
+
* Matching uses subsequence logic:
|
|
45
|
+
* - "git" → matches all git commands (base command match)
|
|
46
|
+
* - "git status" → matches `git status`, `git status --short`, etc.
|
|
47
|
+
* - "git branch --show-current" → matches `git branch --show-current`,
|
|
48
|
+
* `git branch -v --show-current`, etc.
|
|
49
|
+
*
|
|
50
|
+
* Returns undefined if no rule matches.
|
|
51
|
+
*/
|
|
52
|
+
export function resolveBashAction(
|
|
53
|
+
commandName: string,
|
|
54
|
+
commandArgs: string[],
|
|
55
|
+
rules: Record<string, Action>,
|
|
56
|
+
): Action | undefined {
|
|
57
|
+
let result: Action | undefined;
|
|
58
|
+
|
|
59
|
+
for (const [pattern, action] of Object.entries(rules)) {
|
|
60
|
+
if (pattern === "*") {
|
|
61
|
+
result = action;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tokens = pattern.split(" ");
|
|
66
|
+
const patternName = tokens[0]!;
|
|
67
|
+
const patternArgs = tokens.slice(1);
|
|
68
|
+
|
|
69
|
+
if (patternName !== commandName) continue;
|
|
70
|
+
|
|
71
|
+
if (patternArgs.length === 0 || isSubsequence(patternArgs, commandArgs)) {
|
|
72
|
+
result = action;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Resolve the action for a glob-based tool (read, edit, write) against a rules map.
|
|
81
|
+
*
|
|
82
|
+
* Rules are evaluated in insertion order; last match wins.
|
|
83
|
+
* The special pattern "*" matches any path.
|
|
84
|
+
*
|
|
85
|
+
* Returns undefined if no rule matches.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveGlobAction(
|
|
88
|
+
input: string,
|
|
89
|
+
rules: Record<string, Action>,
|
|
90
|
+
): Action | undefined {
|
|
91
|
+
let result: Action | undefined;
|
|
92
|
+
|
|
93
|
+
for (const [pattern, action] of Object.entries(rules)) {
|
|
94
|
+
if (pattern === "*") {
|
|
95
|
+
result = action;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (globMatch(pattern, input)) {
|
|
100
|
+
result = action;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the action for an exact-match tool against a rules map.
|
|
109
|
+
*
|
|
110
|
+
* Rules are evaluated in insertion order; last match wins.
|
|
111
|
+
* The special pattern "*" matches any value.
|
|
112
|
+
*
|
|
113
|
+
* Returns undefined if no rule matches.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveExactAction(
|
|
116
|
+
input: string,
|
|
117
|
+
rules: Record<string, Action>,
|
|
118
|
+
): Action | undefined {
|
|
119
|
+
let result: Action | undefined;
|
|
120
|
+
|
|
121
|
+
for (const [pattern, action] of Object.entries(rules)) {
|
|
122
|
+
if (pattern === "*") {
|
|
123
|
+
result = action;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (pattern === input) {
|
|
128
|
+
result = action;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return result;
|
|
133
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { CommandRef } from "./types.ts";
|
|
2
|
+
import { formatCommand } from "./format.ts";
|
|
3
|
+
|
|
4
|
+
export interface ApprovalPromptOptions {
|
|
5
|
+
maxLength?: number;
|
|
6
|
+
argMaxLength?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildApprovalPrompt(
|
|
10
|
+
allCommands: CommandRef[],
|
|
11
|
+
unauthorizedCommands: CommandRef[],
|
|
12
|
+
options?: ApprovalPromptOptions,
|
|
13
|
+
): string {
|
|
14
|
+
const unauthorizedSet = new Set(unauthorizedCommands);
|
|
15
|
+
const lines = allCommands.map(command => {
|
|
16
|
+
const marker = unauthorizedSet.has(command) ? "✖" : "✔";
|
|
17
|
+
return `${marker} ${formatCommand(command, options)}`;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return [
|
|
21
|
+
"⚠️ Unapproved Commands",
|
|
22
|
+
"",
|
|
23
|
+
...lines,
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Build prompt for file operations (read/edit/write). */
|
|
28
|
+
export function buildFileApprovalPrompt(
|
|
29
|
+
tool: string,
|
|
30
|
+
path: string,
|
|
31
|
+
options?: { maxLength?: number },
|
|
32
|
+
): string {
|
|
33
|
+
const maxLength = options?.maxLength ?? 120;
|
|
34
|
+
const displayPath = path.length > maxLength ? path.slice(0, maxLength - 1) + "…" : path;
|
|
35
|
+
return `⚠️ ${tool.charAt(0).toUpperCase() + tool.slice(1)} Permission Required\n\n${displayPath}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Build prompt for custom tools with exact matchers. */
|
|
39
|
+
export function buildCustomApprovalPrompt(
|
|
40
|
+
tool: string,
|
|
41
|
+
input: string,
|
|
42
|
+
options?: { maxLength?: number },
|
|
43
|
+
): string {
|
|
44
|
+
const maxLength = options?.maxLength ?? 120;
|
|
45
|
+
const display = input.length > maxLength ? input.slice(0, maxLength - 1) + "…" : input;
|
|
46
|
+
return `⚠️ ${tool} Permission Required\n\n${display}`;
|
|
47
|
+
}
|
package/src/resolve.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CommandRef } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export function getCommandName(cmd: CommandRef): string {
|
|
4
|
+
return cmd.node.name?.value ?? cmd.node.name?.text ?? "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function getCommandArgs(cmd: CommandRef): string[] {
|
|
8
|
+
return cmd.node.suffix.map(word => word.value ?? word.text);
|
|
9
|
+
}
|