dsh-dashcam 1.12.1 → 2.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.
@@ -0,0 +1,487 @@
1
+ // dsh-dashcam 面板宿主半侧:把账本摘要成只读 HTTP API,供注入 DSH Web 的浏览器面板消费。
2
+ //
3
+ // 路由(全部 GET、no-store、环回 Host 校验——与 DSH web server 的 loopback-only
4
+ // bind 对齐,防 DNS rebinding 页面订阅):
5
+ // GET /api/dsh-dashcam/status 聚合快照(模式/计数/最近事件/会话)
6
+ // GET /api/dsh-dashcam/records?since=&to=&limit= 增量记录(已脱敏成展示形状)
7
+ // GET /api/dsh-dashcam/history 会话聚类(时间间隔 > 15 分钟切段,短缓存)
8
+ //
9
+ // 安全姿势(与 dsh-deepseek-tan-pet 面板同款):
10
+ // - 只读;只暴露粗粒度事实(路径/命令/主机名、计数、决策、reason);
11
+ // - llm.request 的 scope.sample(消息 head 样本)不透出;llm.inbound 只透出
12
+ // 截断到 48 字的 head(用户自己的话,本地展示与账本同源);
13
+ // - 哈希链字段(hash/prevHash/mac)不透出——展示用不到,不给篡改者对照面;
14
+ // - 历史聚类全量读文件,带 size+mtime 键的 10s 缓存,避免每次打开都全量扫描。
15
+
16
+ import { open, stat, readFile } from 'node:fs/promises';
17
+
18
+ export const STATUS_PATH = '/api/dsh-dashcam/status';
19
+ export const RECORDS_PATH = '/api/dsh-dashcam/records';
20
+ export const HISTORY_PATH = '/api/dsh-dashcam/history';
21
+
22
+ const TAIL_MAX_BYTES = 4 * 1024 * 1024;
23
+ const HISTORY_CACHE_TTL_MS = 10_000;
24
+ const SESSION_GAP_MS = 15 * 60 * 1000;
25
+ const RECORDS_LIMIT_DEFAULT = 100;
26
+ const RECORDS_LIMIT_MAX = 2000;
27
+ const HEAD_LIMIT = 48;
28
+ const HISTORY_MAX_SESSIONS = 60;
29
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
30
+
31
+ export function isLoopbackRequest(req) {
32
+ const host = String((req && req.headers && req.headers.host) || '').toLowerCase();
33
+ const hostname = host.replace(/:\d+$/, '').trim();
34
+ if (hostname.startsWith('[')) return LOOPBACK_HOSTS.has(hostname) || LOOPBACK_HOSTS.has(hostname.slice(1, -1));
35
+ return LOOPBACK_HOSTS.has(hostname);
36
+ }
37
+
38
+ function capStrings(value, cap) {
39
+ if (!Array.isArray(value)) return [];
40
+ return value.filter((item) => typeof item === 'string' && item.length > 0).slice(0, cap);
41
+ }
42
+
43
+ // 从文件尾部读至多 maxBytes,返回升序解析的记录数组;损坏行跳过,文件缺失返回 []。
44
+ export async function readLedgerTail(ledgerPath, maxBytes = TAIL_MAX_BYTES) {
45
+ let handle;
46
+ try {
47
+ handle = await open(ledgerPath, 'r');
48
+ } catch {
49
+ return [];
50
+ }
51
+ try {
52
+ const info = await handle.stat();
53
+ const length = Math.min(info.size, maxBytes);
54
+ const buffer = Buffer.alloc(length);
55
+ await handle.read(buffer, 0, length, info.size - length);
56
+ let text = buffer.toString('utf8');
57
+ if (length < info.size) text = text.slice(text.indexOf('\n') + 1); // 丢掉头部半行
58
+ const records = [];
59
+ for (const line of text.split(/\r?\n/)) {
60
+ const trimmed = line.trim();
61
+ if (!trimmed) continue;
62
+ try {
63
+ records.push(JSON.parse(trimmed));
64
+ } catch {
65
+ // 账本写入中途的半行,忽略
66
+ }
67
+ }
68
+ return records;
69
+ } finally {
70
+ await handle.close();
71
+ }
72
+ }
73
+
74
+ export async function readLedgerAll(ledgerPath) {
75
+ let text;
76
+ try {
77
+ text = await readFile(ledgerPath, 'utf8');
78
+ } catch {
79
+ return [];
80
+ }
81
+ const records = [];
82
+ for (const line of text.split(/\r?\n/)) {
83
+ const trimmed = line.trim();
84
+ if (!trimmed) continue;
85
+ try {
86
+ records.push(JSON.parse(trimmed));
87
+ } catch {
88
+ // 同上
89
+ }
90
+ }
91
+ return records;
92
+ }
93
+
94
+ // 尾部解析缓存:status 3s + records 2s 的轮询下,不缓存意味着每 2–3 秒重读并
95
+ // 逐行 JSON.parse 至多 4MB(约每分钟 50 次)。键同 history 的做法(size+mtime),
96
+ // 文件没变就复用解析结果;追加/轮转会换键,不会吃到旧账。
97
+ const TAIL_CACHE_TTL_MS = 10_000;
98
+ const tailCache = new Map();
99
+
100
+ async function readLedgerTailCached(ledgerPath) {
101
+ let info;
102
+ try {
103
+ info = await stat(ledgerPath);
104
+ } catch {
105
+ return [];
106
+ }
107
+ const key = `${info.size}:${info.mtimeMs}`;
108
+ const cached = tailCache.get(ledgerPath);
109
+ if (cached && cached.key === key && Date.now() - cached.at < TAIL_CACHE_TTL_MS) return cached.records;
110
+ const records = await readLedgerTail(ledgerPath);
111
+ tailCache.set(ledgerPath, { key, at: Date.now(), records });
112
+ return records;
113
+ }
114
+
115
+ // 记录 → 展示形状。字段刻意收窄:不含 hash/prevHash/mac、不含 llm.request 的
116
+ // sample 数组、不含 baseline/coverage 全量结构。
117
+ export function sanitizeRecord(record) {
118
+ if (!record || typeof record !== 'object') return null;
119
+ const capability = typeof record.capability === 'string' ? record.capability : '';
120
+ const action = typeof record.action === 'string' ? record.action : capability;
121
+ const ruleId = typeof record.ruleId === 'string' ? record.ruleId : '';
122
+ const ts = typeof record.ts === 'string' ? record.ts : null;
123
+ if (!ts) return null;
124
+ const scope = record.scope && typeof record.scope === 'object' ? record.scope : {};
125
+ const out = {
126
+ seq: Number(record.seq) || 0,
127
+ ts,
128
+ type: 'tel',
129
+ title: action || 'record',
130
+ detail: typeof record.reason === 'string' ? record.reason : '',
131
+ denied: record.decision === 'deny',
132
+ intercepted: record.intercepted === true,
133
+ ruleId,
134
+ extra: {},
135
+ };
136
+
137
+ const paths = capStrings(scope.paths, 8);
138
+ const commands = capStrings(scope.commands, 4);
139
+ let hosts = capStrings(scope.hosts, 4);
140
+ if (!hosts.length && typeof scope.host === 'string' && scope.host) hosts = [scope.host];
141
+
142
+ if (capability === 'fs.read' || capability === 'cred.read') {
143
+ out.type = 'read';
144
+ out.title = paths[0] ? `读取 ${paths[0]}` : '读取';
145
+ out.detail = paths.length > 1 ? `等 ${paths.length} 个路径` : paths[0] || out.detail;
146
+ if (paths.length) out.extra.paths = paths;
147
+ } else if (capability === 'fs.write') {
148
+ out.type = 'edit';
149
+ out.title = paths[0] ? `编辑 ${paths[0]}` : '编辑';
150
+ out.detail = paths.length > 1 ? `等 ${paths.length} 个路径` : paths[0] || out.detail;
151
+ if (paths.length) out.extra.paths = paths;
152
+ } else if (capability === 'fs.delete') {
153
+ out.type = 'edit';
154
+ out.title = paths[0] ? `删除 ${paths[0]}` : '删除';
155
+ out.detail = paths.length > 1 ? `等 ${paths.length} 个路径` : paths[0] || out.detail;
156
+ if (paths.length) out.extra.paths = paths;
157
+ } else if (capability === 'proc.spawn') {
158
+ out.type = 'bash';
159
+ out.title = commands[0] ? `命令 ${commands[0]}` : '命令';
160
+ out.detail = commands.length > 1 ? `等 ${commands.length} 条` : commands[0] || out.detail;
161
+ if (commands.length) out.extra.commands = commands;
162
+ } else if (capability === 'net.outbound') {
163
+ out.type = 'net';
164
+ out.title = hosts[0] ? `出网 ${hosts[0]}` : '出网';
165
+ if (hosts.length) out.extra.hosts = hosts;
166
+ } else if (capability === 'llm.ingress') {
167
+ if (action === 'llm.request') {
168
+ out.type = 'think';
169
+ out.title = 'LLM 请求';
170
+ const messages = scope.messages && typeof scope.messages === 'object' ? scope.messages : null;
171
+ const delta = messages && messages.delta && typeof messages.delta === 'object' ? messages.delta : null;
172
+ const model = typeof scope.model === 'string' && scope.model
173
+ ? scope.model
174
+ : typeof scope.provider === 'string' && scope.provider
175
+ ? scope.provider
176
+ : null;
177
+ const count = messages && Number.isFinite(messages.count) ? messages.count : null;
178
+ out.detail = [
179
+ model,
180
+ count !== null ? `${count} 条消息` : null,
181
+ delta ? `+${delta.added ?? 0}/−${delta.removed ?? 0}` : null,
182
+ ]
183
+ .filter(Boolean)
184
+ .join(' · ');
185
+ if (model) out.extra.model = model;
186
+ if (typeof scope.sessionId === 'string' && scope.sessionId) out.extra.sessionId = scope.sessionId;
187
+ } else if (action === 'llm.inbound') {
188
+ out.type = 'think';
189
+ out.title = '入站消息';
190
+ const kind = typeof scope.kind === 'string' ? scope.kind : 'user';
191
+ const len = Number.isFinite(scope.len) ? scope.len : null;
192
+ const head = typeof scope.head === 'string' && scope.head ? scope.head.slice(0, HEAD_LIMIT) : '';
193
+ out.detail = [kind, len !== null ? `${len} 字` : null, head].filter(Boolean).join(' · ');
194
+ if (typeof scope.sessionId === 'string' && scope.sessionId) out.extra.sessionId = scope.sessionId;
195
+ } else if (action === 'llm.anomaly') {
196
+ out.type = 'error';
197
+ out.title = '入站异常';
198
+ out.detail = [typeof scope.kind === 'string' ? scope.kind : null, Number.isFinite(scope.count) ? `× ${scope.count}` : null]
199
+ .filter(Boolean)
200
+ .join(' ');
201
+ }
202
+ } else if (capability === 'telemetry') {
203
+ out.type = 'tel';
204
+ if (ruleId === 'commandline-audit' || action === 'proc.commandline') {
205
+ out.title = '命令行存档';
206
+ out.detail = typeof scope.commandLine === 'string' && scope.commandLine
207
+ ? scope.commandLine.slice(0, 160)
208
+ : typeof scope.commandLineHash === 'string' && scope.commandLineHash
209
+ ? scope.commandLineHash.slice(7, 19)
210
+ : '';
211
+ if (typeof scope.commandLine === 'string' && scope.commandLine) {
212
+ out.extra.commandLine = scope.commandLine.slice(0, 300);
213
+ }
214
+ } else if (action === 'unmapped-tool') {
215
+ out.title = scope.tool ? `未映射 · ${scope.tool}` : '未映射工具';
216
+ } else if (action === 'unjudgeable-tool') {
217
+ out.title = scope.tool ? `无法判定 · ${scope.tool}` : '无法判定';
218
+ out.detail = typeof scope.detail === 'string' ? scope.detail : out.detail;
219
+ } else if (ruleId === 'mount' || action === 'mount') {
220
+ out.title = '判定链挂载';
221
+ out.detail = scope.mode ? `模式 ${scope.mode}` : '';
222
+ } else if (ruleId === 'ingress-mount' || action === 'ingress-mount') {
223
+ out.title = 'ingress 挂载';
224
+ out.detail = scope.enabled === false ? '未启用' : '';
225
+ } else if (ruleId === 'ingress-disabled' || action === 'ingress-disabled') {
226
+ out.title = 'ingress 关闭';
227
+ }
228
+ }
229
+ return out;
230
+ }
231
+
232
+ // 聚合快照:以最后一条 mount 记录为当前运行段起点;找不到(尾部窗口太短)就
233
+ // 退化为整个尾部窗口并标记 windowed=true,前端如实显示。
234
+ export function computeStatus(records, meta = {}) {
235
+ let mount = null;
236
+ for (let i = records.length - 1; i >= 0; i -= 1) {
237
+ const record = records[i];
238
+ if (record.action === 'mount' && record.ruleId === 'mount') {
239
+ mount = record;
240
+ break;
241
+ }
242
+ }
243
+ const runSeq = mount ? mount.seq : 0;
244
+ // unmapped:映射表外工具的 telemetry/unmapped-tool 账(MCP、workflow 等都在表外),
245
+ // 与纳管五项分开计数——否则「工具调用」卡片对 MCP 重的会话恒为 0。
246
+ // bypass:advisory 下 decision=deny 但 intercepted=false 的旁路命中(guards.js
247
+ // 口径:旁路模式照样落 deny 账)——不算异常,否则旁路模式下红色卡片常亮失去警示。
248
+ const counts = {
249
+ read: 0, write: 0, del: 0, spawn: 0, net: 0, llm: 0,
250
+ telemetry: 0, unmapped: 0, deny: 0, bypass: 0, intercepted: 0, anomaly: 0,
251
+ };
252
+ const files = new Set();
253
+ let lastRequest = null;
254
+ for (const record of records) {
255
+ if (record.seq <= runSeq) continue;
256
+ const capability = record.capability;
257
+ const scope = record.scope && typeof record.scope === 'object' ? record.scope : {};
258
+ if (capability === 'fs.read' || capability === 'cred.read') counts.read += 1;
259
+ else if (capability === 'fs.write') {
260
+ counts.write += 1;
261
+ for (const path of capStrings(scope.paths, 64)) files.add(path);
262
+ } else if (capability === 'fs.delete') counts.del += 1;
263
+ else if (capability === 'proc.spawn') counts.spawn += 1;
264
+ else if (capability === 'net.outbound') counts.net += 1;
265
+ else if (capability === 'llm.ingress') counts.llm += 1;
266
+ else if (capability === 'telemetry') {
267
+ counts.telemetry += 1;
268
+ if (record.action === 'unmapped-tool') counts.unmapped += 1;
269
+ }
270
+ if (record.decision === 'deny' && capability !== 'telemetry') {
271
+ counts.deny += 1;
272
+ if (record.intercepted === true) counts.intercepted += 1;
273
+ else counts.bypass += 1;
274
+ }
275
+ if (record.action === 'llm.anomaly') counts.anomaly += 1;
276
+ if (record.action === 'llm.request') lastRequest = record;
277
+ }
278
+ const judgments = counts.read + counts.write + counts.del + counts.spawn + counts.net;
279
+ const last = records.length ? records[records.length - 1] : null;
280
+ const lastScope = lastRequest && lastRequest.scope && typeof lastRequest.scope === 'object' ? lastRequest.scope : null;
281
+ return {
282
+ ok: true,
283
+ now: new Date().toISOString(),
284
+ version: meta.version || (mount && mount.pluginVersion) || null,
285
+ mode: (mount && mount.scope && mount.scope.mode) || null,
286
+ startedAt: mount ? mount.ts : records.length ? records[0].ts : null,
287
+ windowed: !mount,
288
+ ledgerSeq: last ? last.seq : 0,
289
+ ledgerPath: meta.ledgerPath || null,
290
+ counts,
291
+ judgments,
292
+ // errors 只算真异常:已拦截(intercepted=true)或 ingress anomaly。
293
+ // advisory 的 deny 是旁路命中,走 counts.bypass 单独展示。
294
+ errors: counts.intercepted + counts.anomaly,
295
+ filesTouched: files.size,
296
+ session: lastScope
297
+ ? {
298
+ id: typeof lastScope.sessionId === 'string' ? lastScope.sessionId : null,
299
+ model:
300
+ (typeof lastScope.model === 'string' && lastScope.model) ||
301
+ (typeof lastScope.provider === 'string' && lastScope.provider) ||
302
+ null,
303
+ }
304
+ : null,
305
+ lastEvent: last ? sanitizeRecord(last) : null,
306
+ };
307
+ }
308
+
309
+ // 会话聚类:相邻记录间隔 > 15 分钟切段;含最后一条记录且距现在 < 15 分钟的段
310
+ // 标记 recording。返回新的在上,至多 60 段。
311
+ export function clusterSessions(records, now = Date.now()) {
312
+ const sessions = [];
313
+ let current = null;
314
+ let prevMs = 0;
315
+ for (const record of records) {
316
+ const ms = Date.parse(record.ts || '');
317
+ if (!Number.isFinite(ms)) continue;
318
+ if (!current || ms - prevMs > SESSION_GAP_MS) {
319
+ current = { from: record.seq, start: record.ts, end: record.ts, startMs: ms, endMs: ms, events: 0, errors: 0, to: record.seq };
320
+ sessions.push(current);
321
+ }
322
+ current.end = record.ts;
323
+ current.endMs = ms;
324
+ current.to = record.seq;
325
+ current.events += 1;
326
+ // 口径与 computeStatus.errors 一致:只算已拦截或 anomaly(advisory 的 deny
327
+ // 是旁路命中,不算录像段异常)。
328
+ if (record.intercepted === true || record.action === 'llm.anomaly') {
329
+ current.errors += 1;
330
+ }
331
+ prevMs = ms;
332
+ }
333
+ const list = sessions.map((session, index) => ({
334
+ id: `S-${String(session.from).padStart(4, '0')}`,
335
+ started: session.start,
336
+ ended: session.end,
337
+ duration: Math.max(0, Math.round((session.endMs - session.startMs) / 1000)),
338
+ events: session.events,
339
+ errors: session.errors,
340
+ from: session.from,
341
+ to: session.to,
342
+ status: index === sessions.length - 1 && now - session.endMs < SESSION_GAP_MS ? 'recording' : 'completed',
343
+ }));
344
+ list.reverse();
345
+ return list.slice(0, HISTORY_MAX_SESSIONS);
346
+ }
347
+
348
+ function parseQuery(req) {
349
+ const url = new URL(req.url || '/', 'http://localhost');
350
+ const since = Number(url.searchParams.get('since'));
351
+ const toRaw = Number(url.searchParams.get('to'));
352
+ const limit = Number(url.searchParams.get('limit'));
353
+ return {
354
+ since: Number.isFinite(since) && since > 0 ? Math.floor(since) : 0,
355
+ to: Number.isFinite(toRaw) && toRaw > 0 ? Math.floor(toRaw) : null,
356
+ limit: Number.isFinite(limit) && limit > 0 ? Math.min(Math.floor(limit), RECORDS_LIMIT_MAX) : RECORDS_LIMIT_DEFAULT,
357
+ };
358
+ }
359
+
360
+ async function serveStatus(req, res, deps) {
361
+ const records = await readLedgerTail(deps.ledgerPath);
362
+ const body = JSON.stringify(computeStatus(records, deps));
363
+ res.writeHead(200, {
364
+ 'content-type': 'application/json; charset=utf-8',
365
+ 'cache-control': 'no-store',
366
+ 'content-length': Buffer.byteLength(body),
367
+ });
368
+ res.end(body);
369
+ }
370
+
371
+ async function serveRecords(req, res, deps) {
372
+ const { since, to, limit } = parseQuery(req);
373
+ const records = await readLedgerTailCached(deps.ledgerPath);
374
+ // 游标口径:lastSeq 只取**实际返回**的最后一条 seq(此前是对整个文件取 max,
375
+ // 首屏 limit 截断时游标直接跳到文件尾部,中间的记录就永久丢了)。
376
+ // 空结果回退 since——前端有 >lastSeq 守护,不会倒退。truncated 标记后面还有
377
+ // 匹配记录被 limit 截掉,前端回放据此显示「已截断」。
378
+ let lastSeq = since;
379
+ let truncated = false;
380
+ const out = [];
381
+ for (const record of records) {
382
+ const seq = Number(record.seq) || 0;
383
+ if (seq <= since) continue;
384
+ if (to && seq > to) continue;
385
+ if (out.length >= limit) {
386
+ truncated = true;
387
+ break;
388
+ }
389
+ const sanitized = sanitizeRecord(record);
390
+ if (sanitized) {
391
+ out.push(sanitized);
392
+ lastSeq = sanitized.seq;
393
+ }
394
+ }
395
+ const body = JSON.stringify({ ok: true, lastSeq, truncated, records: out });
396
+ res.writeHead(200, {
397
+ 'content-type': 'application/json; charset=utf-8',
398
+ 'cache-control': 'no-store',
399
+ 'content-length': Buffer.byteLength(body),
400
+ });
401
+ res.end(body);
402
+ }
403
+
404
+ const historyCache = new Map();
405
+
406
+ async function serveHistory(req, res, deps) {
407
+ let info;
408
+ try {
409
+ info = await stat(deps.ledgerPath);
410
+ } catch {
411
+ const body = JSON.stringify({ ok: true, sessions: [] });
412
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
413
+ res.end(body);
414
+ return;
415
+ }
416
+ const key = `${info.size}:${info.mtimeMs}`;
417
+ const cached = historyCache.get(deps.ledgerPath);
418
+ const fresh = cached && cached.key === key && Date.now() - cached.at < HISTORY_CACHE_TTL_MS;
419
+ let sessions;
420
+ if (fresh) {
421
+ sessions = cached.sessions;
422
+ } else {
423
+ const records = await readLedgerAll(deps.ledgerPath);
424
+ sessions = clusterSessions(records);
425
+ historyCache.set(deps.ledgerPath, { key, at: Date.now(), sessions });
426
+ }
427
+ const body = JSON.stringify({ ok: true, sessions });
428
+ res.writeHead(200, {
429
+ 'content-type': 'application/json; charset=utf-8',
430
+ 'cache-control': 'no-store',
431
+ 'content-length': Buffer.byteLength(body),
432
+ });
433
+ res.end(body);
434
+ }
435
+
436
+ function reply(res, code, payload) {
437
+ const body = JSON.stringify(payload);
438
+ res.writeHead(code, {
439
+ 'content-type': 'application/json; charset=utf-8',
440
+ 'cache-control': 'no-store',
441
+ 'content-length': Buffer.byteLength(body),
442
+ });
443
+ res.end(body);
444
+ }
445
+
446
+ // 与 tan-pet registerPetRoutes 同款契约:webServer.register(route) 返回 disposer。
447
+ // 返回总 disposer;webServer 缺席时抛错,由调用方决定是否吞掉。
448
+ export function registerPanelRoutes(webServer, deps) {
449
+ if (!webServer || typeof webServer.register !== 'function') {
450
+ throw new Error('webServer 服务不可用');
451
+ }
452
+ const disposers = [];
453
+ const guard = (handler) => async (req, res) => {
454
+ try {
455
+ if (req.method !== 'GET') {
456
+ reply(res, 405, { ok: false, error: 'method not allowed' });
457
+ return;
458
+ }
459
+ if (!isLoopbackRequest(req)) {
460
+ reply(res, 403, { ok: false, error: 'loopback only' });
461
+ return;
462
+ }
463
+ await handler(req, res, deps);
464
+ } catch (error) {
465
+ try {
466
+ reply(res, 500, { ok: false, error: String((error && error.message) || error) });
467
+ } catch {
468
+ // 连 reply 都失败(连接已断),无需处理
469
+ }
470
+ }
471
+ };
472
+
473
+ disposers.push(webServer.register({ method: 'GET', path: STATUS_PATH, handler: guard(serveStatus) }));
474
+ disposers.push(webServer.register({ method: 'GET', path: RECORDS_PATH, handler: guard(serveRecords) }));
475
+ disposers.push(webServer.register({ method: 'GET', path: HISTORY_PATH, handler: guard(serveHistory) }));
476
+ if (deps.log) deps.log(`[dsh-dashcam] 面板 API 挂载完成:${STATUS_PATH} ${RECORDS_PATH} ${HISTORY_PATH}`);
477
+
478
+ return () => {
479
+ for (const dispose of disposers) {
480
+ try {
481
+ if (typeof dispose === 'function') dispose();
482
+ } catch {
483
+ // 卸载失败不致命
484
+ }
485
+ }
486
+ };
487
+ }
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "dsh-dashcam-host",
3
+ "version": "1.14.0",
4
+ "description": "把 dsh-dashcam 判定链接入 DeepSeek Harness 宿主(tools/pre-execute waterfall + tool→capability 映射层,默认 advisory 只记账)+ 浏览器面板(Agent Dashcam,lib/client.js)。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": { "default": "./lib/index.js" },
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs",
14
+ "smoke": "node test/smoke-host.mjs && node test/ingress-host.mjs && node test/panel-host.mjs"
15
+ },
16
+ "dsh": {
17
+ "bundle": {
18
+ "patch": "./cordis.patch.yml"
19
+ },
20
+ "client": {
21
+ "platform": "web",
22
+ "inject": [
23
+ "@deepseek-ai/dsh-client-ui-renderer",
24
+ "@deepseek-ai/dsh-client-ui-slots",
25
+ "@deepseek-ai/dsh-client-shortcuts"
26
+ ]
27
+ }
28
+ }
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dashcam",
3
- "version": "1.12.1",
3
+ "version": "2.0.0",
4
4
  "description": "Plugin dashcam for DSH: capability manifests, runtime guards, and a tamper-evident audit ledger (formerly dsh-covenant).",
