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/src/rules.ts ADDED
@@ -0,0 +1,695 @@
1
+ /**
2
+ * The pure rule vocabulary: parsing a `.dsh/rules.yaml` document into a
3
+ * validated {@link RulesFileDoc}, compiling patterns, and first-match
4
+ * evaluation. Every function here is pure — no filesystem, clock, or
5
+ * process state — so the parser, the matcher, and the failure modes are all
6
+ * unit-testable and replayable.
7
+ * @module dsh-permission-rules/rules
8
+ */
9
+
10
+ import { parse } from 'yaml'
11
+ import { compileGlob, compilePatternRegex } from './glob.ts'
12
+
13
+ /** What one matched rule does to a pending tool call. */
14
+ export type RuleAction = 'allow' | 'deny' | 'ask'
15
+
16
+ /** How param, path, and env patterns are interpreted. */
17
+ export type PatternMode = 'glob' | 'regex'
18
+
19
+ /** Closed action list for runtime normalization of untrusted values. */
20
+ export const RULE_ACTIONS: readonly RuleAction[] = ['allow', 'deny', 'ask']
21
+
22
+ /** Platform names a `when.platform` entry may name (Node `process.platform` values). */
23
+ export const PLATFORMS: readonly string[] = ['aix', 'android', 'darwin', 'freebsd', 'linux', 'openbsd', 'sunos', 'win32']
24
+
25
+ /** Recursion bound for nested argument walks (path candidates and scalar leaves). */
26
+ const MAX_ARGUMENT_DEPTH = 8
27
+
28
+ /** Display-truncation length for reasons/descriptions in `/rules` output. */
29
+ const DISPLAY_TRUNCATE = 120
30
+
31
+ /**
32
+ * Raised when a rule document cannot be parsed or compiled. The message
33
+ * names the failing rule and field; loading code maps it onto the
34
+ * `badFilePolicy`.
35
+ */
36
+ export class RuleError extends Error {
37
+ constructor(message: string) {
38
+ super(message)
39
+ this.name = 'RuleError'
40
+ }
41
+ }
42
+
43
+ /** A parsed, shape-validated rule document (patterns not yet compiled). */
44
+ export interface RulesFileDoc {
45
+ rules: RuleDocEntry[]
46
+ }
47
+
48
+ /** One parsed rule: match dimensions plus the action, reason, and metadata. */
49
+ export interface RuleDocEntry {
50
+ match: RuleMatchDoc
51
+ action: RuleAction
52
+ reason: string
53
+ /** `false` keeps the rule visible but inert (displayed as disabled). */
54
+ enabled: boolean
55
+ /** Optional one-line explanation shown by `/rules`. */
56
+ description?: string
57
+ /** Free-form labels shown by `/rules`. */
58
+ tags: string[]
59
+ }
60
+
61
+ /** The parsed match dimensions; absent dimensions are empty (= no restriction). */
62
+ export interface RuleMatchDoc {
63
+ /** Tool-name globs; empty matches every tool. */
64
+ tools: string[]
65
+ /** Param key → patterns; EVERY key must be present and match (AND). A `!`-prefixed pattern negates. */
66
+ params: Record<string, string[]>
67
+ /** Workspace-path globs/regexes; ANY candidate matching ANY pattern satisfies the dimension. */
68
+ paths: string[]
69
+ /** Argument keys that must be ABSENT; every listed key must be missing. */
70
+ absent: string[]
71
+ /** Environment/platform conditions; every listed env key must be present and match. */
72
+ when: WhenDoc
73
+ }
74
+
75
+ /** The parsed `when` conditions. */
76
+ export interface WhenDoc {
77
+ /** Env var name → glob/regex patterns; EVERY key must be present and match (AND). */
78
+ env: Record<string, string[]>
79
+ /** Platform names; ANY match satisfies the dimension. */
80
+ platform: string[]
81
+ }
82
+
83
+ /** One compiled rule, ready for the `tools/pre-execute` hot path. */
84
+ export interface CompiledRule {
85
+ /** 0-based position in the merged rule chain. */
86
+ readonly index: number
87
+ /** 0-based index into the merged source-chain paths (audit attribution). */
88
+ readonly sourceIndex: number
89
+ readonly action: RuleAction
90
+ readonly reason: string
91
+ readonly enabled: boolean
92
+ readonly description?: string
93
+ readonly tags: readonly string[]
94
+ readonly tools: readonly RegExp[]
95
+ /** Param key → positive/negative pattern lists. */
96
+ readonly params: ReadonlyMap<string, { positive: readonly RegExp[]; negative: readonly RegExp[] }>
97
+ readonly paths: readonly RegExp[]
98
+ readonly absent: readonly string[]
99
+ readonly when?: {
100
+ readonly env: ReadonlyMap<string, readonly RegExp[]>
101
+ readonly platform: readonly string[]
102
+ }
103
+ /** The original source strings, kept for the `/rules` display. */
104
+ readonly source: RuleDocEntry
105
+ }
106
+
107
+ /** A compiled, size-capped ruleset. */
108
+ export interface CompiledRuleset {
109
+ readonly rules: readonly CompiledRule[]
110
+ /** Whether path patterns (and workspace-path normalization) ignore ASCII case. */
111
+ readonly caseInsensitivePaths: boolean
112
+ }
113
+
114
+ /** Compile options for {@link compileRules}. */
115
+ export interface CompileOptions {
116
+ /** Glob or regex interpretation of `params`/`paths`/`when.env` patterns. */
117
+ readonly patternMode: PatternMode
118
+ /** Hard cap on rule count; exceeding it fails the compile loudly. */
119
+ readonly maxRules: number
120
+ /** Hard cap on unbounded glob-star quantifiers per pattern (ReDoS degree bound). */
121
+ readonly maxGlobStars: number
122
+ /** Whether path patterns compile with the `i` flag. */
123
+ readonly caseInsensitivePaths: boolean
124
+ }
125
+
126
+ /** One rule-file text for {@link compileRulesChain}. */
127
+ export interface RulesChainEntry {
128
+ /** Absolute rule-file path, for audit attribution and error messages. */
129
+ readonly path: string
130
+ /** The raw YAML text. */
131
+ readonly text: string
132
+ }
133
+
134
+ /** Host facts a `when` dimension evaluates against. */
135
+ export interface MatchContext {
136
+ /** Node platform name; defaults to `process.platform` when omitted. */
137
+ readonly platform?: string
138
+ /** Environment table; defaults to `process.env` when omitted. */
139
+ readonly env?: Readonly<Record<string, string | undefined>>
140
+ }
141
+
142
+ /**
143
+ * Parse one rules file body. Unknown document/rule/match fields, invalid
144
+ * actions, missing reasons, and wrong-shaped collections all throw
145
+ * {@link RuleError} — a malformed file fails loudly at load, never silently.
146
+ * @param text - the raw YAML text.
147
+ * @returns the validated document.
148
+ */
149
+ export function parseRulesDocument(text: string): RulesFileDoc {
150
+ const parsed = parseYaml(text)
151
+ // An empty (or comment-only) file is a valid empty rule set.
152
+ if (parsed === null || parsed === undefined) return { rules: [] }
153
+ const root = asRecord(parsed, 'rules document root')
154
+ const unknownRoot = Object.keys(root).filter(key => key !== 'rules')
155
+ if (unknownRoot.length > 0) {
156
+ throw new RuleError(`unknown top-level field${unknownRoot.length > 1 ? 's' : ''} ${unknownRoot.map(k => JSON.stringify(k)).join(', ')} (only "rules" is allowed)`)
157
+ }
158
+ const rawRules = root['rules']
159
+ if (rawRules === undefined) return { rules: [] }
160
+ if (!Array.isArray(rawRules)) throw new RuleError('"rules" must be a list')
161
+ return { rules: rawRules.map((raw, index) => parseRuleEntry(raw, index)) }
162
+ }
163
+
164
+ /**
165
+ * Compile a parsed document for the hot path: pattern strings become
166
+ * RegExps (invalid globs/regexes throw here, at load), and the rule count
167
+ * is capped by `maxRules`.
168
+ * @param doc - the parsed document.
169
+ * @param options - pattern mode, caps, and case handling.
170
+ * @returns the compiled ruleset (every rule attributed to source 0).
171
+ */
172
+ export function compileRules(doc: RulesFileDoc, options: CompileOptions): CompiledRuleset {
173
+ if (doc.rules.length > options.maxRules) {
174
+ throw new RuleError(`rule count ${doc.rules.length} exceeds maxRules ${options.maxRules}`)
175
+ }
176
+ return {
177
+ rules: doc.rules.map((entry, index) => compileRule(entry, index, 0, options)),
178
+ caseInsensitivePaths: options.caseInsensitivePaths,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Parse and compile an ordered source chain (nearest file first). Rules
184
+ * keep per-file order and are evaluated in chain order, so a nearer file
185
+ * can override a farther one on first-match semantics; `sourceIndex`
186
+ * attributes every rule to its file. The TOTAL rule count is capped by
187
+ * `maxRules`.
188
+ * @param entries - the rule-file texts, nearest file first.
189
+ * @param options - compile options.
190
+ * @returns the merged ruleset and the source paths it references.
191
+ */
192
+ export function compileRulesChain(entries: readonly RulesChainEntry[], options: CompileOptions): { ruleset: CompiledRuleset; sources: string[] } {
193
+ const sources = entries.map(entry => entry.path)
194
+ const merged: CompiledRule[] = []
195
+ for (const entry of entries) {
196
+ const doc = parseRulesDocument(entry.text)
197
+ for (const rule of doc.rules) {
198
+ merged.push(compileRule(rule, merged.length, sources.indexOf(entry.path), options))
199
+ }
200
+ }
201
+ if (merged.length > options.maxRules) {
202
+ throw new RuleError(`rule count ${merged.length} exceeds maxRules ${options.maxRules}`)
203
+ }
204
+ return { ruleset: { rules: merged, caseInsensitivePaths: options.caseInsensitivePaths }, sources }
205
+ }
206
+
207
+ /** Compile one parsed rule into its hot-path form. */
208
+ function compileRule(entry: RuleDocEntry, index: number, sourceIndex: number, options: CompileOptions): CompiledRule {
209
+ const tools = entry.match.tools.map(pattern => compileToolPattern(pattern, options.maxGlobStars))
210
+ const params = new Map<string, { positive: readonly RegExp[]; negative: readonly RegExp[] }>()
211
+ for (const [key, patterns] of Object.entries(entry.match.params)) {
212
+ params.set(key, compileParamPatterns(key, patterns, options))
213
+ }
214
+ const paths = entry.match.paths.map(pattern => compilePathPattern(pattern, options))
215
+ const env = new Map<string, readonly RegExp[]>()
216
+ for (const [key, patterns] of Object.entries(entry.match.when.env)) {
217
+ env.set(key, patterns.map(pattern => compileValuePattern(pattern, options.patternMode, options.maxGlobStars)))
218
+ }
219
+ return {
220
+ index,
221
+ sourceIndex,
222
+ action: entry.action,
223
+ reason: entry.reason,
224
+ enabled: entry.enabled,
225
+ ...(entry.description !== undefined ? { description: entry.description } : {}),
226
+ tags: entry.tags,
227
+ tools,
228
+ params,
229
+ paths,
230
+ absent: entry.match.absent,
231
+ ...(env.size === 0 && entry.match.when.platform.length === 0 ? {} : { when: { env, platform: entry.match.when.platform } }),
232
+ source: entry,
233
+ }
234
+ }
235
+
236
+ /** A matched rule: its index and the compiled rule that fired. */
237
+ export interface RuleHit {
238
+ /** 0-based index of the first matching rule. */
239
+ readonly ruleIndex: number
240
+ readonly rule: CompiledRule
241
+ }
242
+
243
+ /**
244
+ * Evaluate rules in order against one pending tool call. First match wins;
245
+ * no match returns `undefined` (passthrough). Pure and synchronous — this
246
+ * is the `tools/pre-execute` hot path, so it runs no I/O.
247
+ * @param ruleset - compiled rules.
248
+ * @param toolName - the tool being called.
249
+ * @param args - the parsed (JSON) tool arguments; non-object values satisfy
250
+ * only tools/when/absent rules.
251
+ * @param cwd - the session's absolute workspace root, for path normalization.
252
+ * @param context - host facts for `when` evaluation; defaults resolve at match time.
253
+ * @returns the first hit, or undefined when every rule passed.
254
+ */
255
+ export function matchRules(
256
+ ruleset: CompiledRuleset,
257
+ toolName: string,
258
+ args: unknown,
259
+ cwd: string,
260
+ context: MatchContext = {},
261
+ ): RuleHit | undefined {
262
+ const argRecord = isRecord(args) ? args : undefined
263
+ for (const rule of ruleset.rules) {
264
+ if (!rule.enabled) continue
265
+ if (!matchTools(rule, toolName)) continue
266
+ if (!matchWhen(rule, context)) continue
267
+ if (rule.params.size > 0 && (argRecord === undefined || !matchParams(rule, argRecord))) continue
268
+ if (rule.absent.length > 0 && !matchAbsent(rule, argRecord)) continue
269
+ if (rule.paths.length > 0 && (argRecord === undefined || !matchPaths(rule, argRecord, cwd, ruleset.caseInsensitivePaths))) continue
270
+ return { ruleIndex: rule.index, rule }
271
+ }
272
+ return undefined
273
+ }
274
+
275
+ /**
276
+ * Indices of rules that can never fire: every ENABLED rule after the first
277
+ * enabled rule whose every dimension is empty (an unconditional catch-all).
278
+ * Disabled rules are excluded from both roles. Conservative on purpose —
279
+ * pattern-set inclusion across globs is not analyzed.
280
+ * @param ruleset - the compiled rules.
281
+ * @returns 0-based indices of unreachable rules.
282
+ */
283
+ export function findUnreachableRules(ruleset: CompiledRuleset): number[] {
284
+ const unreachable: number[] = []
285
+ let catchAllSeen = false
286
+ for (const rule of ruleset.rules) {
287
+ if (!rule.enabled) continue
288
+ if (catchAllSeen) {
289
+ unreachable.push(rule.index)
290
+ continue
291
+ }
292
+ if (isCatchAll(rule)) catchAllSeen = true
293
+ }
294
+ return unreachable
295
+ }
296
+
297
+ /** Whether every match dimension of one rule is empty. */
298
+ function isCatchAll(rule: CompiledRule): boolean {
299
+ return rule.tools.length === 0 && rule.params.size === 0 && rule.paths.length === 0 && rule.absent.length === 0 && rule.when === undefined
300
+ }
301
+
302
+ /**
303
+ * One-line human summary of a compiled rule, for `/rules` output. The
304
+ * 1-based number, action, match dimensions, and reason render first; a
305
+ * disabled marker, tags, and a truncated description follow.
306
+ * @param rule - the compiled rule.
307
+ * @param tokens - dimension-prefix vocabulary for the configured language.
308
+ * @returns a single display line.
309
+ */
310
+ export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string {
311
+ const parts: string[] = []
312
+ if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(',')}`)
313
+ const params = Object.entries(rule.source.match.params)
314
+ .map(([key, patterns]) => `${key}=${patterns.join('|')}`)
315
+ .join(' ')
316
+ if (params.length > 0) parts.push(`${tokens.params}:${params}`)
317
+ if (rule.source.match.paths.length > 0) parts.push(`${tokens.paths}:${rule.source.match.paths.join(',')}`)
318
+ if (rule.source.match.absent.length > 0) parts.push(`${tokens.absent}:${rule.source.match.absent.join(',')}`)
319
+ const whenParts = Object.entries(rule.source.match.when.env)
320
+ .map(([key, patterns]) => `${key}=${patterns.join('|')}`)
321
+ .join(' ')
322
+ const platformPart = rule.source.match.when.platform.length > 0 ? `${tokens.platform}:${rule.source.match.when.platform.join(',')}` : ''
323
+ if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? ' ' : ''}${platformPart}`)
324
+ const match = parts.length > 0 ? `[${parts.join(' ')}]` : `[${tokens.allTools}]`
325
+ const disabled = rule.enabled ? '' : ` (${tokens.disabled})`
326
+ const description = rule.description !== undefined ? ` (${truncate(rule.description, DISPLAY_TRUNCATE)})` : ''
327
+ const tags = rule.tags.length > 0 ? ` [${tokens.tags}:${rule.tags.join(',')}]` : ''
328
+ return `${rule.index + 1}. ${rule.action}${disabled} ${match}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`
329
+ }
330
+
331
+ /** The localized dimension-prefix vocabulary {@link describeRule} renders. */
332
+ export interface DescribeTokens {
333
+ readonly allTools: string
334
+ readonly tools: string
335
+ readonly params: string
336
+ readonly paths: string
337
+ readonly absent: string
338
+ readonly when: string
339
+ readonly platform: string
340
+ readonly disabled: string
341
+ readonly tags: string
342
+ }
343
+
344
+ /** Cut a display string at `limit` characters, marking truncation with `…`. */
345
+ function truncate(text: string, limit: number): string {
346
+ return text.length > limit ? `${text.slice(0, limit)}…` : text
347
+ }
348
+
349
+ /** Whether a rule's tools dimension selects the tool. */
350
+ function matchTools(rule: CompiledRule, toolName: string): boolean {
351
+ if (rule.tools.length === 0) return true
352
+ return rule.tools.some(regex => regex.test(toolName))
353
+ }
354
+
355
+ /** Whether a rule's params dimension holds: EVERY key present and matching. */
356
+ function matchParams(rule: CompiledRule, args: Record<string, unknown>): boolean {
357
+ for (const [key, { positive, negative }] of rule.params) {
358
+ const value = args[key]
359
+ if (value === undefined) return false
360
+ const candidates = scalarCandidates(value, 0)
361
+ if (candidates.length === 0) return false
362
+ if (positive.length > 0 && !candidates.some(candidate => positive.some(regex => regex.test(candidate)))) return false
363
+ if (negative.length > 0 && candidates.some(candidate => negative.some(regex => regex.test(candidate)))) return false
364
+ }
365
+ return true
366
+ }
367
+
368
+ /** Whether a rule's absent dimension holds: EVERY listed key missing. */
369
+ function matchAbsent(rule: CompiledRule, argRecord: Record<string, unknown> | undefined): boolean {
370
+ for (const key of rule.absent) {
371
+ if (argRecord !== undefined && argRecord[key] !== undefined) return false
372
+ }
373
+ return true
374
+ }
375
+
376
+ /** Whether a rule's when dimension holds for the host facts. */
377
+ function matchWhen(rule: CompiledRule, context: MatchContext): boolean {
378
+ const when = rule.when
379
+ if (when === undefined) return true
380
+ if (when.platform.length > 0 && !when.platform.includes(context.platform ?? process.platform)) return false
381
+ const env = context.env ?? process.env
382
+ for (const [key, patterns] of when.env) {
383
+ const value = env[key]
384
+ if (value === undefined) return false
385
+ if (!patterns.some(regex => regex.test(value))) return false
386
+ }
387
+ return true
388
+ }
389
+
390
+ /** Whether a rule's paths dimension holds: at least one candidate matches one pattern. */
391
+ function matchPaths(rule: CompiledRule, args: Record<string, unknown>, cwd: string, caseInsensitive: boolean): boolean {
392
+ const candidates = extractPathCandidates(args)
393
+ .map(candidate => normalizeWorkspacePath(cwd, candidate, caseInsensitive))
394
+ .filter((candidate): candidate is string => candidate.length > 0)
395
+ if (candidates.length === 0) return false
396
+ return candidates.some(candidate => rule.paths.some(regex => regex.test(candidate)))
397
+ }
398
+
399
+ /** Tool-name patterns are always globs (a tool name is not a path). */
400
+ function compileToolPattern(pattern: string, maxGlobStars: number): RegExp {
401
+ return compileGlob(pattern, { segments: false, maxStars: maxGlobStars })
402
+ }
403
+
404
+ /** Param-value patterns follow the configured mode; `/` is an ordinary character. */
405
+ function compileValuePattern(pattern: string, mode: PatternMode, maxGlobStars: number): RegExp {
406
+ return mode === 'regex' ? compilePatternRegex(pattern) : compileGlob(pattern, { segments: false, maxStars: maxGlobStars })
407
+ }
408
+
409
+ /** Path patterns follow the configured mode with path-segment glob semantics. */
410
+ function compilePathPattern(pattern: string, options: CompileOptions): RegExp {
411
+ return options.patternMode === 'regex'
412
+ ? compilePatternRegex(pattern)
413
+ : compileGlob(pattern, { segments: true, maxStars: options.maxGlobStars, caseInsensitive: options.caseInsensitivePaths })
414
+ }
415
+
416
+ /**
417
+ * Split one param key's patterns into positive and negative lists. A
418
+ * leading `!` negates: the value must NOT match any negated pattern; a bare
419
+ * `!` is a load-time error.
420
+ */
421
+ function compileParamPatterns(key: string, patterns: readonly string[], options: CompileOptions): { positive: readonly RegExp[]; negative: readonly RegExp[] } {
422
+ const positive: RegExp[] = []
423
+ const negative: RegExp[] = []
424
+ for (const pattern of patterns) {
425
+ if (pattern.startsWith('!')) {
426
+ const body = pattern.slice(1)
427
+ if (body.length === 0) throw new RuleError(`param key "${key}" has an empty negated pattern`)
428
+ negative.push(compileValuePattern(body, options.patternMode, options.maxGlobStars))
429
+ } else {
430
+ positive.push(compileValuePattern(pattern, options.patternMode, options.maxGlobStars))
431
+ }
432
+ }
433
+ return { positive, negative }
434
+ }
435
+
436
+ // --- YAML/shape validation -------------------------------------------------
437
+
438
+ /** Parse YAML through the `yaml` dependency, mapping parse failures onto {@link RuleError}. */
439
+ function parseYaml(text: string): unknown {
440
+ try {
441
+ return parse(text)
442
+ } catch (error) {
443
+ throw new RuleError(`invalid YAML: ${String(error)}`)
444
+ }
445
+ }
446
+
447
+ /** Validate one raw rule entry from the YAML document. */
448
+ function parseRuleEntry(raw: unknown, index: number): RuleDocEntry {
449
+ const at = `rule ${index + 1}`
450
+ const record = asRecord(raw, at)
451
+ const unknownFields = Object.keys(record).filter(key => key !== 'match' && key !== 'action' && key !== 'reason' && key !== 'enabled' && key !== 'description' && key !== 'tags')
452
+ if (unknownFields.length > 0) {
453
+ throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: match, action, reason, enabled, description, tags)`)
454
+ }
455
+ const action = record['action']
456
+ if (typeof action !== 'string' || !RULE_ACTIONS.includes(action as RuleAction)) {
457
+ throw new RuleError(`${at}: action must be one of ${RULE_ACTIONS.map(a => JSON.stringify(a)).join(' | ')}, got ${JSON.stringify(action)}`)
458
+ }
459
+ const reason = record['reason']
460
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
461
+ throw new RuleError(`${at}: reason must be a non-empty string`)
462
+ }
463
+ const enabled = record['enabled']
464
+ if (enabled !== undefined && typeof enabled !== 'boolean') {
465
+ throw new RuleError(`${at}: enabled must be a boolean, got ${typeof enabled}`)
466
+ }
467
+ const description = record['description']
468
+ if (description !== undefined && (typeof description !== 'string' || description.trim().length === 0)) {
469
+ throw new RuleError(`${at}: description must be a non-empty string`)
470
+ }
471
+ const match = parseMatch(record['match'], at)
472
+ return { match, action: action as RuleAction, reason, enabled: enabled !== false, ...(description !== undefined ? { description } : {}), tags: parseStringList(record['tags'], `${at}.tags`) }
473
+ }
474
+
475
+ /** Validate the `match` block of one rule. */
476
+ function parseMatch(raw: unknown, at: string): RuleMatchDoc {
477
+ if (raw === undefined) return { tools: [], params: {}, paths: [], absent: [], when: { env: {}, platform: [] } }
478
+ const record = asRecord(raw, `${at}.match`)
479
+ const unknownFields = Object.keys(record).filter(key => key !== 'tools' && key !== 'params' && key !== 'paths' && key !== 'absent' && key !== 'when')
480
+ if (unknownFields.length > 0) {
481
+ throw new RuleError(`${at}.match: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: tools, params, paths, absent, when)`)
482
+ }
483
+ return {
484
+ tools: parseStringList(record['tools'], `${at}.match.tools`),
485
+ params: parseParams(record['params'], `${at}.match.params`),
486
+ paths: parseStringList(record['paths'], `${at}.match.paths`),
487
+ absent: parseStringList(record['absent'], `${at}.match.absent`),
488
+ when: parseWhen(record['when'], `${at}.match.when`),
489
+ }
490
+ }
491
+
492
+ /** Validate the params map: non-empty string keys → one pattern or a pattern list. */
493
+ function parseParams(raw: unknown, at: string): Record<string, string[]> {
494
+ if (raw === undefined) return {}
495
+ const record = asRecord(raw, at)
496
+ const out: Record<string, string[]> = {}
497
+ for (const [key, value] of Object.entries(record)) {
498
+ if (key.length === 0) throw new RuleError(`${at}: param keys must be non-empty strings`)
499
+ const patterns = parsePatternValues(value, `${at}.${key}`)
500
+ if (patterns.length === 0) throw new RuleError(`${at}.${key}: param patterns must be non-empty`)
501
+ out[key] = patterns
502
+ }
503
+ return out
504
+ }
505
+
506
+ /** Validate the `when` block: env patterns and a closed platform vocabulary. */
507
+ function parseWhen(raw: unknown, at: string): WhenDoc {
508
+ if (raw === undefined) return { env: {}, platform: [] }
509
+ const record = asRecord(raw, at)
510
+ const unknownFields = Object.keys(record).filter(key => key !== 'env' && key !== 'platform')
511
+ if (unknownFields.length > 0) {
512
+ throw new RuleError(`${at}: unknown field${unknownFields.length > 1 ? 's' : ''} ${unknownFields.map(k => JSON.stringify(k)).join(', ')} (allowed: env, platform)`)
513
+ }
514
+ const env: Record<string, string[]> = {}
515
+ const rawEnv = record['env']
516
+ if (rawEnv !== undefined) {
517
+ const envRecord = asRecord(rawEnv, `${at}.env`)
518
+ for (const [key, value] of Object.entries(envRecord)) {
519
+ if (key.length === 0) throw new RuleError(`${at}.env: env var names must be non-empty strings`)
520
+ const patterns = parsePatternValues(value, `${at}.env.${key}`)
521
+ if (patterns.length === 0) throw new RuleError(`${at}.env.${key}: env patterns must be non-empty`)
522
+ env[key] = patterns
523
+ }
524
+ }
525
+ const platform = parseStringList(record['platform'], `${at}.platform`)
526
+ for (const name of platform) {
527
+ if (!PLATFORMS.includes(name)) {
528
+ throw new RuleError(`${at}.platform: unknown platform ${JSON.stringify(name)} (allowed: ${PLATFORMS.map(p => JSON.stringify(p)).join(', ')})`)
529
+ }
530
+ }
531
+ return { env, platform }
532
+ }
533
+
534
+ /**
535
+ * Validate one pattern or a pattern list; scalar strings/numbers/booleans
536
+ * are single patterns (matching the runtime scalar-stringify semantics).
537
+ */
538
+ function parsePatternValues(raw: unknown, at: string): string[] {
539
+ if (typeof raw === 'string') return [raw]
540
+ if (typeof raw === 'number' || typeof raw === 'boolean') return [String(raw)]
541
+ if (raw === undefined) return []
542
+ if (Array.isArray(raw)) {
543
+ return raw.map((value, index) => {
544
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
545
+ throw new RuleError(`${at}[${index}] must be a string, number, or boolean`)
546
+ }
547
+ const pattern = String(value)
548
+ if (pattern.length === 0) throw new RuleError(`${at}[${index}] must be non-empty`)
549
+ return pattern
550
+ })
551
+ }
552
+ throw new RuleError(`${at} must be a string, number, boolean, or a list of them`)
553
+ }
554
+
555
+ /** Validate a string or string list, absent = empty list. */
556
+ function parseStringList(raw: unknown, at: string): string[] {
557
+ if (raw === undefined) return []
558
+ const values = typeof raw === 'string' ? [raw] : raw
559
+ if (!Array.isArray(values)) throw new RuleError(`${at} must be a string or a list of strings`)
560
+ return values.map((value, index) => {
561
+ if (typeof value !== 'string' || value.length === 0) {
562
+ throw new RuleError(`${at}[${index}] must be a non-empty string`)
563
+ }
564
+ return value
565
+ })
566
+ }
567
+
568
+ /** Narrow an unknown value to a plain record, throwing a {@link RuleError}. */
569
+ function asRecord(value: unknown, at: string): Record<string, unknown> {
570
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
571
+ throw new RuleError(`${at} must be a mapping, got ${Array.isArray(value) ? 'a list' : typeof value}`)
572
+ }
573
+ return value as Record<string, unknown>
574
+ }
575
+
576
+ /** Narrow an unknown value to a plain string-keyed record (tool arguments). */
577
+ function isRecord(value: unknown): value is Record<string, unknown> {
578
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
579
+ }
580
+
581
+ // --- Candidate extraction --------------------------------------------------
582
+
583
+ /** Top-level argument keys whose string values are path candidates. */
584
+ export const PATH_CANDIDATE_KEYS: readonly string[] = [
585
+ 'path',
586
+ 'paths',
587
+ 'file',
588
+ 'files',
589
+ 'file_path',
590
+ 'dir',
591
+ 'directory',
592
+ 'directories',
593
+ 'cwd',
594
+ 'workspace',
595
+ 'root',
596
+ 'target',
597
+ 'targets',
598
+ 'output',
599
+ ]
600
+
601
+ /**
602
+ * Collect path-candidate strings from tool arguments at ANY nesting depth
603
+ * (capped at {@link MAX_ARGUMENT_DEPTH}): scalar strings under the
604
+ * documented {@link PATH_CANDIDATE_KEYS}, plus string elements of arrays
605
+ * under those keys. Nested objects are walked so MCP-style
606
+ * `{ arguments: { path } }` shapes still feed path matching. Iterates the
607
+ * argument object's own key order, so extraction order is deterministic.
608
+ * @param args - the parsed tool arguments.
609
+ * @returns candidate strings in argument key order (duplicates kept).
610
+ */
611
+ export function extractPathCandidates(args: Record<string, unknown>): string[] {
612
+ const candidates: string[] = []
613
+ const walk = (node: unknown, depth: number): void => {
614
+ if (depth > MAX_ARGUMENT_DEPTH) return
615
+ if (Array.isArray(node)) {
616
+ for (const element of node) walk(element, depth + 1)
617
+ return
618
+ }
619
+ if (typeof node !== 'object' || node === null) return
620
+ for (const [key, value] of Object.entries(node)) {
621
+ if (PATH_CANDIDATE_KEYS.includes(key)) {
622
+ if (typeof value === 'string') candidates.push(value)
623
+ else if (Array.isArray(value)) {
624
+ for (const element of value) {
625
+ if (typeof element === 'string') candidates.push(element)
626
+ }
627
+ }
628
+ }
629
+ walk(value, depth + 1)
630
+ }
631
+ }
632
+ walk(args, 0)
633
+ return candidates
634
+ }
635
+
636
+ /**
637
+ * Normalize one candidate path against the workspace root: separators
638
+ * become `/`, `./` prefixes drop, and in-root paths become workspace
639
+ * relative. Candidates OUTSIDE the root (absolute or drive-prefixed
640
+ * elsewhere) yield `''` — path patterns address workspace-relative paths
641
+ * only; a still-relative `../…` input is kept so explicit out-of-root
642
+ * globs like `../shared/**` can address it. When `caseInsensitive` is set
643
+ * (Windows default), the drive and root-prefix comparisons ignore ASCII
644
+ * case, matching the case-insensitive filesystem.
645
+ * @param cwd - the session's absolute workspace root (any separator).
646
+ * @param candidate - the raw candidate path.
647
+ * @param caseInsensitive - compare root prefixes without ASCII case.
648
+ * @returns the workspace-relative posix path, or `''` to drop the candidate.
649
+ */
650
+ export function normalizeWorkspacePath(cwd: string, candidate: string, caseInsensitive = false): string {
651
+ const root = toPosix(cwd).replace(/\/+$/, '')
652
+ const raw = toPosix(candidate)
653
+ if (raw.length === 0) return ''
654
+ let path = raw
655
+ while (path.startsWith('./')) path = path.slice(2)
656
+ if (/^[A-Za-z]:\//.test(path)) {
657
+ // Windows drive path: relative only when on the root's own drive.
658
+ if (!/^[A-Za-z]:\//.test(root)) return ''
659
+ if (path.slice(0, 2).toLowerCase() !== root.slice(0, 2).toLowerCase()) return ''
660
+ return relFromRoot(root, path, path.slice(2), root.slice(2), caseInsensitive)
661
+ }
662
+ if (path.startsWith('/')) {
663
+ // Absolute posix path: relative when inside the root, dropped otherwise.
664
+ return relFromRoot(root, path, path, root, caseInsensitive)
665
+ }
666
+ return path
667
+ }
668
+
669
+ /** Strip a common root prefix from one absolute path, else drop the path. */
670
+ function relFromRoot(root: string, path: string, rest: string, rootRest: string, caseInsensitive: boolean): string {
671
+ const restLower = caseInsensitive ? rest.toLowerCase() : rest
672
+ const rootRestLower = caseInsensitive ? rootRest.toLowerCase() : rootRest
673
+ if (rootRestLower.length === 0) return rest.slice(1)
674
+ if (path.toLowerCase() === root.toLowerCase()) return ''
675
+ return restLower.startsWith(`${rootRestLower}/`) ? rest.slice(rootRest.length + 1) : ''
676
+ }
677
+
678
+ /** Convert a path to posix separators. */
679
+ function toPosix(path: string): string {
680
+ return path.replaceAll('\\', '/')
681
+ }
682
+
683
+ /** Collect scalar candidate strings from one param value, recursing into arrays and objects. */
684
+ function scalarCandidates(value: unknown, depth: number): string[] {
685
+ if (depth > MAX_ARGUMENT_DEPTH) return []
686
+ if (typeof value === 'string') return [value]
687
+ if (typeof value === 'number' || typeof value === 'boolean') return [String(value)]
688
+ if (Array.isArray(value)) {
689
+ return value.flatMap(element => scalarCandidates(element, depth + 1))
690
+ }
691
+ if (typeof value === 'object' && value !== null) {
692
+ return Object.values(value).flatMap(child => scalarCandidates(child, depth + 1))
693
+ }
694
+ return []
695
+ }