dsh-perm-gate 3.0.0 → 4.0.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/CHANGELOG.md +15 -0
- package/INSTALL.ja.md +6 -6
- package/INSTALL.ko.md +4 -4
- package/INSTALL.md +7 -7
- package/INSTALL.zh.md +5 -5
- package/README.ja.md +2 -3
- package/README.ko.md +2 -3
- package/README.md +2 -3
- package/README.zh.md +2 -3
- package/dsh.plugin.json +2 -2
- package/lib/client.js +162 -89
- package/lib/client.js.map +1 -1
- package/lib/events.d.ts +35 -0
- package/lib/events.js +128 -0
- package/lib/index.js +6 -1
- package/package.json +10 -9
- package/docs/plan-rules-to-settings.md +0 -217
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
|
@@ -4,7 +4,7 @@ import { parse } from 'yaml';
|
|
|
4
4
|
import { Config, RULES_NAMESPACE, RulesSchema, isRulesConfigured, readRulesFromSettings, resolveDshHome, resolveDataDir, resolveGatePresets, resolvePermissiveStrategies, resolveRulesFile } from './config.js';
|
|
5
5
|
import { appendAllowToSettings, replaceAllowInSettings } from './allowlist.js';
|
|
6
6
|
import { runDryRun } from './dry-run.js';
|
|
7
|
-
import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute } from './events.js';
|
|
7
|
+
import { registerDryRunRoute, registerEventsRoute, registerHealthRoute, registerLearningRoute, registerNetworkRoute, registerReceiverRoute, registerReviewRoutes, registerRulesRoute, registerStreamRoute } from './events.js';
|
|
8
8
|
import { buildReceiverInfo } from './receiver-info.js';
|
|
9
9
|
import { readRulesView, readRulesViewFromSettings } from './rules-view.js';
|
|
10
10
|
import { PermGateRuntime } from './runtime.js';
|
|
@@ -520,6 +520,11 @@ export function apply(ctx, config = {}) {
|
|
|
520
520
|
const offEvents = registerEventsRoute(webServer, runtime.eventLog);
|
|
521
521
|
if (offEvents !== undefined)
|
|
522
522
|
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');
|
|
523
528
|
}
|
|
524
529
|
// Review-page plane (diff / revert / snapshot stats / snapshot clear): the
|
|
525
530
|
// approval-history view's file chips and snapshot bar drive these.
|
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.0.0",
|
|
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": {
|
|
@@ -1,217 +0,0 @@
|
|
|
1
|
-
# Plan: rules.yml → DSH Settings Namespace Migration
|
|
2
|
-
|
|
3
|
-
## Goal
|
|
4
|
-
|
|
5
|
-
Eliminate the external `rules.yml` file dependency by migrating rules into the DSH settings namespace (`dsh-perm-gate.rules`). This solves the cold-start directory problem at the root cause: settings are managed by the framework and loaded before any plugin `apply()` runs.
|
|
6
|
-
|
|
7
|
-
## Current Architecture
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
User edits rules.yml (or allowlist card writes to it)
|
|
11
|
-
↓
|
|
12
|
-
resolveRulesFile() → path string
|
|
13
|
-
↓
|
|
14
|
-
readFileSafe() → raw YAML text
|
|
15
|
-
↓
|
|
16
|
-
parsePermissionsDocument() → structured doc
|
|
17
|
-
↓
|
|
18
|
-
compileDocument() → CompiledRuleset
|
|
19
|
-
↓
|
|
20
|
-
PermGateRuntime.decide() → verdict
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
Read-only view:
|
|
24
|
-
```
|
|
25
|
-
GET /api/dsh-perm-gate/rules
|
|
26
|
-
→ readRulesView(rulesFile)
|
|
27
|
-
→ statSync + readFileSync + parse + compile
|
|
28
|
-
→ RulesView { path, exists, bytes, hash, raw, counts, error }
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Write path (allowlist card):
|
|
32
|
-
```
|
|
33
|
-
appendAllowCommand() / replaceAllowCommands()
|
|
34
|
-
→ readFileSync → parse → mutate → stringify → writeFileSync
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
## Target Architecture
|
|
38
|
-
|
|
39
|
-
```
|
|
40
|
-
User edits via settings card (or API)
|
|
41
|
-
↓
|
|
42
|
-
settings.get('dsh-perm-gate.rules') → structured object (already parsed)
|
|
43
|
-
↓
|
|
44
|
-
compileDocument(rules) → CompiledRuleset
|
|
45
|
-
↓
|
|
46
|
-
PermGateRuntime.decide() → verdict
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
Read-only view:
|
|
50
|
-
```
|
|
51
|
-
GET /api/dsh-perm-gate/rules
|
|
52
|
-
→ settings.get('dsh-perm-gate.rules')
|
|
53
|
-
→ RulesView { source: 'settings', rules: {...}, counts, hash }
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Write path (allowlist card):
|
|
57
|
-
```
|
|
58
|
-
settings.update({ rules: { allow: [...] } })
|
|
59
|
-
→ framework persists to settings.yaml
|
|
60
|
-
→ settings.watch() triggers reload
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
## Schema (config.ts)
|
|
64
|
-
|
|
65
|
-
```typescript
|
|
66
|
-
// New settings schema for rules
|
|
67
|
-
const RuleEntrySchema = z.object({
|
|
68
|
-
command: z.array(z.string()).optional(),
|
|
69
|
-
args: z.array(z.string()).optional(),
|
|
70
|
-
tools: z.array(z.string()).optional(),
|
|
71
|
-
network: z.object({
|
|
72
|
-
domains: z.array(z.string()),
|
|
73
|
-
}).optional(),
|
|
74
|
-
reason: z.string().default(''),
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
const RulesSchema = z.object({
|
|
78
|
-
defaultAction: z.enum(['allow', 'deny', 'ask']).default('ask'),
|
|
79
|
-
deny: z.array(RuleEntrySchema).default([]),
|
|
80
|
-
allow: z.array(RuleEntrySchema).default([]),
|
|
81
|
-
ask: z.array(RuleEntrySchema).default([]),
|
|
82
|
-
network: z.object({
|
|
83
|
-
domains: z.array(z.string()).default([]),
|
|
84
|
-
}).optional(),
|
|
85
|
-
}).default({ defaultAction: 'ask', deny: [], allow: [] })
|
|
86
|
-
```
|
|
87
|
-
|
|
88
|
-
## Migration Steps
|
|
89
|
-
|
|
90
|
-
### Phase 1: Dual-Source (backward compatible)
|
|
91
|
-
|
|
92
|
-
1. **Add `rules` to settings schema** — new field in `dsh-perm-gate` namespace
|
|
93
|
-
2. **Runtime reads from settings first, falls back to file** — `settings.get('rules') ?? loadRulesFile()`
|
|
94
|
-
3. **Allowlist card writes to settings** — `settings.update({ rules: { allow: [...] } })`
|
|
95
|
-
4. **Rules view serves from settings** — `readRulesView()` reads from settings object, not file
|
|
96
|
-
5. **Module-level: still create dataDir** — belt-and-suspenders for the transition period
|
|
97
|
-
|
|
98
|
-
### Phase 2: File Deprecation
|
|
99
|
-
|
|
100
|
-
6. **Log deprecation warning** — when `rulesFile` is configured, warn that it's deprecated
|
|
101
|
-
7. **Import button** — UI can import existing `rules.yml` into settings
|
|
102
|
-
8. **Export button** — UI can export settings rules as YAML for version control
|
|
103
|
-
|
|
104
|
-
### Phase 3: File Removal
|
|
105
|
-
|
|
106
|
-
9. **Remove `rulesFile` config option** — no longer accepted
|
|
107
|
-
10. **Remove file I/O code** — `allowlist.ts`, `rules-view.ts` file operations
|
|
108
|
-
11. **Remove chokidar watcher** — settings has built-in `watch()`
|
|
109
|
-
12. **Remove dataDir bootstrap** — no longer needed
|
|
110
|
-
|
|
111
|
-
## How the Read-Only View Changes
|
|
112
|
-
|
|
113
|
-
### Before (file-based)
|
|
114
|
-
|
|
115
|
-
```json
|
|
116
|
-
{
|
|
117
|
-
"path": "C:\\Users\\joshua\\.dsh\\perm-gate\\rules.yml",
|
|
118
|
-
"exists": true,
|
|
119
|
-
"bytes": 1513,
|
|
120
|
-
"hash": "abc123...",
|
|
121
|
-
"lines": 61,
|
|
122
|
-
"raw": "permissions:\n defaultAction: ask\n deny:\n ...",
|
|
123
|
-
"truncated": false,
|
|
124
|
-
"defaultAction": "ask",
|
|
125
|
-
"counts": { "allow": 11, "deny": 1, "ask": 0 },
|
|
126
|
-
"error": null
|
|
127
|
-
}
|
|
128
|
-
```
|
|
129
|
-
|
|
130
|
-
### After (settings-based)
|
|
131
|
-
|
|
132
|
-
```json
|
|
133
|
-
{
|
|
134
|
-
"source": "settings",
|
|
135
|
-
"defaultAction": "ask",
|
|
136
|
-
"counts": { "allow": 11, "deny": 1, "ask": 0 },
|
|
137
|
-
"hash": "abc123...",
|
|
138
|
-
"rules": {
|
|
139
|
-
"defaultAction": "ask",
|
|
140
|
-
"deny": [
|
|
141
|
-
{ "tools": ["shell", "pwsh"], "command": ["rm#recursive"], "reason": "destructive" }
|
|
142
|
-
],
|
|
143
|
-
"allow": [
|
|
144
|
-
{ "command": ["grep"], "reason": "permissive allowlist" }
|
|
145
|
-
],
|
|
146
|
-
"network": {
|
|
147
|
-
"domains": ["github.com", "registry.npmjs.org"]
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
**Key differences:**
|
|
154
|
-
- `path` / `exists` / `bytes` / `raw` / `truncated` → removed (no file)
|
|
155
|
-
- `source: 'settings'` → added (provenance)
|
|
156
|
-
- `rules` → the structured object (already parsed, not raw YAML)
|
|
157
|
-
- `counts` / `defaultAction` / `hash` → kept (useful for UI)
|
|
158
|
-
|
|
159
|
-
## How the Allowlist Write Path Changes
|
|
160
|
-
|
|
161
|
-
### Before (file-based)
|
|
162
|
-
|
|
163
|
-
```typescript
|
|
164
|
-
// appendAllowCommand reads YAML, mutates, writes back
|
|
165
|
-
appendAllowCommand(rulesFile, 'git push', 'permissive allow-everywhere')
|
|
166
|
-
// → readFileSync → parse → push → stringify → writeFileSync
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### After (settings-based)
|
|
170
|
-
|
|
171
|
-
```typescript
|
|
172
|
-
// Direct settings update
|
|
173
|
-
const current = settings.get('dsh-perm-gate.rules')
|
|
174
|
-
const allow = [...(current.allow ?? []), { command: ['git push'], reason: 'permissive allow-everywhere' }]
|
|
175
|
-
settings.update({ rules: { ...current, allow } })
|
|
176
|
-
// → framework persists → settings.watch() triggers reload
|
|
177
|
-
```
|
|
178
|
-
|
|
179
|
-
## Files Changed
|
|
180
|
-
|
|
181
|
-
| File | Change |
|
|
182
|
-
|---|---|
|
|
183
|
-
| `src/config.ts` | Add `RulesSchema`, keep `resolveRulesFile` for backward compat |
|
|
184
|
-
| `src/index.ts` | Read from settings first, fall back to file; register settings schema |
|
|
185
|
-
| `src/runtime.ts` | Accept `rules` object directly (no file read) |
|
|
186
|
-
| `src/rules-view.ts` | Add `readRulesViewFromSettings()` |
|
|
187
|
-
| `src/allowlist.ts` | Add `appendAllowToSettings()`, `replaceAllowInSettings()` |
|
|
188
|
-
| `src/events.ts` | Update `registerRulesRoute` to use settings view |
|
|
189
|
-
| `src/watch.ts` | Remove chokidar watcher (settings has built-in watch) |
|
|
190
|
-
|
|
191
|
-
## Cold-Start Resilience (All Plugins)
|
|
192
|
-
|
|
193
|
-
The same pattern should be applied to other plugins that read files at startup:
|
|
194
|
-
|
|
195
|
-
| Plugin | Data | Risk | Fix |
|
|
196
|
-
|---|---|---|---|
|
|
197
|
-
| `dsh-perm-gate` | rules.yml | HIGH | Settings migration (this plan) |
|
|
198
|
-
| `dsh-search-index` | index files | MEDIUM | dataDir bootstrap + try/catch |
|
|
199
|
-
| `dsh-session-guard` | guard config | LOW | Already settings-backed |
|
|
200
|
-
| `dsh-prime-memory` | memory files | MEDIUM | dataDir bootstrap + try/catch |
|
|
201
|
-
| `dsh-context-compression` | config | LOW | Already settings-backed |
|
|
202
|
-
|
|
203
|
-
**Universal pattern:**
|
|
204
|
-
1. Module-level `ensureDataDir()` — belt
|
|
205
|
-
2. Read paths: `dataDirReady()` check → empty defaults if missing
|
|
206
|
-
3. Write paths: `ensureDataDir()` before first write
|
|
207
|
-
4. All file reads wrapped in try/catch with graceful fallback
|
|
208
|
-
|
|
209
|
-
## Testing
|
|
210
|
-
|
|
211
|
-
- [ ] Unit: `compileDocument` with settings-sourced rules (same as YAML-sourced)
|
|
212
|
-
- [ ] Unit: `readRulesViewFromSettings` returns correct `RulesView`
|
|
213
|
-
- [ ] Unit: `appendAllowToSettings` correctly mutates settings object
|
|
214
|
-
- [ ] Integration: settings card reads and displays rules
|
|
215
|
-
- [ ] Integration: allowlist card writes persist to settings
|
|
216
|
-
- [ ] Migration: existing `rules.yml` imports into settings correctly
|
|
217
|
-
- [ ] Cold start: plugin loads with empty settings (no rules configured)
|