dsh-perm-gate 4.0.0 → 4.1.0-beta.1

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/lib/config.d.ts CHANGED
@@ -10,6 +10,9 @@ import { DEFAULT_GATE_PRESETS, resolveGatePresets } from './preset.js';
10
10
  export declare function resolveDshHome(configured?: string): string;
11
11
  /** This plugin's data directory (`<dshHome>/perm-gate`), always defined. */
12
12
  export declare function resolveDataDir(configured?: string): string;
13
+ export declare function ensureDataDir(dataDir: string): boolean;
14
+ /** Check whether the data directory exists without creating it. */
15
+ export declare function dataDirReady(dataDir: string): boolean;
13
16
  /**
14
17
  * The permissions document the gate loads. An explicit `rulesFile` wins; unset
15
18
  * falls back to `<dataDir>/rules.yml`, so a rules file the user drops in the
package/lib/config.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * loader validates and fills defaults before `apply`. Invalid values fail loud.
4
4
  */
5
5
  import { homedir } from 'node:os';
6
+ import { mkdirSync, statSync } from 'node:fs';
6
7
  import { join } from 'node:path';
7
8
  import z from '@deepseek-ai/schemastery';
8
9
  import { DEFAULT_GATE_PRESETS, resolveGatePresets } from './preset.js';
