dsh-permission-rules 0.2.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/runtime.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  * `tools/pre-execute` listener that turns a first-match hit into a
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
- * (`list | reload | decisions [n] | test <tool> <json>`), and
8
- * Chokidar-driven reloads. Every registration is an effect.
7
+ * (`list | reload | decisions [n] | test [flags] <tool> <json>`), and
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,31 +197,45 @@ 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
 
192
- /** The match context `when` conditions evaluate against. */
193
- private matchContext(): MatchContext {
194
- return { platform: process.platform, env: process.env }
219
+ /**
220
+ * The match context `when`/`agents` conditions evaluate against: host
221
+ * facts plus the caller's agent-identity candidates derived from the
222
+ * session header (`main` for top-level sessions, `subagent` for
223
+ * subagent children, `preset:<name>` when a preset composed the agent).
224
+ * @param exec - the pending call whose caller supplies the identity.
225
+ */
226
+ private matchContext(exec?: ToolExecution): MatchContext {
227
+ return { platform: process.platform, env: process.env, agents: agentCandidates(exec?.agent) }
195
228
  }
196
229
 
197
230
  /**
198
231
  * The `tools/pre-execute` listener. A deny/ask hit returns the decision
199
232
  * (first match wins, short-circuiting downstream listeners); an allow hit
200
233
  * and a passthrough MUST delegate via `next()` so later listeners keep
201
- * their say. Audit is appended once the final outcome is known, so the
202
- * recorded `outcome` matches what the waterfall settled on.
234
+ * their say. Under `enforce: false` (dry-run) a deny/ask hit also
235
+ * delegates — the record keeps the would-be action with `dryRun: true`
236
+ * and the actual downstream outcome. Audit is appended once the final
237
+ * outcome is known, so the recorded `outcome` matches what the waterfall
238
+ * settled on.
203
239
  * @param exec - the pending call (name, parsed arguments, caller agent).
204
240
  * @param next - the downstream chain.
205
241
  * @returns the pre-execute decision.
@@ -207,12 +243,18 @@ export class PermissionRulesRuntime {
207
243
  async preExecute(exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision> {
208
244
  const cwd = exec.agent?.session.header.cwd ?? process.cwd()
209
245
  const loaded = this.rulesFor(cwd)
210
- const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext())
246
+ const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec))
211
247
  if (hit === undefined || hit.rule.action === 'allow') {
212
248
  const decision = await next()
213
249
  this.audit(exec, loaded, hit, decision.kind)
214
250
  return decision
215
251
  }
252
+ if (!this.config.enforce) {
253
+ // Dry-run: match the rule, log what it WOULD do, and delegate.
254
+ const decision = await next()
255
+ this.audit(exec, loaded, hit, decision.kind, true)
256
+ return decision
257
+ }
216
258
  const outcome: DecisionOutcome = hit.rule.action
217
259
  this.audit(exec, loaded, hit, outcome)
218
260
  if (outcome === 'deny') return { kind: 'deny', reason: hit.rule.reason }
@@ -225,15 +267,18 @@ export class PermissionRulesRuntime {
225
267
  * envelope's `ignorable: true` marker so any harness build can load the
226
268
  * log (readers that do not know the type skip the audit record instead of
227
269
  * refusing the session). `source` names the matched rule's file, or the
228
- * nearest effective file on a passthrough. Agentless calls have no
229
- * session to audit; append failures are contained so an audit hiccup can
230
- * never change a permission decision.
270
+ * nearest effective file on a passthrough; `cwd` names the workspace the
271
+ * rules were resolved for; `dryRun` marks would-be deny/ask hits under
272
+ * `enforce: false`. Agentless calls have no session to audit; append
273
+ * failures are contained so an audit hiccup can never change a
274
+ * permission decision.
231
275
  * @param exec - the pending call.
232
276
  * @param loaded - the rules in effect.
233
277
  * @param hit - the first matching rule, or undefined for passthrough.
234
278
  * @param outcome - the final pre-execute decision.
279
+ * @param dryRun - mark the record as a would-be decision (dry-run mode).
235
280
  */
236
- audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome): void {
281
+ audit(exec: ToolExecution, loaded: LoadedRules, hit: RuleHit | undefined, outcome: DecisionOutcome, dryRun = false): void {
237
282
  if (this.config.audit === 'hits' && hit === undefined) return
238
283
  const agent = exec.agent
239
284
  if (agent === undefined) return
@@ -244,7 +289,9 @@ export class PermissionRulesRuntime {
244
289
  source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
245
290
  action: hit === undefined ? 'passthrough' : hit.rule.action,
246
291
  outcome,
292
+ cwd: loaded.cwd,
247
293
  ...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
294
+ ...dryRun ? { dryRun: true as const } : {},
248
295
  }, { ignorable: true })
249
296
  } catch (error: unknown) {
250
297
  this.ctx.logger.warn(`permission-rules: audit append failed: ${String(error)}`)
@@ -254,8 +301,9 @@ export class PermissionRulesRuntime {
254
301
  /**
255
302
  * Execute the `/rules` command: bare `/rules` lists the active rules and
256
303
  * their sources; `/rules reload` re-reads the chain; `/rules decisions
257
- * [n]` shows the session's audit trail; `/rules test <tool> <json>`
258
- * dry-evaluates the rules against a hypothetical call. Command output
304
+ * [n]` shows the session's audit trail; `/rules test [flags] <tool>
305
+ * <json>` dry-evaluates the rules against a hypothetical call (flags
306
+ * override the workspace, host env, and agent identity). Command output
259
307
  * stays in the UI — nothing here is injected into the model context.
260
308
  * @param invocation - the received command invocation.
261
309
  * @returns the command result shown to the user.
@@ -269,12 +317,13 @@ export class PermissionRulesRuntime {
269
317
  if (verb === 'reload') {
270
318
  if (rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
271
319
  this.reload(cwd)
272
- const reloaded = this.byCwd.get(cwd)
320
+ const reloaded = this.byCwd.get(this.cacheKey(cwd))
273
321
  if (reloaded?.lastError !== undefined) return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) }
274
322
  const rules = reloaded?.compiled.rules ?? []
275
323
  const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ')
276
324
  return { kind: 'success', text: prose.reloaded(rules.length, source) }
277
325
  }
326
+ if (verb === 'list' && rest.length > 0) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
278
327
  if (verb === 'decisions') {
279
328
  if (rest.length > 1) return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) }
280
329
  let count = 10
@@ -286,31 +335,9 @@ export class PermissionRulesRuntime {
286
335
  return this.decisionsCommand(invocation, count, prose)
287
336
  }
288
337
  if (verb === 'test') {
289
- const verbText = verbRaw ?? ''
290
- const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : ''
291
- const match = /^(\S+)\s*(.*)$/s.exec(restLine)
292
- const tool = match?.[1]
293
- const jsonText = match?.[2] ?? ''
294
- if (tool === undefined || tool.length === 0) return { kind: 'error', text: prose.testUsage }
295
- let args: unknown
296
- try {
297
- args = jsonText.length > 0 ? JSON.parse(jsonText) : {}
298
- } catch {
299
- return { kind: 'error', text: prose.testBadJson(jsonText) }
300
- }
301
- let loaded: LoadedRules
302
- try {
303
- loaded = this.rulesFor(cwd)
304
- } catch (error: unknown) {
305
- return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
306
- }
307
- const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext())
308
- return {
309
- kind: 'success',
310
- text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
311
- }
338
+ return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose)
312
339
  }
313
- 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()) }
314
341
  let loaded: LoadedRules
315
342
  try {
316
343
  loaded = this.rulesFor(cwd)
@@ -322,17 +349,102 @@ export class PermissionRulesRuntime {
322
349
  lines.push(prose.noRules(cwd, this.config.fallbackPath !== undefined ? prose.fallbackMissing : ''))
323
350
  } else {
324
351
  lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd))
325
- 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)))
326
354
  const unreachable = findUnreachableRules(loaded.compiled)
327
355
  if (unreachable.length > 0) lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)))
328
356
  }
329
357
  if (loaded.lastError !== undefined) {
330
358
  lines.push(prose.lastReloadWarning(loaded.lastError))
331
359
  }
360
+ if (!this.config.enforce) {
361
+ lines.push(prose.dryRunNotice)
362
+ }
332
363
  lines.push(prose.usage)
333
364
  return { kind: 'success', text: lines.join('\n') }
334
365
  }
335
366
 
367
+ /**
368
+ * Execute `/rules test` with optional leading flags: `--cwd <dir>`
369
+ * evaluates against that workspace (rule discovery AND path
370
+ * normalization), `--env KEY=VALUE` (repeatable) overrides host env for
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.
376
+ * @param raw - the full raw command input.
377
+ * @param verbRaw - the verb as typed.
378
+ * @param invocation - the received command invocation (session agent).
379
+ * @param sessionCwd - the session's workspace root.
380
+ * @param prose - localized output vocabulary.
381
+ * @returns the dry-evaluation result.
382
+ */
383
+ private testCommand(raw: string, verbRaw: string, invocation: CommandInvocation, sessionCwd: string, prose: UiProse): CommandResult {
384
+ let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : ''
385
+ const envOverrides: Record<string, string> = {}
386
+ const agentSelectors: string[] = []
387
+ let testCwd: string | undefined
388
+ let testPlatform: string | undefined
389
+ let parsed = nextToken(rest)
390
+ while (parsed !== undefined && parsed.token.startsWith('--')) {
391
+ const flag = parsed.token
392
+ const value = nextToken(parsed.rest)
393
+ if (value === undefined || value.token.startsWith('--')) {
394
+ return { kind: 'error', text: prose.testBadFlag(flag) }
395
+ }
396
+ if (flag === '--cwd') {
397
+ testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token)
398
+ } else if (flag === '--env') {
399
+ const equals = value.token.indexOf('=')
400
+ if (equals <= 0) return { kind: 'error', text: prose.testBadFlag(`${flag} ${value.token}`) }
401
+ envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1)
402
+ } else if (flag === '--agent') {
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
407
+ } else {
408
+ return { kind: 'error', text: prose.testUnknownFlag(flag) }
409
+ }
410
+ rest = value.rest
411
+ parsed = nextToken(rest)
412
+ }
413
+ const tool = parsed?.token
414
+ const jsonText = parsed === undefined ? '' : parsed.rest.trim()
415
+ if (tool === undefined || tool.length === 0) return { kind: 'error', text: prose.testUsage }
416
+ let args: unknown
417
+ try {
418
+ args = jsonText.length > 0 ? JSON.parse(jsonText) : {}
419
+ } catch {
420
+ return { kind: 'error', text: prose.testBadJson(jsonText) }
421
+ }
422
+ const evalCwd = testCwd ?? sessionCwd
423
+ let loaded: LoadedRules
424
+ try {
425
+ loaded = this.rulesFor(evalCwd)
426
+ } catch (error: unknown) {
427
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) }
428
+ }
429
+ const context: MatchContext = {
430
+ platform: testPlatform ?? process.platform,
431
+ env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
432
+ agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
433
+ }
434
+ const hit = matchRules(loaded.compiled, tool, args, evalCwd, context)
435
+ return {
436
+ kind: 'success',
437
+ text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
438
+ }
439
+ }
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
+
336
448
  /** Render the session's `permissionRules/decision` audit trail, newest last. */