5
5
  "homepage": "https://github.com/odysseykernelt/dsh-dashcam#readme",
6
6
  "repository": {
@@ -20,8 +20,28 @@
20
20
  "llm",
21
21
  "ai-agents"
22
22
  ],
23
- "main": "src/index.js",
23
+ "main": "dsh-dashcam-host/lib/index.js",
24
24
  "type": "commonjs",
25
+ "exports": {
26
+ ".": "./dsh-dashcam-host/lib/index.js",
27
+ "./core": "./src/index.js",
28
+ "./client": "./dsh-dashcam-host/lib/client.js",
29
+ "./cordis.patch.yml": "./cordis.patch.yml",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "platform": "web",
38
+ "inject": [
39
+ "@deepseek-ai/dsh-client-ui-renderer",
40
+ "@deepseek-ai/dsh-client-ui-slots",
41
+ "@deepseek-ai/dsh-client-shortcuts"
42
+ ]
43
+ }
44
+ },
25
45
  "bin": {
26
46
  "dsh-dashcam": "bin/dsh-dashcam.js",
27
47
  "dsh-covenant": "bin/dsh-dashcam.js"
@@ -42,7 +62,11 @@
42
62
  "!docs/*-audit-*.md",
43
63
  "!docs/*review*.md",
44
64
  "!docs/*verify*.md",
45
- "!docs/audiences.md"
65
+ "!docs/audiences.md",
66
+ "!docs/*checklist*.md",
67
+ "dsh-dashcam-host/lib/",
68
+ "dsh-dashcam-host/package.json",
69
+ "./cordis.patch.yml"
46
70
  ],
47
71
  "engines": {
48
72
  "node": ">=20"