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.
@@ -4,15 +4,17 @@
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
  import { existsSync, readFileSync } from 'node:fs';
12
14
  import { dirname, isAbsolute, join, resolve } from 'node:path';
13
15
  import chokidar from 'chokidar';
14
16
  import { resolveConfig } from "./config.js";
15
- import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, parseRulesDocument, RuleError } from "./rules.js";
17
+ import { compileRules, compileRulesChain, describeRule, findUnreachableRules, matchRules, normalizeWorkspacePath, parseRulesDocument, PLATFORMS, RuleError } from "./rules.js";
16
18
  import { DESCRIBE_TOKENS, UI_PROSE } from "./prose.js";
17
19
  export const name = 'permission-rules';
18
20
  /** Services required before the plugin mounts. */
@@ -26,7 +28,7 @@ export class PermissionRulesRuntime {
26
28
  config;
27
29
  /** Loaded (or failed) rules per workspace cwd, least-recently-used first for eviction. */
28
30
  byCwd = new Map();
29
- /** Live watchers per rule-file path, with the cwds each serves. */
31
+ /** Live watchers per watched path (rule file or candidate ancestor directory), with the cwds each serves. */
30
32
  watchers = new Map();
31
33
  /** Debounce timers per rule-file path. */
32
34
  timers = new Map();
@@ -108,6 +110,17 @@ export class PermissionRulesRuntime {
108
110
  throw error instanceof RuleError ? error : new RuleError(`cannot load ${sources.join(', ')}: ${message}`);
109
111
  }
110
112
  }
113
+ /**
114
+ * Canonical per-workspace cache key: the resolved root, case-folded on
115
+ * Windows so differently-spelled paths to the same workspace share one
116
+ * cache entry and one watcher set instead of doubling both.
117
+ * @param cwd - the session's absolute workspace root.
118
+ * @returns the canonical key.
119
+ */
120
+ cacheKey(cwd) {
121
+ const resolved = resolve(cwd);
122
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
123
+ }
111
124
  /**
112
125
  * The rules in effect for one cwd, loading on first use. Under
113
126
  * `badFilePolicy: 'fail'` a bad initial load throws on EVERY use (the
@@ -119,27 +132,28 @@ export class PermissionRulesRuntime {
119
132
  * @returns the loaded rules.
120
133
  */