337
449
  private decisionsCommand(invocation: CommandInvocation, count: number, prose: UiProse): CommandResult {
338
450
  const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision')
@@ -341,7 +453,7 @@ export class PermissionRulesRuntime {
341
453
  const lines = [prose.decisionsHeader(shown.length, decisions.length)]
342
454
  for (const event of shown) {
343
455
  const data = event.data as AuditDecision
344
- lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason))
456
+ lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome))
345
457
  }
346
458
  return { kind: 'success', text: lines.join('\n') }
347
459
  }
@@ -417,18 +529,99 @@ export class PermissionRulesRuntime {
417
529
  * Rebind `cwd` to its current rule-source chain. When a concrete source
418
530
  * left the chain (deleted file, fallback switch), detach the workspace
419
531
  * from watchers serving other sources and close the ones left empty, so
420
- * long-running hosts cannot accumulate stale watchers. An empty chain (no
421
- * rule file in effect) keeps the previous watchers alive: they own the
422
- * "file recreated → adopted" signal, and the workspace-cache bound keeps
423
- * 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.
424
539
  */
425
540
  private reconcileWatch(cwd: string, sources: readonly string[]): void {
426
- if (sources.length === 0) return
427
541
  for (const [watchedSource, entry] of this.watchers) {
428
- if (!sources.includes(watchedSource)) entry.cwds.delete(cwd)
542
+ if (!sources.includes(watchedSource)) {
543
+ entry.cwds.delete(cwd)
544
+ entry.candidates?.delete(cwd)
545
+ }
429
546
  }
430
547
  this.pruneWatchers()
431
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
+ }
432
625
  }
