dsh-perm-gate 4.0.0 → 4.1.0-beta.2
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 +0 -15
- package/INSTALL.ja.md +9 -15
- package/INSTALL.ko.md +10 -16
- package/INSTALL.md +9 -17
- package/INSTALL.zh.md +8 -13
- package/README.md +1 -1
- package/dsh.plugin.json +2 -2
- package/lib/client.js +52 -16
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +3 -0
- package/lib/config.js +37 -1
- package/lib/index.js +61 -81
- package/package.json +1 -1
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(
|
|
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 {
|
|
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, RULES_NAMESPACE, RulesSchema, readRulesFromSettings, 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
|
|
5
|
+
import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
|
|
8
6
|
import { buildReceiverInfo } from './receiver-info.js';
|
|
9
7
|
import { readRulesView, readRulesViewFromSettings } 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).
|
|
@@ -181,41 +192,15 @@ export function apply(ctx, config = {}) {
|
|
|
181
192
|
// Runtime-adjustable config: the composition entry is the base; the settings
|
|
182
193
|
// namespace layers on top and `current()` always reads the active section.
|
|
183
194
|
let current = () => config;
|
|
195
|
+
// ─── Rules settings scope (dsh-perm-gate-rules namespace) ────────────────
|
|
196
|
+
// Holds the live settings-sourced rules document. When non-undefined, the
|
|
197
|
+
// runtime prefers it over the rules file on disk.
|
|
198
|
+
let rulesScope;
|
|
199
|
+
const readRulesDocument = () => readRulesFromSettings(rulesScope);
|
|
184
200
|
// Plugin-owned data files live under $DSH_HOME (node_modules may be
|
|
185
201
|
// read-only). The default resolves to `$DSH_HOME` / `~/.dsh`, so a profile
|
|
186
202
|
// entry that omits `config` still records events, snapshots and learning.
|
|
187
203
|
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
204
|
// Host model-group services (typed minimally; supplied by the dsh runtime
|
|
220
205
|
// through the loader `inject` — dsh-approval-gate demonstrates the same
|
|
221
206
|
// contract). The llm service backs the `host` receiver; agentDefaultModel
|
|
@@ -356,19 +341,10 @@ export function apply(ctx, config = {}) {
|
|
|
356
341
|
: join(dataDir, 'learning.json'),
|
|
357
342
|
// The rules document lives in the plugin's data dir by default, so a
|
|
358
343
|
// `$DSH_HOME/perm-gate/rules.yml` the user writes is actually loaded without
|
|
359
|
-
// also having to declare `rulesFile` in the composition entry.
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
},
|
|
344
|
+
// also having to declare `rulesFile` in the composition entry.
|
|
345
|
+
rulesFile: resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir),
|
|
346
|
+
// Settings-sourced rules take precedence over the file when present.
|
|
347
|
+
readRulesDocument,
|
|
372
348
|
// The whole gate is scoped to the presets that opt into it (default: the
|
|
373
349
|
// `permissive` tier this plugin adds); elsewhere it stands down entirely.
|
|
374
350
|
gatePresets: resolveGatePresets(Array.isArray(config.gatePresets) ? config.gatePresets : undefined),
|
|
@@ -459,17 +435,15 @@ export function apply(ctx, config = {}) {
|
|
|
459
435
|
},
|
|
460
436
|
onChange: () => { },
|
|
461
437
|
onScope: (scope) => {
|
|
462
|
-
// Seed the editable whitelist from the rules
|
|
463
|
-
//
|
|
464
|
-
// patterns.
|
|
438
|
+
// Seed the editable whitelist from the rules file only when the namespace
|
|
439
|
+
// carries no override yet, so the card shows the current allow patterns.
|
|
465
440
|
const surface = asSurface(scope.get());
|
|
466
441
|
if (surface !== undefined && !Array.isArray(surface.allowlist)) {
|
|
467
442
|
const patterns = runtime.allowlist();
|
|
468
443
|
if (patterns.length > 0)
|
|
469
444
|
void scope.update({ allowlist: patterns.slice() });
|
|
470
445
|
}
|
|
471
|
-
// Card edits -> namespace ->
|
|
472
|
-
// writer, else the rules file mirror) + reload.
|
|
446
|
+
// Card edits -> namespace -> rulesFile (mirror + reload).
|
|
473
447
|
scope.watch(() => {
|
|
474
448
|
const next = asSurface(scope.get());
|
|
475
449
|
if (next !== undefined && Array.isArray(next.allowlist)) {
|
|
@@ -489,20 +463,19 @@ export function apply(ctx, config = {}) {
|
|
|
489
463
|
});
|
|
490
464
|
},
|
|
491
465
|
});
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
//
|
|
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.
|
|
466
|
+
// ─── Rules settings namespace (dsh-perm-gate-rules) ─────────────────────
|
|
467
|
+
// Registers the rules document in its own DSH settings namespace so the gate
|
|
468
|
+
// loads rules from settings (when configured) instead of the rules file.
|
|
497
469
|
installSettingsSection(ctx, RULES_NAMESPACE, RulesSchema, {}, {
|
|
498
470
|
setSource: (source) => {
|
|
499
|
-
|
|
500
|
-
},
|
|
501
|
-
onChange: () => {
|
|
502
|
-
runtime.reload();
|
|
471
|
+
rulesScope = { get: source };
|
|
503
472
|
},
|
|
473
|
+
onChange: () => { },
|
|
504
474
|
onScope: (scope) => {
|
|
505
|
-
|
|
475
|
+
rulesScope = scope;
|
|
476
|
+
// When rules change in settings, reload the runtime so the new ruleset
|
|
477
|
+
// takes effect on the next tool call.
|
|
478
|
+
scope.watch(() => { runtime.reload(); });
|
|
506
479
|
},
|
|
507
480
|
});
|
|
508
481
|
// Event feed HTTP API (best effort): the dsh webServer service exposes the
|
|
@@ -520,11 +493,6 @@ export function apply(ctx, config = {}) {
|
|
|
520
493
|
const offEvents = registerEventsRoute(webServer, runtime.eventLog);
|
|
521
494
|
if (offEvents !== undefined)
|
|
522
495
|
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
496
|
}
|
|
529
497
|
// Review-page plane (diff / revert / snapshot stats / snapshot clear): the
|
|
530
498
|
// approval-history view's file chips and snapshot bar drive these.
|
|
@@ -572,16 +540,15 @@ export function apply(ctx, config = {}) {
|
|
|
572
540
|
});
|
|
573
541
|
if (offDryRun !== undefined)
|
|
574
542
|
ctx.effect(() => () => { offDryRun(); }, 'dsh-perm-gate: dry-run route');
|
|
575
|
-
// Permissions view: the
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
// fallback resolves to otherwise.
|
|
543
|
+
// Permissions view: prefer the settings namespace (dsh-perm-gate-rules)
|
|
544
|
+
// when it carries a real configuration; fall back to the rules file.
|
|
545
|
+
const rulesFilePath = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
|
|
579
546
|
const offRules = registerRulesRoute(webServer, {
|
|
580
547
|
view: () => {
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
548
|
+
const settingsDoc = readRulesDocument();
|
|
549
|
+
return settingsDoc !== undefined
|
|
550
|
+
? readRulesViewFromSettings(settingsDoc)
|
|
551
|
+
: readRulesView(rulesFilePath);
|
|
585
552
|
},
|
|
586
553
|
});
|
|
587
554
|
if (offRules !== undefined)
|
|
@@ -731,10 +698,23 @@ export function apply(ctx, config = {}) {
|
|
|
731
698
|
networkRefs.snapshot = () => networkLifecycle.snapshot();
|
|
732
699
|
if (networkRefs.appliedKey === undefined)
|
|
733
700
|
networkRefs.appliedKey = networkKeyOf(current());
|
|
734
|
-
// ───
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
701
|
+
// ─── Hot reload watcher (Phase 3, T3.6) ────────────────────────────
|
|
702
|
+
const watchEnabled = typeof config.watch === 'boolean' ? config.watch : true;
|
|
703
|
+
if (watchEnabled) {
|
|
704
|
+
const watchDebounceMs = typeof config.watchDebounceMs === 'number' ? config.watchDebounceMs : 300;
|
|
705
|
+
const ruleWatcher = new RuleWatcher({
|
|
706
|
+
debounceMs: watchDebounceMs,
|
|
707
|
+
logger: { warn: (msg) => console.warn(msg) },
|
|
708
|
+
});
|
|
709
|
+
// Watch the effective rules file for the current workspace.
|
|
710
|
+
const rulesFile = resolveRulesFile(typeof config.rulesFile === 'string' ? config.rulesFile : undefined, dataDir);
|
|
711
|
+
const cwd = typeof config.cwd === 'string' ? config.cwd : process.cwd();
|
|
712
|
+
ruleWatcher.watch(cwd, [rulesFile], () => {
|
|
713
|
+
runtime.reload();
|
|
714
|
+
});
|
|
715
|
+
ctx.effect(() => () => {
|
|
716
|
+
ruleWatcher.closeAll();
|
|
717
|
+
}, 'dsh-perm-gate: rule watcher');
|
|
718
|
+
}
|
|
739
719
|
return runtime;
|
|
740
720
|
}
|