121
134
  rulesFor(cwd) {
122
- const existing = this.byCwd.get(cwd);
135
+ const key = this.cacheKey(cwd);
136
+ const existing = this.byCwd.get(key);
123
137
  if (existing !== undefined) {
124
138
  if (existing.failed === true) {
125
139
  throw new RuleError(existing.lastError ?? `rule load failed for ${cwd}`);
126
140
  }
127
- this.byCwd.delete(cwd);
128
- this.byCwd.set(cwd, existing);
141
+ this.byCwd.delete(key);
142
+ this.byCwd.set(key, existing);
129
143
  return existing;
130
144
  }
131
- this.evictIfFull(cwd);
145
+ this.evictIfFull(key);
132
146
  try {
133
147
  const loaded = this.load(cwd);
134
- this.byCwd.set(cwd, loaded);
135
- this.reconcileWatch(cwd, loaded.sources);
148
+ this.byCwd.set(key, loaded);
149
+ this.reconcileWatch(key, loaded.sources);
136
150
  return loaded;
137
151
  }
138
152
  catch (error) {
139
153
  const message = error instanceof Error ? error.message : String(error);
140
154
  const sources = this.resolveSources(cwd);
141
- this.byCwd.set(cwd, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
142
- this.reconcileWatch(cwd, sources);
155
+ this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
156
+ this.reconcileWatch(key, sources);
143
157
  throw error;
144
158
  }
145
159
  }
@@ -151,32 +165,45 @@ export class PermissionRulesRuntime {
151
165
  * @param cwd - the workspace root.
152
166
  */
153
167
  reload(cwd) {
154
- const previous = this.byCwd.get(cwd);
168
+ const key = this.cacheKey(cwd);
169
+ const previous = this.byCwd.get(key);
155
170
  try {
156
171
  const loaded = this.load(cwd);
157
- this.byCwd.set(cwd, loaded);
158
- this.reconcileWatch(cwd, loaded.sources);
172
+ this.byCwd.set(key, loaded);
173
+ this.reconcileWatch(key, loaded.sources);
159
174
  this.ctx.logger.info(`permission-rules: reloaded ${loaded.compiled.rules.length} rule(s) from ${loaded.sources.join(', ') || '(empty rule set)'} for ${cwd}`);
160
175
  }
161
176
  catch (error) {
162
177
  const message = error instanceof Error ? error.message : String(error);
163
178
  this.ctx.logger.warn(`permission-rules: reload failed for ${cwd}: ${message} (keeping previous rules)`);
164
179
  if (previous !== undefined)
165
- this.byCwd.set(cwd, { ...previous, lastError: message });
166
- else
167
- this.byCwd.set(cwd, { cwd, sources: this.resolveSources(cwd), compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
180
+ this.byCwd.set(key, { ...previous, lastError: message });
181
+ else {
182
+ const sources = this.resolveSources(cwd);
183
+ this.byCwd.set(key, { cwd, sources, compiled: { rules: [], caseInsensitivePaths: this.config.caseInsensitivePaths }, lastError: message, failed: true });
184
+ this.reconcileWatch(key, sources);
185
+ }
168
186
  }
169
187
  }
170
- /** The match context `when` conditions evaluate against. */
171
- matchContext() {
172
- return { platform: process.platform, env: process.env };
188
+ /**
189
+ * The match context `when`/`agents` conditions evaluate against: host
190
+ * facts plus the caller's agent-identity candidates derived from the
191
+ * session header (`main` for top-level sessions, `subagent` for
192
+ * subagent children, `preset:<name>` when a preset composed the agent).
193
+ * @param exec - the pending call whose caller supplies the identity.
194
+ */
195
+ matchContext(exec) {
196
+ return { platform: process.platform, env: process.env, agents: agentCandidates(exec?.agent) };
173
197
  }
174
198
  /**
175
199
  * The `tools/pre-execute` listener. A deny/ask hit returns the decision
176
200
  * (first match wins, short-circuiting downstream listeners); an allow hit
177
201
  * and a passthrough MUST delegate via `next()` so later listeners keep
178
- * their say. Audit is appended once the final outcome is known, so the
179
- * recorded `outcome` matches what the waterfall settled on.
202
+ * their say. Under `enforce: false` (dry-run) a deny/ask hit also
203
+ * delegates — the record keeps the would-be action with `dryRun: true`
204
+ * and the actual downstream outcome. Audit is appended once the final
205
+ * outcome is known, so the recorded `outcome` matches what the waterfall
206
+ * settled on.
180
207
  * @param exec - the pending call (name, parsed arguments, caller agent).
181
208
  * @param next - the downstream chain.
182
209
  * @returns the pre-execute decision.
@@ -184,12 +211,18 @@ export class PermissionRulesRuntime {
184
211
  async preExecute(exec, next) {
185
212
  const cwd = exec.agent?.session.header.cwd ?? process.cwd();
186
213
  const loaded = this.rulesFor(cwd);
187
- const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext());
214
+ const hit = matchRules(loaded.compiled, exec.name, exec.arguments, cwd, this.matchContext(exec));
188
215
  if (hit === undefined || hit.rule.action === 'allow') {
189
216
  const decision = await next();
190
217
  this.audit(exec, loaded, hit, decision.kind);
191
218
  return decision;
192
219
  }
220
+ if (!this.config.enforce) {
221
+ // Dry-run: match the rule, log what it WOULD do, and delegate.
222
+ const decision = await next();
223
+ this.audit(exec, loaded, hit, decision.kind, true);
224
+ return decision;
225
+ }
193
226
  const outcome = hit.rule.action;
194
227
  this.audit(exec, loaded, hit, outcome);
195
228
  if (outcome === 'deny')
@@ -202,15 +235,18 @@ export class PermissionRulesRuntime {
202
235
  * envelope's `ignorable: true` marker so any harness build can load the
203
236
  * log (readers that do not know the type skip the audit record instead of
204
237
  * refusing the session). `source` names the matched rule's file, or the
205
- * nearest effective file on a passthrough. Agentless calls have no
206
- * session to audit; append failures are contained so an audit hiccup can
207
- * never change a permission decision.
238
+ * nearest effective file on a passthrough; `cwd` names the workspace the
239
+ * rules were resolved for; `dryRun` marks would-be deny/ask hits under
240
+ * `enforce: false`. Agentless calls have no session to audit; append
241
+ * failures are contained so an audit hiccup can never change a
242
+ * permission decision.
208
243
  * @param exec - the pending call.
209
244
  * @param loaded - the rules in effect.
210
245
  * @param hit - the first matching rule, or undefined for passthrough.
211
246
  * @param outcome - the final pre-execute decision.
247
+ * @param dryRun - mark the record as a would-be decision (dry-run mode).
212
248
  */
213
- audit(exec, loaded, hit, outcome) {
249
+ audit(exec, loaded, hit, outcome, dryRun = false) {
214
250
  if (this.config.audit === 'hits' && hit === undefined)
215
251
  return;
216
252
  const agent = exec.agent;
@@ -224,7 +260,9 @@ export class PermissionRulesRuntime {
224
260
  source: hit === undefined ? (loaded.sources[0] ?? '') : (loaded.sources[hit.rule.sourceIndex] ?? ''),
225
261
  action: hit === undefined ? 'passthrough' : hit.rule.action,
226
262
  outcome,
263
+ cwd: loaded.cwd,
227
264
  ...hit !== undefined ? { ruleIndex: hit.ruleIndex, reason: hit.rule.reason } : {},
265
+ ...dryRun ? { dryRun: true } : {},
228
266
  }, { ignorable: true });
229
267
  }
230
268
  catch (error) {
@@ -234,8 +272,9 @@ export class PermissionRulesRuntime {
234
272
  /**
235
273
  * Execute the `/rules` command: bare `/rules` lists the active rules and
236
274
  * their sources; `/rules reload` re-reads the chain; `/rules decisions
237
- * [n]` shows the session's audit trail; `/rules test <tool> <json>`
238
- * dry-evaluates the rules against a hypothetical call. Command output
275
+ * [n]` shows the session's audit trail; `/rules test [flags] <tool>
276
+ * <json>` dry-evaluates the rules against a hypothetical call (flags
277
+ * override the workspace, host env, and agent identity). Command output
239
278
  * stays in the UI — nothing here is injected into the model context.
240
279
  * @param invocation - the received command invocation.
241
280
  * @returns the command result shown to the user.
@@ -250,13 +289,15 @@ export class PermissionRulesRuntime {
250
289
  if (rest.length > 0)
251
290
  return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
252
291
  this.reload(cwd);
253
- const reloaded = this.byCwd.get(cwd);
292
+ const reloaded = this.byCwd.get(this.cacheKey(cwd));
254
293
  if (reloaded?.lastError !== undefined)
255
294
  return { kind: 'error', text: prose.reloadFailed(reloaded.lastError) };
256
295
  const rules = reloaded?.compiled.rules ?? [];
257
296
  const source = reloaded === undefined || reloaded.sources.length === 0 ? prose.emptySource : reloaded.sources.join(', ');
258
297
  return { kind: 'success', text: prose.reloaded(rules.length, source) };
259
298
  }
299
+ if (verb === 'list' && rest.length > 0)
300
+ return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
260
301
  if (verb === 'decisions') {
261
302
  if (rest.length > 1)
262
303
  return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
@@ -270,34 +311,9 @@ export class PermissionRulesRuntime {
270
311
  return this.decisionsCommand(invocation, count, prose);
271
312
  }
272
313
  if (verb === 'test') {
273
- const verbText = verbRaw ?? '';
274
- const restLine = raw.length > verbText.length ? raw.slice(verbText.length).trim() : '';
275
- const match = /^(\S+)\s*(.*)$/s.exec(restLine);
276
- const tool = match?.[1];
277
- const jsonText = match?.[2] ?? '';
278
- if (tool === undefined || tool.length === 0)
279
- return { kind: 'error', text: prose.testUsage };
280
- let args;
281
- try {
282
- args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
283
- }
284
- catch {
285
- return { kind: 'error', text: prose.testBadJson(jsonText) };
286
- }
287
- let loaded;
288
- try {
289
- loaded = this.rulesFor(cwd);
290
- }
291
- catch (error) {
292
- return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
293
- }
294
- const hit = matchRules(loaded.compiled, tool, args, cwd, this.matchContext());
295
- return {
296
- kind: 'success',
297
- text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
298
- };
314
+ return this.testCommand(raw, verbRaw ?? '', invocation, cwd, prose);
299
315
  }
300
- if (verb !== '')
316
+ if (verb !== '' && verb !== 'list')
301
317
  return { kind: 'error', text: prose.unknownArg(invocation.rawInput.trim()) };
302
318
  let loaded;
303
319
  try {
@@ -312,7 +328,8 @@ export class PermissionRulesRuntime {
312
328
  }
313
329
  else {
314
330
  lines.push(prose.rulesHeader(loaded.compiled.rules.length, loaded.sources, cwd));
315
- lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language])));
331
+ const multiSource = loaded.sources.length > 1;
332
+ lines.push(...loaded.compiled.rules.map(rule => describeRule(rule, DESCRIBE_TOKENS[this.config.language], multiSource ? this.displaySource(loaded.sources[rule.sourceIndex] ?? '', cwd) : undefined)));
316
333
  const unreachable = findUnreachableRules(loaded.compiled);
317
334
  if (unreachable.length > 0)
318
335
  lines.push(prose.unreachableWarning(unreachable.map(index => index + 1)));
@@ -320,9 +337,101 @@ export class PermissionRulesRuntime {
320
337
  if (loaded.lastError !== undefined) {
321
338
  lines.push(prose.lastReloadWarning(loaded.lastError));
322
339
  }
340
+ if (!this.config.enforce) {
341
+ lines.push(prose.dryRunNotice);
342
+ }
323
343
  lines.push(prose.usage);
324
344
  return { kind: 'success', text: lines.join('\n') };
325
345
  }
346
+ /**
347
+ * Execute `/rules test` with optional leading flags: `--cwd <dir>`
348
+ * evaluates against that workspace (rule discovery AND path
349
+ * normalization), `--env KEY=VALUE` (repeatable) overrides host env for
350
+ * `when.env` matching, `--agent <selector>` (repeatable) supplies
351
+ * agent-identity candidates for the `agents` dimension, and
352
+ * `--platform <name>` overrides the host platform for `when.platform`.
353
+ * The JSON argument tail is kept verbatim, so quoted JSON survives
354
+ * unchanged.
355
+ * @param raw - the full raw command input.
356
+ * @param verbRaw - the verb as typed.
357
+ * @param invocation - the received command invocation (session agent).
358
+ * @param sessionCwd - the session's workspace root.
359
+ * @param prose - localized output vocabulary.
360
+ * @returns the dry-evaluation result.
361
+ */
362
+ testCommand(raw, verbRaw, invocation, sessionCwd, prose) {
363
+ let rest = raw.length > verbRaw.length ? raw.slice(verbRaw.length).trim() : '';
364
+ const envOverrides = {};
365
+ const agentSelectors = [];
366
+ let testCwd;
367
+ let testPlatform;
368
+ let parsed = nextToken(rest);
369
+ while (parsed !== undefined && parsed.token.startsWith('--')) {
370
+ const flag = parsed.token;
371
+ const value = nextToken(parsed.rest);
372
+ if (value === undefined || value.token.startsWith('--')) {
373
+ return { kind: 'error', text: prose.testBadFlag(flag) };
374
+ }
375
+ if (flag === '--cwd') {
376
+ testCwd = isAbsolute(value.token) ? value.token : resolve(sessionCwd, value.token);
377
+ }
378
+ else if (flag === '--env') {
379
+ const equals = value.token.indexOf('=');
380
+ if (equals <= 0)
381
+ return { kind: 'error', text: prose.testBadFlag(`${flag} ${value.token}`) };
382
+ envOverrides[value.token.slice(0, equals)] = value.token.slice(equals + 1);
383
+ }
384
+ else if (flag === '--agent') {
385
+ agentSelectors.push(value.token);
386
+ }
387
+ else if (flag === '--platform') {
388
+ if (!PLATFORMS.includes(value.token))
389
+ return { kind: 'error', text: prose.testBadPlatform(value.token) };
390
+ testPlatform = value.token;
391
+ }
392
+ else {
393
+ return { kind: 'error', text: prose.testUnknownFlag(flag) };
394
+ }
395
+ rest = value.rest;
396
+ parsed = nextToken(rest);
397
+ }
398
+ const tool = parsed?.token;
399
+ const jsonText = parsed === undefined ? '' : parsed.rest.trim();
400
+ if (tool === undefined || tool.length === 0)
401
+ return { kind: 'error', text: prose.testUsage };
402
+ let args;
403
+ try {
404
+ args = jsonText.length > 0 ? JSON.parse(jsonText) : {};
405
+ }
406
+ catch {
407
+ return { kind: 'error', text: prose.testBadJson(jsonText) };
408
+ }
409
+ const evalCwd = testCwd ?? sessionCwd;
410
+ let loaded;
411
+ try {
412
+ loaded = this.rulesFor(evalCwd);
413
+ }
414
+ catch (error) {
415
+ return { kind: 'error', text: error instanceof Error ? error.message : String(error) };
416
+ }
417
+ const context = {
418
+ platform: testPlatform ?? process.platform,
419
+ env: Object.keys(envOverrides).length === 0 ? process.env : { ...process.env, ...envOverrides },
420
+ agents: agentSelectors.length > 0 ? agentSelectors : agentCandidates(invocation.agent),
421
+ };
422
+ const hit = matchRules(loaded.compiled, tool, args, evalCwd, context);
423
+ return {
424
+ kind: 'success',
425
+ text: hit === undefined ? prose.testNoMatch(tool) : prose.testHit(tool, hit.ruleIndex, hit.rule.action, hit.rule.reason),
426
+ };
427
+ }
428
+ /** Render one rule-file source for per-rule attribution: workspace-relative when inside the cwd, raw otherwise. */
429
+ displaySource(source, cwd) {
430
+ if (source.length === 0)
431
+ return '';
432
+ const relative = normalizeWorkspacePath(cwd, source, this.config.caseInsensitivePaths);
433
+ return relative.length > 0 ? relative : source;
434
+ }
326
435
  /** Render the session's `permissionRules/decision` audit trail, newest last. */
327
436
  decisionsCommand(invocation, count, prose) {
328
437
  const decisions = invocation.agent.session.events.filter(event => event.type === 'permissionRules/decision');
@@ -332,7 +441,7 @@ export class PermissionRulesRuntime {
332
441
  const lines = [prose.decisionsHeader(shown.length, decisions.length)];
333
442
  for (const event of shown) {
334
443
  const data = event.data;
335
- lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason));
444
+ lines.push(prose.decisionLine(event.seq, data.action, data.toolName, data.ruleIndex, data.reason, data.dryRun === true, data.outcome));
336
445
  }
337
446
  return { kind: 'success', text: lines.join('\n') };
338
447
  }
@@ -406,21 +515,102 @@ export class PermissionRulesRuntime {
406
515
  * Rebind `cwd` to its current rule-source chain. When a concrete source
407
516
  * left the chain (deleted file, fallback switch), detach the workspace
408
517
  * from watchers serving other sources and close the ones left empty, so
409
- * long-running hosts cannot accumulate stale watchers. An empty chain (no
410
- * rule file in effect) keeps the previous watchers alive: they own the
411
- * "file recreated → adopted" signal, and the workspace-cache bound keeps
412
- * the set of such watchers finite.
518
+ * long-running hosts cannot accumulate stale watchers. Expected-but-absent
519
+ * rule files (the project file when it is not effective, and a configured
520
+ * fallback that does not exist) are covered by candidate watchers on
521
+ * their deepest existing ancestor directory, so a file created
522
+ * mid-session is adopted without a manual `/rules reload`. With
523
+ * `searchUp`, only the immediate cwd-level candidate is watched —
524
+ * deeper ancestors are discovered on the next load.
413
525
  */
414
526
  reconcileWatch(cwd, sources) {
415
- if (sources.length === 0)
416
- return;
417
527
  for (const [watchedSource, entry] of this.watchers) {
418
- if (!sources.includes(watchedSource))
528
+ if (!sources.includes(watchedSource)) {
419
529
  entry.cwds.delete(cwd);
530
+ entry.candidates?.delete(cwd);
531
+ }
420
532
  }
421
533
  this.pruneWatchers();
422
534
  for (const source of sources)
423
535
  this.attachWatch(cwd, source);
536
+ for (const candidate of this.candidateSources(cwd)) {
537
+ if (sources.includes(candidate) || existsSync(candidate))
538
+ continue;
539
+ this.attachCandidateWatch(cwd, candidate);
540
+ }
541
+ }
542
+ /**
543
+ * The rule-file paths a workspace COULD be served by: the project file
544
+ * (an absolute `rulesFile`, or `<cwd>/<rulesFile>` — the immediate level
545
+ * only under `searchUp`) plus the configured fallback.
546
+ */
547
+ candidateSources(cwd) {
548
+ const candidates = [isAbsolute(this.config.rulesFile) ? this.config.rulesFile : join(cwd, this.config.rulesFile)];
549
+ const fallback = this.config.fallbackPath;
550
+ if (fallback !== undefined)
551
+ candidates.push(isAbsolute(fallback) ? fallback : resolve(fallback));
552
+ return candidates;
553
+ }
554
+ /**
555
+ * Watch one expected-but-absent rule file through its deepest existing
556
+ * ancestor directory. Chokidar cannot reliably watch a missing path when
557
+ * its parent is also missing, but directory watching is dependable; every
558
+ * relevant event re-checks existence and only triggers a reload once the
559
+ * candidate actually appeared, so unrelated workspace activity while the
560
+ * file is absent costs a stat, not a reload.
561
+ */
562
+ attachCandidateWatch(cwd, candidate) {
563
+ if (!this.config.watch)
564
+ return;
565
+ const dir = this.deepestExistingDir(dirname(candidate));
566
+ const existing = this.watchers.get(dir);
567
+ if (existing !== undefined) {
568
+ existing.cwds.add(cwd);
569
+ existing.candidates?.set(cwd, candidate);
570
+ return;
571
+ }
572
+ const cwds = new Set([cwd]);
573
+ const candidates = new Map([[cwd, candidate]]);
574
+ const watcher = chokidar.watch(dir, { persistent: true, ignoreInitial: true });
575
+ const onEvent = () => this.scheduleReload(dir);
576
+ watcher.on('add', onEvent);
577
+ watcher.on('addDir', onEvent);
578
+ watcher.on('change', onEvent);
579
+ watcher.on('unlink', onEvent);
580
+ watcher.on('unlinkDir', onEvent);
581
+ watcher.on('error', (error) => {
582
+ this.ctx.logger.warn(`permission-rules: watcher error on ${dir}: ${String(error)}`);
583
+ });
584
+ this.watchers.set(dir, {
585
+ cwds,
586
+ candidates,
587
+ close: () => {
588
+ void watcher.close().catch((error) => {
589
+ this.ctx.logger.warn(`permission-rules: failed to close watcher on ${dir}: ${String(error)}`);
590
+ });
591
+ },
592
+ });
593
+ this.ctx.effect(() => () => {
594
+ this.watchers.get(dir)?.close();
595
+ this.watchers.delete(dir);
596
+ const timer = this.timers.get(dir);
597
+ if (timer !== undefined) {
598
+ clearTimeout(timer);
599
+ this.timers.delete(dir);
600
+ }
601
+ });
602
+ }
603
+ /** The deepest existing ancestor directory of `dir` (candidate watchers target directories, never missing files). */
604
+ deepestExistingDir(dir) {
605
+ let current = dir;
606
+ for (;;) {
607
+ if (existsSync(current))
608
+ return current;
609
+ const parent = dirname(current);
610
+ if (parent === current)
611
+ return current;
612
+ current = parent;
613
+ }
424
614
  }
425
615
  /** Close and drop watchers whose workspace sets are empty, clearing their debounce timers. */
426
616
  pruneWatchers() {
@@ -450,8 +640,10 @@ export class PermissionRulesRuntime {
450
640
  if (oldest === undefined)
451
641
  return;
452
642
  this.byCwd.delete(oldest);
453
- for (const entry of this.watchers.values())
643
+ for (const entry of this.watchers.values()) {
454
644
  entry.cwds.delete(oldest);
645
+ entry.candidates?.delete(oldest);
646
+ }
455
647
  this.pruneWatchers();
456
648
  }
457
649
  /** Number of live watchers, for observability and tests. */
@@ -462,15 +654,27 @@ export class PermissionRulesRuntime {
462
654
  pendingReloadCount() {
463
655
  return this.timers.size;
464
656
  }
465
- /** Debounce watch events into one reload per stability window. */
657
+ /**
658
+ * Debounce watch events into one reload per stability window. A
659
+ * candidate (directory) watcher only reloads a cwd once its expected
660
+ * file actually exists — unrelated events while the file is absent are
661
+ * dropped before any re-read.
662
+ */
466
663
  scheduleReload(source) {
467
664
  const existing = this.timers.get(source);
468
665
  if (existing !== undefined)
469
666
  clearTimeout(existing);
470
667
  const timer = setTimeout(() => {
471
668
  this.timers.delete(source);
472
- for (const cwd of this.watchers.get(source)?.cwds ?? [])
669
+ const entry = this.watchers.get(source);
670
+ if (entry === undefined)
671
+ return;
672
+ for (const cwd of entry.cwds) {
673
+ const candidate = entry.candidates?.get(cwd);
674
+ if (candidate !== undefined && !existsSync(candidate))
675
+ continue;
473
676
  this.reload(cwd);
677
+ }
474
678
  }, this.config.watchStabilityThresholdMs);
475
679
  this.timers.set(source, timer);
476
680
  }
@@ -490,8 +694,59 @@ export function apply(ctx, config) {
490
694
  ctx.commands.register({
491
695
  name: 'rules',
492
696
  description: 'list, reload, audit, or dry-test the active permission rules for this workspace',
493
- input: { hint: '[reload | decisions [n] | test <tool> <json-args>]' },
697
+ input: { hint: '[list | reload | decisions [n] | test [--cwd <dir>] [--env K=V] [--agent <sel>] [--platform <name>] <tool> <json-args>]' },
494
698
  handler: invocation => runtime.command(invocation),
495
699
  });
496
700
  }
701
+ /**
702
+ * Identity candidates for the `agents` match dimension, derived from the
703
+ * caller agent's session header: `main` for top-level sessions, `subagent`
704
+ * for subagent children (`header.origin === 'subagent'`), and
705
+ * `preset:<name>` when a preset composed the agent. No agent (or an
706
+ * unidentifiable one) yields no candidates, so agent-scoped rules fail
707
+ * closed instead of matching an unknown caller.
708
+ * @param agent - the calling agent, when the call has one.
709
+ * @returns the candidate strings, in a stable order.
710
+ */
711
+ function agentCandidates(agent) {
712
+ if (agent === undefined)
713
+ return [];
714
+ const header = agent.session.header;
715
+ const candidates = [header.origin === 'subagent' ? 'subagent' : 'main'];
716
+ if (typeof header.agentPreset === 'string' && header.agentPreset.length > 0)
717
+ candidates.push(`preset:${header.agentPreset}`);
718
+ return candidates;
719
+ }
720
+ /**
721
+ * Pull the next whitespace-delimited argument from a command tail,
722
+ * honoring single/double quotes (with backslash escapes) so JSON blobs and
723
+ * paths with spaces survive. The remainder is returned verbatim — the JSON
724
+ * argument tail of `/rules test` is never re-tokenized.
725
+ * @param text - the remaining command tail.
726
+ * @returns the token plus the untouched remainder, or undefined when only
727
+ * whitespace remains.
728
+ */
729
+ function nextToken(text) {
730
+ let i = 0;
731
+ while (i < text.length && /\s/.test(text[i]))
732
+ i += 1;
733
+ if (i >= text.length)
734
+ return undefined;
735
+ const quote = text[i];
736
+ if (quote === '"' || quote === "'") {
737
+ let end = i + 1;
738
+ while (end < text.length && text[end] !== quote) {
739
+ if (text[end] === '\\')
740
+ end += 1;
741
+ end += 1;
742
+ }
743
+ if (end >= text.length)
744
+ return { token: text.slice(i + 1), rest: '' };
745
+ return { token: text.slice(i + 1, end), rest: text.slice(end + 1) };
746
+ }
747
+ let end = i;
748
+ while (end < text.length && !/\s/.test(text[end]))
749
+ end += 1;
750
+ return { token: text.slice(i, end), rest: text.slice(end) };
751
+ }
497
752
  //# sourceMappingURL=runtime.js.map