433
626
 
434
627
  /** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
@@ -457,7 +650,10 @@ export class PermissionRulesRuntime {
457
650
  const oldest = this.byCwd.keys().next().value
458
651
  if (oldest === undefined) return
459
652
  this.byCwd.delete(oldest)
460
- 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
+ }
461
657
  this.pruneWatchers()
462
658
  }
463
659
 
@@ -471,13 +667,24 @@ export class PermissionRulesRuntime {
471
667
  return this.timers.size
472
668
  }
473
669
 
474
- /** 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
+ */
475
676
  private scheduleReload(source: string): void {
476
677
  const existing = this.timers.get(source)
477
678
  if (existing !== undefined) clearTimeout(existing)
478
679
  const timer = setTimeout(() => {
479
680
  this.timers.delete(source)
480
- 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
+ }
481
688
  }, this.config.watchStabilityThresholdMs)
482
689
  this.timers.set(source, timer)
483
690
  }
@@ -498,7 +705,53 @@ export function apply(ctx: Context, config: Config): void {
498
705
  ctx.commands.register({
499
706
  name: 'rules',
500
707
  description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
501
- input: { hint: '[reload | decisions [n] | test <tool> <json-args>]' },
708
+ input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
502
709
  handler: invocation => runtime.command(invocation),
503
710
  })
504
711
  }
