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.
Files changed (49) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +182 -0
  4. package/README.hi.md +182 -0
  5. package/README.md +182 -0
  6. package/README.pt.md +182 -0
  7. package/README.zh.md +182 -0
  8. package/cordis.patch.yml +16 -0
  9. package/docs/rules-format.en.md +105 -0
  10. package/docs/rules-format.md +103 -0
  11. package/docs/rules-format.schema.json +117 -0
  12. package/lib/index.js +1606 -0
  13. package/lib/types/config.d.ts +83 -0
  14. package/lib/types/config.d.ts.map +1 -0
  15. package/lib/types/config.js +72 -0
  16. package/lib/types/config.js.map +1 -0
  17. package/lib/types/events.d.ts +68 -0
  18. package/lib/types/events.d.ts.map +1 -0
  19. package/lib/types/events.js +21 -0
  20. package/lib/types/events.js.map +1 -0
  21. package/lib/types/glob.d.ts +55 -0
  22. package/lib/types/glob.d.ts.map +1 -0
  23. package/lib/types/glob.js +307 -0
  24. package/lib/types/glob.js.map +1 -0
  25. package/lib/types/index.d.ts +25 -0
  26. package/lib/types/index.d.ts.map +1 -0
  27. package/lib/types/index.js +25 -0
  28. package/lib/types/index.js.map +1 -0
  29. package/lib/types/prose.d.ts +66 -0
  30. package/lib/types/prose.d.ts.map +1 -0
  31. package/lib/types/prose.js +143 -0
  32. package/lib/types/prose.js.map +1 -0
  33. package/lib/types/rules.d.ts +226 -0
  34. package/lib/types/rules.d.ts.map +1 -0
  35. package/lib/types/rules.js +581 -0
  36. package/lib/types/rules.js.map +1 -0
  37. package/lib/types/runtime.d.ts +175 -0
  38. package/lib/types/runtime.d.ts.map +1 -0
  39. package/lib/types/runtime.js +497 -0
  40. package/lib/types/runtime.js.map +1 -0
  41. package/package.json +103 -0
  42. package/scripts/repair-session-logs.mjs +327 -0
  43. package/src/config.ts +141 -0
  44. package/src/events.ts +75 -0
  45. package/src/glob.ts +303 -0
  46. package/src/index.ts +27 -0
  47. package/src/prose.ts +206 -0
  48. package/src/rules.ts +695 -0
  49. 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 };