dsh-permission-rules 0.2.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 +50 -0
- package/LICENSE +201 -0
- package/README.es.md +182 -0
- package/README.hi.md +182 -0
- package/README.md +182 -0
- package/README.pt.md +182 -0
- package/README.zh.md +182 -0
- package/cordis.patch.yml +16 -0
- package/docs/rules-format.en.md +105 -0
- package/docs/rules-format.md +103 -0
- package/docs/rules-format.schema.json +117 -0
- package/lib/index.js +1606 -0
- package/lib/types/config.d.ts +83 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +72 -0
- package/lib/types/config.js.map +1 -0
- package/lib/types/events.d.ts +68 -0
- package/lib/types/events.d.ts.map +1 -0
- package/lib/types/events.js +21 -0
- package/lib/types/events.js.map +1 -0
- package/lib/types/glob.d.ts +55 -0
- package/lib/types/glob.d.ts.map +1 -0
- package/lib/types/glob.js +307 -0
- package/lib/types/glob.js.map +1 -0
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +25 -0
- package/lib/types/index.js.map +1 -0
- package/lib/types/prose.d.ts +66 -0
- package/lib/types/prose.d.ts.map +1 -0
- package/lib/types/prose.js +143 -0
- package/lib/types/prose.js.map +1 -0
- package/lib/types/rules.d.ts +226 -0
- package/lib/types/rules.d.ts.map +1 -0
- package/lib/types/rules.js +581 -0
- package/lib/types/rules.js.map +1 -0
- package/lib/types/runtime.d.ts +175 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/runtime.js +497 -0
- package/lib/types/runtime.js.map +1 -0
- package/package.json +103 -0
- package/scripts/repair-session-logs.mjs +327 -0
- package/src/config.ts +141 -0
- package/src/events.ts +75 -0
- package/src/glob.ts +303 -0
- package/src/index.ts +27 -0
- package/src/prose.ts +206 -0
- package/src/rules.ts +695 -0
- package/src/runtime.ts +504 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import chokidar from "chokidar";
|
|
4
|
+
import z from "@deepseek-ai/schemastery";
|
|
5
|
+
import { parse } from "yaml";
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* Config schema and resolution for `dsh-permission-rules`. Every tunable is
|
|
9
|
+
* a validated {@link Config} field changeable from cordis.yml; the
|
|
10
|
+
* resolution step validates the numeric bounds and compiles nothing — rule
|
|
11
|
+
* files are external documents resolved per session cwd at load time.
|
|
12
|
+
* @module dsh-permission-rules/config
|
|
13
|
+
*/
|
|
14
|
+
/** Schemastery schema: the loader validates and fills defaults before `apply`. */
|
|
15
|
+
const Config = z.object({
|
|
16
|
+
rulesFile: z.string().default(".dsh/rules.yaml"),
|
|
17
|
+
fallbackPath: z.string(),
|
|
18
|
+
badFilePolicy: z.union(["fail", "ignore-with-warning"]).default("fail"),
|
|
19
|
+
maxRules: z.number().default(256),
|
|
20
|
+
maxCachedWorkspaces: z.number().default(512),
|
|
21
|
+
patternMode: z.union(["glob", "regex"]).default("glob"),
|
|
22
|
+
watch: z.boolean().default(true),
|
|
23
|
+
watchStabilityThresholdMs: z.number().default(200),
|
|
24
|
+
language: z.union([
|
|
25
|
+
"en",
|
|
26
|
+
"zh",
|
|
27
|
+
"es",
|
|
28
|
+
"pt",
|
|
29
|
+
"hi"
|
|
30
|
+
]).default("en"),
|
|
31
|
+
caseInsensitivePaths: z.boolean().default(process.platform === "win32"),
|
|
32
|
+
audit: z.union(["all", "hits"]).default("all"),
|
|
33
|
+
searchUp: z.boolean().default(false),
|
|
34
|
+
maxGlobStars: z.number().default(2)
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* Validate raw values and fill explicit defaults. A `maxRules`,
|
|
38
|
+
* `maxCachedWorkspaces`, or `maxGlobStars` that is not a positive safe
|
|
39
|
+
* integer, a non-positive stability window, or `searchUp` combined with an
|
|
40
|
+
* absolute `rulesFile` throws here — misconfiguration fails loud at mount.
|
|
41
|
+
* @param config - raw (possibly partial) plugin config.
|
|
42
|
+
* @returns the fully resolved config.
|
|
43
|
+
*/
|
|
44
|
+
function resolveConfig(config = {}) {
|
|
45
|
+
const rulesFile = config.rulesFile ?? ".dsh/rules.yaml";
|
|
46
|
+
const searchUp = config.searchUp ?? false;
|
|
47
|
+
if (searchUp && isAbsolute(rulesFile)) throw new TypeError(`searchUp cannot be combined with an absolute rulesFile (${JSON.stringify(rulesFile)}): parent-directory walking needs a relative file name`);
|
|
48
|
+
const maxRules = config.maxRules ?? 256;
|
|
49
|
+
if (!Number.isSafeInteger(maxRules) || maxRules <= 0) throw new TypeError(`maxRules must be a positive safe integer, got ${String(config.maxRules)}`);
|
|
50
|
+
const watchStabilityThresholdMs = config.watchStabilityThresholdMs ?? 200;
|
|
51
|
+
if (!Number.isSafeInteger(watchStabilityThresholdMs) || watchStabilityThresholdMs < 0) throw new TypeError(`watchStabilityThresholdMs must be a non-negative safe integer, got ${String(config.watchStabilityThresholdMs)}`);
|
|
52
|
+
const maxCachedWorkspaces = config.maxCachedWorkspaces ?? 512;
|
|
53
|
+
if (!Number.isSafeInteger(maxCachedWorkspaces) || maxCachedWorkspaces <= 0) throw new TypeError(`maxCachedWorkspaces must be a positive safe integer, got ${String(config.maxCachedWorkspaces)}`);
|
|
54
|
+
const maxGlobStars = config.maxGlobStars ?? 2;
|
|
55
|
+
if (!Number.isSafeInteger(maxGlobStars) || maxGlobStars <= 0) throw new TypeError(`maxGlobStars must be a positive safe integer, got ${String(config.maxGlobStars)}`);
|
|
56
|
+
return {
|
|
57
|
+
rulesFile,
|
|
58
|
+
fallbackPath: config.fallbackPath,
|
|
59
|
+
badFilePolicy: config.badFilePolicy ?? "fail",
|
|
60
|
+
maxRules,
|
|
61
|
+
maxCachedWorkspaces,
|
|
62
|
+
patternMode: config.patternMode ?? "glob",
|
|
63
|
+
watch: config.watch ?? true,
|
|
64
|
+
watchStabilityThresholdMs,
|
|
65
|
+
language: config.language ?? "en",
|
|
66
|
+
caseInsensitivePaths: config.caseInsensitivePaths ?? process.platform === "win32",
|
|
67
|
+
audit: config.audit ?? "all",
|
|
68
|
+
searchUp,
|
|
69
|
+
maxGlobStars
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
//#endregion
|
|
73
|
+
//#region src/glob.ts
|
|
74
|
+
/**
|
|
75
|
+
* Strict glob→RegExp compilation and regex-safety validation for rule
|
|
76
|
+
* patterns. A conservative, validated glob subset: `*` (within one path
|
|
77
|
+
* segment), `**` (any depth, including zero), `?` (one character),
|
|
78
|
+
* `[abc]` / `[!abc]` character classes, and `\x` escapes. Unbalanced
|
|
79
|
+
* brackets, empty classes, and trailing escapes throw at compile time — a
|
|
80
|
+
* bad glob must fail loudly at load, never silently match nothing at
|
|
81
|
+
* runtime.
|
|
82
|
+
*
|
|
83
|
+
* Catastrophic-backtracking defense, tuned per pattern flavor:
|
|
84
|
+
* - Glob output is fully machine-generated, so the backtracking degree is
|
|
85
|
+
* exactly the number of star expansions: each `*`/`**` emits exactly one
|
|
86
|
+
* unbounded quantifier and no group is ever emitted. {@link compileGlob}
|
|
87
|
+
* therefore caps that count via `maxStars`.
|
|
88
|
+
* - User regexes (patternMode `regex`) are scanned structurally: a
|
|
89
|
+
* quantifier applied to a group containing an unbounded quantifier, and a
|
|
90
|
+
* quantified group whose literal alternation branches overlap, are
|
|
91
|
+
* rejected at load. Chains of independent top-level quantifiers
|
|
92
|
+
* (`\d+\.\d+\.\d+`) stay allowed — a documented, deliberate limitation:
|
|
93
|
+
* regex mode is the escape hatch, glob mode is the guarded default.
|
|
94
|
+
* @module dsh-permission-rules/glob
|
|
95
|
+
*/
|
|
96
|
+
/** Raised by {@link compileGlob} on a syntactically invalid or over-quantified pattern. */
|
|
97
|
+
var GlobError = class extends TypeError {
|
|
98
|
+
constructor(message) {
|
|
99
|
+
super(message);
|
|
100
|
+
this.name = "GlobError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Compile one glob pattern to an anchored regular expression.
|
|
105
|
+
* @param pattern - the glob source.
|
|
106
|
+
* @param options - segment semantics, case handling, and the star cap.
|
|
107
|
+
* @returns a RegExp matching the whole candidate string.
|
|
108
|
+
* @throws {@link GlobError} on unbalanced `[`, empty classes, a trailing
|
|
109
|
+
* escape, or more star expansions than `options.maxStars` allows.
|
|
110
|
+
*/
|
|
111
|
+
function compileGlob(pattern, options) {
|
|
112
|
+
let out = "";
|
|
113
|
+
let stars = 0;
|
|
114
|
+
let i = 0;
|
|
115
|
+
while (i < pattern.length) {
|
|
116
|
+
const ch = pattern[i];
|
|
117
|
+
if (ch === "\\") {
|
|
118
|
+
const next = pattern[i + 1];
|
|
119
|
+
if (next === void 0) throw new GlobError(`glob ${JSON.stringify(pattern)} ends with a dangling escape`);
|
|
120
|
+
out += escapeRegExpChar(next);
|
|
121
|
+
i += 2;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === "*") {
|
|
125
|
+
if (pattern[i + 1] === "*") {
|
|
126
|
+
out += ".*";
|
|
127
|
+
stars += 1;
|
|
128
|
+
i += 2;
|
|
129
|
+
while (pattern[i] === "*") i += 1;
|
|
130
|
+
if (pattern[i] === "/") i += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
out += options.segments ? "[^/]*" : ".*";
|
|
134
|
+
stars += 1;
|
|
135
|
+
i += 1;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (ch === "?") {
|
|
139
|
+
out += options.segments ? "[^/]" : ".";
|
|
140
|
+
i += 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (ch === "[") {
|
|
144
|
+
const end = pattern.indexOf("]", i + 1);
|
|
145
|
+
if (end < 0) throw new GlobError(`glob ${JSON.stringify(pattern)} has an unbalanced "["`);
|
|
146
|
+
const body = pattern.slice(i + 1, end);
|
|
147
|
+
if (body.length === 0) throw new GlobError(`glob ${JSON.stringify(pattern)} has an empty character class`);
|
|
148
|
+
let content = body;
|
|
149
|
+
let negate = false;
|
|
150
|
+
if (content.startsWith("!")) {
|
|
151
|
+
negate = true;
|
|
152
|
+
content = content.slice(1);
|
|
153
|
+
}
|
|
154
|
+
if (content.length === 0) throw new GlobError(`glob ${JSON.stringify(pattern)} has an empty character class`);
|
|
155
|
+
out += `[${negate ? "^" : ""}${content.replaceAll("\\", "\\\\")}]`;
|
|
156
|
+
i = end + 1;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (ch === "]") throw new GlobError(`glob ${JSON.stringify(pattern)} has an unbalanced "]"`);
|
|
160
|
+
out += escapeRegExpChar(ch);
|
|
161
|
+
i += 1;
|
|
162
|
+
}
|
|
163
|
+
if (options.maxStars !== void 0 && stars > options.maxStars) throw new GlobError(`glob ${JSON.stringify(pattern)} expands to ${stars} unbounded star quantifiers (max ${options.maxStars}); split it into several patterns`);
|
|
164
|
+
try {
|
|
165
|
+
return new RegExp(`^${out}$`, options.caseInsensitive === true ? "iu" : "u");
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new GlobError(`glob ${JSON.stringify(pattern)} compiles to an invalid regular expression: ${String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** Escape one literal character for a RegExp source. */
|
|
171
|
+
function escapeRegExpChar(ch) {
|
|
172
|
+
return /[.*+?^${}()|[\]\\/]/.test(ch) ? `\\${ch}` : ch;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Compile one pattern as an unanchored regex, rejecting syntax errors and
|
|
176
|
+
* catastrophic-backtracking structures loudly.
|
|
177
|
+
* @param pattern - the regex source.
|
|
178
|
+
* @returns the compiled RegExp.
|
|
179
|
+
* @throws TypeError when the pattern is invalid, nests an unbounded
|
|
180
|
+
* quantifier, or quantifies a group with overlapping literal alternation
|
|
181
|
+
* branches.
|
|
182
|
+
*/
|
|
183
|
+
function compilePatternRegex(pattern) {
|
|
184
|
+
let compiled;
|
|
185
|
+
try {
|
|
186
|
+
compiled = new RegExp(pattern, "u");
|
|
187
|
+
} catch (error) {
|
|
188
|
+
throw new TypeError(`pattern ${JSON.stringify(pattern)} is not a valid regular expression: ${String(error)}`, { cause: error });
|
|
189
|
+
}
|
|
190
|
+
assertSafeRegexStructure(pattern);
|
|
191
|
+
return compiled;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Structural ReDoS scan. Rejects (a) an unbounded quantifier (`*`, `+`,
|
|
195
|
+
* `{m,}`) applied to a group whose body contains an unbounded quantifier,
|
|
196
|
+
* and (b) a quantified group whose fully-literal alternation branches
|
|
197
|
+
* overlap as prefixes. Everything else — including chains of independent
|
|
198
|
+
* top-level quantifiers — compiles untouched.
|
|
199
|
+
* @param pattern - the already-syntax-valid regex source.
|
|
200
|
+
* @throws TypeError on a rejected structure.
|
|
201
|
+
*/
|
|
202
|
+
function assertSafeRegexStructure(pattern) {
|
|
203
|
+
const stack = [];
|
|
204
|
+
let i = 0;
|
|
205
|
+
const markUnbounded = () => {
|
|
206
|
+
const top = stack[stack.length - 1];
|
|
207
|
+
if (top !== void 0) top.unbounded = true;
|
|
208
|
+
};
|
|
209
|
+
const reject = (message) => {
|
|
210
|
+
throw new TypeError(`pattern ${JSON.stringify(pattern)} is rejected: ${message} risks catastrophic backtracking; restructure the pattern or use glob mode`);
|
|
211
|
+
};
|
|
212
|
+
while (i < pattern.length) {
|
|
213
|
+
const ch = pattern[i];
|
|
214
|
+
if (ch === "\\") {
|
|
215
|
+
i += 2;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (ch === "[") {
|
|
219
|
+
i += 1;
|
|
220
|
+
while (i < pattern.length && pattern[i] !== "]") {
|
|
221
|
+
if (pattern[i] === "\\") i += 1;
|
|
222
|
+
i += 1;
|
|
223
|
+
}
|
|
224
|
+
i += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (ch === "(") {
|
|
228
|
+
stack.push({
|
|
229
|
+
start: i,
|
|
230
|
+
unbounded: false
|
|
231
|
+
});
|
|
232
|
+
i += 1;
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (ch === ")") {
|
|
236
|
+
const group = stack.pop();
|
|
237
|
+
if (group === void 0) {
|
|
238
|
+
i += 1;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const quantifier = quantifierAt(pattern, i + 1);
|
|
242
|
+
if (quantifier !== void 0) {
|
|
243
|
+
if (quantifier.unbounded) {
|
|
244
|
+
if (group.unbounded) reject(`quantifier ${quantifier.text} applies to a group containing an unbounded quantifier`);
|
|
245
|
+
if (hasAmbiguousAlternation(pattern, group.start + 1, i)) reject(`quantified group ${JSON.stringify(pattern.slice(group.start, quantifier.end + 1))} has overlapping alternation branches`);
|
|
246
|
+
markUnbounded();
|
|
247
|
+
}
|
|
248
|
+
i = quantifier.end + 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (group.unbounded) markUnbounded();
|
|
252
|
+
i += 1;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (ch === "*" || ch === "+") {
|
|
256
|
+
markUnbounded();
|
|
257
|
+
i += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (ch === "{") {
|
|
261
|
+
const parsed = parseBraceQuantifier(pattern, i);
|
|
262
|
+
if (parsed !== void 0) {
|
|
263
|
+
if (parsed.unbounded) markUnbounded();
|
|
264
|
+
i = parsed.end + 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
i += 1;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
i += 1;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** One parsed quantifier token, when the character at `index` starts one. */
|
|
274
|
+
function quantifierAt(pattern, index) {
|
|
275
|
+
const ch = pattern[index];
|
|
276
|
+
if (ch === "*" || ch === "+") return {
|
|
277
|
+
text: ch,
|
|
278
|
+
end: index,
|
|
279
|
+
unbounded: true
|
|
280
|
+
};
|
|
281
|
+
if (ch === "?") return {
|
|
282
|
+
text: ch,
|
|
283
|
+
end: index,
|
|
284
|
+
unbounded: false
|
|
285
|
+
};
|
|
286
|
+
if (ch === "{") return parseBraceQuantifier(pattern, index);
|
|
287
|
+
}
|
|
288
|
+
/** Parse `{m}`, `{m,}`, or `{m,n}` at `start`; other braces are not quantifiers. */
|
|
289
|
+
function parseBraceQuantifier(pattern, start) {
|
|
290
|
+
const close = pattern.indexOf("}", start + 1);
|
|
291
|
+
if (close < 0) return void 0;
|
|
292
|
+
const body = pattern.slice(start + 1, close);
|
|
293
|
+
if (!/^\d+(,\d*)?$/.test(body)) return void 0;
|
|
294
|
+
const unbounded = body.includes(",") && body.endsWith(",");
|
|
295
|
+
return {
|
|
296
|
+
text: pattern.slice(start, close + 1),
|
|
297
|
+
end: close,
|
|
298
|
+
unbounded
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Whether a group body (between the parens) contains a top-level
|
|
303
|
+
* alternation with two fully-literal branches where one is a prefix of the
|
|
304
|
+
* other — the `(a|aa)+` ambiguity that makes repetition exponential.
|
|
305
|
+
* @param pattern - the full pattern.
|
|
306
|
+
* @param start - index just after the group's `(`.
|
|
307
|
+
* @param end - index of the group's `)`.
|
|
308
|
+
* @returns true when such an overlap exists.
|
|
309
|
+
*/
|
|
310
|
+
function hasAmbiguousAlternation(pattern, start, end) {
|
|
311
|
+
const branches = [];
|
|
312
|
+
let depth = 0;
|
|
313
|
+
let branchStart = start;
|
|
314
|
+
let hasPipe = false;
|
|
315
|
+
for (let i = start; i < end; i += 1) {
|
|
316
|
+
const ch = pattern[i];
|
|
317
|
+
if (ch === "\\") {
|
|
318
|
+
i += 1;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (ch === "[") {
|
|
322
|
+
while (i < end && pattern[i] !== "]") {
|
|
323
|
+
if (pattern[i] === "\\") i += 1;
|
|
324
|
+
i += 1;
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
if (ch === "(") {
|
|
329
|
+
depth += 1;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (ch === ")") {
|
|
333
|
+
depth -= 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (ch === "|" && depth === 0) {
|
|
337
|
+
hasPipe = true;
|
|
338
|
+
branches.push(pattern.slice(branchStart, i));
|
|
339
|
+
branchStart = i + 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (!hasPipe) return false;
|
|
343
|
+
branches.push(pattern.slice(branchStart, end));
|
|
344
|
+
for (let a = 0; a < branches.length; a += 1) for (let b = a + 1; b < branches.length; b += 1) if (literalPrefixOf(branches[a], branches[b])) return true;
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
/** True when two fully-literal branches share their complete shorter text as a prefix. */
|
|
348
|
+
function literalPrefixOf(x, y) {
|
|
349
|
+
const literal = /^[^*+?()|[\]{}^$.\\]+$/;
|
|
350
|
+
if (!literal.test(x) || !literal.test(y)) return false;
|
|
351
|
+
const min = Math.min(x.length, y.length);
|
|
352
|
+
return x.slice(0, min) === y.slice(0, min);
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/rules.ts
|
|
356
|
+
/**
|
|
357
|
+
* The pure rule vocabulary: parsing a `.dsh/rules.yaml` document into a
|
|
358
|
+
* validated {@link RulesFileDoc}, compiling patterns, and first-match
|
|
359
|
+
* evaluation. Every function here is pure — no filesystem, clock, or
|
|
360
|
+
* process state — so the parser, the matcher, and the failure modes are all
|
|
361
|
+
* unit-testable and replayable.
|
|
362
|
+
* @module dsh-permission-rules/rules
|
|
363
|
+
*/
|
|
364
|
+
/** Closed action list for runtime normalization of untrusted values. */
|
|
365
|
+
const RULE_ACTIONS = [
|
|
366
|
+
"allow",
|
|
367
|
+
"deny",
|
|
368
|
+
"ask"
|
|
369
|
+
];
|
|
370
|
+
/** Platform names a `when.platform` entry may name (Node `process.platform` values). */
|
|
371
|
+
const PLATFORMS = [
|
|
372
|
+
"aix",
|
|
373
|
+
"android",
|
|
374
|
+
"darwin",
|
|
375
|
+
"freebsd",
|
|
376
|
+
"linux",
|
|
377
|
+
"openbsd",
|
|
378
|
+
"sunos",
|
|
379
|
+
"win32"
|
|
380
|
+
];
|
|
381
|
+
/** Recursion bound for nested argument walks (path candidates and scalar leaves). */
|
|
382
|
+
const MAX_ARGUMENT_DEPTH = 8;
|
|
383
|
+
/** Display-truncation length for reasons/descriptions in `/rules` output. */
|
|
384
|
+
const DISPLAY_TRUNCATE = 120;
|
|
385
|
+
/**
|
|
386
|
+
* Raised when a rule document cannot be parsed or compiled. The message
|
|
387
|
+
* names the failing rule and field; loading code maps it onto the
|
|
388
|
+
* `badFilePolicy`.
|
|
389
|
+
*/
|
|
390
|
+
var RuleError = class extends Error {
|
|
391
|
+
constructor(message) {
|
|
392
|
+
super(message);
|
|
393
|
+
this.name = "RuleError";
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
/**
|
|
397
|
+
* Parse one rules file body. Unknown document/rule/match fields, invalid
|
|
398
|
+
* actions, missing reasons, and wrong-shaped collections all throw
|
|
399
|
+
* {@link RuleError} — a malformed file fails loudly at load, never silently.
|
|
400
|
+
* @param text - the raw YAML text.
|
|
401
|
+
* @returns the validated document.
|
|
402
|
+
*/
|
|
403
|
+
function parseRulesDocument(text) {
|
|
404
|
+
const parsed = parseYaml(text);
|
|
405
|
+
if (parsed === null || parsed === void 0) return { rules: [] };
|
|
406
|
+
const root = asRecord(parsed, "rules document root");
|
|
407
|
+
const unknownRoot = Object.keys(root).filter((key) => key !== "rules");
|
|
408
|
+
if (unknownRoot.length > 0) throw new RuleError(`unknown top-level field${unknownRoot.length > 1 ? "s" : ""} ${unknownRoot.map((k) => JSON.stringify(k)).join(", ")} (only "rules" is allowed)`);
|
|
409
|
+
const rawRules = root["rules"];
|
|
410
|
+
if (rawRules === void 0) return { rules: [] };
|
|
411
|
+
if (!Array.isArray(rawRules)) throw new RuleError("\"rules\" must be a list");
|
|
412
|
+
return { rules: rawRules.map((raw, index) => parseRuleEntry(raw, index)) };
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Compile a parsed document for the hot path: pattern strings become
|
|
416
|
+
* RegExps (invalid globs/regexes throw here, at load), and the rule count
|
|
417
|
+
* is capped by `maxRules`.
|
|
418
|
+
* @param doc - the parsed document.
|
|
419
|
+
* @param options - pattern mode, caps, and case handling.
|
|
420
|
+
* @returns the compiled ruleset (every rule attributed to source 0).
|
|
421
|
+
*/
|
|
422
|
+
function compileRules(doc, options) {
|
|
423
|
+
if (doc.rules.length > options.maxRules) throw new RuleError(`rule count ${doc.rules.length} exceeds maxRules ${options.maxRules}`);
|
|
424
|
+
return {
|
|
425
|
+
rules: doc.rules.map((entry, index) => compileRule(entry, index, 0, options)),
|
|
426
|
+
caseInsensitivePaths: options.caseInsensitivePaths
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Parse and compile an ordered source chain (nearest file first). Rules
|
|
431
|
+
* keep per-file order and are evaluated in chain order, so a nearer file
|
|
432
|
+
* can override a farther one on first-match semantics; `sourceIndex`
|
|
433
|
+
* attributes every rule to its file. The TOTAL rule count is capped by
|
|
434
|
+
* `maxRules`.
|
|
435
|
+
* @param entries - the rule-file texts, nearest file first.
|
|
436
|
+
* @param options - compile options.
|
|
437
|
+
* @returns the merged ruleset and the source paths it references.
|
|
438
|
+
*/
|
|
439
|
+
function compileRulesChain(entries, options) {
|
|
440
|
+
const sources = entries.map((entry) => entry.path);
|
|
441
|
+
const merged = [];
|
|
442
|
+
for (const entry of entries) {
|
|
443
|
+
const doc = parseRulesDocument(entry.text);
|
|
444
|
+
for (const rule of doc.rules) merged.push(compileRule(rule, merged.length, sources.indexOf(entry.path), options));
|
|
445
|
+
}
|
|
446
|
+
if (merged.length > options.maxRules) throw new RuleError(`rule count ${merged.length} exceeds maxRules ${options.maxRules}`);
|
|
447
|
+
return {
|
|
448
|
+
ruleset: {
|
|
449
|
+
rules: merged,
|
|
450
|
+
caseInsensitivePaths: options.caseInsensitivePaths
|
|
451
|
+
},
|
|
452
|
+
sources
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
/** Compile one parsed rule into its hot-path form. */
|
|
456
|
+
function compileRule(entry, index, sourceIndex, options) {
|
|
457
|
+
const tools = entry.match.tools.map((pattern) => compileToolPattern(pattern, options.maxGlobStars));
|
|
458
|
+
const params = /* @__PURE__ */ new Map();
|
|
459
|
+
for (const [key, patterns] of Object.entries(entry.match.params)) params.set(key, compileParamPatterns(key, patterns, options));
|
|
460
|
+
const paths = entry.match.paths.map((pattern) => compilePathPattern(pattern, options));
|
|
461
|
+
const env = /* @__PURE__ */ new Map();
|
|
462
|
+
for (const [key, patterns] of Object.entries(entry.match.when.env)) env.set(key, patterns.map((pattern) => compileValuePattern(pattern, options.patternMode, options.maxGlobStars)));
|
|
463
|
+
return {
|
|
464
|
+
index,
|
|
465
|
+
sourceIndex,
|
|
466
|
+
action: entry.action,
|
|
467
|
+
reason: entry.reason,
|
|
468
|
+
enabled: entry.enabled,
|
|
469
|
+
...entry.description !== void 0 ? { description: entry.description } : {},
|
|
470
|
+
tags: entry.tags,
|
|
471
|
+
tools,
|
|
472
|
+
params,
|
|
473
|
+
paths,
|
|
474
|
+
absent: entry.match.absent,
|
|
475
|
+
...env.size === 0 && entry.match.when.platform.length === 0 ? {} : { when: {
|
|
476
|
+
env,
|
|
477
|
+
platform: entry.match.when.platform
|
|
478
|
+
} },
|
|
479
|
+
source: entry
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Evaluate rules in order against one pending tool call. First match wins;
|
|
484
|
+
* no match returns `undefined` (passthrough). Pure and synchronous — this
|
|
485
|
+
* is the `tools/pre-execute` hot path, so it runs no I/O.
|
|
486
|
+
* @param ruleset - compiled rules.
|
|
487
|
+
* @param toolName - the tool being called.
|
|
488
|
+
* @param args - the parsed (JSON) tool arguments; non-object values satisfy
|
|
489
|
+
* only tools/when/absent rules.
|
|
490
|
+
* @param cwd - the session's absolute workspace root, for path normalization.
|
|
491
|
+
* @param context - host facts for `when` evaluation; defaults resolve at match time.
|
|
492
|
+
* @returns the first hit, or undefined when every rule passed.
|
|
493
|
+
*/
|
|
494
|
+
function matchRules(ruleset, toolName, args, cwd, context = {}) {
|
|
495
|
+
const argRecord = isRecord(args) ? args : void 0;
|
|
496
|
+
for (const rule of ruleset.rules) {
|
|
497
|
+
if (!rule.enabled) continue;
|
|
498
|
+
if (!matchTools(rule, toolName)) continue;
|
|
499
|
+
if (!matchWhen(rule, context)) continue;
|
|
500
|
+
if (rule.params.size > 0 && (argRecord === void 0 || !matchParams(rule, argRecord))) continue;
|
|
501
|
+
if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue;
|
|
502
|
+
if (rule.paths.length > 0 && (argRecord === void 0 || !matchPaths(rule, argRecord, cwd, ruleset.caseInsensitivePaths))) continue;
|
|
503
|
+
return {
|
|
504
|
+
ruleIndex: rule.index,
|
|
505
|
+
rule
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Indices of rules that can never fire: every ENABLED rule after the first
|
|
511
|
+
* enabled rule whose every dimension is empty (an unconditional catch-all).
|
|
512
|
+
* Disabled rules are excluded from both roles. Conservative on purpose —
|
|
513
|
+
* pattern-set inclusion across globs is not analyzed.
|
|
514
|
+
* @param ruleset - the compiled rules.
|
|
515
|
+
* @returns 0-based indices of unreachable rules.
|
|
516
|
+
*/
|
|
517
|
+
function findUnreachableRules(ruleset) {
|
|
518
|
+
const unreachable = [];
|
|
519
|
+
let catchAllSeen = false;
|
|
520
|
+
for (const rule of ruleset.rules) {
|
|
521
|
+
if (!rule.enabled) continue;
|
|
522
|
+
if (catchAllSeen) {
|
|
523
|
+
unreachable.push(rule.index);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (isCatchAll(rule)) catchAllSeen = true;
|
|
527
|
+
}
|
|
528
|
+
return unreachable;
|
|
529
|
+
}
|
|
530
|
+
/** Whether every match dimension of one rule is empty. */
|
|
531
|
+
function isCatchAll(rule) {
|
|
532
|
+
return rule.tools.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === void 0;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* One-line human summary of a compiled rule, for `/rules` output. The
|
|
536
|
+
* 1-based number, action, match dimensions, and reason render first; a
|
|
537
|
+
* disabled marker, tags, and a truncated description follow.
|
|
538
|
+
* @param rule - the compiled rule.
|
|
539
|
+
* @param tokens - dimension-prefix vocabulary for the configured language.
|
|
540
|
+
* @returns a single display line.
|
|
541
|
+
*/
|
|
542
|
+
function describeRule(rule, tokens) {
|
|
543
|
+
const parts = [];
|
|
544
|
+
if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(",")}`);
|
|
545
|
+
const params = Object.entries(rule.source.match.params).map(([key, patterns]) => `${key}=${patterns.join("|")}`).join(" ");
|
|
546
|
+
if (params.length > 0) parts.push(`${tokens.params}:${params}`);
|
|
547
|
+
if (rule.source.match.paths.length > 0) parts.push(`${tokens.paths}:${rule.source.match.paths.join(",")}`);
|
|
548
|
+
if (rule.source.match.absent.length > 0) parts.push(`${tokens.absent}:${rule.source.match.absent.join(",")}`);
|
|
549
|
+
const whenParts = Object.entries(rule.source.match.when.env).map(([key, patterns]) => `${key}=${patterns.join("|")}`).join(" ");
|
|
550
|
+
const platformPart = rule.source.match.when.platform.length > 0 ? `${tokens.platform}:${rule.source.match.when.platform.join(",")}` : "";
|
|
551
|
+
if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? " " : ""}${platformPart}`);
|
|
552
|
+
const match = parts.length > 0 ? `[${parts.join(" ")}]` : `[${tokens.allTools}]`;
|
|
553
|
+
const disabled = rule.enabled ? "" : ` (${tokens.disabled})`;
|
|
554
|
+
const description = rule.description !== void 0 ? ` (${truncate(rule.description, DISPLAY_TRUNCATE)})` : "";
|
|
555
|
+
const tags = rule.tags.length > 0 ? ` [${tokens.tags}:${rule.tags.join(",")}]` : "";
|
|
556
|
+
return `${rule.index + 1}. ${rule.action}${disabled} ${match}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`;
|
|
557
|
+
}
|
|
558
|
+
/** Cut a display string at `limit` characters, marking truncation with `…`. */
|
|
559
|
+
function truncate(text, limit) {
|
|
560
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
561
|
+
}
|
|
562
|
+
/** Whether a rule's tools dimension selects the tool. */
|
|
563
|
+
function matchTools(rule, toolName) {
|
|
564
|
+
if (rule.tools.length === 0) return true;
|
|
565
|
+
return rule.tools.some((regex) => regex.test(toolName));
|
|
566
|
+
}
|
|
567
|
+
/** Whether a rule's params dimension holds: EVERY key present and matching. */
|
|
568
|
+
function matchParams(rule, args) {
|
|
569
|
+
for (const [key, { positive, negative }] of rule.params) {
|
|
570
|
+
const value = args[key];
|
|
571
|
+
if (value === void 0) return false;
|
|
572
|
+
const candidates = scalarCandidates(value, 0);
|
|
573
|
+
if (candidates.length === 0) return false;
|
|
574
|
+
if (positive.length > 0 && !candidates.some((candidate) => positive.some((regex) => regex.test(candidate)))) return false;
|
|
575
|
+
if (negative.length > 0 && candidates.some((candidate) => negative.some((regex) => regex.test(candidate)))) return false;
|
|
576
|
+
}
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
/** Whether a rule's absent dimension holds: EVERY listed key missing. */
|
|
580
|
+
function matchAbsent(rule, argRecord) {
|
|
581
|
+
for (const key of rule.absent) if (argRecord !== void 0 && argRecord[key] !== void 0) return false;
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
/** Whether a rule's when dimension holds for the host facts. */
|
|
585
|
+
function matchWhen(rule, context) {
|
|
586
|
+
const when = rule.when;
|
|
587
|
+
if (when === void 0) return true;
|
|
588
|
+
if (when.platform.length > 0 && !when.platform.includes(context.platform ?? process.platform)) return false;
|
|
589
|
+
const env = context.env ?? process.env;
|
|
590
|
+
for (const [key, patterns] of when.env) {
|
|
591
|
+
const value = env[key];
|
|
592
|
+
if (value === void 0) return false;
|
|
593
|
+
if (!patterns.some((regex) => regex.test(value))) return false;
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
/** Whether a rule's paths dimension holds: at least one candidate matches one pattern. */
|
|
598
|
+
function matchPaths(rule, args, cwd, caseInsensitive) {
|
|
599
|
+
const candidates = extractPathCandidates(args).map((candidate) => normalizeWorkspacePath(cwd, candidate, caseInsensitive)).filter((candidate) => candidate.length > 0);
|
|
600
|
+
if (candidates.length === 0) return false;
|
|
601
|
+
return candidates.some((candidate) => rule.paths.some((regex) => regex.test(candidate)));
|
|
602
|
+
}
|
|
603
|
+
/** Tool-name patterns are always globs (a tool name is not a path). */
|
|
604
|
+
function compileToolPattern(pattern, maxGlobStars) {
|
|
605
|
+
return compileGlob(pattern, {
|
|
606
|
+
segments: false,
|
|
607
|
+
maxStars: maxGlobStars
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
/** Param-value patterns follow the configured mode; `/` is an ordinary character. */
|
|
611
|
+
function compileValuePattern(pattern, mode, maxGlobStars) {
|
|
612
|
+
return mode === "regex" ? compilePatternRegex(pattern) : compileGlob(pattern, {
|
|
613
|
+
segments: false,
|
|
614
|
+
maxStars: maxGlobStars
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
/** Path patterns follow the configured mode with path-segment glob semantics. */
|
|
618
|
+
function compilePathPattern(pattern, options) {
|
|
619
|
+
return options.patternMode === "regex" ? compilePatternRegex(pattern) : compileGlob(pattern, {
|
|
620
|
+
segments: true,
|
|
621
|
+
maxStars: options.maxGlobStars,
|
|
622
|
+
caseInsensitive: options.caseInsensitivePaths
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Split one param key's patterns into positive and negative lists. A
|
|
627
|
+
* leading `!` negates: the value must NOT match any negated pattern; a bare
|
|
628
|
+
* `!` is a load-time error.
|
|
629
|
+
*/
|
|
630
|
+
function compileParamPatterns(key, patterns, options) {
|
|
631
|
+
const positive = [];
|
|
632
|
+
const negative = [];
|
|
633
|
+
for (const pattern of patterns) if (pattern.startsWith("!")) {
|
|
634
|
+
const body = pattern.slice(1);
|
|
635
|
+
if (body.length === 0) throw new RuleError(`param key "${key}" has an empty negated pattern`);
|
|
636
|
+
negative.push(compileValuePattern(body, options.patternMode, options.maxGlobStars));
|
|
637
|
+
} else positive.push(compileValuePattern(pattern, options.patternMode, options.maxGlobStars));
|
|
638
|
+
return {
|
|
639
|
+
positive,
|
|
640
|
+
negative
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/** Parse YAML through the `yaml` dependency, mapping parse failures onto {@link RuleError}. */
|
|
644
|
+
function parseYaml(text) {
|
|
645
|
+
try {
|
|
646
|
+
return parse(text);
|
|
647
|
+
} catch (error) {
|
|
648
|
+
throw new RuleError(`invalid YAML: ${String(error)}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Validate one raw rule entry from the YAML document. */
|
|
652
|
+
function parseRuleEntry(raw, index) {
|
|
653
|
+
const at = `rule ${index + 1}`;
|
|
654
|
+
const record = asRecord(raw, at);
|
|
655
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "match" && key !== "action" && key !== "reason" && key !== "enabled" && key !== "description" && key !== "tags");
|
|
656
|
+
if (unknownFields.length > 0) throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: match, action, reason, enabled, description, tags)`);
|
|
657
|
+
const action = record["action"];
|
|
658
|
+
if (typeof action !== "string" || !RULE_ACTIONS.includes(action)) throw new RuleError(`${at}: action must be one of ${RULE_ACTIONS.map((a) => JSON.stringify(a)).join(" | ")}, got ${JSON.stringify(action)}`);
|
|
659
|
+
const reason = record["reason"];
|
|
660
|
+
if (typeof reason !== "string" || reason.trim().length === 0) throw new RuleError(`${at}: reason must be a non-empty string`);
|
|
661
|
+
const enabled = record["enabled"];
|
|
662
|
+
if (enabled !== void 0 && typeof enabled !== "boolean") throw new RuleError(`${at}: enabled must be a boolean, got ${typeof enabled}`);
|
|
663
|
+
const description = record["description"];
|
|
664
|
+
if (description !== void 0 && (typeof description !== "string" || description.trim().length === 0)) throw new RuleError(`${at}: description must be a non-empty string`);
|
|
665
|
+
return {
|
|
666
|
+
match: parseMatch(record["match"], at),
|
|
667
|
+
action,
|
|
668
|
+
reason,
|
|
669
|
+
enabled: enabled !== false,
|
|
670
|
+
...description !== void 0 ? { description } : {},
|
|
671
|
+
tags: parseStringList(record["tags"], `${at}.tags`)
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
/** Validate the `match` block of one rule. */
|
|
675
|
+
function parseMatch(raw, at) {
|
|
676
|
+
if (raw === void 0) return {
|
|
677
|
+
tools: [],
|
|
678
|
+
params: {},
|
|
679
|
+
paths: [],
|
|
680
|
+
absent: [],
|
|
681
|
+
when: {
|
|
682
|
+
env: {},
|
|
683
|
+
platform: []
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
const record = asRecord(raw, `${at}.match`);
|
|
687
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "tools" && key !== "params" && key !== "paths" && key !== "absent" && key !== "when");
|
|
688
|
+
if (unknownFields.length > 0) throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: tools, params, paths, absent, when)`);
|
|
689
|
+
return {
|
|
690
|
+
tools: parseStringList(record["tools"], `${at}.match.tools`),
|
|
691
|
+
params: parseParams(record["params"], `${at}.match.params`),
|
|
692
|
+
paths: parseStringList(record["paths"], `${at}.match.paths`),
|
|
693
|
+
absent: parseStringList(record["absent"], `${at}.match.absent`),
|
|
694
|
+
when: parseWhen(record["when"], `${at}.match.when`)
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
/** Validate the params map: non-empty string keys → one pattern or a pattern list. */
|
|
698
|
+
function parseParams(raw, at) {
|
|
699
|
+
if (raw === void 0) return {};
|
|
700
|
+
const record = asRecord(raw, at);
|
|
701
|
+
const out = {};
|
|
702
|
+
for (const [key, value] of Object.entries(record)) {
|
|
703
|
+
if (key.length === 0) throw new RuleError(`${at}: param keys must be non-empty strings`);
|
|
704
|
+
const patterns = parsePatternValues(value, `${at}.${key}`);
|
|
705
|
+
if (patterns.length === 0) throw new RuleError(`${at}.${key}: param patterns must be non-empty`);
|
|
706
|
+
out[key] = patterns;
|
|
707
|
+
}
|
|
708
|
+
return out;
|
|
709
|
+
}
|
|
710
|
+
/** Validate the `when` block: env patterns and a closed platform vocabulary. */
|
|
711
|
+
function parseWhen(raw, at) {
|
|
712
|
+
if (raw === void 0) return {
|
|
713
|
+
env: {},
|
|
714
|
+
platform: []
|
|
715
|
+
};
|
|
716
|
+
const record = asRecord(raw, at);
|
|
717
|
+
const unknownFields = Object.keys(record).filter((key) => key !== "env" && key !== "platform");
|
|
718
|
+
if (unknownFields.length > 0) throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? "s" : ""} ${unknownFields.map((k) => JSON.stringify(k)).join(", ")} (allowed: env, platform)`);
|
|
719
|
+
const env = {};
|
|
720
|
+
const rawEnv = record["env"];
|
|
721
|
+
if (rawEnv !== void 0) {
|
|
722
|
+
const envRecord = asRecord(rawEnv, `${at}.env`);
|
|
723
|
+
for (const [key, value] of Object.entries(envRecord)) {
|
|
724
|
+
if (key.length === 0) throw new RuleError(`${at}.env: env var names must be non-empty strings`);
|
|
725
|
+
const patterns = parsePatternValues(value, `${at}.env.${key}`);
|
|
726
|
+
if (patterns.length === 0) throw new RuleError(`${at}.env.${key}: env patterns must be non-empty`);
|
|
727
|
+
env[key] = patterns;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const platform = parseStringList(record["platform"], `${at}.platform`);
|
|
731
|
+
for (const name of platform) if (!PLATFORMS.includes(name)) throw new RuleError(`${at}.platform: unknown platform ${JSON.stringify(name)} (allowed: ${PLATFORMS.map((p) => JSON.stringify(p)).join(", ")})`);
|
|
732
|
+
return {
|
|
733
|
+
env,
|
|
734
|
+
platform
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Validate one pattern or a pattern list; scalar strings/numbers/booleans
|
|
739
|
+
* are single patterns (matching the runtime scalar-stringify semantics).
|
|
740
|
+
*/
|
|
741
|
+
function parsePatternValues(raw, at) {
|
|
742
|
+
if (typeof raw === "string") return [raw];
|
|
743
|
+
if (typeof raw === "number" || typeof raw === "boolean") return [String(raw)];
|
|
744
|
+
if (raw === void 0) return [];
|
|
745
|
+
if (Array.isArray(raw)) return raw.map((value, index) => {
|
|
746
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") throw new RuleError(`${at}[${index}] must be a string, number, or boolean`);
|
|
747
|
+
const pattern = String(value);
|
|
748
|
+
if (pattern.length === 0) throw new RuleError(`${at}[${index}] must be non-empty`);
|
|
749
|
+
return pattern;
|
|
750
|
+
});
|
|
751
|
+
throw new RuleError(`${at} must be a string, number, boolean, or a list of them`);
|
|
752
|
+
}
|
|
753
|
+
/** Validate a string or string list, absent = empty list. */
|
|
754
|
+
function parseStringList(raw, at) {
|
|
755
|
+
if (raw === void 0) return [];
|
|
756
|
+
const values = typeof raw === "string" ? [raw] : raw;
|
|
757
|
+
if (!Array.isArray(values)) throw new RuleError(`${at} must be a string or a list of strings`);
|
|
758
|
+
return values.map((value, index) => {
|
|
759
|
+
if (typeof value !== "string" || value.length === 0) throw new RuleError(`${at}[${index}] must be a non-empty string`);
|
|
760
|
+
return value;
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
/** Narrow an unknown value to a plain record, throwing a {@link RuleError}. */
|
|
764
|
+
function asRecord(value, at) {
|
|
765
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new RuleError(`${at} must be a mapping, got ${Array.isArray(value) ? "a list" : typeof value}`);
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
/** Narrow an unknown value to a plain string-keyed record (tool arguments). */
|
|
769
|
+
function isRecord(value) {
|
|
770
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
771
|
+
}
|
|
772
|
+
/** Top-level argument keys whose string values are path candidates. */
|
|
773
|
+
const PATH_CANDIDATE_KEYS = [
|
|
774
|
+
"path",
|
|
775
|
+
"paths",
|
|
776
|
+
"file",
|
|
777
|
+
"files",
|
|
778
|
+
"file_path",
|
|
779
|
+
"dir",
|
|
780
|
+
"directory",
|
|
781
|
+
"directories",
|
|
782
|
+
"cwd",
|
|
783
|
+
"workspace",
|
|
784
|
+
"root",
|
|
785
|
+
"target",
|
|
786
|
+
"targets",
|
|
787
|
+
"output"
|
|
788
|
+
];
|
|
789
|
+
/**
|
|
790
|
+
* Collect path-candidate strings from tool arguments at ANY nesting depth
|
|
791
|
+
* (capped at {@link MAX_ARGUMENT_DEPTH}): scalar strings under the
|
|
792
|
+
* documented {@link PATH_CANDIDATE_KEYS}, plus string elements of arrays
|
|
793
|
+
* under those keys. Nested objects are walked so MCP-style
|
|
794
|
+
* `{ arguments: { path } }` shapes still feed path matching. Iterates the
|
|
795
|
+
* argument object's own key order, so extraction order is deterministic.
|
|
796
|
+
* @param args - the parsed tool arguments.
|
|
797
|
+
* @returns candidate strings in argument key order (duplicates kept).
|
|
798
|
+
*/
|
|
799
|
+
function extractPathCandidates(args) {
|
|
800
|
+
const candidates = [];
|
|
801
|
+
const walk = (node, depth) => {
|
|
802
|
+
if (depth > MAX_ARGUMENT_DEPTH) return;
|
|
803
|
+
if (Array.isArray(node)) {
|
|
804
|
+
for (const element of node) walk(element, depth + 1);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (typeof node !== "object" || node === null) return;
|
|
808
|
+
for (const [key, value] of Object.entries(node)) {
|
|
809
|
+
if (PATH_CANDIDATE_KEYS.includes(key)) {
|
|
810
|
+
if (typeof value === "string") candidates.push(value);
|
|
811
|
+
else if (Array.isArray(value)) {
|
|
812
|
+
for (const element of value) if (typeof element === "string") candidates.push(element);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
walk(value, depth + 1);
|
|
816
|
+
}
|
|
817
|
+
};
|
|
818
|
+
walk(args, 0);
|
|
819
|
+
return candidates;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Normalize one candidate path against the workspace root: separators
|
|
823
|
+
* become `/`, `./` prefixes drop, and in-root paths become workspace
|
|
824
|
+
* relative. Candidates OUTSIDE the root (absolute or drive-prefixed
|
|
825
|
+
* elsewhere) yield `''` — path patterns address workspace-relative paths
|
|
826
|
+
* only; a still-relative `../…` input is kept so explicit out-of-root
|
|
827
|
+
* globs like `../shared/**` can address it. When `caseInsensitive` is set
|
|
828
|
+
* (Windows default), the drive and root-prefix comparisons ignore ASCII
|
|
829
|
+
* case, matching the case-insensitive filesystem.
|
|
830
|
+
* @param cwd - the session's absolute workspace root (any separator).
|
|
831
|
+
* @param candidate - the raw candidate path.
|
|
832
|
+
* @param caseInsensitive - compare root prefixes without ASCII case.
|
|
833
|
+
* @returns the workspace-relative posix path, or `''` to drop the candidate.
|
|
834
|
+
*/
|
|
835
|
+
function normalizeWorkspacePath(cwd, candidate, caseInsensitive = false) {
|
|
836
|
+
const root = toPosix(cwd).replace(/\/+$/, "");
|
|
837
|
+
const raw = toPosix(candidate);
|
|
838
|
+
if (raw.length === 0) return "";
|
|
839
|
+
let path = raw;
|
|
840
|
+
while (path.startsWith("./")) path = path.slice(2);
|
|
841
|
+
if (/^[A-Za-z]:\//.test(path)) {
|
|
842
|
+
if (!/^[A-Za-z]:\//.test(root)) return "";
|
|
843
|
+
if (path.slice(0, 2).toLowerCase() !== root.slice(0, 2).toLowerCase()) return "";
|
|
844
|
+
return relFromRoot(root, path, path.slice(2), root.slice(2), caseInsensitive);
|
|
845
|
+
}
|
|
846
|
+
if (path.startsWith("/")) return relFromRoot(root, path, path, root, caseInsensitive);
|
|
847
|
+
return path;
|
|
848
|
+
}
|
|
849
|
+
/** Strip a common root prefix from one absolute path, else drop the path. */
|
|
850
|
+
function relFromRoot(root, path, rest, rootRest, caseInsensitive) {
|
|
851
|
+
const restLower = caseInsensitive ? rest.toLowerCase() : rest;
|
|
852
|
+
const rootRestLower = caseInsensitive ? rootRest.toLowerCase() : rootRest;
|
|
853
|
+
if (rootRestLower.length === 0) return rest.slice(1);
|
|
854
|
+
if (path.toLowerCase() === root.toLowerCase()) return "";
|
|
855
|
+
return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : "";
|
|
856
|
+
}
|
|
857
|
+
/** Convert a path to posix separators. */
|
|
858
|
+
function toPosix(path) {
|
|
859
|
+
return path.replaceAll("\\", "/");
|
|
860
|
+
}
|
|
861
|
+
/** Collect scalar candidate strings from one param value, recursing into arrays and objects. */
|
|
862
|
+
function scalarCandidates(value, depth) {
|
|
863
|
+
if (depth > MAX_ARGUMENT_DEPTH) return [];
|
|
864
|
+
if (typeof value === "string") return [value];
|
|
865
|
+
if (typeof value === "number" || typeof value === "boolean") return [String(value)];
|
|
866
|
+
if (Array.isArray(value)) return value.flatMap((element) => scalarCandidates(element, depth + 1));
|
|
867
|
+
if (typeof value === "object" && value !== null) return Object.values(value).flatMap((child) => scalarCandidates(child, depth + 1));
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
//#endregion
|
|
871
|
+
//#region src/prose.ts
|
|
872
|
+
/** Truncate long reasons/descriptions in one-line displays. */
|
|
873
|
+
function short(text, limit = 120) {
|
|
874
|
+
return text.length > limit ? `${text.slice(0, limit)}…` : text;
|
|
875
|
+
}
|
|
876
|
+
const EN = {
|
|
877
|
+
rulesHeader: (count, sources, cwd) => `Permission rules: ${count} rule(s) from ${sources.join(", ")} (workspace ${cwd}).`,
|
|
878
|
+
noRules: (cwd, fallbackNote) => `No permission rules active: no rule file found for workspace ${cwd}${fallbackNote}; the empty rule set passes everything through.`,
|
|
879
|
+
fallbackMissing: " (and the configured fallback path is missing)",
|
|
880
|
+
reloaded: (count, source) => `Reloaded ${count} rule(s) from ${source}.`,
|
|
881
|
+
reloadFailed: (error) => `Reload failed: ${error}. The previous rules are still active.`,
|
|
882
|
+
lastReloadWarning: (error) => `Warning: the last reload failed (${error}); the rules listed above are the previous ones.`,
|
|
883
|
+
unknownArg: (arg) => `Unknown /rules argument "${arg}". ${EN.usage}`,
|
|
884
|
+
usage: "Usage: /rules [reload | decisions [n] | test <tool> <json-args>]",
|
|
885
|
+
decisionsHeader: (shown, total) => `Last ${shown} of ${total} permission decision(s) for this session:`,
|
|
886
|
+
noDecisions: "No permission decisions recorded in this session yet.",
|
|
887
|
+
invalidDecisionsCount: (arg) => `Invalid decisions count "${arg}": give a positive integer (default 10).`,
|
|
888
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
889
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (rule ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
890
|
+
},
|
|
891
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" matches rule ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
892
|
+
testNoMatch: (tool) => `"${tool}" matches no rule — the call passes through.`,
|
|
893
|
+
testBadJson: (text) => `Invalid JSON arguments "${text}": the arguments must parse as JSON.`,
|
|
894
|
+
testUsage: "Usage: /rules test <tool> <json-args>, e.g. /rules test bash {\"command\":\"git push origin main\"}",
|
|
895
|
+
unreachableWarning: (numbers) => `Warning: rule${numbers.length > 1 ? "s" : ""} ${numbers.join(", ")} ${numbers.length > 1 ? "are" : "is"} unreachable (shadowed by an earlier catch-all rule).`,
|
|
896
|
+
emptySource: "(no rule file — empty rule set)"
|
|
897
|
+
};
|
|
898
|
+
const ZH = {
|
|
899
|
+
rulesHeader: (count, sources, cwd) => `权限规则:共 ${count} 条,来自 ${sources.join("、")}(工作区 ${cwd})。`,
|
|
900
|
+
noRules: (cwd, fallbackNote) => `当前没有生效的权限规则:工作区 ${cwd} 未找到规则文件${fallbackNote};空规则集全部放行。`,
|
|
901
|
+
fallbackMissing: "(且配置的回退路径不存在)",
|
|
902
|
+
reloaded: (count, source) => `已重载 ${count} 条规则,来自 ${source}。`,
|
|
903
|
+
reloadFailed: (error) => `重载失败:${error}。之前的规则仍然生效。`,
|
|
904
|
+
lastReloadWarning: (error) => `警告:上次重载失败(${error});以上列出的是之前的规则。`,
|
|
905
|
+
unknownArg: (arg) => `未知的 /rules 参数 "${arg}"。${ZH.usage}`,
|
|
906
|
+
usage: "用法:/rules [reload | decisions [n] | test <工具名> <json-参数>]",
|
|
907
|
+
decisionsHeader: (shown, total) => `本会话最近 ${shown}/${total} 条权限裁决:`,
|
|
908
|
+
noDecisions: "本会话还没有记录任何权限裁决。",
|
|
909
|
+
invalidDecisionsCount: (arg) => `无效的裁决条数 "${arg}":请给正整数(默认 10)。`,
|
|
910
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
911
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : `(规则 ${ruleIndex + 1})`}${reason === void 0 ? "" : `:${short(reason)}`}`;
|
|
912
|
+
},
|
|
913
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" 命中规则 ${ruleIndex + 1}(${action}):${short(reason)}`,
|
|
914
|
+
testNoMatch: (tool) => `"${tool}" 未命中任何规则——该调用将透传。`,
|
|
915
|
+
testBadJson: (text) => `无效的 JSON 参数 "${text}":参数必须能被解析为 JSON。`,
|
|
916
|
+
testUsage: "用法:/rules test <工具名> <json-参数>,例如 /rules test bash {\"command\":\"git push origin main\"}",
|
|
917
|
+
unreachableWarning: (numbers) => `警告:规则 ${numbers.join("、")} 不可达(被前面的通配规则遮蔽)。`,
|
|
918
|
+
emptySource: "(无规则文件——空规则集)"
|
|
919
|
+
};
|
|
920
|
+
const ES = {
|
|
921
|
+
rulesHeader: (count, sources, cwd) => `Reglas de permiso: ${count} regla(s) desde ${sources.join(", ")} (espacio de trabajo ${cwd}).`,
|
|
922
|
+
noRules: (cwd, fallbackNote) => `No hay reglas de permiso activas: no se encontró archivo de reglas para el espacio de trabajo ${cwd}${fallbackNote}; el conjunto vacío lo permite todo.`,
|
|
923
|
+
fallbackMissing: " (y la ruta de respaldo configurada no existe)",
|
|
924
|
+
reloaded: (count, source) => `Recargadas ${count} regla(s) desde ${source}.`,
|
|
925
|
+
reloadFailed: (error) => `Error de recarga: ${error}. Las reglas anteriores siguen activas.`,
|
|
926
|
+
lastReloadWarning: (error) => `Aviso: la última recarga falló (${error}); las reglas mostradas son las anteriores.`,
|
|
927
|
+
unknownArg: (arg) => `Argumento de /rules desconocido "${arg}". ${ES.usage}`,
|
|
928
|
+
usage: "Uso: /rules [reload | decisions [n] | test <herramienta> <json-args>]",
|
|
929
|
+
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisión(es) de permiso de esta sesión:`,
|
|
930
|
+
noDecisions: "Esta sesión aún no registra decisiones de permiso.",
|
|
931
|
+
invalidDecisionsCount: (arg) => `Cantidad de decisiones inválida "${arg}": indique un entero positivo (por defecto 10).`,
|
|
932
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
933
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (regla ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
934
|
+
},
|
|
935
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" coincide con la regla ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
936
|
+
testNoMatch: (tool) => `"${tool}" no coincide con ninguna regla — la llamada pasa sin restricciones.`,
|
|
937
|
+
testBadJson: (text) => `Argumentos JSON inválidos "${text}": los argumentos deben poderse analizar como JSON.`,
|
|
938
|
+
testUsage: "Uso: /rules test <herramienta> <json-args>, p. ej. /rules test bash {\"command\":\"git push origin main\"}",
|
|
939
|
+
unreachableWarning: (numbers) => `Aviso: ${numbers.length > 1 ? "las reglas" : "la regla"} ${numbers.join(", ")} ${numbers.length > 1 ? "son inalcanzables" : "es inalcanzable"} (tapada por una regla general anterior).`,
|
|
940
|
+
emptySource: "(sin archivo de reglas — conjunto vacío)"
|
|
941
|
+
};
|
|
942
|
+
const PT = {
|
|
943
|
+
rulesHeader: (count, sources, cwd) => `Regras de permissão: ${count} regra(s) de ${sources.join(", ")} (workspace ${cwd}).`,
|
|
944
|
+
noRules: (cwd, fallbackNote) => `Nenhuma regra de permissão ativa: nenhum arquivo de regras encontrado para o workspace ${cwd}${fallbackNote}; o conjunto vazio permite tudo.`,
|
|
945
|
+
fallbackMissing: " (e o caminho de fallback configurado não existe)",
|
|
946
|
+
reloaded: (count, source) => `Recarregadas ${count} regra(s) de ${source}.`,
|
|
947
|
+
reloadFailed: (error) => `Falha ao recarregar: ${error}. As regras anteriores continuam ativas.`,
|
|
948
|
+
lastReloadWarning: (error) => `Aviso: a última recarga falhou (${error}); as regras listadas são as anteriores.`,
|
|
949
|
+
unknownArg: (arg) => `Argumento de /rules desconhecido "${arg}". ${PT.usage}`,
|
|
950
|
+
usage: "Uso: /rules [reload | decisions [n] | test <ferramenta> <json-args>]",
|
|
951
|
+
decisionsHeader: (shown, total) => `Últimas ${shown} de ${total} decisão(ões) de permissão desta sessão:`,
|
|
952
|
+
noDecisions: "Esta sessão ainda não registrou decisões de permissão.",
|
|
953
|
+
invalidDecisionsCount: (arg) => `Quantidade de decisões inválida "${arg}": informe um inteiro positivo (padrão 10).`,
|
|
954
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
955
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (regra ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
956
|
+
},
|
|
957
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" corresponde à regra ${ruleIndex + 1} (${action}): ${short(reason)}`,
|
|
958
|
+
testNoMatch: (tool) => `"${tool}" não corresponde a nenhuma regra — a chamada passa livremente.`,
|
|
959
|
+
testBadJson: (text) => `Argumentos JSON inválidos "${text}": os argumentos precisam ser JSON analisável.`,
|
|
960
|
+
testUsage: "Uso: /rules test <ferramenta> <json-args>, ex.: /rules test bash {\"command\":\"git push origin main\"}",
|
|
961
|
+
unreachableWarning: (numbers) => `Aviso: ${numbers.length > 1 ? "as regras" : "a regra"} ${numbers.join(", ")} ${numbers.length > 1 ? "são inalcançáveis" : "é inalcançável"} (encoberta por uma regra geral anterior).`,
|
|
962
|
+
emptySource: "(sem arquivo de regras — conjunto vazio)"
|
|
963
|
+
};
|
|
964
|
+
const HI = {
|
|
965
|
+
rulesHeader: (count, sources, cwd) => `अनुमति नियम: ${count} नियम, स्रोत ${sources.join(", ")} (कार्यक्षेत्र ${cwd})।`,
|
|
966
|
+
noRules: (cwd, fallbackNote) => `कोई अनुमति नियम सक्रिय नहीं: कार्यक्षेत्र ${cwd} के लिए नियम फ़ाइल नहीं मिली${fallbackNote}; खाली नियम-समूह सब कुछ पास कर देता है।`,
|
|
967
|
+
fallbackMissing: " (और कॉन्फ़िगर किया गया फ़ॉलबैक पथ मौजूद नहीं है)",
|
|
968
|
+
reloaded: (count, source) => `${count} नियम पुनः लोड किए गए, स्रोत ${source}।`,
|
|
969
|
+
reloadFailed: (error) => `पुनः लोड विफल: ${error}। पिछले नियम अब भी सक्रिय हैं।`,
|
|
970
|
+
lastReloadWarning: (error) => `चेतावनी: अंतिम पुनः लोड विफल रहा (${error}); ऊपर सूचीबद्ध नियम पिछले वाले हैं।`,
|
|
971
|
+
unknownArg: (arg) => `अज्ञात /rules तर्क "${arg}"। ${HI.usage}`,
|
|
972
|
+
usage: "उपयोग: /rules [reload | decisions [n] | test <टूल> <json-args>]",
|
|
973
|
+
decisionsHeader: (shown, total) => `इस सत्र के अंतिम ${shown}/${total} अनुमति निर्णय:`,
|
|
974
|
+
noDecisions: "इस सत्र में अभी कोई अनुमति निर्णय दर्ज नहीं है।",
|
|
975
|
+
invalidDecisionsCount: (arg) => `अमान्य निर्णय-संख्या "${arg}": एक धनात्मक पूर्णांक दें (डिफ़ॉल्ट 10)।`,
|
|
976
|
+
decisionLine: (seq, action, toolName, ruleIndex, reason) => {
|
|
977
|
+
return `- #${seq} ${action} ${toolName}${ruleIndex === void 0 ? "" : ` (नियम ${ruleIndex + 1})`}${reason === void 0 ? "" : `: ${short(reason)}`}`;
|
|
978
|
+
},
|
|
979
|
+
testHit: (tool, ruleIndex, action, reason) => `"${tool}" नियम ${ruleIndex + 1} से मेल खाता है (${action}): ${short(reason)}`,
|
|
980
|
+
testNoMatch: (tool) => `"${tool}" किसी नियम से मेल नहीं खाता — कॉल बिना रोक पास होगी।`,
|
|
981
|
+
testBadJson: (text) => `अमान्य JSON तर्क "${text}": तर्क JSON के रूप में पार्स होने चाहिए।`,
|
|
982
|
+
testUsage: "उपयोग: /rules test <टूल> <json-args>, जैसे /rules test bash {\"command\":\"git push origin main\"}",
|
|
983
|
+
unreachableWarning: (numbers) => `चेतावनी: नियम ${numbers.join(", ")} अप्राप्य हैं (पहले वाले सर्व-मिलान नियम से ढके हुए)।`,
|
|
984
|
+
emptySource: "(कोई नियम फ़ाइल नहीं — खाली नियम-समूह)"
|
|
985
|
+
};
|
|
986
|
+
/** The localized prose tables by language. */
|
|
987
|
+
const UI_PROSE = {
|
|
988
|
+
en: EN,
|
|
989
|
+
zh: ZH,
|
|
990
|
+
es: ES,
|
|
991
|
+
pt: PT,
|
|
992
|
+
hi: HI
|
|
993
|
+
};
|
|
994
|
+
/** `describeRule` match-dimension tokens by language. */
|
|
995
|
+
const DESCRIBE_TOKENS = {
|
|
996
|
+
en: {
|
|
997
|
+
allTools: "all tools",
|
|
998
|
+
tools: "tools",
|
|
999
|
+
params: "params",
|
|
1000
|
+
paths: "paths",
|
|
1001
|
+
absent: "absent",
|
|
1002
|
+
when: "when",
|
|
1003
|
+
platform: "platform",
|
|
1004
|
+
disabled: "disabled",
|
|
1005
|
+
tags: "tags"
|
|
1006
|
+
},
|
|
1007
|
+
zh: {
|
|
1008
|
+
allTools: "全部工具",
|
|
1009
|
+
tools: "工具",
|
|
1010
|
+
params: "参数",
|
|
1011
|
+
paths: "路径",
|
|
1012
|
+
absent: "缺省键",
|
|
1013
|
+
when: "条件",
|
|
1014
|
+
platform: "平台",
|
|
1015
|
+
disabled: "已禁用",
|
|
1016
|
+
tags: "标签"
|
|
1017
|
+
},
|
|
1018
|
+
es: {
|
|
1019
|
+
allTools: "todas las herramientas",
|
|
1020
|
+
tools: "herramientas",
|
|
1021
|
+
params: "parámetros",
|
|
1022
|
+
paths: "rutas",
|
|
1023
|
+
absent: "ausentes",
|
|
1024
|
+
when: "cuándo",
|
|
1025
|
+
platform: "plataforma",
|
|
1026
|
+
disabled: "deshabilitada",
|
|
1027
|
+
tags: "etiquetas"
|
|
1028
|
+
},
|
|
1029
|
+
pt: {
|
|
1030
|
+
allTools: "todas as ferramentas",
|
|
1031
|
+
tools: "ferramentas",
|
|
1032
|
+
params: "parâmetros",
|
|
1033
|
+
paths: "caminhos",
|
|
1034
|
+
absent: "ausentes",
|
|
1035
|
+
when: "quando",
|
|
1036
|
+
platform: "plataforma",
|
|
1037
|
+
disabled: "desativada",
|
|
1038
|
+
tags: "etiquetas"
|
|
1039
|
+
},
|
|
1040
|
+
hi: {
|
|
1041
|
+
allTools: "सभी टूल",
|
|
1042
|
+
tools: "टूल",
|
|
1043
|
+
params: "पैरामीटर",
|
|
1044
|
+
paths: "पथ",
|
|
1045
|
+
absent: "अनुपस्थित",
|
|
1046
|
+
when: "शर्त",
|
|
1047
|
+
platform: "प्लेटफ़ॉर्म",
|
|
1048
|
+
disabled: "अक्षम",
|
|
1049
|
+
tags: "टैग"
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
//#endregion
|
|
1053
|
+
//#region src/runtime.ts
|
|
1054
|
+
/**
|
|
1055
|
+
* Runtime of `dsh-permission-rules`: per-workspace rule loading (project
|
|
1056
|
+
* file chain by session cwd → fallback path → empty set), the
|
|
1057
|
+
* `tools/pre-execute` listener that turns a first-match hit into a
|
|
1058
|
+
* deny/ask decision (and NEVER short-circuits on allow or passthrough),
|
|
1059
|
+
* the `permissionRules/decision` audit event, the `/rules` session command
|
|
1060
|
+
* (`list | reload | decisions [n] | test <tool> <json>`), and
|
|
1061
|
+
* Chokidar-driven reloads. Every registration is an effect.
|
|
1062
|
+
* @module dsh-permission-rules/runtime
|
|
1063
|
+
*/
|
|
1064
|
+
/**
|
|
1065
|
+
* State and behavior. One instance per plugin mount; disposals are owned by
|
|
1066
|
+
* the watcher/timer effects registered in {@link apply}.
|
|
1067
|
+
*/
|
|
1068
|
+
var PermissionRulesRuntime = class {
|
|
1069
|
+
ctx;
|
|
1070
|
+
config;
|
|
1071
|
+
/** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
|
|
1072
|
+
byCwd = /* @__PURE__ */ new Map();
|
|
1073
|
+
/** Live watchers per rule-file path, with the cwds each serves. */
|
|
1074
|
+
watchers = /* @__PURE__ */ new Map();
|
|
1075
|
+
/** Debounce timers per rule-file path. */
|
|
1076
|
+
timers = /* @__PURE__ */ new Map();
|
|
1077
|
+
constructor(ctx, config) {
|
|
1078
|
+
this.ctx = ctx;
|
|
1079
|
+
this.config = config;
|
|
1080
|
+
}
|
|
1081
|
+
/** The shared compile options derived from config. */
|
|
1082
|
+
compileOptions() {
|
|
1083
|
+
return {
|
|
1084
|
+
patternMode: this.config.patternMode,
|
|
1085
|
+
maxRules: this.config.maxRules,
|
|
1086
|
+
maxGlobStars: this.config.maxGlobStars,
|
|
1087
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths
|
|
1088
|
+
};
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Resolve which files serve a workspace, nearest first: the project file
|
|
1092
|
+
* under the session cwd (or an absolute `rulesFile`), with `searchUp`
|
|
1093
|
+
* also merging every parent directory's file on the way to the root,
|
|
1094
|
+
* else the configured fallback, else `[]` (empty rule set). Nearer files
|
|
1095
|
+
* evaluate first, so a child can override a parent rule.
|
|
1096
|
+
* @param cwd - the session's absolute workspace root.
|
|
1097
|
+
* @returns the absolute rule-file paths in effect, nearest first.
|
|
1098
|
+
*/
|
|
1099
|
+
resolveSources(cwd) {
|
|
1100
|
+
if (this.config.searchUp) {
|
|
1101
|
+
const sources = [];
|
|
1102
|
+
let dir = cwd;
|
|
1103
|
+
for (;;) {
|
|
1104
|
+
const candidate = join(dir, this.config.rulesFile);
|
|
1105
|
+
if (existsSync(candidate)) sources.push(candidate);
|
|
1106
|
+
const parent = dirname(dir);
|
|
1107
|
+
if (parent === dir) break;
|
|
1108
|
+
dir = parent;
|
|
1109
|
+
}
|
|
1110
|
+
return sources.length === 0 ? this.resolveFallback() : sources;
|
|
1111
|
+
}
|
|
1112
|
+
const projectPath = isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile);
|
|
1113
|
+
if (existsSync(projectPath)) return [projectPath];
|
|
1114
|
+
return this.resolveFallback();
|
|
1115
|
+
}
|
|
1116
|
+
/** The configured fallback file when it exists, else `[]`. */
|
|
1117
|
+
resolveFallback() {
|
|
1118
|
+
const fallback = this.config.fallbackPath;
|
|
1119
|
+
if (fallback !== void 0) {
|
|
1120
|
+
const fallbackPath = isAbsolute(fallback) ? fallback : resolve(fallback);
|
|
1121
|
+
if (existsSync(fallbackPath)) return [fallbackPath];
|
|
1122
|
+
}
|
|
1123
|
+
return [];
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Read, parse, and compile the rule-file chain serving `cwd`. Under
|
|
1127
|
+
* `badFilePolicy: 'ignore-with-warning'` a bad file degrades to an empty
|
|
1128
|
+
* rule set with a warning and keeps its source paths (so a later fix is
|
|
1129
|
+
* watched and adopted); under `'fail'` it throws.
|
|
1130
|
+
* @param cwd - the workspace root.
|
|
1131
|
+
* @returns the loaded state.
|
|
1132
|
+
*/
|
|
1133
|
+
load(cwd) {
|
|
1134
|
+
const sources = this.resolveSources(cwd);
|
|
1135
|
+
if (sources.length === 0) return {
|
|
1136
|
+
cwd,
|
|
1137
|
+
sources: [],
|
|
1138
|
+
compiled: {
|
|
1139
|
+
rules: [],
|
|
1140
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
try {
|
|
1144
|
+
const { ruleset } = compileRulesChain(sources.map((path) => ({
|
|
1145
|
+
path,
|
|
1146
|
+
text: readFileSync(path, "utf8")
|
|
1147
|
+
})), this.compileOptions());
|
|
1148
|
+
return {
|
|
1149
|
+
cwd,
|
|
1150
|
+
sources,
|
|
1151
|
+
compiled: ruleset
|
|
1152
|
+
};
|
|
1153
|
+
} catch (error) {
|
|
1154
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1155
|
+
if (this.config.badFilePolicy === "ignore-with-warning") {
|
|
1156
|
+
this.ctx.logger.warn(`permission-rules: ignoring ${sources.join(", ")}: ${message} (empty rule set)`);
|
|
1157
|
+
return {
|
|
1158
|
+
cwd,
|
|
1159
|
+
sources,
|
|
1160
|
+
compiled: {
|
|
1161
|
+
rules: [],
|
|
1162
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths
|
|
1163
|
+
},
|
|
1164
|
+
lastError: message
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
throw error instanceof RuleError ? error : new RuleError(`cannot load ${sources.join(", ")}: ${message}`);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* The rules in effect for one cwd, loading on first use. Under
|
|
1172
|
+
* `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
|
|
1173
|
+
* pending tool call errors loudly) while the watcher keeps observing the
|
|
1174
|
+
* files so a fix reloads into active rules. Cache hits refresh the
|
|
1175
|
+
* least-recently-used position, so eviction drops the workspace that
|
|
1176
|
+
* went longest without a decision.
|
|
1177
|
+
* @param cwd - the workspace root.
|
|
1178
|
+
* @returns the loaded rules.
|
|
1179
|
+
*/
|
|
1180
|
+
rulesFor(cwd) {
|
|
1181
|
+
const existing = this.byCwd.get(cwd);
|
|
1182
|
+
if (existing !== void 0) {
|
|
1183
|
+
if (existing.failed === true) throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`);
|
|
1184
|
+
this.byCwd.delete(cwd);
|
|
1185
|
+
this.byCwd.set(cwd, existing);
|
|
1186
|
+
return existing;
|
|
1187
|
+
}
|
|
1188
|
+
this.evictIfFull(cwd);
|
|
1189
|
+
try {
|
|
1190
|
+
const loaded = this.load(cwd);
|
|
1191
|
+
this.byCwd.set(cwd, loaded);
|
|
1192
|
+
this.reconcileWatch(cwd, loaded.sources);
|
|
1193
|
+
return loaded;
|
|
1194
|
+
} catch (error) {
|
|
1195
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1196
|
+
const sources = this.resolveSources(cwd);
|
|
1197
|
+
this.byCwd.set(cwd, {
|
|
1198
|
+
cwd,
|
|
1199
|
+
sources,
|
|
1200
|
+
compiled: {
|
|
1201
|
+
rules: [],
|
|
1202
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths
|
|
1203
|
+
},
|
|
1204
|
+
lastError: message,
|
|
1205
|
+
failed: true
|
|
1206
|
+
});
|
|
1207
|
+
this.reconcileWatch(cwd, sources);
|
|
1208
|
+
throw error;
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Re-read the rule-file chain for one cwd (watch-driven or
|
|
1213
|
+
* `/rules reload`). A bad file NEVER crashes the process: the previous
|
|
1214
|
+
* rules stay active, the error is logged and reported on the next
|
|
1215
|
+
* `/rules` output.
|
|
1216
|
+
* @param cwd - the workspace root.
|
|
1217
|
+
*/
|
|
1218
|
+
reload(cwd) {
|
|
1219
|
+
const previous = this.byCwd.get(cwd);
|
|
1220
|
+
try {
|
|
1221
|
+
const loaded = this.load(cwd);
|
|
1222
|
+
this.byCwd.set(cwd, loaded);
|
|
1223
|
+
this.reconcileWatch(cwd, loaded.sources);
|
|
1224
|
+
this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(", ") || "(empty rule set)"} for ${cwd}`);
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1227
|
+
this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`);
|
|
1228
|
+
if (previous !== void 0) this.byCwd.set(cwd, {
|
|
1229
|
+
...previous,
|
|
1230
|
+
lastError: message
|
|
1231
|
+
});
|
|
1232
|
+
else this.byCwd.set(cwd, {
|
|
1233
|
+
cwd,
|
|
1234
|
+
sources: this.resolveSources(cwd),
|
|
1235
|
+
compiled: {
|
|
1236
|
+
rules: [],
|
|
1237
|
+
caseInsensitivePaths: this.config.caseInsensitivePaths
|
|
1238
|
+
},
|
|
1239
|
+
lastError: message,
|
|
1240
|
+
failed: true
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
/** The match context `when` conditions evaluate against. */
|
|
1245
|
+
matchContext() {
|
|
1246
|
+
return {
|
|
1247
|
+
platform: process.platform,
|
|
1248
|
+
env: process.env
|
|
1249
|
+
};
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* The `tools/pre-execute` listener. A deny/ask hit returns the decision
|
|
1253
|
+
* (first match wins, short-circuiting downstream listeners); an allow hit
|
|
1254
|
+
* and a passthrough MUST delegate via `next()` so later listeners keep
|
|
1255
|
+
* their say. Audit is appended once the final outcome is known, so the
|
|
1256
|
+
* recorded `outcome` matches what the waterfall settled on.
|
|
1257
|
+
* @param exec - the pending call (name, parsed arguments, caller agent).
|
|
1258
|
+
* @param next - the downstream chain.
|
|
1259
|
+
* @returns the pre-execute decision.
|
|
1260
|
+
*/
|
|
1261
|
+
async preExecute(exec, next) {
|
|
1262
|
+
const cwd = exec.agent?.session.header.cwd ?? process.cwd();
|
|
1263
|
+
const loaded = this.rulesFor(cwd);
|
|
1264
|
+
const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext());
|
|
1265
|
+
if (hit === void 0 || hit.rule.action === "allow") {
|
|
1266
|
+
const decision = await next();
|
|
1267
|
+
this.audit(exec, loaded, hit, decision.kind);
|
|
1268
|
+
return decision;
|
|
1269
|
+
}
|
|
1270
|
+
const outcome = hit.rule.action;
|
|
1271
|
+
this.audit(exec, loaded, hit, outcome);
|
|
1272
|
+
if (outcome === "deny") return {
|
|
1273
|
+
kind: "deny",
|
|
1274
|
+
reason: hit.rule.reason
|
|
1275
|
+
};
|
|
1276
|
+
return {
|
|
1277
|
+
kind: "ask",
|
|
1278
|
+
reason: hit.rule.reason
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Append the log-only `permissionRules/decision` audit event for every
|
|
1283
|
+
* decision (passthrough included unless `audit: 'hits'`), requesting the
|
|
1284
|
+
* envelope's `ignorable: true` marker so any harness build can load the
|
|
1285
|
+
* log (readers that do not know the type skip the audit record instead of
|
|
1286
|
+
* refusing the session). `source` names the matched rule's file, or the
|
|
1287
|
+
* nearest effective file on a passthrough. Agentless calls have no
|
|
1288
|
+
* session to audit; append failures are contained so an audit hiccup can
|
|
1289
|
+
* never change a permission decision.
|
|
1290
|
+
* @param exec - the pending call.
|
|
1291
|
+
* @param loaded - the rules in effect.
|
|
1292
|
+
* @param hit - the first matching rule, or undefined for passthrough.
|
|
1293
|
+
* @param outcome - the final pre-execute decision.
|
|
1294
|
+
*/
|
|
1295
|
+
audit(exec, loaded, hit, outcome) {
|
|
1296
|
+
if (this.config.audit === "hits" && hit === void 0) return;
|
|
1297
|
+
const agent = exec.agent;
|
|
1298
|
+
if (agent === void 0) return;
|
|
1299
|
+
try {
|
|
1300
|
+
agent.session.append("permissionRules/decision", {
|
|
1301
|
+
toolName: exec.name,
|
|
1302
|
+
callId: exec.callId,
|
|
1303
|
+
source: hit === void 0 ? loaded.sources[0] ?? "" : loaded.sources[hit.rule.sourceIndex] ?? "",
|
|
1304
|
+
action: hit === void 0 ? "passthrough" : hit.rule.action,
|
|
1305
|
+
outcome,
|
|
1306
|
+
...hit !== void 0 ? {
|
|
1307
|
+
ruleIndex: hit.ruleIndex,
|
|
1308
|
+
reason: hit.rule.reason
|
|
1309
|
+
} : {}
|
|
1310
|
+
}, { ignorable: true });
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`);
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
/**
|
|
1316
|
+
* Execute the `/rules` command: bare `/rules` lists the active rules and
|
|
1317
|
+
* their sources; `/rules reload` re-reads the chain; `/rules decisions
|
|
1318
|
+
* [n]` shows the session's audit trail; `/rules test <tool> <json>`
|
|
1319
|
+
* dry-evaluates the rules against a hypothetical call. Command output
|
|
1320
|
+
* stays in the UI — nothing here is injected into the model context.
|
|
1321
|
+
* @param invocation - the received command invocation.
|
|
1322
|
+
* @returns the command result shown to the user.
|
|
1323
|
+
*/
|
|
1324
|
+
command(invocation) {
|
|
1325
|
+
const prose = UI_PROSE[this.config.language];
|
|
1326
|
+
const raw = invocation.rawInput.trim();
|
|
1327
|
+
const [verbRaw, ...rest] = raw.split(/\s+/);
|
|
1328
|
+
const verb = (verbRaw ?? "").toLowerCase();
|
|
1329
|
+
const cwd = invocation.agent.session.header.cwd ?? process.cwd();
|
|
1330
|
+
if (verb === "reload") {
|
|
1331
|
+
if (rest.length > 0) return {
|
|
1332
|
+
kind: "error",
|
|
1333
|
+
text: prose.unknownArg(invocation.rawInput.trim())
|
|
1334
|
+
};
|
|
1335
|
+
this.reload(cwd);
|
|
1336
|
+
const reloaded = this.byCwd.get(cwd);
|
|
1337
|
+
if (reloaded?.lastError !== void 0) return {
|
|
1338
|
+
kind: "error",
|
|
1339
|
+
text: prose.reloadFailed(reloaded.lastError)
|
|
1340
|
+
};
|
|
1341
|
+
const rules = reloaded?.compiled.rules ?? [];
|
|
1342
|
+
const source = reloaded === void 0 || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(", ");
|
|
1343
|
+
return {
|
|
1344
|
+
kind: "success",
|
|
1345
|
+
text: prose.reloaded(rules.length, source)
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (verb === "decisions") {
|
|
1349
|
+
if (rest.length > 1) return {
|
|
1350
|
+
kind: "error",
|
|
1351
|
+
text: prose.unknownArg(invocation.rawInput.trim())
|
|
1352
|
+
};
|
|
1353
|
+
let count = 10;
|
|
1354
|
+
if (rest.length === 1) {
|
|
1355
|
+
const parsed = Number(rest[0]);
|
|
1356
|
+
if (!Number.isSafeInteger(parsed) || parsed <= 0) return {
|
|
1357
|
+
kind: "error",
|
|
1358
|
+
text: prose.invalidDecisionsCount(rest[0])
|
|
1359
|
+
};
|
|
1360
|
+
count = parsed;
|
|
1361
|
+
}
|
|
1362
|
+
return this.decisionsCommand(invocation, count, prose);
|
|
1363
|
+
}
|
|
1364
|
+
if (verb === "test") {
|
|
1365
|
+
const verbText = verbRaw ?? "";
|
|
1366
|
+
const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : "";
|
|
1367
|
+
const match = /^(\S+)\s*(.*)$/s.exec(restLine);
|
|
1368
|
+
const tool = match?.[1];
|
|
1369
|
+
const jsonText = match?.[2] ?? "";
|
|
1370
|
+
if (tool === void 0 || tool.length === 0) return {
|
|
1371
|
+
kind: "error",
|
|
1372
|
+
text: prose.testUsage
|
|
1373
|
+
};
|
|
1374
|
+
let args;
|
|
1375
|
+
try {
|
|
1376
|
+
args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
|
|
1377
|
+
} catch {
|
|
1378
|
+
return {
|
|
1379
|
+
kind: "error",
|
|
1380
|
+
text: prose.testBadJson(jsonText)
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
let loaded;
|
|
1384
|
+
try {
|
|
1385
|
+
loaded = this.rulesFor(cwd);
|
|
1386
|
+
} catch (error) {
|
|
1387
|
+
return {
|
|
1388
|
+
kind: "error",
|
|
1389
|
+
text: error instanceof Error ? error.message : String(error)
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext());
|
|
1393
|
+
return {
|
|
1394
|
+
kind: "success",
|
|
1395
|
+
text: hit === void 0 ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason)
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
if (verb !== "") return {
|
|
1399
|
+
kind: "error",
|
|
1400
|
+
text: prose.unknownArg(invocation.rawInput.trim())
|
|
1401
|
+
};
|
|
1402
|
+
let loaded;
|
|
1403
|
+
try {
|
|
1404
|
+
loaded = this.rulesFor(cwd);
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
return {
|
|
1407
|
+
kind: "error",
|
|
1408
|
+
text: error instanceof Error ? error.message : String(error)
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
const lines = [];
|
|
1412
|
+
if (loaded.sources.length === 0) lines.push(prose.noRules(cwd, this.config.fallbackPath !== void 0 ? prose.fallbackMissing : ""));
|
|
1413
|
+
else {
|
|
1414
|
+
lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd));
|
|
1415
|
+
lines.push(...loaded.compiled.rules.map((rule) => describeRule(rule, DESCRIBE_TOKENS[this.config.language])));
|
|
1416
|
+
const unreachable = findUnreachableRules(loaded.compiled);
|
|
1417
|
+
if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map((index) => index + 1)));
|
|
1418
|
+
}
|
|
1419
|
+
if (loaded.lastError !== void 0) lines.push(prose.lastReloadWarning(loaded.lastError));
|
|
1420
|
+
lines.push(prose.usage);
|
|
1421
|
+
return {
|
|
1422
|
+
kind: "success",
|
|
1423
|
+
text: lines.join("\n")
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1426
|
+
/** Render the session's `permissionRules/decision` audit trail, newest last. */
|
|
1427
|
+
decisionsCommand(invocation, count, prose) {
|
|
1428
|
+
const decisions = invocation.agent.session.events.filter((event) => event.type === "permissionRules/decision");
|
|
1429
|
+
if (decisions.length === 0) return {
|
|
1430
|
+
kind: "success",
|
|
1431
|
+
text: prose.noDecisions
|
|
1432
|
+
};
|
|
1433
|
+
const shown = decisions.slice(-count);
|
|
1434
|
+
const lines = [prose.decisionsHeader(shown.length, decisions.length)];
|
|
1435
|
+
for (const event of shown) {
|
|
1436
|
+
const data = event.data;
|
|
1437
|
+
lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason));
|
|
1438
|
+
}
|
|
1439
|
+
return {
|
|
1440
|
+
kind: "success",
|
|
1441
|
+
text: lines.join("\n")
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Validate deployment-level file references at mount — the earliest
|
|
1446
|
+
* resolvable point. An absolute `rulesFile` or a configured
|
|
1447
|
+
* `fallbackPath` must exist and parse; a missing referent fails the mount
|
|
1448
|
+
* loudly instead of silently degrading later.
|
|
1449
|
+
* @throws when a mandated file is missing or invalid.
|
|
1450
|
+
*/
|
|
1451
|
+
validateDeploymentFiles() {
|
|
1452
|
+
if (isAbsolute(this.config.rulesFile)) this.loadForValidation(this.config.rulesFile);
|
|
1453
|
+
if (this.config.fallbackPath !== void 0) {
|
|
1454
|
+
const fallbackPath = isAbsolute(this.config.fallbackPath) ? this.config.fallbackPath : resolve(this.config.fallbackPath);
|
|
1455
|
+
if (!existsSync(fallbackPath)) throw new RuleError(`permission-rules: fallbackPath ${JSON.stringify(this.config.fallbackPath)} does not exist`);
|
|
1456
|
+
this.loadForValidation(fallbackPath);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
/** Parse and compile one mandated file, rethrowing as a mount failure. */
|
|
1460
|
+
loadForValidation(filePath) {
|
|
1461
|
+
try {
|
|
1462
|
+
compileRules(parseRulesDocument(readFileSync(filePath, "utf8")), this.compileOptions());
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1465
|
+
throw error instanceof RuleError ? error : new RuleError(`permission-rules: cannot load ${filePath}: ${message}`);
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
/** Attach (once per file path) the Chokidar watcher feeding {@link reload}. */
|
|
1469
|
+
attachWatch(cwd, source) {
|
|
1470
|
+
if (!this.config.watch || source === "") return;
|
|
1471
|
+
const existing = this.watchers.get(source);
|
|
1472
|
+
if (existing !== void 0) {
|
|
1473
|
+
existing.cwds.add(cwd);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const cwds = /* @__PURE__ */ new Set([cwd]);
|
|
1477
|
+
const watcher = chokidar.watch(source, {
|
|
1478
|
+
persistent: true,
|
|
1479
|
+
ignoreInitial: true
|
|
1480
|
+
});
|
|
1481
|
+
const onEvent = () => this.scheduleReload(source);
|
|
1482
|
+
watcher.on("add", onEvent);
|
|
1483
|
+
watcher.on("change", onEvent);
|
|
1484
|
+
watcher.on("unlink", onEvent);
|
|
1485
|
+
watcher.on("error", (error) => {
|
|
1486
|
+
this.ctx.logger.warn(`permission-rules: watcher error on ${source}: ${String(error)}`);
|
|
1487
|
+
});
|
|
1488
|
+
this.watchers.set(source, {
|
|
1489
|
+
cwds,
|
|
1490
|
+
close: () => {
|
|
1491
|
+
watcher.close().catch((error) => {
|
|
1492
|
+
this.ctx.logger.warn(`permission-rules: failed to close watcher on ${source}: ${String(error)}`);
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
});
|
|
1496
|
+
this.ctx.effect(() => () => {
|
|
1497
|
+
this.watchers.get(source)?.close();
|
|
1498
|
+
this.watchers.delete(source);
|
|
1499
|
+
const timer = this.timers.get(source);
|
|
1500
|
+
if (timer !== void 0) {
|
|
1501
|
+
clearTimeout(timer);
|
|
1502
|
+
this.timers.delete(source);
|
|
1503
|
+
}
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Rebind `cwd` to its current rule-source chain. When a concrete source
|
|
1508
|
+
* left the chain (deleted file, fallback switch), detach the workspace
|
|
1509
|
+
* from watchers serving other sources and close the ones left empty, so
|
|
1510
|
+
* long-running hosts cannot accumulate stale watchers. An empty chain (no
|
|
1511
|
+
* rule file in effect) keeps the previous watchers alive: they own the
|
|
1512
|
+
* "file recreated → adopted" signal, and the workspace-cache bound keeps
|
|
1513
|
+
* the set of such watchers finite.
|
|
1514
|
+
*/
|
|
1515
|
+
reconcileWatch(cwd, sources) {
|
|
1516
|
+
if (sources.length === 0) return;
|
|
1517
|
+
for (const [watchedSource, entry] of this.watchers) if (!sources.includes(watchedSource)) entry.cwds.delete(cwd);
|
|
1518
|
+
this.pruneWatchers();
|
|
1519
|
+
for (const source of sources) this.attachWatch(cwd, source);
|
|
1520
|
+
}
|
|
1521
|
+
/** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
|
|
1522
|
+
pruneWatchers() {
|
|
1523
|
+
for (const [source, entry] of this.watchers) {
|
|
1524
|
+
if (entry.cwds.size > 0) continue;
|
|
1525
|
+
entry.close();
|
|
1526
|
+
this.watchers.delete(source);
|
|
1527
|
+
const timer = this.timers.get(source);
|
|
1528
|
+
if (timer !== void 0) {
|
|
1529
|
+
clearTimeout(timer);
|
|
1530
|
+
this.timers.delete(source);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Bound the per-workspace cache: when a NEW workspace would exceed
|
|
1536
|
+
* `maxCachedWorkspaces`, evict the least-recently-used entry (the Map
|
|
1537
|
+
* head, refreshed by every {@link rulesFor} hit) and release its watcher
|
|
1538
|
+
* slots. Long-lived hosts that visit many workspaces therefore keep a
|
|
1539
|
+
* bounded memory footprint.
|
|
1540
|
+
*/
|
|
1541
|
+
evictIfFull(cwd) {
|
|
1542
|
+
if (this.byCwd.size < this.config.maxCachedWorkspaces || this.byCwd.has(cwd)) return;
|
|
1543
|
+
const oldest = this.byCwd.keys().next().value;
|
|
1544
|
+
if (oldest === void 0) return;
|
|
1545
|
+
this.byCwd.delete(oldest);
|
|
1546
|
+
for (const entry of this.watchers.values()) entry.cwds.delete(oldest);
|
|
1547
|
+
this.pruneWatchers();
|
|
1548
|
+
}
|
|
1549
|
+
/** Number of live watchers, for observability and tests. */
|
|
1550
|
+
activeWatcherCount() {
|
|
1551
|
+
return this.watchers.size;
|
|
1552
|
+
}
|
|
1553
|
+
/** Number of pending debounce timers, for observability and tests. */
|
|
1554
|
+
pendingReloadCount() {
|
|
1555
|
+
return this.timers.size;
|
|
1556
|
+
}
|
|
1557
|
+
/** Debounce watch events into one reload per stability window. */
|
|
1558
|
+
scheduleReload(source) {
|
|
1559
|
+
const existing = this.timers.get(source);
|
|
1560
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
1561
|
+
const timer = setTimeout(() => {
|
|
1562
|
+
this.timers.delete(source);
|
|
1563
|
+
for (const cwd of this.watchers.get(source)?.cwds ?? []) this.reload(cwd);
|
|
1564
|
+
}, this.config.watchStabilityThresholdMs);
|
|
1565
|
+
this.timers.set(source, timer);
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
/**
|
|
1569
|
+
* Mount the plugin: resolve config, validate deployment-level rule files,
|
|
1570
|
+
* register the pre-execute listener and the `/rules` command.
|
|
1571
|
+
* @param ctx - the host context.
|
|
1572
|
+
* @param config - raw plugin config.
|
|
1573
|
+
*/
|
|
1574
|
+
function apply(ctx, config) {
|
|
1575
|
+
const runtime = new PermissionRulesRuntime(ctx, resolveConfig(config));
|
|
1576
|
+
runtime.validateDeploymentFiles();
|
|
1577
|
+
ctx.provide("permissionRulesRuntime", runtime);
|
|
1578
|
+
ctx.on("tools/pre-execute", (exec, next) => runtime.preExecute(exec, next));
|
|
1579
|
+
ctx.commands.register({
|
|
1580
|
+
name: "rules",
|
|
1581
|
+
description: "list, reload, audit, or dry-test the active permission rules for this workspace",
|
|
1582
|
+
input: { hint: "[reload | decisions [n] | test <tool> <json-args>]" },
|
|
1583
|
+
handler: (invocation) => runtime.command(invocation)
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
//#endregion
|
|
1587
|
+
//#region src/index.ts
|
|
1588
|
+
/**
|
|
1589
|
+
* `dsh-permission-rules` — Claude Code-style declarative permission rules
|
|
1590
|
+
* for DeepSeek Harness. A `tools/pre-execute` waterfall listener evaluates
|
|
1591
|
+
* an ordered `allow`/`deny`/`ask` rule list (tool-name globs, argument
|
|
1592
|
+
* glob/regex matching, workspace-path matching) per session cwd; deny and
|
|
1593
|
+
* ask decisions short-circuit the chain, while allow and passthrough always
|
|
1594
|
+
* delegate via `next()`. `ask` rides the official approval seam, where
|
|
1595
|
+
* answerers such as `dsh-auto-review` or a human UI decide — the plugin
|
|
1596
|
+
* itself never runs a reviewer. Every decision is audit-logged.
|
|
1597
|
+
*
|
|
1598
|
+
* Function plugin — no default export (the Loader unwraps
|
|
1599
|
+
* `exports.default ?? exports`, and a stray default would discard
|
|
1600
|
+
* `name`/`inject`/`Config`/`apply`).
|
|
1601
|
+
* @module dsh-permission-rules
|
|
1602
|
+
*/
|
|
1603
|
+
const name = "permission-rules";
|
|
1604
|
+
const inject = ["commands", "tools"];
|
|
1605
|
+
//#endregion
|
|
1606
|
+
export { Config, GlobError, PATH_CANDIDATE_KEYS, PLATFORMS, PermissionRulesRuntime, RULE_ACTIONS, RuleError, apply, compileGlob, compilePatternRegex, compileRules, compileRulesChain, describeRule, extractPathCandidates, findUnreachableRules, inject, matchRules, name, normalizeWorkspacePath, parseRulesDocument, resolveConfig };
|