@itc-steve/pi-ask-complete 0.2.0 → 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/CHANGELOG.md +14 -0
- package/README.md +40 -125
- package/index.ts +6 -18
- package/package.json +4 -5
- package/src/ask-user-panel.ts +1 -1
- package/permission.json.example +0 -299
- package/src/base-command.ts +0 -287
- package/src/bash-scan.ts +0 -402
- package/src/describe-tool.ts +0 -141
- package/src/permission-panel.ts +0 -276
- package/src/permission-store.ts +0 -580
- package/src/permission.ts +0 -364
- package/src/wildcard.ts +0 -91
package/src/permission.ts
DELETED
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
// ask_permission + automatic gate for bash / write / edit / sudo_run / sensitive reads.
|
|
2
|
-
// Path wildcards: any matching deny wins; directory allows act like scoped yolo.
|
|
3
|
-
// Prompt: Allow this | Allow for this session | Allow permanently | Deny with reason
|
|
4
|
-
// Bash: every unit is checked; one prompt per tool call (not per unit).
|
|
5
|
-
// Session remembers all ask-bases; permanent writes the primary base only.
|
|
6
|
-
|
|
7
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { Type } from "typebox";
|
|
9
|
-
import {
|
|
10
|
-
baseCommand,
|
|
11
|
-
isPersistableBashKey,
|
|
12
|
-
isSudoPrefixed,
|
|
13
|
-
stripSudoPrefix,
|
|
14
|
-
} from "./base-command.ts";
|
|
15
|
-
import { splitUnits } from "./bash-scan.ts";
|
|
16
|
-
import { describeToolCall } from "./describe-tool.ts";
|
|
17
|
-
import {
|
|
18
|
-
type PermissionDecision,
|
|
19
|
-
type PermissionPrompt,
|
|
20
|
-
runPermissionPanel,
|
|
21
|
-
} from "./permission-panel.ts";
|
|
22
|
-
import { PermissionStore } from "./permission-store.ts";
|
|
23
|
-
|
|
24
|
-
export type { PermissionDecision };
|
|
25
|
-
export { describeToolCall };
|
|
26
|
-
|
|
27
|
-
/** Tools that prompt unless allow/deny rule hits. */
|
|
28
|
-
const ASK_TOOLS = new Set(["bash", "write", "edit"]);
|
|
29
|
-
/** Only auto-block on path deny — never prompt on normal reads. */
|
|
30
|
-
const PATH_DENY_TOOLS = new Set(["read", "write", "edit"]);
|
|
31
|
-
|
|
32
|
-
let queue: Promise<unknown> = Promise.resolve();
|
|
33
|
-
function enqueue<T>(fn: () => Promise<T>): Promise<T> {
|
|
34
|
-
const run = queue.then(fn, fn);
|
|
35
|
-
queue = run.then(
|
|
36
|
-
() => undefined,
|
|
37
|
-
() => undefined,
|
|
38
|
-
);
|
|
39
|
-
return run;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function blockReason(
|
|
43
|
-
kind: "path" | "bash" | "tool" | "sudo_redirect",
|
|
44
|
-
label: string,
|
|
45
|
-
extra?: string,
|
|
46
|
-
): string {
|
|
47
|
-
if (kind === "sudo_redirect") {
|
|
48
|
-
return (
|
|
49
|
-
`Privileged commands must use the sudo_run tool, not bash. ` +
|
|
50
|
-
`Call sudo_run with command: ${label}` +
|
|
51
|
-
(extra ? ` (${extra})` : "")
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
if (kind === "path") {
|
|
55
|
-
return (
|
|
56
|
-
`Blocked by path rule for \`${label}\`. ` +
|
|
57
|
-
`Remove or narrow the matching deny in permission.json.`
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
return (
|
|
61
|
-
`Blocked by permission rule for \`${label}\`. ` +
|
|
62
|
-
`Remove or narrow the matching deny in permission.json.` +
|
|
63
|
-
(extra ? ` ${extra}` : "")
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function promptPermission(
|
|
68
|
-
ctx: Pick<ExtensionContext, "ui" | "hasUI">,
|
|
69
|
-
store: PermissionStore,
|
|
70
|
-
toolName: string,
|
|
71
|
-
subject: string,
|
|
72
|
-
prompt: PermissionPrompt,
|
|
73
|
-
filePath?: string,
|
|
74
|
-
): Promise<PermissionDecision> {
|
|
75
|
-
if (!ctx.hasUI) {
|
|
76
|
-
return {
|
|
77
|
-
action: "deny",
|
|
78
|
-
reason: "Action requires approval, but no interactive UI is available.",
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return enqueue(async () => {
|
|
83
|
-
if (store.isAllowed(toolName, subject, filePath)) return { action: "allow_once" };
|
|
84
|
-
if (store.isDenied(toolName, subject, filePath)) {
|
|
85
|
-
return {
|
|
86
|
-
action: "deny",
|
|
87
|
-
reason: blockReason(filePath ? "path" : "bash", prompt.base),
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const decision = await runPermissionPanel(ctx, prompt);
|
|
92
|
-
|
|
93
|
-
if (decision.action === "allow_always") {
|
|
94
|
-
const entry = store.allowPermanently(toolName, subject, filePath);
|
|
95
|
-
return { action: "allow_always", entry };
|
|
96
|
-
}
|
|
97
|
-
if (decision.action === "allow_session") {
|
|
98
|
-
store.allowSession(toolName, subject, filePath);
|
|
99
|
-
return { action: "allow_session" };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return decision;
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export type BashGateResult =
|
|
107
|
-
| { ok: true; decision: "already_allowed" | "approved"; entries: string[] }
|
|
108
|
-
| { ok: false; reason: string; decision: "deny" | "cancelled" };
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Gate a bash line: every unit + path arg is checked, but the user is prompted
|
|
112
|
-
* at most once for the whole tool call. Session allow covers every ask-base;
|
|
113
|
-
* permanent allow writes only the primary base.
|
|
114
|
-
*/
|
|
115
|
-
export async function gateBashCommand(
|
|
116
|
-
ctx: Pick<ExtensionContext, "ui" | "hasUI">,
|
|
117
|
-
store: PermissionStore,
|
|
118
|
-
command: string,
|
|
119
|
-
opts?: { cwd?: string; detail?: string },
|
|
120
|
-
): Promise<BashGateResult> {
|
|
121
|
-
const plan = store.planBash(command, { cwd: opts?.cwd });
|
|
122
|
-
if (plan.action === "deny") {
|
|
123
|
-
return {
|
|
124
|
-
ok: false,
|
|
125
|
-
decision: "deny",
|
|
126
|
-
reason: blockReason(plan.kind, plan.label),
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
if (plan.action === "allow") {
|
|
130
|
-
return { ok: true, decision: "already_allowed", entries: [] };
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Session remembers every real base in the chain; permanent only the primary.
|
|
134
|
-
const bases = store.askBases(plan.units);
|
|
135
|
-
const cmdBase = baseCommand(command);
|
|
136
|
-
const primary =
|
|
137
|
-
bases[0] || (isPersistableBashKey(cmdBase) ? cmdBase : "") || "bash";
|
|
138
|
-
|
|
139
|
-
const prompt: PermissionPrompt = {
|
|
140
|
-
base: primary,
|
|
141
|
-
display: command,
|
|
142
|
-
detail: opts?.detail?.trim() || undefined,
|
|
143
|
-
// Fixed short labels (A/B/C) — bases stay out of the option text.
|
|
144
|
-
session: "Allow for this session",
|
|
145
|
-
permanent: "Allow permanently",
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
if (!ctx.hasUI) {
|
|
149
|
-
return {
|
|
150
|
-
ok: false,
|
|
151
|
-
decision: "deny",
|
|
152
|
-
reason: "Action requires approval, but no interactive UI is available.",
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const result = await enqueue(async () => {
|
|
157
|
-
// Re-check after queue wait — a prior session allow may already cover us.
|
|
158
|
-
const again = store.planBash(command, { cwd: opts?.cwd });
|
|
159
|
-
if (again.action === "allow") return { action: "allow_once" as const };
|
|
160
|
-
if (again.action === "deny") {
|
|
161
|
-
return {
|
|
162
|
-
action: "deny" as const,
|
|
163
|
-
reason: blockReason(again.kind, again.label),
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
return runPermissionPanel(ctx, prompt);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
switch (result.action) {
|
|
170
|
-
case "allow_once":
|
|
171
|
-
return { ok: true, decision: "approved", entries: [] };
|
|
172
|
-
case "allow_session": {
|
|
173
|
-
// Whole chain: every base that still needed approval this call.
|
|
174
|
-
const entries = store.allowSessionBases(bases.length ? bases : [primary]);
|
|
175
|
-
return { ok: true, decision: "approved", entries };
|
|
176
|
-
}
|
|
177
|
-
case "allow_always": {
|
|
178
|
-
// C: permanent = primary base only (not every unit in the pipeline).
|
|
179
|
-
const entries = store.allowPermanentlyBases([primary]);
|
|
180
|
-
return { ok: true, decision: "approved", entries };
|
|
181
|
-
}
|
|
182
|
-
case "deny":
|
|
183
|
-
return { ok: false, decision: "deny", reason: result.reason };
|
|
184
|
-
case "cancelled":
|
|
185
|
-
return {
|
|
186
|
-
ok: false,
|
|
187
|
-
decision: "cancelled",
|
|
188
|
-
reason: "Permission prompt cancelled by user.",
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
const AskPermissionParams = Type.Object({
|
|
194
|
-
command: Type.String({
|
|
195
|
-
description: "The system command (or action description) that needs approval",
|
|
196
|
-
}),
|
|
197
|
-
detail: Type.Optional(
|
|
198
|
-
Type.String({
|
|
199
|
-
description: "Optional extra context shown under the command (why it's needed)",
|
|
200
|
-
}),
|
|
201
|
-
),
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
export function registerPermission(pi: ExtensionAPI, store: PermissionStore): void {
|
|
205
|
-
store.ensureUserFile();
|
|
206
|
-
|
|
207
|
-
pi.registerTool({
|
|
208
|
-
name: "ask_permission",
|
|
209
|
-
label: "Ask Permission",
|
|
210
|
-
description:
|
|
211
|
-
"Request user approval before a sensitive system action. Prefer letting the automatic gate handle bash/write/edit/sudo_run — call this only for other sensitive actions. One prompt per call (chains checked as units, prompted once).",
|
|
212
|
-
parameters: AskPermissionParams,
|
|
213
|
-
executionMode: "sequential",
|
|
214
|
-
|
|
215
|
-
async execute(_id, params, _signal, _onUpdate, ctx) {
|
|
216
|
-
const command = params.command;
|
|
217
|
-
const units = splitUnits(command);
|
|
218
|
-
const bases = units.map((u) => baseCommand(u) || u);
|
|
219
|
-
|
|
220
|
-
const result = await gateBashCommand(ctx, store, command, {
|
|
221
|
-
cwd: ctx.cwd,
|
|
222
|
-
detail: params.detail,
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
const payload = result.ok
|
|
226
|
-
? {
|
|
227
|
-
approved: true as const,
|
|
228
|
-
decision: result.decision,
|
|
229
|
-
command,
|
|
230
|
-
bases,
|
|
231
|
-
entries: result.entries.length ? result.entries : undefined,
|
|
232
|
-
}
|
|
233
|
-
: {
|
|
234
|
-
approved: false as const,
|
|
235
|
-
decision: result.decision,
|
|
236
|
-
command,
|
|
237
|
-
bases,
|
|
238
|
-
reason: result.reason,
|
|
239
|
-
};
|
|
240
|
-
|
|
241
|
-
return {
|
|
242
|
-
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
243
|
-
details: payload,
|
|
244
|
-
};
|
|
245
|
-
},
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
pi.on("tool_call", async (event, ctx) => {
|
|
249
|
-
const toolName = event.toolName;
|
|
250
|
-
const input = (event.input ?? {}) as Record<string, unknown>;
|
|
251
|
-
|
|
252
|
-
// ── bash: one prompt per call; sudo/doas (any unit) → sudo_run redirect ──
|
|
253
|
-
if (toolName === "bash") {
|
|
254
|
-
const command = String(input.command ?? "");
|
|
255
|
-
if (!command.trim()) return undefined;
|
|
256
|
-
|
|
257
|
-
// Leading sudo still short-circuits here; mid-chain is caught in planBash.
|
|
258
|
-
if (isSudoPrefixed(command)) {
|
|
259
|
-
const inner = stripSudoPrefix(command) || command;
|
|
260
|
-
return {
|
|
261
|
-
block: true,
|
|
262
|
-
reason: blockReason("sudo_redirect", JSON.stringify(inner)),
|
|
263
|
-
};
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const result = await gateBashCommand(ctx, store, command, { cwd: ctx.cwd });
|
|
267
|
-
if (result.ok) return undefined;
|
|
268
|
-
return { block: true, reason: result.reason };
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
// ── sudo_run: not gated here. pix-sudo's PAM password prompt IS the gate
|
|
272
|
-
// (no password entered = denied). JSON gating would be redundant. ──
|
|
273
|
-
|
|
274
|
-
const isAskTool = ASK_TOOLS.has(toolName);
|
|
275
|
-
const isPathDenyTool = PATH_DENY_TOOLS.has(toolName);
|
|
276
|
-
if (!isAskTool && !isPathDenyTool) return undefined;
|
|
277
|
-
|
|
278
|
-
const desc = describeToolCall(toolName, input, ctx.cwd);
|
|
279
|
-
const decision = store.decide(toolName, desc.subject, desc.filePath);
|
|
280
|
-
|
|
281
|
-
if (decision.state === "allow") return undefined;
|
|
282
|
-
|
|
283
|
-
if (decision.state === "deny") {
|
|
284
|
-
return {
|
|
285
|
-
block: true,
|
|
286
|
-
reason: blockReason(
|
|
287
|
-
desc.filePath ? "path" : "tool",
|
|
288
|
-
desc.filePath ?? desc.prompt.base,
|
|
289
|
-
),
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// state === "ask" — read only auto-denies; never prompt for normal reads
|
|
294
|
-
if (!isAskTool) return undefined;
|
|
295
|
-
|
|
296
|
-
const result = await promptPermission(
|
|
297
|
-
ctx,
|
|
298
|
-
store,
|
|
299
|
-
toolName,
|
|
300
|
-
desc.subject,
|
|
301
|
-
desc.prompt,
|
|
302
|
-
desc.filePath,
|
|
303
|
-
);
|
|
304
|
-
|
|
305
|
-
switch (result.action) {
|
|
306
|
-
case "allow_once":
|
|
307
|
-
case "allow_session":
|
|
308
|
-
case "allow_always":
|
|
309
|
-
return undefined;
|
|
310
|
-
case "deny":
|
|
311
|
-
return { block: true, reason: result.reason };
|
|
312
|
-
case "cancelled":
|
|
313
|
-
return { block: true, reason: "Permission prompt cancelled by user." };
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
pi.on("session_start", () => {
|
|
318
|
-
store.clearSession();
|
|
319
|
-
store.reload();
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
pi.registerCommand("permissions", {
|
|
323
|
-
description: "Show permission allows/denies (bash, tools, path wildcards)",
|
|
324
|
-
handler: async (_args, ctx) => {
|
|
325
|
-
store.reload();
|
|
326
|
-
const { bash, tools, paths, sessionBash, sessionTools, sessionPaths } = store.listAllowed();
|
|
327
|
-
const denied = store.listDeniedPaths();
|
|
328
|
-
const lines = [
|
|
329
|
-
...(store.yolo
|
|
330
|
-
? ["YOLO: on (auto-approving asks; denies + sudo_run redirect still enforced)"]
|
|
331
|
-
: []),
|
|
332
|
-
bash.length ? `bash allow: ${bash.join(", ")}` : "bash allow: (none extra)",
|
|
333
|
-
tools.length ? `tools allow: ${tools.join(", ")}` : "tools allow: (none)",
|
|
334
|
-
paths.length ? `paths allow:\n ${paths.join("\n ")}` : "paths allow: (none extra)",
|
|
335
|
-
denied.length ? `paths deny: ${denied.length} patterns` : "paths deny: (none)",
|
|
336
|
-
sessionBash.length || sessionTools.length || sessionPaths.length
|
|
337
|
-
? `session: bash[${sessionBash.join(", ") || "—"}] tools[${sessionTools.join(", ") || "—"}] paths[${sessionPaths.length}]`
|
|
338
|
-
: "session: (none)",
|
|
339
|
-
"",
|
|
340
|
-
"bash+sudo → redirected to sudo_run. sudo_run → gated by pix-sudo password prompt.",
|
|
341
|
-
`File: ${store.filePath}`,
|
|
342
|
-
];
|
|
343
|
-
ctx.ui.notify(lines.join("\n"), "info");
|
|
344
|
-
},
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
pi.registerCommand("yolo", {
|
|
348
|
-
description:
|
|
349
|
-
"Toggle session YOLO (auto-approve asks; deny rules + sudo_run redirect still enforced)",
|
|
350
|
-
handler: async (args, ctx) => {
|
|
351
|
-
const a = (args ?? "").trim().toLowerCase();
|
|
352
|
-
if (a === "on") store.setYolo(true);
|
|
353
|
-
else if (a === "off") store.setYolo(false);
|
|
354
|
-
else store.setYolo(!store.yolo);
|
|
355
|
-
|
|
356
|
-
ctx.ui.notify(
|
|
357
|
-
store.yolo
|
|
358
|
-
? "YOLO on — auto-approving asks this session. Deny rules and sudo_run redirect are still enforced."
|
|
359
|
-
: "YOLO off — prompts restored.",
|
|
360
|
-
"info",
|
|
361
|
-
);
|
|
362
|
-
},
|
|
363
|
-
});
|
|
364
|
-
}
|
package/src/wildcard.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Path / command glob matching.
|
|
3
|
-
*
|
|
4
|
-
* * → any chars except /
|
|
5
|
-
* ** → any chars including /
|
|
6
|
-
* ? → one char except /
|
|
7
|
-
*
|
|
8
|
-
* Patterns with no `/` also match against the basename
|
|
9
|
-
* (so `*.env` matches `/a/b/foo.env` and `.env`, but not `.env.example`).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
const MAX_PATTERN = 500;
|
|
13
|
-
|
|
14
|
-
export function normalizePath(p: string): string {
|
|
15
|
-
return p.replaceAll("\\", "/").replace(/\/{2,}/g, "/");
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** Compile a glob to a RegExp. Invalid/oversized patterns never match. */
|
|
19
|
-
export function compileGlob(pattern: string): RegExp {
|
|
20
|
-
if (!pattern || pattern.length > MAX_PATTERN) return /$^/;
|
|
21
|
-
|
|
22
|
-
const p = normalizePath(pattern);
|
|
23
|
-
let out = "^";
|
|
24
|
-
for (let i = 0; i < p.length; ) {
|
|
25
|
-
if (p[i] === "*" && p[i + 1] === "*") {
|
|
26
|
-
if (p[i + 2] === "/") {
|
|
27
|
-
// **/ → zero or more directories
|
|
28
|
-
out += "(?:.*/)?";
|
|
29
|
-
i += 3;
|
|
30
|
-
} else {
|
|
31
|
-
out += ".*";
|
|
32
|
-
i += 2;
|
|
33
|
-
}
|
|
34
|
-
} else if (p[i] === "*") {
|
|
35
|
-
out += "[^/]*";
|
|
36
|
-
i += 1;
|
|
37
|
-
} else if (p[i] === "?") {
|
|
38
|
-
out += "[^/]";
|
|
39
|
-
i += 1;
|
|
40
|
-
} else {
|
|
41
|
-
const c = p[i]!;
|
|
42
|
-
out += /[.+^${}()|[\]\\]/.test(c) ? `\\${c}` : c;
|
|
43
|
-
i += 1;
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
out += "$";
|
|
47
|
-
// nosemgrep: pattern is length-bounded + escaped; only * ? ** are wild
|
|
48
|
-
return new RegExp(out);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export function matchGlob(pattern: string, value: string): boolean {
|
|
52
|
-
const re = compileGlob(pattern);
|
|
53
|
-
const norm = normalizePath(value);
|
|
54
|
-
if (re.test(norm)) return true;
|
|
55
|
-
|
|
56
|
-
// File-name-only patterns also match basename
|
|
57
|
-
if (!pattern.includes("/")) {
|
|
58
|
-
const base = norm.split("/").pop() ?? norm;
|
|
59
|
-
if (re.test(base)) return true;
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Specificity score for a glob: more literal (non-wildcard) chars = more
|
|
66
|
-
* specific; fewer `*` breaks ties. So a deep dotfile deny beats a broad
|
|
67
|
-
* `Projects` tree allow for the same file even though both match.
|
|
68
|
-
*/
|
|
69
|
-
export function globSpecificity(pattern: string): number {
|
|
70
|
-
const p = normalizePath(pattern);
|
|
71
|
-
// Literal chars AFTER the last wildcard pin the filename/tail — the strongest
|
|
72
|
-
// specificity signal. A pattern ending in `**` (broad tree) pins nothing → 0,
|
|
73
|
-
// so two trees tie and resolveRules' deny-wins tiebreak decides.
|
|
74
|
-
const lastWild = Math.max(p.lastIndexOf("*"), p.lastIndexOf("?"));
|
|
75
|
-
const suffix = lastWild < 0 ? p : p.slice(lastWild + 1);
|
|
76
|
-
return suffix.replace(/[*?]/g, "").length;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Any matching deny wins. Otherwise, any matching allow wins. */
|
|
80
|
-
export function resolveRules(
|
|
81
|
-
rules: ReadonlyArray<{ pattern: string; state: "allow" | "deny" }>,
|
|
82
|
-
value: string,
|
|
83
|
-
): "allow" | "deny" | undefined {
|
|
84
|
-
let allowed = false;
|
|
85
|
-
for (const rule of rules) {
|
|
86
|
-
if (!matchGlob(rule.pattern, value)) continue;
|
|
87
|
-
if (rule.state === "deny") return "deny";
|
|
88
|
-
allowed = true;
|
|
89
|
-
}
|
|
90
|
-
return allowed ? "allow" : undefined;
|
|
91
|
-
}
|