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/CHANGELOG.md +17 -0
- package/README.es.md +6 -5
- package/README.hi.md +6 -5
- package/README.md +6 -5
- package/README.pt.md +6 -5
- package/README.zh.md +6 -5
- package/docs/rules-format.en.md +4 -2
- package/docs/rules-format.md +4 -2
- package/lib/index.js +209 -60
- package/lib/types/prose.d.ts +4 -0
- package/lib/types/prose.d.ts.map +1 -1
- package/lib/types/prose.js +20 -15
- package/lib/types/prose.js.map +1 -1
- package/lib/types/rules.d.ts +8 -2
- package/lib/types/rules.d.ts.map +1 -1
- package/lib/types/rules.js +8 -3
- package/lib/types/rules.js.map +1 -1
- package/lib/types/runtime.d.ts +49 -10
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +166 -35
- package/lib/types/runtime.js.map +1 -1
- package/package.json +1 -1
- package/src/prose.ts +24 -15
- package/src/rules.ts +10 -3
- package/src/runtime.ts +171 -34
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
|
|
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
|
|
53
|
-
private readonly watchers = new Map<string,
|
|
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
|
|
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(
|
|
152
|
-
this.byCwd.set(
|
|
173
|
+
this.byCwd.delete(key)
|
|
174
|
+
this.byCwd.set(key, existing)
|
|
153
175
|
return existing
|
|
154
176
|
}
|
|
155
|
-
this.evictIfFull(
|
|
177
|
+
this.evictIfFull(key)
|
|
156
178
|
try {
|
|
157
179
|
const loaded = this.load(cwd)
|
|
158
|
-
this.byCwd.set(
|
|
159
|
-
this.reconcileWatch(
|
|
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(
|
|
165
|
-
this.reconcileWatch(
|
|
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
|
|
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(
|
|
182
|
-
this.reconcileWatch(
|
|
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(
|
|
188
|
-
else
|
|
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
|
-
|
|
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,
|
|
343
|
-
* agent-identity candidates for the `agents` dimension
|
|
344
|
-
*
|
|
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.
|
|
491
|
-
* rule
|
|
492
|
-
*
|
|
493
|
-
*
|
|
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))
|
|
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())
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|