dsh-permission-rules 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/rules.ts CHANGED
@@ -321,12 +321,16 @@ function isCatchAll(rule: CompiledRule): boolean {
321
321
  /**
322
322
  * One-line human summary of a compiled rule, for `/rules` output. The
323
323
  * 1-based number, action, match dimensions, and reason render first; a
324
- * disabled marker, tags, and a truncated description follow.
324
+ * disabled marker, tags, and a truncated description follow. When `source`
325
+ * is provided (multi-file chains), the rule's origin file is attributed on
326
+ * the line.
325
327
  * @param rule - the compiled rule.
326
328
  * @param tokens - dimension-prefix vocabulary for the configured language.
329
+ * @param source - display path of the rule's source file, when the listing
330
+ * spans several files.
327
331
  * @returns a single display line.
328
332
  */
329
- export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string {
333
+ export function describeRule(rule: CompiledRule, tokens: DescribeTokens, source?: string): string {
330
334
  const parts: string[] = []
331
335
  if (rule.source.match.tools.length > 0) parts.push(`${tokens.tools}:${rule.source.match.tools.join(',')}`)
332
336
  if (rule.source.match.agents.length > 0) parts.push(`${tokens.agents}:${rule.source.match.agents.join(',')}`)
@@ -343,9 +347,10 @@ export function describeRule(rule: CompiledRule, tokens: DescribeTokens): string
343
347
  if (whenParts.length > 0 || platformPart.length > 0) parts.push(`${tokens.when}:${whenParts}${whenParts.length > 0 && platformPart.length > 0 ? ' ' : ''}${platformPart}`)
344
348
  const match = parts.length > 0 ? `[${parts.join(' ')}]` : `[${tokens.allTools}]`
345
349
  const disabled = rule.enabled ? '' : ` (${tokens.disabled})`
350
+ const sourcePart = source !== undefined && source.length > 0 ? ` [${tokens.src}:${source}]` : ''
346
351
  const description = rule.description !== undefined ? ` (${truncate(rule.description, DISPLAY_TRUNCATE)})` : ''
347
352
  const tags = rule.tags.length > 0 ? ` [${tokens.tags}:${rule.tags.join(',')}]` : ''
348
- return `${rule.index + 1}. ${rule.action}${disabled} ${match}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`
353
+ return `${rule.index + 1}. ${rule.action}${disabled} ${match}${sourcePart}: ${truncate(rule.reason, DISPLAY_TRUNCATE)}${description}${tags}`
349
354
  }
350
355
 
351
356
  /** The localized dimension-prefix vocabulary {@link describeRule} renders. */
@@ -360,6 +365,8 @@ export interface DescribeTokens {
360
365
  readonly platform: string
361
366
  readonly disabled: string
362
367
  readonly tags: string
368
+ /** Prefix of the per-rule source-file attribution. */
369
+ readonly src: string
363
370
  }
364
371
 
365
372
  /** Cut a display string at `limit` characters, marking truncation with `…`. */
package/src/runtime.ts CHANGED
@@ -5,7 +5,9 @@
5
5
  * deny/ask decision (and NEVER short-circuits on allow or passthrough),
6
6
  * the `permissionRules/decision` audit event, the `/rules` session command
7
7
  * (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
8
- * Chokidar-driven reloads. Every registration is an effect.
8
+ * Chokidar-driven reloads (effective rule files plus candidate watches on
9
+ * expected-but-absent files, so mid-session creation is adopted). Every
10
+ * registration is an effect.
9
11
  * @module dsh-permission-rules/runtime
10
12
  */
11
13
 
@@ -17,7 +19,7 @@ import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands
17
19
  import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
18
20
  import { resolveConfig } from './config.ts'
19
21
  import type { Config, ResolvedConfig } from './config.ts'
20
- import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, parseRulesDocument, RuleError } from './rules.ts'
22
+ import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from './rules.ts'
21
23
  import type { CompileOptions, CompiledRuleset, MatchContext, RuleHit } from './rules.ts'
22
24
  import { DESCRIBE_TOKENS, UI_PROSE } from './prose.ts'
23
25
  import type { UiProse } from './prose.ts'
@@ -41,6 +43,13 @@ interface LoadedRules {
41
43
  readonly failed?: true
42
44
  }
43
45
 
46
+ /** One live watcher: rule-file watchers key by the file path; candidate watchers key by an ancestor directory and map each cwd to the absent file they are waiting for. */
47
+ interface WatcherEntry {
48
+ readonly cwds: Set<string>
49
+ readonly close: () => void
50
+ readonly candidates?: Map<string, string>
51
+ }
52
+
44
53
  /**
45
54
  * State and behavior. One instance per plugin mount; disposals are owned by
46
55
  * the watcher/timer effects registered in {@link apply}.
@@ -49,8 +58,8 @@ export class PermissionRulesRuntime {
49
58
  /** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
50
59
  private readonly byCwd = new Map<string, LoadedRules>()
51
60
 
52
- /** Live watchers per rule-file path, with the cwds each serves. */
53
- private readonly watchers = new Map<string, { readonly cwds: Set<string>; readonly close: () => void }>()
61
+ /** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
62
+ private readonly watchers = new Map<string, WatcherEntry>()
54
63
 
55
64
  /** Debounce timers per rule-file path. */
56
65
  private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
@@ -132,6 +141,18 @@ export class PermissionRulesRuntime {
132
141
  }
133
142
  }
134
143
 
144
+ /**
145
+ * Canonical per-workspace cache key: the resolved root, case-folded on
146
+ * Windows so differently-spelled paths to the same workspace share one
147
+ * cache entry and one watcher set instead of doubling both.
148
+ * @param cwd - the session's absolute workspace root.
149
+ * @returns the canonical key.
150
+ */
151
+ private cacheKey(cwd: string): string {
152
+ const resolved = resolve(cwd)
153
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved
154
+ }
155
+
135
156
  /**
136
157
  * The rules in effect for one cwd, loading on first use. Under
137
158
  * `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
@@ -143,26 +164,27 @@ export class PermissionRulesRuntime {
143
164
  * @returns the loaded rules.
144
165
  */
145
166
  rulesFor(cwd: string): LoadedRules {
146
- const existing = this.byCwd.get(cwd)
167
+ const key = this.cacheKey(cwd)
168
+ const existing = this.byCwd.get(key)
147
169
  if (existing !== undefined) {
148
170
  if (existing.failed === true) {
149
171
  throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`)
150
172
  }
151
- this.byCwd.delete(cwd)
152
- this.byCwd.set(cwd, existing)
173
+ this.byCwd.delete(key)
174
+ this.byCwd.set(key, existing)
153
175
  return existing
154
176
  }
155
- this.evictIfFull(cwd)
177
+ this.evictIfFull(key)
156
178
  try {
157
179
  const loaded = this.load(cwd)
158
- this.byCwd.set(cwd, loaded)
159
- this.reconcileWatch(cwd, loaded.sources)
180
+ this.byCwd.set(key, loaded)
181
+ this.reconcileWatch(key, loaded.sources)
160
182
  return loaded
161
183
  } catch (error: unknown) {
162
184
  const message = error instanceof Error ? error.message : String(error)
163
185
  const sources = this.resolveSources(cwd)
164
- this.byCwd.set(cwd, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
165
- this.reconcileWatch(cwd, sources)
186
+ this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
187
+ this.reconcileWatch(key, sources)
166
188
  throw error
167
189
  }
168
190
  }
@@ -175,17 +197,22 @@ export class PermissionRulesRuntime {
175
197
  * @param cwd - the workspace root.
176
198
  */
177
199
  reload(cwd: string): void {
178
- const previous = this.byCwd.get(cwd)
200
+ const key = this.cacheKey(cwd)
201
+ const previous = this.byCwd.get(key)
179
202
  try {
180
203
  const loaded = this.load(cwd)
181
- this.byCwd.set(cwd, loaded)
182
- this.reconcileWatch(cwd, loaded.sources)
204
+ this.byCwd.set(key, loaded)
205
+ this.reconcileWatch(key, loaded.sources)
183
206
  this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`)
184
207
  } catch (error: unknown) {
185
208
  const message = error instanceof Error ? error.message : String(error)
186
209
  this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`)
187
- if (previous !== undefined) this.byCwd.set(cwd, { ...previous, lastError: message })
188
- else this.byCwd.set(cwd, { cwd, sources: this.resolveSources(cwd), compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
210
+ if (previous !== undefined) this.byCwd.set(key, { ...previous, lastError: message })
211
+ else {
212
+ const sources = this.resolveSources(cwd)
213
+ this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true })
214
+ this.reconcileWatch(key, sources)
215
+ }
189
216
  }
190
217
  }
191
218
 
@@ -290,12 +317,13 @@ export class PermissionRulesRuntime {
290
317
  if (verb === 'reload') {
291
318
  if (rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
292
319
  this.reload(cwd)
293
- const reloaded = this.byCwd.get(cwd)
320
+ const reloaded = this.byCwd.get(this.cacheKey(cwd))
294
321
  if (reloaded?.lastError !== undefined) return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) }
295
322
  const rules = reloaded?.compiled.rules ?? []
296
323
  const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ')
297
324
  return { kind: 'success', text: prose.reloaded(rules.length, source) }
298
325
  }
326
+ if (verb === 'list' && rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
299
327
  if (verb === 'decisions') {
300
328
  if (rest.length > 1) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
301
329
  let count = 10
@@ -309,7 +337,7 @@ export class PermissionRulesRuntime {
309
337
  if (verb === 'test') {
310
338
  return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose)
311
339
  }
312
- if (verb !== '') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
340
+ if (verb !== '' && verb !== 'list') return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
313
341
  let loaded: LoadedRules
314
342
  try {
315
343
  loaded = this.rulesFor(cwd)
@@ -321,7 +349,8 @@ export class PermissionRulesRuntime {
321
349
  lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''))
322
350
  } else {
323
351
  lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd))
324
- lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language])))
352
+ const multiSource = loaded.sources.length > 1
353
+ lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)))
325
354
  const unreachable = findUnreachableRules(loaded.compiled)
326
355
  if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)))
327
356
  }
@@ -339,9 +368,11 @@ export class PermissionRulesRuntime {
339
368
  * Execute `/rules test` with optional leading flags: `--cwd <dir>`
340
369
  * evaluates against that workspace (rule discovery AND path
341
370
  * normalization), `--env KEY=VALUE` (repeatable) overrides host env for
342
- * `when.env` matching, and `--agent <selector>` (repeatable) supplies
343
- * agent-identity candidates for the `agents` dimension. The JSON
344
- * argument tail is kept verbatim, so quoted JSON survives unchanged.
371
+ * `when.env` matching, `--agent <selector>` (repeatable) supplies
372
+ * agent-identity candidates for the `agents` dimension, and
373
+ * `--platform <name>` overrides the host platform for `when.platform`.
374
+ * The JSON argument tail is kept verbatim, so quoted JSON survives
375
+ * unchanged.
345
376
  * @param raw - the full raw command input.
346
377
  * @param verbRaw - the verb as typed.
347
378
  * @param invocation - the received command invocation (session agent).
@@ -354,6 +385,7 @@ export class PermissionRulesRuntime {
354
385
  const envOverrides: Record<string, string> = {}
355
386
  const agentSelectors: string[] = []
356
387
  let testCwd: string | undefined
388
+ let testPlatform: string | undefined
357
389
  let parsed = nextToken(rest)
358
390
  while (parsed !== undefined && parsed.token.startsWith('--')) {
359
391
  const flag = parsed.token
@@ -369,6 +401,9 @@ export class PermissionRulesRuntime {
369
401
  envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1)
370
402
  } else if (flag === '--agent') {
371
403
  agentSelectors.push(value.token)
404
+ } else if (flag === '--platform') {
405
+ if (!PLATFORMS.includes(value.token)) return { kind: 'error', text: prose.testBadPlatform(value.token) }
406
+ testPlatform = value.token
372
407
  } else {
373
408
  return { kind: 'error', text: prose.testUnknownFlag(flag) }
374
409
  }
@@ -392,7 +427,7 @@ export class PermissionRulesRuntime {
392
427
  return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
393
428
  }
394
429
  const context: MatchContext = {
395
- platform: process.platform,
430
+ platform: testPlatform ?? process.platform,
396
431
  env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
397
432
  agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
398
433
  }
@@ -403,6 +438,13 @@ export class PermissionRulesRuntime {
403
438
  }
404
439
  }
405
440
 
441
+ /** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
442
+ private displaySource(source: string, cwd: string): string {
443
+ if (source.length === 0) return ''
444
+ const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths)
445
+ return relative.length > 0 ? relative : source
446
+ }
447
+
406
448
  /** Render the session's `permissionRules/decision` audit trail, newest last. */
407
449
  private decisionsCommand(invocation: CommandInvocation, count: number, prose: UiProse): CommandResult {
408
450
  const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision')
@@ -487,18 +529,99 @@ export class PermissionRulesRuntime {
487
529
  * Rebind `cwd` to its current rule-source chain. When a concrete source
488
530
  * left the chain (deleted file, fallback switch), detach the workspace
489
531
  * from watchers serving other sources and close the ones left empty, so
490
- * long-running hosts cannot accumulate stale watchers. An empty chain (no
491
- * rule file in effect) keeps the previous watchers alive: they own the
492
- * "file recreated → adopted" signal, and the workspace-cache bound keeps
493
- * the set of such watchers finite.
532
+ * long-running hosts cannot accumulate stale watchers. Expected-but-absent
533
+ * rule files (the project file when it is not effective, and a configured
534
+ * fallback that does not exist) are covered by candidate watchers on
535
+ * their deepest existing ancestor directory, so a file created
536
+ * mid-session is adopted without a manual `/rules reload`. With
537
+ * `searchUp`, only the immediate cwd-level candidate is watched —
538
+ * deeper ancestors are discovered on the next load.
494
539
  */
495
540
  private reconcileWatch(cwd: string, sources: readonly string[]): void {
496
- if (sources.length === 0) return
497
541
  for (const [watchedSource, entry] of this.watchers) {
498
- if (!sources.includes(watchedSource)) entry.cwds.delete(cwd)
542
+ if (!sources.includes(watchedSource)) {
543
+ entry.cwds.delete(cwd)
544
+ entry.candidates?.delete(cwd)
545
+ }
499
546
  }
500
547
  this.pruneWatchers()
501
548
  for (const source of sources) this.attachWatch(cwd, source)
549
+ for (const candidate of this.candidateSources(cwd)) {
550
+ if (sources.includes(candidate) || existsSync(candidate)) continue
551
+ this.attachCandidateWatch(cwd, candidate)
552
+ }
553
+ }
554
+
555
+ /**
556
+ * The rule-file paths a workspace COULD be served by: the project file
557
+ * (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
558
+ * only under `searchUp`) plus the configured fallback.
559
+ */
560
+ private candidateSources(cwd: string): string[] {
561
+ const candidates: string[] = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)]
562
+ const fallback = this.config.fallbackPath
563
+ if (fallback !== undefined) candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback))
564
+ return candidates
565
+ }
566
+
567
+ /**
568
+ * Watch one expected-but-absent rule file through its deepest existing
569
+ * ancestor directory. Chokidar cannot reliably watch a missing path when
570
+ * its parent is also missing, but directory watching is dependable; every
571
+ * relevant event re-checks existence and only triggers a reload once the
572
+ * candidate actually appeared, so unrelated workspace activity while the
573
+ * file is absent costs a stat, not a reload.
574
+ */
575
+ private attachCandidateWatch(cwd: string, candidate: string): void {
576
+ if (!this.config.watch) return
577
+ const dir = this.deepestExistingDir(dirname(candidate))
578
+ const existing = this.watchers.get(dir)
579
+ if (existing !== undefined) {
580
+ existing.cwds.add(cwd)
581
+ existing.candidates?.set(cwd, candidate)
582
+ return
583
+ }
584
+ const cwds = new Set([cwd])
585
+ const candidates = new Map([[cwd, candidate]])
586
+ const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true })
587
+ const onEvent = (): void => this.scheduleReload(dir)
588
+ watcher.on('add', onEvent)
589
+ watcher.on('addDir', onEvent)
590
+ watcher.on('change', onEvent)
591
+ watcher.on('unlink', onEvent)
592
+ watcher.on('unlinkDir', onEvent)
593
+ watcher.on('error', (error: unknown) => {
594
+ this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`)
595
+ })
596
+ this.watchers.set(dir, {
597
+ cwds,
598
+ candidates,
599
+ close: () => {
600
+ void watcher.close().catch((error: unknown) => {
601
+ this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`)
602
+ })
603
+ },
604
+ })
605
+ this.ctx.effect(() => () => {
606
+ this.watchers.get(dir)?.close()
607
+ this.watchers.delete(dir)
608
+ const timer = this.timers.get(dir)
609
+ if (timer !== undefined) {
610
+ clearTimeout(timer)
611
+ this.timers.delete(dir)
612
+ }
613
+ })
614
+ }
615
+
616
+ /** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
617
+ private deepestExistingDir(dir: string): string {
618
+ let current = dir
619
+ for (;;) {
620
+ if (existsSync(current)) return current
621
+ const parent = dirname(current)
622
+ if (parent === current) return current
623
+ current = parent
624
+ }
502
625
  }
503
626
 
504
627
  /** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
@@ -527,7 +650,10 @@ export class PermissionRulesRuntime {
527
650
  const oldest = this.byCwd.keys().next().value
528
651
  if (oldest === undefined) return
529
652
  this.byCwd.delete(oldest)
530
- for (const entry of this.watchers.values()) entry.cwds.delete(oldest)
653
+ for (const entry of this.watchers.values()) {
654
+ entry.cwds.delete(oldest)
655
+ entry.candidates?.delete(oldest)
656
+ }
531
657
  this.pruneWatchers()
532
658
  }
533
659
 
@@ -541,13 +667,24 @@ export class PermissionRulesRuntime {
541
667
  return this.timers.size
542
668
  }
543
669
 
544
- /** Debounce watch events into one reload per stability window. */
670
+ /**
671
+ * Debounce watch events into one reload per stability window. A
672
+ * candidate (directory) watcher only reloads a cwd once its expected
673
+ * file actually exists — unrelated events while the file is absent are
674
+ * dropped before any re-read.
675
+ */
545
676
  private scheduleReload(source: string): void {
546
677
  const existing = this.timers.get(source)
547
678
  if (existing !== undefined) clearTimeout(existing)
548
679
  const timer = setTimeout(() => {
549
680
  this.timers.delete(source)
550
- for (const cwd of this.watchers.get(source)?.cwds ?? []) this.reload(cwd)
681
+ const entry = this.watchers.get(source)
682
+ if (entry === undefined) return
683
+ for (const cwd of entry.cwds) {
684
+ const candidate = entry.candidates?.get(cwd)
685
+ if (candidate !== undefined && !existsSync(candidate)) continue
686
+ this.reload(cwd)
687
+ }
551
688
  }, this.config.watchStabilityThresholdMs)
552
689
  this.timers.set(source, timer)
553
690
  }
@@ -568,7 +705,7 @@ export function apply(ctx: Context, config: Config): void {
568
705
  ctx.commands.register({
569
706
  name: 'rules',
570
707
  description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
571
- input: { hint: '[reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] <tool> <json-args>]' },
708
+ input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
572
709
  handler: invocation => runtime.command(invocation),
573
710
  })
574
711
  }