dsh-perm-gate 3.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/INSTALL.ja.md +8 -14
- package/INSTALL.ko.md +9 -15
- package/INSTALL.md +8 -16
- package/INSTALL.zh.md +7 -12
- package/README.ja.md +2 -3
- package/README.ko.md +2 -3
- package/README.md +1 -2
- package/README.zh.md +2 -3
- package/lib/client.js +160 -51
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +3 -0
- package/lib/config.js +37 -1
- package/lib/events.d.ts +35 -0
- package/lib/events.js +128 -0
- package/lib/index.js +44 -86
- package/package.json +10 -9
- package/docs/plan-rules-to-settings.md +0 -217
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/events.d.ts
CHANGED
|
@@ -100,6 +100,13 @@ export declare class EventLog {
|
|
|
100
100
|
/** Snapshot directory; `undefined` disables diff/revert support. */
|
|
101
101
|
private readonly snapshotsDir;
|
|
102
102
|
private seq;
|
|
103
|
+
/**
|
|
104
|
+
* Live subscribers, notified once per successfully appended event. They exist
|
|
105
|
+
* so a browser half can be pushed to instead of re-reading the whole JSONL on
|
|
106
|
+
* a timer; a subscriber that throws is dropped from the notification only, it
|
|
107
|
+
* never costs the append its event.
|
|
108
|
+
*/
|
|
109
|
+
private readonly listeners;
|
|
103
110
|
constructor(
|
|
104
111
|
/** JSONL path; `undefined` disables event recording entirely. */
|
|
105
112
|
filePath: string | undefined, now?: () => number,
|
|
@@ -107,6 +114,15 @@ export declare class EventLog {
|
|
|
107
114
|
snapshotsDir?: string | undefined);
|
|
108
115
|
/** Append one event; returns it, or undefined when recording is disabled/failed. */
|
|
109
116
|
append(input: GateEventInput): GateEvent | undefined;
|
|
117
|
+
/**
|
|
118
|
+
* Watch events as they are appended. The listener is called once per event,
|
|
119
|
+
* after the JSONL write, from inside `append` — so it runs on the gate's own
|
|
120
|
+
* decision path and must stay cheap and non-throwing.
|
|
121
|
+
*
|
|
122
|
+
* @param listener - receives each appended event.
|
|
123
|
+
* @returns the unsubscribe.
|
|
124
|
+
*/
|
|
125
|
+
subscribe(listener: (event: GateEvent) => void): () => void;
|
|
110
126
|
/** Events with `id > since`, optionally filtered to one session. */
|
|
111
127
|
query({ sessionId, since }?: {
|
|
112
128
|
sessionId?: string;
|
|
@@ -128,6 +144,7 @@ export interface WebServerLike {
|
|
|
128
144
|
}): () => void;
|
|
129
145
|
}
|
|
130
146
|
export declare const EVENTS_ROUTE = "/api/dsh-perm-gate/events";
|
|
147
|
+
export declare const STREAM_ROUTE = "/api/dsh-perm-gate/stream";
|
|
131
148
|
export declare const LEARNING_ROUTE = "/api/dsh-perm-gate/learning";
|
|
132
149
|
export declare const HEALTH_ROUTE = "/api/dsh-perm-gate/health";
|
|
133
150
|
export declare const NETWORK_ROUTE = "/api/dsh-perm-gate/network";
|
|
@@ -144,6 +161,24 @@ export declare const RULES_ROUTE = "/api/dsh-perm-gate/rules";
|
|
|
144
161
|
* unavailable — the host keeps running, only the HTTP API is missing).
|
|
145
162
|
*/
|
|
146
163
|
export declare function registerEventsRoute(server: unknown, log: EventLog): (() => void) | undefined;
|
|
164
|
+
/**
|
|
165
|
+
* Register `GET /api/dsh-perm-gate/stream?sessionId=&since=` on the webServer
|
|
166
|
+
* service: the same events {@link registerEventsRoute} serves, but pushed.
|
|
167
|
+
*
|
|
168
|
+
* The response is `text/event-stream`. On connect the backlog after `since` is
|
|
169
|
+
* written as one `data: {"events":[…],"backlog":true}` frame, then each event
|
|
170
|
+
* appended from that moment on is written as it lands as `data: {"events":[…]}` —
|
|
171
|
+
* so the browser half is never forced to re-read the whole JSONL on a timer.
|
|
172
|
+
* `: ping` comments hold the connection open while nothing is being decided.
|
|
173
|
+
*
|
|
174
|
+
* The session filter applies to both the backlog and the live pushes. Nothing
|
|
175
|
+
* is buffered for a disconnected client: it reconnects with its own cursor and
|
|
176
|
+
* the backlog frame restores it.
|
|
177
|
+
*
|
|
178
|
+
* Returns the unregister, or `undefined` when the webServer service is
|
|
179
|
+
* unavailable — the host keeps running, only the HTTP API is missing.
|
|
180
|
+
*/
|
|
181
|
+
export declare function registerStreamRoute(server: unknown, log: EventLog): (() => void) | undefined;
|
|
147
182
|
/** Delivers a revert instruction into the conversation (see index.ts wiring). */
|
|
148
183
|
export type SessionSender = (sessionId: string, content: string) => Promise<{
|
|
149
184
|
ok: boolean;
|
package/lib/events.js
CHANGED
|
@@ -224,6 +224,13 @@ export class EventLog {
|
|
|
224
224
|
now;
|
|
225
225
|
snapshotsDir;
|
|
226
226
|
seq = -1; // lazily restored from the file on first append
|
|
227
|
+
/**
|
|
228
|
+
* Live subscribers, notified once per successfully appended event. They exist
|
|
229
|
+
* so a browser half can be pushed to instead of re-reading the whole JSONL on
|
|
230
|
+
* a timer; a subscriber that throws is dropped from the notification only, it
|
|
231
|
+
* never costs the append its event.
|
|
232
|
+
*/
|
|
233
|
+
listeners = new Set();
|
|
227
234
|
constructor(
|
|
228
235
|
/** JSONL path; `undefined` disables event recording entirely. */
|
|
229
236
|
filePath, now = Date.now,
|
|
@@ -266,12 +273,34 @@ export class EventLog {
|
|
|
266
273
|
if ((ev.kind === 'auto' || ev.kind === 'ask') && this.snapshotsDir !== undefined && files !== undefined) {
|
|
267
274
|
saveEventSnapshots(this.snapshotsDir, ev.id, files, input.baseDir, ev.sessionId, this.now);
|
|
268
275
|
}
|
|
276
|
+
for (const listener of this.listeners) {
|
|
277
|
+
try {
|
|
278
|
+
listener(ev);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// A dead subscriber must not fail the append, and must not stop the others.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
269
284
|
return ev;
|
|
270
285
|
}
|
|
271
286
|
catch {
|
|
272
287
|
return undefined; // best-effort only
|
|
273
288
|
}
|
|
274
289
|
}
|
|
290
|
+
/**
|
|
291
|
+
* Watch events as they are appended. The listener is called once per event,
|
|
292
|
+
* after the JSONL write, from inside `append` — so it runs on the gate's own
|
|
293
|
+
* decision path and must stay cheap and non-throwing.
|
|
294
|
+
*
|
|
295
|
+
* @param listener - receives each appended event.
|
|
296
|
+
* @returns the unsubscribe.
|
|
297
|
+
*/
|
|
298
|
+
subscribe(listener) {
|
|
299
|
+
this.listeners.add(listener);
|
|
300
|
+
return () => {
|
|
301
|
+
this.listeners.delete(listener);
|
|
302
|
+
};
|
|
303
|
+
}
|
|
275
304
|
/** Events with `id > since`, optionally filtered to one session. */
|
|
276
305
|
query({ sessionId, since } = {}) {
|
|
277
306
|
if (this.filePath === undefined)
|
|
@@ -349,6 +378,7 @@ function readBody(req, limit = 1024 * 1024) {
|
|
|
349
378
|
});
|
|
350
379
|
}
|
|
351
380
|
export const EVENTS_ROUTE = '/api/dsh-perm-gate/events';
|
|
381
|
+
export const STREAM_ROUTE = '/api/dsh-perm-gate/stream';
|
|
352
382
|
export const LEARNING_ROUTE = '/api/dsh-perm-gate/learning';
|
|
353
383
|
export const HEALTH_ROUTE = '/api/dsh-perm-gate/health';
|
|
354
384
|
export const NETWORK_ROUTE = '/api/dsh-perm-gate/network';
|
|
@@ -395,6 +425,104 @@ export function registerEventsRoute(server, log) {
|
|
|
395
425
|
},
|
|
396
426
|
});
|
|
397
427
|
}
|
|
428
|
+
/** Keep-alive cadence of {@link registerStreamRoute}: a comment line that keeps an idle stream from being reaped. */
|
|
429
|
+
const STREAM_PING_MS = 15_000;
|
|
430
|
+
/**
|
|
431
|
+
* Register `GET /api/dsh-perm-gate/stream?sessionId=&since=` on the webServer
|
|
432
|
+
* service: the same events {@link registerEventsRoute} serves, but pushed.
|
|
433
|
+
*
|
|
434
|
+
* The response is `text/event-stream`. On connect the backlog after `since` is
|
|
435
|
+
* written as one `data: {"events":[…],"backlog":true}` frame, then each event
|
|
436
|
+
* appended from that moment on is written as it lands as `data: {"events":[…]}` —
|
|
437
|
+
* so the browser half is never forced to re-read the whole JSONL on a timer.
|
|
438
|
+
* `: ping` comments hold the connection open while nothing is being decided.
|
|
439
|
+
*
|
|
440
|
+
* The session filter applies to both the backlog and the live pushes. Nothing
|
|
441
|
+
* is buffered for a disconnected client: it reconnects with its own cursor and
|
|
442
|
+
* the backlog frame restores it.
|
|
443
|
+
*
|
|
444
|
+
* Returns the unregister, or `undefined` when the webServer service is
|
|
445
|
+
* unavailable — the host keeps running, only the HTTP API is missing.
|
|
446
|
+
*/
|
|
447
|
+
export function registerStreamRoute(server, log) {
|
|
448
|
+
const ws = routeServer(server);
|
|
449
|
+
if (ws === undefined)
|
|
450
|
+
return undefined;
|
|
451
|
+
return ws.register({
|
|
452
|
+
kind: 'exact',
|
|
453
|
+
path: STREAM_ROUTE,
|
|
454
|
+
handler: (rawReq, rawRes) => {
|
|
455
|
+
const req = rawReq;
|
|
456
|
+
const res = rawRes;
|
|
457
|
+
if (req.method !== 'GET') {
|
|
458
|
+
res.writeHead(405);
|
|
459
|
+
res.end();
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (res.write === undefined) {
|
|
463
|
+
// A server that cannot stream: fail the handshake instead of hanging the browser's EventSource.
|
|
464
|
+
res.writeHead(500);
|
|
465
|
+
res.end();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
let sessionId;
|
|
469
|
+
let since;
|
|
470
|
+
try {
|
|
471
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
472
|
+
sessionId = url.searchParams.get('sessionId') ?? undefined;
|
|
473
|
+
const raw = url.searchParams.get('since');
|
|
474
|
+
if (raw !== null && raw !== '')
|
|
475
|
+
since = Number.parseInt(raw, 10) || 0;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
// unparseable URL: stream the unfiltered tail
|
|
479
|
+
}
|
|
480
|
+
const scoped = sessionId !== undefined && sessionId !== '';
|
|
481
|
+
res.writeHead(200, {
|
|
482
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
483
|
+
'cache-control': 'no-cache, no-transform',
|
|
484
|
+
connection: 'keep-alive',
|
|
485
|
+
'x-accel-buffering': 'no',
|
|
486
|
+
});
|
|
487
|
+
const push = (events, backlog = false) => {
|
|
488
|
+
// The backlog frame is written even when it is empty: it marks where
|
|
489
|
+
// history stops, so the consumer never mistakes a live event for a replay.
|
|
490
|
+
if (events.length === 0 && !backlog)
|
|
491
|
+
return;
|
|
492
|
+
try {
|
|
493
|
+
res.write?.(`data: ${JSON.stringify(backlog ? { events, backlog: true } : { events })}\n\n`);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
// The socket died between the close event and this write; `close` owns the teardown.
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
push(log.query({ sessionId, since }), true);
|
|
500
|
+
const unsubscribe = log.subscribe((event) => {
|
|
501
|
+
if (scoped && event.sessionId !== sessionId)
|
|
502
|
+
return;
|
|
503
|
+
push([event]);
|
|
504
|
+
});
|
|
505
|
+
const keepAlive = setInterval(() => {
|
|
506
|
+
try {
|
|
507
|
+
res.write?.(': ping\n\n');
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// ignore: `close` owns the teardown
|
|
511
|
+
}
|
|
512
|
+
}, STREAM_PING_MS);
|
|
513
|
+
let closed = false;
|
|
514
|
+
const close = () => {
|
|
515
|
+
if (closed)
|
|
516
|
+
return;
|
|
517
|
+
closed = true;
|
|
518
|
+
clearInterval(keepAlive);
|
|
519
|
+
unsubscribe();
|
|
520
|
+
};
|
|
521
|
+
res.on?.('close', close);
|
|
522
|
+
res.on?.('error', close);
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
}
|
|
398
526
|
/**
|
|
399
527
|
* Register the review-page routes:
|
|
400
528
|
* `GET /diff?eventId=&path=` → before/after line diff for one file
|
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, resolveDshHome, resolveDataDir, ensureDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
|
|
6
4
|
import { runDryRun } from './dry-run.js';
|
|
7
5
|
import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
|
|
8
6
|
import { buildReceiverInfo } from './receiver-info.js';
|
|
9
|
-
import { readRulesView
|
|
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.
|
|
360
|
-
|
|
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
|
|
463
|
-
//
|
|
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 ->
|
|
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 {
|
|
@@ -567,18 +518,12 @@ export function apply(ctx, config = {}) {
|
|
|
567
518
|
});
|
|
568
519
|
if (offDryRun !== undefined)
|
|
569
520
|
ctx.effect(() => () => { offDryRun(); }, 'dsh-perm-gate: dry-run route');
|
|
570
|
-
// Permissions view: the document the gate is actually loading, shown
|
|
571
|
-
// the panel is never editing
|
|
572
|
-
// resolved exactly as the gate resolves it —
|
|
573
|
-
//
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
const doc = rulesFromSettings();
|
|
577
|
-
if (isRulesConfigured(doc))
|
|
578
|
-
return readRulesViewFromSettings(doc);
|
|
579
|
-
return readRulesView(rulesFilePath);
|
|
580
|
-
},
|
|
581
|
-
});
|
|
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) });
|
|
582
527
|
if (offRules !== undefined)
|
|
583
528
|
ctx.effect(() => () => { offRules(); }, 'dsh-perm-gate: rules route');
|
|
584
529
|
// Receiver projection for the settings card (provider/model catalog is
|
|
@@ -726,10 +671,23 @@ export function apply(ctx, config = {}) {
|
|
|
726
671
|
networkRefs.snapshot = () => networkLifecycle.snapshot();
|
|
727
672
|
if (networkRefs.appliedKey === undefined)
|
|
728
673
|
networkRefs.appliedKey = networkKeyOf(current());
|
|
729
|
-
// ───
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
+
}
|
|
734
692
|
return runtime;
|
|
735
693
|
}
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-perm-gate",
|
|
3
|
-
"version": "
|
|
4
|
-
"publishConfig": {
|
|
5
|
-
"registry": "https://registry.npmjs.org",
|
|
6
|
-
"access": "public",
|
|
7
|
-
"tag": "dsh-0.1.
|
|
3
|
+
"version": "4.1.0-beta.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"registry": "https://registry.npmjs.org",
|
|
6
|
+
"access": "public",
|
|
7
|
+
"tag": "dsh-0.1.5"
|
|
8
8
|
},
|
|
9
|
-
"description": "DSH permission-gate for the DeepSeek Harness 0.1.
|
|
9
|
+
"description": "DSH permission-gate for the DeepSeek Harness 0.1.5 line: a single self-sufficient, deterministic-first, fail-closed gate covering P0 hard-deny -> P1 session grant -> P2 static rule (allow/deny) chain -> P3 optional LLM semantic classifier -> P4 ask, with command whitelist/blacklist.",
|
|
10
10
|
"type": "module",
|
|
11
11
|
"files": [
|
|
12
12
|
"lib",
|
|
@@ -66,8 +66,8 @@
|
|
|
66
66
|
"dsh-perm-gate-patch-glyph": "./scripts/patch-permission-glyph.mjs"
|
|
67
67
|
},
|
|
68
68
|
"engines": {
|
|
69
|
-
"node": ">=
|
|
70
|
-
"dsh": ">=0.1.
|
|
69
|
+
"node": ">=24",
|
|
70
|
+
"dsh": ">=0.1.5-rc.1 <0.2.0-0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"@deepseek-ai/cordis": "^4.0.1"
|
|
@@ -106,7 +106,8 @@
|
|
|
106
106
|
"check:line": "node scripts/sync-0.1.5-line.mjs check",
|
|
107
107
|
"release:latest": "npm publish --tag latest",
|
|
108
108
|
"release:2x": "npm publish --tag 2.x",
|
|
109
|
-
"release:legacy": "npm publish --tag legacy"
|
|
109
|
+
"release:legacy": "npm publish --tag legacy",
|
|
110
|
+
"release:3x": "npm publish --tag dsh-0.1.5"
|
|
110
111
|
},
|
|
111
112
|
"license": "MIT",
|
|
112
113
|
"repository": {
|