@@ -24,6 +25,41 @@ export function resolveDshHome(configured) {
24
25
  export function resolveDataDir(configured) {
25
26
  return join(resolveDshHome(configured), 'perm-gate');
26
27
  }
28
+ /**
29
+ * Lazily ensure the data directory exists. Safe to call repeatedly —
30
+ * `mkdirSync({recursive:true})` is a no-op when the directory already
31
+ * exists. Returns `true` if the directory is now available, `false`
32
+ * if creation failed (caller should degrade to empty defaults).
33
+ *
34
+ * Used by every write path. Read paths should call `dataDirReady()`
35
+ * instead to avoid creating the directory as a side effect of reading.
36
+ */
37
+ let _dataDirReady = false;
38
+ export function ensureDataDir(dataDir) {
39
+ if (_dataDirReady)
40
+ return true;
41
+ try {
42
+ mkdirSync(dataDir, { recursive: true });
43
+ _dataDirReady = true;
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ }
50
+ /** Check whether the data directory exists without creating it. */
51
+ export function dataDirReady(dataDir) {
52
+ if (_dataDirReady)
53
+ return true;
54
+ try {
55
+ statSync(dataDir);
56
+ _dataDirReady = true;
57
+ return true;
58
+ }
59
+ catch {
60
+ return false;
61
+ }
62
+ }
27
63
  /**
28
64
  * The permissions document the gate loads. An explicit `rulesFile` wins; unset
29
65
  * falls back to `<dataDir>/rules.yml`, so a rules file the user drops in the
@@ -157,7 +193,7 @@ export const Config = z.object({
157
193
  networkAskTimeoutMs: z.number().min(1000).max(600_000).default(120_000),
158
194
  networkGrantTtlMs: z.number().min(0).max(24 * 60 * 60_000).default(30 * 60_000),
159
195
  // Hot reload (Phase 3)
160
- watch: z.boolean().default(true),
196
+ watch: z.boolean().default(false),
161
197
  watchDebounceMs: z.number().min(50).max(5000).default(300),
162
198
  });
163
199
  export function resolveConfig(config = {}) {
package/lib/index.js CHANGED
@@ -1,16 +1,27 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { parse } from 'yaml';
4
- import { Config, RULES_NAMESPACE, RulesSchema, isRulesConfigured, readRulesFromSettings, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
5
- import { appendAllowToSettings, replaceAllowInSettings } from './allowlist.js';
3
+ import { Config, resolveDshHome, resolveDataDir, ensureDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
6
4
  import { runDryRun } from './dry-run.js';
7
- import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute, registerStreamRoute } from './events.js';
5
+ import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
8
6
  import { buildReceiverInfo } from './receiver-info.js';
9
- import { readRulesView, readRulesViewFromSettings } from './rules-view.js';
7
+ import { readRulesView } from './rules-view.js';
10
8
  import { PermGateRuntime } from './runtime.js';
11
9
  import { classifySessions, sweepSessionData } from './session-sweep.js';
12
10
  import { decideNetworkTarget } from './network.js';
13
11
  import { NetworkLifecycle } from './network-lifecycle.js';
12
+ import { RuleWatcher } from './watch.js';
13
+ // ─── Cold-start directory bootstrap ─────────────────────────────────────────
14
+ // Ensure the plugin data directory exists BEFORE cordis calls apply().
15
+ // The DSH framework (or other plugins) may scan dataDir during plugin
16
+ // registration — before our apply() entry point runs. If the directory
17
+ // is missing at that point, the scan throws and the plugin (or the
18
+ // entire profile) fails to load.
19
+ //
20
+ // This is a belt-and-suspenders approach:
21
+ // 1. Module-level: ensure dataDir exists at import time (earliest possible)
22
+ // 2. Read paths: check dataDirReady() → return empty defaults if missing
23
+ // 3. Write paths: call ensureDataDir() before first write
24
+ ensureDataDir(resolveDataDir());
14
25
  export const name = 'dsh-perm-gate';
15
26
  /**
16
27
  * The `tools` service drives `tools/pre-execute`/`tools/result` (dsh-tools).
@@ -185,37 +196,6 @@ export function apply(ctx, config = {}) {
185
196
  // read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
186
197
  // entry that omits `config` still records events, snapshots and learning.
187
198
  const dataDir = resolveDataDir(typeof config.dshHome === 'string' ? config.dshHome : undefined);
188
- // ─── Rules sources (dual-source: settings first, rulesFile fallback) ──
189
- // The `dsh-perm-gate-rules` settings namespace is the primary rules store —
190
- // the framework owns it, so the gate loads with no rules file on disk. A
191
- // namespace still at bare defaults never shadows the file, and the first
192
- // settings write seeds the file document along (implicit migration). Both
193
- // references are filled in by the settings registration below; until then
194
- // the file paths run exactly as before.
195
- const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
196
- let rulesFromSettings = () => ({});
197
- const rulesScopeRef = { scope: undefined };
198
- /** The raw file document, for seeding the namespace on its first write. */
199
- const fileSeedDoc = () => {
200
- try {
201
- const text = readFileSync(rulesFilePath, 'utf8');
202
- if (text.trim() === '')
203
- return undefined;
204
- return parse(text);
205
- }
206
- catch {
207
- return undefined;
208
- }
209
- };
210
- const settingsWrite = (write, fallback) => {
211
- const scope = rulesScopeRef.scope;
212
- if (scope === undefined)
213
- return fallback();
214
- void write(scope, fileSeedDoc()).catch((e) => {
215
- console.warn('[dsh-perm-gate] settings rules write failed:', e);
216
- });
217
- return true;
218
- };
219
199
  // Host model-group services (typed minimally; supplied by the dsh runtime
220
200
  // through the loader `inject` — dsh-approval-gate demonstrates the same
221
201
  // contract). The llm service backs the `host` receiver; agentDefaultModel
@@ -356,19 +336,8 @@ export function apply(ctx, config = {}) {
356
336
  : join(dataDir, 'learning.json'),
357
337
  // The rules document lives in the plugin's data dir by default, so a
358
338
  // `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
359
- // also having to declare `rulesFile` in the composition entry. It is the
360
- // FALLBACK source: a configured `dsh-perm-gate-rules` namespace wins.
361
- rulesFile: rulesFilePath,
362
- // Settings-first rules: the live `dsh-perm-gate-rules` document when the
363
- // namespace carries a real configuration, else undefined (file fallback).
364
- readRulesDocument: () => readRulesFromSettings({ get: () => rulesFromSettings() }),
365
- // Settings-backed allowlist writes; a falsy verdict (settings service
366
- // absent) makes the runtime fall back to its own rules-file paths, and the
367
- // namespace's watch triggers the reload once a write lands.
368
- allowlistWriter: {
369
- append: (pattern, reason) => settingsWrite((scope, seed) => appendAllowToSettings(scope, pattern, reason, seed), () => false),
370
- replace: (patterns, reason) => settingsWrite((scope, seed) => replaceAllowInSettings(scope, patterns, reason, seed), () => false),
371
- },
339
+ // also having to declare `rulesFile` in the composition entry.
340
+ rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
372
341
  // The whole gate is scoped to the presets that opt into it (default: the
373
342
  // `permissive` tier this plugin adds); elsewhere it stands down entirely.
374
343
  gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
@@ -459,17 +428,15 @@ export function apply(ctx, config = {}) {
459
428
  },
460
429
  onChange: () => { },
461
430
  onScope: (scope) => {
462
- // Seed the editable whitelist from the rules source only when the
463
- // namespace carries no override yet, so the card shows the current allow
464
- // patterns.
431
+ // Seed the editable whitelist from the rules file only when the namespace
432
+ // carries no override yet, so the card shows the current allow patterns.
465
433
  const surface = asSurface(scope.get());
466
434
  if (surface !== undefined && !Array.isArray(surface.allowlist)) {
467
435
  const patterns = runtime.allowlist();
468
436
  if (patterns.length > 0)
469
437
  void scope.update({ allowlist: patterns.slice() });
470
438
  }
471
- // Card edits -> namespace -> rules source (settings via the runtime's
472
- // writer, else the rules file mirror) + reload.
439
+ // Card edits -> namespace -> rulesFile (mirror + reload).
473
440
  scope.watch(() => {
474
441
  const next = asSurface(scope.get());
475
442
  if (next !== undefined && Array.isArray(next.allowlist)) {
@@ -489,22 +456,6 @@ export function apply(ctx, config = {}) {
489
456
  });
490
457
  },
491
458
  });
492
- // The rules namespace: the primary rules store (JSON twin of rules.yml).
493
- // Every change recompiles the gate — the settings half of the hot reload
494
- // that used to be a chokidar file watcher. `onScope` hands the live scope to
495
- // the allowlist writers above; the seed entry is `{}`, so an untouched
496
- // namespace reads as "not configured" and the file paths stay in force.
497
- installSettingsSection(ctx, RULES_NAMESPACE, RulesSchema, {}, {
498
- setSource: (source) => {
499
- rulesFromSettings = source;
500
- },
501
- onChange: () => {
502
- runtime.reload();
503
- },
504
- onScope: (scope) => {
505
- rulesScopeRef.scope = scope;
506
- },
507
- });
508
459
  // Event feed HTTP API (best effort): the dsh webServer service exposes the
509
460
  // JSONL decision events to the browser half; without it events stay on disk.
510
461
  try {
@@ -520,11 +471,6 @@ export function apply(ctx, config = {}) {
520
471
  const offEvents = registerEventsRoute(webServer, runtime.eventLog);
521
472
  if (offEvents !== undefined)
522
473
  ctx.effect(() => () => { offEvents(); }, 'dsh-perm-gate: events route');
523
- // Push plane: the browser half follows decisions over one SSE connection
524
- // instead of re-reading the event log on a timer.
525
- const offStream = registerStreamRoute(webServer, runtime.eventLog);
526
- if (offStream !== undefined)
527
- ctx.effect(() => () => { offStream(); }, 'dsh-perm-gate: stream route');
528
474
  }
529
475
  // Review-page plane (diff / revert / snapshot stats / snapshot clear): the
530
476
  // approval-history view's file chips and snapshot bar drive these.
@@ -572,18 +518,12 @@ export function apply(ctx, config = {}) {
572
518
  });
573
519
  if (offDryRun !== undefined)
574
520
  ctx.effect(() => () => { offDryRun(); }, 'dsh-perm-gate: dry-run route');
575
- // Permissions view: the document the gate is actually loading, shown so
576
- // the panel is never editing rules it cannot display. Read-only, and
577
- // resolved exactly as the gate resolves it — settings first, the file the
578
- // fallback resolves to otherwise.
579
- const offRules = registerRulesRoute(webServer, {
580
- view: () => {
581
- const doc = rulesFromSettings();
582
- if (isRulesConfigured(doc))
583
- return readRulesViewFromSettings(doc);
584
- return readRulesView(rulesFilePath);
585
- },
586
- });
521
+ // Permissions YAML view: the document the gate is actually loading, shown
522
+ // so the panel is never editing a file it cannot display. Read-only, and
523
+ // resolved exactly as the gate resolves it — a view that resolved its own
524
+ // path could describe a different file than the one in force.
525
+ const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
526
+ const offRules = registerRulesRoute(webServer, { view: () => readRulesView(rulesFilePath) });
587
527
  if (offRules !== undefined)
588
528
  ctx.effect(() => () => { offRules(); }, 'dsh-perm-gate: rules route');
589
529
  // Receiver projection for the settings card (provider/model catalog is
@@ -731,10 +671,23 @@ export function apply(ctx, config = {}) {
731
671
  networkRefs.snapshot = () => networkLifecycle.snapshot();
732
672
  if (networkRefs.appliedKey === undefined)
733
673
  networkRefs.appliedKey = networkKeyOf(current());
734
- // ─── Rule hot reload ────────────────────────────────────────────────
735
- // The file watcher (chokidar) is gone: the settings namespace is the primary
736
- // rules store and its `scope.watch` recompiles the gate on every change
737
- // (see the RULES_NAMESPACE registration above). A rules FILE edit is no
738
- // longer picked up mid-session — the settings document is the source to edit.
674
+ // ─── Hot reload watcher (Phase 3, T3.6) ────────────────────────────
675
+ const watchEnabled = typeof config.watch === 'boolean' ? config.watch : true;
676
+ if (watchEnabled) {
677
+ const watchDebounceMs = typeof config.watchDebounceMs === 'number' ? config.watchDebounceMs : 300;
678
+ const ruleWatcher = new RuleWatcher({
679
+ debounceMs: watchDebounceMs,
680
+ logger: { warn: (msg) => console.warn(msg) },
681
+ });
682
+ // Watch the effective rules file for the current workspace.
683
+ const rulesFile = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
684
+ const cwd = typeof config.cwd === 'string' ? config.cwd : process.cwd();
685
+ ruleWatcher.watch(cwd, [rulesFile], () => {
686
+ runtime.reload();
687
+ });
688
+ ctx.effect(() => () => {
689
+ ruleWatcher.closeAll();
690
+ }, 'dsh-perm-gate: rule watcher');
691
+ }
739
692
  return runtime;
740
693
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-perm-gate",
3
- "version": "4.0.0",
3
+ "version": "4.1.0-beta.1",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public",