dsh-perm-gate 0.2.0 → 0.2.1-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.ja.md +97 -0
- package/CHANGELOG.ko.md +96 -0
- package/CHANGELOG.md +146 -0
- package/INSTALL.ja.md +2 -2
- package/INSTALL.ko.md +2 -2
- package/INSTALL.md +3 -2
- package/INSTALL.zh.md +2 -2
- package/README.ja.md +29 -6
- package/README.ko.md +37 -8
- package/README.md +73 -11
- package/README.zh.md +44 -9
- package/cordis.patch.yml +19 -12
- package/lib/audit.d.ts +1 -1
- package/lib/classifier.d.ts +2 -0
- package/lib/classifier.js +7 -4
- package/lib/client.js +1786 -53
- package/lib/client.js.map +1 -1
- package/lib/config.d.ts +63 -4
- package/lib/config.js +50 -2
- package/lib/deny-defaults.d.ts +12 -0
- package/lib/deny-defaults.js +19 -0
- package/lib/engine.d.ts +13 -1
- package/lib/engine.js +85 -8
- package/lib/evaluate.d.ts +10 -0
- package/lib/evaluate.js +101 -11
- package/lib/events.d.ts +140 -4
- package/lib/events.js +578 -13
- package/lib/host-llm.d.ts +61 -0
- package/lib/host-llm.js +52 -0
- package/lib/index.d.ts +15 -1
- package/lib/index.js +266 -23
- package/lib/learning.d.ts +19 -2
- package/lib/learning.js +47 -5
- package/lib/llm-presets.d.ts +16 -0
- package/lib/llm-presets.js +11 -0
- package/lib/preset.d.ts +30 -0
- package/lib/preset.js +41 -0
- package/lib/receiver-info.d.ts +76 -0
- package/lib/receiver-info.js +70 -0
- package/lib/risk.d.ts +16 -1
- package/lib/risk.js +15 -3
- package/lib/runtime.d.ts +210 -4
- package/lib/runtime.js +589 -33
- package/package.json +6 -5
package/lib/events.js
CHANGED
|
@@ -2,22 +2,236 @@
|
|
|
2
2
|
* Gate decision events for dsh-perm-gate: a JSONL audit feed the browser half
|
|
3
3
|
* polls so every automatic allow / ask / deny is visible in the conversation UI.
|
|
4
4
|
*
|
|
5
|
+
* This module also owns the review-page data plane demonstrated by
|
|
6
|
+
* dsh-approval-gate: a per-event snapshot of the files a decision touched (the
|
|
7
|
+
* pre-change content), a line-level diff against the current content, and the
|
|
8
|
+
* revert / snapshot-management routes the approval-history view drives.
|
|
9
|
+
*
|
|
5
10
|
* Pure append-only logging: every I/O error is swallowed (events are best-effort
|
|
6
11
|
* and must never influence gating), and the id sequence resumes from the file on
|
|
7
12
|
* first use so restarts never reuse an id the client has already seen.
|
|
8
13
|
*/
|
|
9
|
-
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
10
|
-
import {
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
11
17
|
const REASON_MAX = 200;
|
|
18
|
+
const JUSTIFICATION_MAX = 400;
|
|
19
|
+
const FILES_MAX = 8;
|
|
20
|
+
// Snapshot bounds: one file ≤256KB, ≤5 files per event.
|
|
21
|
+
const SNAPSHOT_MAX_BYTES = 256 * 1024;
|
|
22
|
+
const SNAPSHOT_MAX_FILES = 5;
|
|
23
|
+
/** Text-file probe: binary payloads and oversized files are not snapshotted. */
|
|
24
|
+
const SNAPSHOT_BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
|
|
25
|
+
function isSnapshotText(buf) {
|
|
26
|
+
if (buf.length > SNAPSHOT_MAX_BYTES)
|
|
27
|
+
return false;
|
|
28
|
+
const head = buf.subarray(0, Math.min(buf.length, 8192));
|
|
29
|
+
return !SNAPSHOT_BINARY_RE.test(head.toString('latin1'));
|
|
30
|
+
}
|
|
31
|
+
/** Read one file as snapshot text; null when unreadable, binary, or oversized. */
|
|
32
|
+
export function readSnapshotFile(absPath) {
|
|
33
|
+
try {
|
|
34
|
+
const buf = readFileSync(absPath);
|
|
35
|
+
return isSnapshotText(buf) ? buf.toString('utf8') : null;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Device/pseudo files carry no reviewable content — never snapshotted. */
|
|
42
|
+
function isDevicePath(absPath) {
|
|
43
|
+
return /^(\/dev\/|\/proc\/|\/sys\/)/.test(absPath)
|
|
44
|
+
|| /^[A-Za-z]:[\\/](?:nul|con|prn|aux|com\d|lpt\d)$/i.test(absPath);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve a path from a decision into an absolute path: `~` expands to home,
|
|
48
|
+
* absolute paths pass through, relative paths try the session cwd, the process
|
|
49
|
+
* cwd, then home (first existing wins; otherwise the first candidate).
|
|
50
|
+
*/
|
|
51
|
+
export function resolveAbsPath(p, baseDir) {
|
|
52
|
+
const raw = String(p ?? '');
|
|
53
|
+
if (raw === '')
|
|
54
|
+
return raw;
|
|
55
|
+
if (raw.startsWith('~'))
|
|
56
|
+
return join(homedir(), raw.slice(1));
|
|
57
|
+
if (raw.startsWith('/') || /^[A-Za-z]:[\\/]/.test(raw) || raw.startsWith('\\\\'))
|
|
58
|
+
return raw;
|
|
59
|
+
const candidates = [baseDir, process.cwd(), homedir()].filter((b) => typeof b === 'string' && b !== '');
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const b of candidates) {
|
|
62
|
+
const abs = join(b, raw);
|
|
63
|
+
if (seen.has(abs))
|
|
64
|
+
continue;
|
|
65
|
+
seen.add(abs);
|
|
66
|
+
if (existsSync(abs))
|
|
67
|
+
return abs;
|
|
68
|
+
}
|
|
69
|
+
return join(candidates[0] ?? process.cwd(), raw);
|
|
70
|
+
}
|
|
71
|
+
/** Write the pre-change snapshot of every file one event touched. */
|
|
72
|
+
export function saveEventSnapshots(dir, eventId, files, baseDir, sessionId, now = Date.now) {
|
|
73
|
+
if (files.length === 0)
|
|
74
|
+
return;
|
|
75
|
+
const snapshots = [];
|
|
76
|
+
const seen = new Set();
|
|
77
|
+
for (const f of files) {
|
|
78
|
+
if (snapshots.length >= SNAPSHOT_MAX_FILES)
|
|
79
|
+
break;
|
|
80
|
+
const abs = resolveAbsPath(f, baseDir);
|
|
81
|
+
if (abs === '' || seen.has(abs) || isDevicePath(abs))
|
|
82
|
+
continue;
|
|
83
|
+
seen.add(abs);
|
|
84
|
+
const content = readSnapshotFile(abs);
|
|
85
|
+
// Missing, binary, oversized, or empty content has no diff meaning.
|
|
86
|
+
if (content === null || content === '')
|
|
87
|
+
continue;
|
|
88
|
+
snapshots.push({ path: abs, content, ts: new Date(now()).toISOString() });
|
|
89
|
+
}
|
|
90
|
+
if (snapshots.length === 0)
|
|
91
|
+
return;
|
|
92
|
+
try {
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
const body = {
|
|
95
|
+
eventId,
|
|
96
|
+
sessionId: String(sessionId ?? ''),
|
|
97
|
+
cwd: baseDir ?? process.cwd(),
|
|
98
|
+
snapshots,
|
|
99
|
+
};
|
|
100
|
+
writeFileSync(join(dir, `${eventId}.json`), JSON.stringify(body, null, 2), 'utf8');
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// snapshot persistence is best-effort; the event itself is already logged
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/** Load one event's snapshots ([] when absent or corrupt). */
|
|
107
|
+
export function loadEventSnapshots(dir, eventId) {
|
|
108
|
+
try {
|
|
109
|
+
const data = JSON.parse(readFileSync(join(dir, `${eventId}.json`), 'utf8'));
|
|
110
|
+
return Array.isArray(data.snapshots) ? data.snapshots : [];
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Whether one snapshot file belongs to a session (no sessionId = legacy, never matches a filter). */
|
|
117
|
+
function snapshotMatchesSession(absPath, sessionId) {
|
|
118
|
+
if (sessionId === '')
|
|
119
|
+
return true;
|
|
120
|
+
try {
|
|
121
|
+
const data = JSON.parse(readFileSync(absPath, 'utf8'));
|
|
122
|
+
return String(data.sessionId ?? '') === String(sessionId);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Line-level diff with context: a greedy in-order LCS approximation (each `a`
|
|
130
|
+
* line matches the next unused equal `b` line), then change windows of ±`CTX`
|
|
131
|
+
* context lines clustered into hunks. Ported from dsh-approval-gate so both
|
|
132
|
+
* review pages render identical diffs.
|
|
133
|
+
*/
|
|
134
|
+
export function diffLines(before, after, contextLines = 5) {
|
|
135
|
+
const CTX = Number.isInteger(contextLines) && contextLines >= 0 ? contextLines : 5;
|
|
136
|
+
const a = String(before ?? '').split('\n');
|
|
137
|
+
const b = String(after ?? '').split('\n');
|
|
138
|
+
const bPos = new Map();
|
|
139
|
+
for (let j = 0; j < b.length; j += 1) {
|
|
140
|
+
const list = bPos.get(b[j]);
|
|
141
|
+
if (list === undefined)
|
|
142
|
+
bPos.set(b[j], [j]);
|
|
143
|
+
else
|
|
144
|
+
list.push(j);
|
|
145
|
+
}
|
|
146
|
+
const aMatch = new Array(a.length).fill(-1);
|
|
147
|
+
const bUsed = new Array(b.length).fill(false);
|
|
148
|
+
let limit = 0;
|
|
149
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
150
|
+
const q = bPos.get(a[i]);
|
|
151
|
+
if (q === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
for (const pos of q) {
|
|
154
|
+
if (pos >= limit && !bUsed[pos]) {
|
|
155
|
+
aMatch[i] = pos;
|
|
156
|
+
bUsed[pos] = true;
|
|
157
|
+
limit = pos + 1;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const ops = [];
|
|
163
|
+
let i = 0;
|
|
164
|
+
let j = 0;
|
|
165
|
+
while (i < a.length || j < b.length) {
|
|
166
|
+
if (i < a.length && aMatch[i] >= 0) {
|
|
167
|
+
const target = aMatch[i];
|
|
168
|
+
while (j < target) {
|
|
169
|
+
ops.push({ type: 'add', bNo: j + 1, text: b[j] });
|
|
170
|
+
j += 1;
|
|
171
|
+
}
|
|
172
|
+
ops.push({ type: 'same', aNo: i + 1, bNo: target + 1, text: a[i] });
|
|
173
|
+
j = target + 1;
|
|
174
|
+
i += 1;
|
|
175
|
+
}
|
|
176
|
+
else if (i < a.length) {
|
|
177
|
+
ops.push({ type: 'del', aNo: i + 1, text: a[i] });
|
|
178
|
+
i += 1;
|
|
179
|
+
}
|
|
180
|
+
else {
|
|
181
|
+
ops.push({ type: 'add', bNo: j + 1, text: b[j] });
|
|
182
|
+
j += 1;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const show = new Array(ops.length).fill(false);
|
|
186
|
+
for (let idx = 0; idx < ops.length; idx += 1) {
|
|
187
|
+
if (ops[idx].type === 'same')
|
|
188
|
+
continue;
|
|
189
|
+
for (let k = Math.max(0, idx - CTX); k <= Math.min(ops.length - 1, idx + CTX); k += 1)
|
|
190
|
+
show[k] = true;
|
|
191
|
+
}
|
|
192
|
+
const hunks = [];
|
|
193
|
+
let hiddenBefore = 0;
|
|
194
|
+
let pending = [];
|
|
195
|
+
let started = false;
|
|
196
|
+
for (let idx = 0; idx < ops.length; idx += 1) {
|
|
197
|
+
if (show[idx]) {
|
|
198
|
+
started = true;
|
|
199
|
+
pending.push(ops[idx]);
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
if (started && pending.length > 0) {
|
|
203
|
+
hunks.push({ hiddenBefore, lines: pending });
|
|
204
|
+
pending = [];
|
|
205
|
+
started = false;
|
|
206
|
+
}
|
|
207
|
+
hiddenBefore += 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (pending.length > 0 && started)
|
|
211
|
+
hunks.push({ hiddenBefore, lines: pending });
|
|
212
|
+
if (hunks.length > 0)
|
|
213
|
+
hunks[0] = { hiddenBefore: 0, lines: hunks[0].lines };
|
|
214
|
+
const added = ops.filter((o) => o.type === 'add').length;
|
|
215
|
+
const removed = ops.filter((o) => o.type === 'del').length;
|
|
216
|
+
return {
|
|
217
|
+
hunks,
|
|
218
|
+
stats: { added, removed, contextLines: Math.max(a.length, b.length) - (added + removed) },
|
|
219
|
+
changedLines: ops.filter((o) => o.type !== 'same').slice(0, 500),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
12
222
|
export class EventLog {
|
|
13
223
|
filePath;
|
|
14
224
|
now;
|
|
225
|
+
snapshotsDir;
|
|
15
226
|
seq = -1; // lazily restored from the file on first append
|
|
16
227
|
constructor(
|
|
17
228
|
/** JSONL path; `undefined` disables event recording entirely. */
|
|
18
|
-
filePath, now = Date.now
|
|
229
|
+
filePath, now = Date.now,
|
|
230
|
+
/** Snapshot directory; `undefined` disables diff/revert support. */
|
|
231
|
+
snapshotsDir = undefined) {
|
|
19
232
|
this.filePath = filePath;
|
|
20
233
|
this.now = now;
|
|
234
|
+
this.snapshotsDir = snapshotsDir;
|
|
21
235
|
}
|
|
22
236
|
/** Append one event; returns it, or undefined when recording is disabled/failed. */
|
|
23
237
|
append(input) {
|
|
@@ -25,6 +239,9 @@ export class EventLog {
|
|
|
25
239
|
return undefined;
|
|
26
240
|
try {
|
|
27
241
|
const id = this.nextId();
|
|
242
|
+
const files = input.files === undefined || input.files.length === 0
|
|
243
|
+
? undefined
|
|
244
|
+
: input.files.slice(0, FILES_MAX);
|
|
28
245
|
const ev = {
|
|
29
246
|
id,
|
|
30
247
|
ts: new Date(this.now()).toISOString(),
|
|
@@ -33,9 +250,22 @@ export class EventLog {
|
|
|
33
250
|
kind: input.kind,
|
|
34
251
|
...(input.risk === undefined ? {} : { risk: input.risk }),
|
|
35
252
|
reason: String(input.reason).slice(0, REASON_MAX),
|
|
253
|
+
...(input.verdict === undefined ? {} : { verdict: input.verdict }),
|
|
254
|
+
...(input.justification === undefined ? {} : { justification: String(input.justification).slice(0, JUSTIFICATION_MAX) }),
|
|
255
|
+
...(input.mode === undefined ? {} : { mode: input.mode }),
|
|
256
|
+
...(input.category === undefined ? {} : { category: input.category }),
|
|
257
|
+
...(files === undefined ? {} : { files }),
|
|
258
|
+
...(input.learningCount === undefined ? {} : { learningCount: input.learningCount }),
|
|
259
|
+
...(input.threshold === undefined ? {} : { threshold: input.threshold }),
|
|
36
260
|
};
|
|
37
261
|
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
38
262
|
appendFileSync(this.filePath, JSON.stringify(ev) + '\n', 'utf8');
|
|
263
|
+
// `auto`/`ask` are both "before the change lands" moments (an approved ask
|
|
264
|
+
// executes right after): snapshot the files so the review page can diff
|
|
265
|
+
// and revert them later.
|
|
266
|
+
if ((ev.kind === 'auto' || ev.kind === 'ask') && this.snapshotsDir !== undefined && files !== undefined) {
|
|
267
|
+
saveEventSnapshots(this.snapshotsDir, ev.id, files, input.baseDir, ev.sessionId, this.now);
|
|
268
|
+
}
|
|
39
269
|
return ev;
|
|
40
270
|
}
|
|
41
271
|
catch {
|
|
@@ -73,6 +303,10 @@ export class EventLog {
|
|
|
73
303
|
}
|
|
74
304
|
return out;
|
|
75
305
|
}
|
|
306
|
+
/** One event by id (undefined when absent). */
|
|
307
|
+
byId(id) {
|
|
308
|
+
return this.query().find((ev) => ev.id === id);
|
|
309
|
+
}
|
|
76
310
|
nextId() {
|
|
77
311
|
if (this.seq < 0) {
|
|
78
312
|
this.seq = 0;
|
|
@@ -85,19 +319,53 @@ export class EventLog {
|
|
|
85
319
|
return this.seq;
|
|
86
320
|
}
|
|
87
321
|
}
|
|
322
|
+
function routeServer(server) {
|
|
323
|
+
if (typeof server !== 'object' || server === null)
|
|
324
|
+
return undefined;
|
|
325
|
+
const candidate = server;
|
|
326
|
+
return typeof candidate.register === 'function' ? server : undefined;
|
|
327
|
+
}
|
|
328
|
+
function json(res, code, body) {
|
|
329
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' });
|
|
330
|
+
res.end(JSON.stringify(body));
|
|
331
|
+
}
|
|
332
|
+
function readBody(req, limit = 1024 * 1024) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
let body = '';
|
|
335
|
+
req.on?.('data', (chunk) => {
|
|
336
|
+
body += String(chunk);
|
|
337
|
+
if (body.length > limit)
|
|
338
|
+
reject(new Error('payload too large'));
|
|
339
|
+
});
|
|
340
|
+
req.on?.('error', (err) => reject(err instanceof Error ? err : new Error(String(err))));
|
|
341
|
+
req.on?.('end', () => {
|
|
342
|
+
try {
|
|
343
|
+
resolve(body === '' ? {} : JSON.parse(body));
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
88
351
|
export const EVENTS_ROUTE = '/api/dsh-perm-gate/events';
|
|
352
|
+
export const LEARNING_ROUTE = '/api/dsh-perm-gate/learning';
|
|
353
|
+
export const HEALTH_ROUTE = '/api/dsh-perm-gate/health';
|
|
354
|
+
export const RECEIVER_ROUTE = '/api/dsh-perm-gate/receiver';
|
|
355
|
+
export const DIFF_ROUTE = '/api/dsh-perm-gate/diff';
|
|
356
|
+
export const REVERT_ROUTE = '/api/dsh-perm-gate/revert';
|
|
357
|
+
export const SNAPSHOTS_STATS_ROUTE = '/api/dsh-perm-gate/snapshots-stats';
|
|
358
|
+
export const SNAPSHOTS_CLEAR_ROUTE = '/api/dsh-perm-gate/snapshots-clear';
|
|
89
359
|
/**
|
|
90
360
|
* Register `GET /api/dsh-perm-gate/events?sessionId=&since=` on the webServer
|
|
91
361
|
* service. Returns whether the route was registered (false when the service is
|
|
92
362
|
* unavailable — the host keeps running, only the HTTP API is missing).
|
|
93
363
|
*/
|
|
94
364
|
export function registerEventsRoute(server, log) {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return false;
|
|
100
|
-
candidate.register({
|
|
365
|
+
const ws = routeServer(server);
|
|
366
|
+
if (ws === undefined)
|
|
367
|
+
return undefined;
|
|
368
|
+
return ws.register({
|
|
101
369
|
kind: 'exact',
|
|
102
370
|
path: EVENTS_ROUTE,
|
|
103
371
|
handler: (rawReq, rawRes) => {
|
|
@@ -120,10 +388,307 @@ export function registerEventsRoute(server, log) {
|
|
|
120
388
|
catch {
|
|
121
389
|
// unparseable URL: answer with the unfiltered tail
|
|
122
390
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
391
|
+
json(res, 200, { events: log.query({ sessionId, since }) });
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Register the review-page routes:
|
|
397
|
+
* `GET /diff?eventId=&path=` → before/after line diff for one file
|
|
398
|
+
* `POST /revert` → deliver a revert instruction into the session
|
|
399
|
+
* `GET /snapshots-stats?sessionId=` → snapshot count/bytes/ids/file map
|
|
400
|
+
* `POST /snapshots-clear` → drop snapshots (session-scoped or all)
|
|
401
|
+
*/
|
|
402
|
+
export function registerReviewRoutes(server, deps) {
|
|
403
|
+
const ws = routeServer(server);
|
|
404
|
+
if (ws === undefined)
|
|
405
|
+
return [];
|
|
406
|
+
const { log, snapshotsDir, send, audit } = deps;
|
|
407
|
+
const offs = [];
|
|
408
|
+
offs.push(ws.register({
|
|
409
|
+
kind: 'exact',
|
|
410
|
+
path: DIFF_ROUTE,
|
|
411
|
+
handler: (rawReq, rawRes) => {
|
|
412
|
+
const req = rawReq;
|
|
413
|
+
const res = rawRes;
|
|
414
|
+
try {
|
|
415
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
416
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (snapshotsDir === undefined) {
|
|
420
|
+
json(res, 404, { ok: false, error: '快照目录未配置' });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
424
|
+
const eventId = Number.parseInt(url.searchParams.get('eventId') ?? '', 10);
|
|
425
|
+
const path = url.searchParams.get('path') ?? '';
|
|
426
|
+
if (!Number.isInteger(eventId) || path === '') {
|
|
427
|
+
json(res, 400, { ok: false, error: 'eventId/path 必填' });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const snaps = loadEventSnapshots(snapshotsDir, eventId);
|
|
431
|
+
// The client sends the raw path recorded on the event (absolute,
|
|
432
|
+
// relative, or a bare name): align it against the snapshot's absolute
|
|
433
|
+
// path, falling back to a suffix/basename match.
|
|
434
|
+
const base = resolveAbsPath(path);
|
|
435
|
+
const baseName = path.split(/[\\/]/).pop() ?? '';
|
|
436
|
+
const snap = snaps.find((s) => s.path === base || s.path === path)
|
|
437
|
+
?? snaps.find((s) => s.path.endsWith('/' + path)
|
|
438
|
+
|| (baseName !== '' && (s.path.endsWith('/' + baseName) || s.path.endsWith('\\' + baseName))));
|
|
439
|
+
if (snap === undefined) {
|
|
440
|
+
json(res, 404, { ok: false, error: '该事件没有此文件的快照' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const after = readSnapshotFile(snap.path);
|
|
444
|
+
const result = diffLines(snap.content, after);
|
|
445
|
+
json(res, 200, {
|
|
446
|
+
ok: true,
|
|
447
|
+
path,
|
|
448
|
+
eventId,
|
|
449
|
+
beforeExists: true,
|
|
450
|
+
afterExists: after !== null,
|
|
451
|
+
changedLines: result.changedLines,
|
|
452
|
+
hunks: result.hunks,
|
|
453
|
+
stats: result.stats,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
catch (e) {
|
|
457
|
+
json(res, 400, { ok: false, error: String(e?.message ?? e) });
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
}));
|
|
461
|
+
offs.push(ws.register({
|
|
462
|
+
kind: 'exact',
|
|
463
|
+
path: REVERT_ROUTE,
|
|
464
|
+
handler: (rawReq, rawRes) => {
|
|
465
|
+
const req = rawReq;
|
|
466
|
+
const res = rawRes;
|
|
467
|
+
if (req.method !== 'POST') {
|
|
468
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
void (async () => {
|
|
472
|
+
try {
|
|
473
|
+
const body = await readBody(req);
|
|
474
|
+
const sessionId = String(body.sessionId ?? '');
|
|
475
|
+
const eventId = Number.parseInt(String(body.eventId ?? ''), 10);
|
|
476
|
+
if (sessionId === '' || !Number.isInteger(eventId)) {
|
|
477
|
+
json(res, 400, { ok: false, error: 'sessionId/eventId 必填' });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const event = log.byId(eventId);
|
|
481
|
+
if (event === undefined) {
|
|
482
|
+
json(res, 404, { ok: false, error: '未找到该事件' });
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const files = (event.files ?? []).map((f) => '`' + f + '`').join('、');
|
|
486
|
+
const snaps = snapshotsDir === undefined ? [] : loadEventSnapshots(snapshotsDir, eventId);
|
|
487
|
+
const snapHint = snaps.length > 0
|
|
488
|
+
? '改动前的文件内容快照保存在 ' + snapshotsDir + '(按事件 ID 命名),可参考恢复;请确认改动内容后执行撤销。'
|
|
489
|
+
: '注意:该事件已无可用快照(可能已被清除),请基于当前文件内容判断如何恢复原状;无法确定时请先说明再操作。';
|
|
490
|
+
const content = '请撤销以下自动放行操作带来的文件改动(恢复为审批前的状态):\n'
|
|
491
|
+
+ '- 操作:' + (event.justification ?? event.reason ?? '(无说明)') + '\n'
|
|
492
|
+
+ '- 涉及文件:' + (files !== '' ? files : '(未知)') + '\n'
|
|
493
|
+
+ '- 判定:' + (event.verdict ?? event.kind) + '(自动放行)\n'
|
|
494
|
+
+ '- 事件时间:' + event.ts + '\n'
|
|
495
|
+
+ snapHint;
|
|
496
|
+
const result = await send(sessionId, content);
|
|
497
|
+
audit?.(`REVERT event=${eventId} session=${sessionId} via=${result.via ?? 'none'}`);
|
|
498
|
+
json(res, result.ok ? 200 : 500, result);
|
|
499
|
+
}
|
|
500
|
+
catch (e) {
|
|
501
|
+
json(res, 400, { ok: false, error: String(e?.message ?? e) });
|
|
502
|
+
}
|
|
503
|
+
})();
|
|
504
|
+
},
|
|
505
|
+
}));
|
|
506
|
+
offs.push(ws.register({
|
|
507
|
+
kind: 'exact',
|
|
508
|
+
path: SNAPSHOTS_STATS_ROUTE,
|
|
509
|
+
handler: (rawReq, rawRes) => {
|
|
510
|
+
const req = rawReq;
|
|
511
|
+
const res = rawRes;
|
|
512
|
+
try {
|
|
513
|
+
if (snapshotsDir === undefined) {
|
|
514
|
+
json(res, 200, { ok: true, count: 0, bytes: 0, ids: [], files: {}, sessionId: null });
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
518
|
+
const filterSession = url.searchParams.get('sessionId') ?? '';
|
|
519
|
+
let count = 0;
|
|
520
|
+
let bytes = 0;
|
|
521
|
+
const ids = [];
|
|
522
|
+
const files = {};
|
|
523
|
+
for (const name of readdirSafe(snapshotsDir)) {
|
|
524
|
+
if (!name.endsWith('.json'))
|
|
525
|
+
continue;
|
|
526
|
+
const abs = join(snapshotsDir, name);
|
|
527
|
+
if (filterSession !== '' && !snapshotMatchesSession(abs, filterSession))
|
|
528
|
+
continue;
|
|
529
|
+
const id = name.slice(0, -'.json'.length);
|
|
530
|
+
count += 1;
|
|
531
|
+
ids.push(id);
|
|
532
|
+
bytes += statSize(abs);
|
|
533
|
+
try {
|
|
534
|
+
const data = JSON.parse(readFileSync(abs, 'utf8'));
|
|
535
|
+
if (Array.isArray(data.snapshots)) {
|
|
536
|
+
files[id] = data.snapshots.map((s) => String(s?.path ?? '')).filter((p) => p !== '');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
// corrupt snapshot file: still counted, no file map
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
json(res, 200, { ok: true, count, bytes, ids, files, sessionId: filterSession !== '' ? filterSession : null });
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
json(res, 400, { ok: false, error: String(e?.message ?? e) });
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
}));
|
|
550
|
+
offs.push(ws.register({
|
|
551
|
+
kind: 'exact',
|
|
552
|
+
path: SNAPSHOTS_CLEAR_ROUTE,
|
|
553
|
+
handler: (rawReq, rawRes) => {
|
|
554
|
+
const req = rawReq;
|
|
555
|
+
const res = rawRes;
|
|
556
|
+
if (req.method !== 'POST') {
|
|
557
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
void (async () => {
|
|
561
|
+
try {
|
|
562
|
+
const body = await readBody(req);
|
|
563
|
+
const filterSession = String(body.sessionId ?? '');
|
|
564
|
+
let removed = 0;
|
|
565
|
+
if (snapshotsDir !== undefined) {
|
|
566
|
+
for (const name of readdirSafe(snapshotsDir)) {
|
|
567
|
+
if (!name.endsWith('.json'))
|
|
568
|
+
continue;
|
|
569
|
+
const abs = join(snapshotsDir, name);
|
|
570
|
+
if (filterSession !== '' && !snapshotMatchesSession(abs, filterSession))
|
|
571
|
+
continue;
|
|
572
|
+
try {
|
|
573
|
+
rmSync(abs, { force: true });
|
|
574
|
+
removed += 1;
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// skip unremovable entry
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
audit?.(`CONFIG snapshots-clear session=${filterSession !== '' ? filterSession : '*'} removed=${removed}`);
|
|
582
|
+
json(res, 200, { ok: true, removed, sessionId: filterSession !== '' ? filterSession : null });
|
|
583
|
+
}
|
|
584
|
+
catch (e) {
|
|
585
|
+
json(res, 400, { ok: false, error: String(e?.message ?? e) });
|
|
586
|
+
}
|
|
587
|
+
})();
|
|
588
|
+
},
|
|
589
|
+
}));
|
|
590
|
+
return offs;
|
|
591
|
+
}
|
|
592
|
+
function readdirSafe(dir) {
|
|
593
|
+
try {
|
|
594
|
+
return readdirSync(dir);
|
|
595
|
+
}
|
|
596
|
+
catch {
|
|
597
|
+
return [];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function statSize(absPath) {
|
|
601
|
+
try {
|
|
602
|
+
return statSync(absPath).size;
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Register `GET /api/dsh-perm-gate/receiver` — the receiver projection for the
|
|
610
|
+
* settings card: the effective provider/model plus (host mode) the live
|
|
611
|
+
* provider/model-group catalog from the DSH `llm` service.
|
|
612
|
+
*/
|
|
613
|
+
export function registerReceiverRoute(server, provider) {
|
|
614
|
+
const ws = routeServer(server);
|
|
615
|
+
if (ws === undefined)
|
|
616
|
+
return undefined;
|
|
617
|
+
return ws.register({
|
|
618
|
+
kind: 'exact',
|
|
619
|
+
path: RECEIVER_ROUTE,
|
|
620
|
+
handler: (rawReq, rawRes) => {
|
|
621
|
+
const req = rawReq;
|
|
622
|
+
const res = rawRes;
|
|
623
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
624
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
provider.info().then((info) => json(res, 200, { ok: true, ...info }), (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
|
|
628
|
+
},
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Register `POST /api/dsh-perm-gate/health` — runs one minimal completion
|
|
633
|
+
* through the currently configured llmAssist receiver and returns
|
|
634
|
+
* `{ ok, ms, detail }` (the settings card's health test).
|
|
635
|
+
*/
|
|
636
|
+
export function registerHealthRoute(server, provider) {
|
|
637
|
+
const ws = routeServer(server);
|
|
638
|
+
if (ws === undefined)
|
|
639
|
+
return undefined;
|
|
640
|
+
return ws.register({
|
|
641
|
+
kind: 'exact',
|
|
642
|
+
path: HEALTH_ROUTE,
|
|
643
|
+
handler: (rawReq, rawRes) => {
|
|
644
|
+
const req = rawReq;
|
|
645
|
+
const res = rawRes;
|
|
646
|
+
if (req.method !== 'POST') {
|
|
647
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
provider.check().then((result) => json(res, 200, { ...result, ok: true }), (e) => json(res, 500, { ok: false, error: String(e?.message ?? e) }));
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Register the learning-store routes on the webServer service:
|
|
656
|
+
* `GET /api/dsh-perm-gate/learning` → the store snapshot + live threshold,
|
|
657
|
+
* `POST /api/dsh-perm-gate/learning` → `{ key, fp? }` terminates one key's
|
|
658
|
+
* learning or drops one sedimented sample. Returns whether registered.
|
|
659
|
+
*/
|
|
660
|
+
export function registerLearningRoute(server, provider) {
|
|
661
|
+
const ws = routeServer(server);
|
|
662
|
+
if (ws === undefined)
|
|
663
|
+
return undefined;
|
|
664
|
+
return ws.register({
|
|
665
|
+
kind: 'exact',
|
|
666
|
+
path: LEARNING_ROUTE,
|
|
667
|
+
handler: (rawReq, rawRes) => {
|
|
668
|
+
const req = rawReq;
|
|
669
|
+
const res = rawRes;
|
|
670
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
671
|
+
json(res, 200, { ok: true, threshold: provider.threshold(), ...provider.snapshot() });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (req.method !== 'POST') {
|
|
675
|
+
json(res, 405, { ok: false, error: 'method not allowed' });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
void (async () => {
|
|
679
|
+
try {
|
|
680
|
+
const parsed = await readBody(req);
|
|
681
|
+
if (typeof parsed.key !== 'string' || parsed.key === '') {
|
|
682
|
+
json(res, 400, { ok: false, error: 'missing key' });
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
provider.reset(parsed.key, typeof parsed.fp === 'string' && parsed.fp !== '' ? parsed.fp : undefined);
|
|
686
|
+
json(res, 200, { ok: true });
|
|
687
|
+
}
|
|
688
|
+
catch (e) {
|
|
689
|
+
json(res, 400, { ok: false, error: String(e?.message ?? e) });
|
|
690
|
+
}
|
|
691
|
+
})();
|
|
126
692
|
},
|
|
127
693
|
});
|
|
128
|
-
return true;
|
|
129
694
|
}
|