712
+
713
+ /**
714
+ * Identity candidates for the `agents` match dimension, derived from the
715
+ * caller agent's session header: `main` for top-level sessions, `subagent`
716
+ * for subagent children (`header.origin === 'subagent'`), and
717
+ * `preset:<name>` when a preset composed the agent. No agent (or an
718
+ * unidentifiable one) yields no candidates, so agent-scoped rules fail
719
+ * closed instead of matching an unknown caller.
720
+ * @param agent - the calling agent, when the call has one.
721
+ * @returns the candidate strings, in a stable order.
722
+ */
723
+ function agentCandidates(agent: ToolExecution['agent']): string[] {
724
+ if (agent === undefined) return []
725
+ const header = agent.session.header
726
+ const candidates = [header.origin === 'subagent' ? 'subagent' : 'main']
727
+ if (typeof header.agentPreset === 'string' && header.agentPreset.length > 0) candidates.push(`preset:${header.agentPreset}`)
728
+ return candidates
729
+ }
730
+
731
+ /**
732
+ * Pull the next whitespace-delimited argument from a command tail,
733
+ * honoring single/double quotes (with backslash escapes) so JSON blobs and
734
+ * paths with spaces survive. The remainder is returned verbatim — the JSON
735
+ * argument tail of `/rules test` is never re-tokenized.
736
+ * @param text - the remaining command tail.
737
+ * @returns the token plus the untouched remainder, or undefined when only
738
+ * whitespace remains.
739
+ */
740
+ function nextToken(text: string): { token: string; rest: string } | undefined {
741
+ let i = 0
742
+ while (i < text.length && /\s/.test(text[i] as string)) i += 1
743
+ if (i >= text.length) return undefined
744
+ const quote = text[i] as string
745
+ if (quote === '"' || quote === "'") {
746
+ let end = i + 1
747
+ while (end < text.length && text[end] !== quote) {
748
+ if (text[end] === '\\') end += 1
749
+ end += 1
750
+ }
751
+ if (end >= text.length) return { token: text.slice(i + 1), rest: '' }
752
+ return { token: text.slice(i + 1, end), rest: text.slice(end + 1) }
753
+ }
754
+ let end = i
755
+ while (end < text.length && !/\s/.test(text[end] as string)) end += 1
756
+ return { token: text.slice(i, end), rest: text.slice(end) }
757
+ }