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,971 @@
1
+ /**
2
+ * dsh-dashcam-host —— 把 dsh-dashcam 的判定链接入 DeepSeek Harness(cordis 宿主)。
3
+ *
4
+ * 接线(2026-11-06 实跑验证后修正):
5
+ * - DSH 工具拦截点只有 `tools/pre-execute`(waterfall,签名 `async (exec, next)`):
6
+ * 放行必须 `return next()`,拦截 `return { kind: 'deny', reason }`。
7
+ * 旧版监听 'pre-execute'——该事件在 DSH 里不存在(asar 内出现 0 次),
8
+ * 判定链从未被调用过,账本恒为空。
9
+ * - 映射层:DSH tool exec → dashcam capability。见工具集合与 mapToolRequests。
10
+ * 一次工具调用可展开为**多条**判定请求(copy=读源+写目标、move=读+删源+写目标、
11
+ * ops_batch 逐条展开 operations[]、ops_read_files 逐 path)。
12
+ * - 定位边界(重要):工具的真实执行在子进程/工具层,本判定链是**入口策略门**
13
+ * (拦命令/拦路径参数),不覆盖工具执行后的真实副作用;进程内 Node 调用
14
+ * 由 patchBuiltins 的 Bridge 看护(默认关闭,见下)。
15
+ *
16
+ * 模式(与 dsh-dashcam 设计讨论的「先观测后收紧」路线一致):
17
+ * - 默认 advisory:映射到的调用照常走 guard.check 记账(含 deny 落账),但放行——
18
+ * 账本先积累「插件实际在碰什么」的事实,供后续 suggest / 增量授权。
19
+ * - `enforce: true`:判定不允许即拦截({ kind: 'deny' })。此时需在 config.grants
20
+ * 里授予对应能力(Two-Key:声明在 MANIFEST,钥匙在 grants)。
21
+ * 无法判定目标的调用(如 batch 缺 operations[]、glob 缺搜索基目录)按 fail-closed
22
+ * 拒绝,且拒绝前必落 deny 账(F2:审计先于执行)。
23
+ * - enforce 默认只覆盖映射表;`enforceUnmapped: true` 才把未映射工具也纳入总闸(F6)。
24
+ *
25
+ * 1.10.12 复核修复(F1–F6):目录语义判定对象转子树形态 dir/**(F3,SPEC 的
26
+ * `dir/**` 不含 dir 本身语义不变);命令行存档默认 hash-only(F4,full 需显式
27
+ * 开启且打码);存档 decision 随处置改写(F5);MAC 在 v2 历史账本上启用会被
28
+ * 防呆拦截并给出迁移路径(F1)。详见宿主 README。
29
+ *
30
+ * 设计取舍:默认 `patchBuiltins = false`。宿主是 Electron 主进程,Bridge 是进程级补丁,
31
+ * 一装就会接管应用自身的 fs/net。需要真实拦截时,在 cordis 配置里显式 `patchBuiltins: true`。
32
+ */
33
+ import { createRequire } from 'node:module';
34
+ import { createHash } from 'node:crypto';
35
+ import { homedir } from 'node:os';
36
+ import { join, dirname } from 'node:path';
37
+ import { readFileSync, statSync, existsSync, appendFileSync, mkdirSync, renameSync } from 'node:fs';
38
+ import { basename } from 'node:path';
39
+ import { attachIngress } from './ingress.js';
40
+ import { registerPanelRoutes } from './panel.js';
41
+
42
+ const require = createRequire(import.meta.url);
43
+ const dashcam = require('../../src/index.js');
44
+ // manifestHash 要与守卫链同源同算法:同一段声明清单、同一个稳定序列化函数,
45
+ // 两处算出的哈希才可比(对账本时能对出「当时声明了什么」)。
46
+ const { stableStringify, sha256 } = require('../../src/util/stable.js');
47
+ // 零契约记录的凭证升级与 deriveOperation 同源:读到 id_rsa/.npmrc 这类路径时
48
+ // capability 升级为 cred.read——零契约只是不判定,不降低记录口径。
49
+ const { credentialCapabilityForPath } = require('../../src/contract/capabilities.js');
50
+
51
+ export const name = 'dsh-dashcam-host';
52
+ export const inject = [];
53
+
54
+ const PLUGIN_VERSION = '1.14.0';
55
+
56
+ const MANIFEST = {
57
+ id: 'dsh-dashcam-host',
58
+ name: 'dsh-dashcam-host',
59
+ version: PLUGIN_VERSION,
60
+ entry: 'lib/index.js',
61
+ dashcam: {
62
+ spec: 'dsh-dashcam/1',
63
+ capabilities: { requests: [] },
64
+ content: {
65
+ rating: 'general',
66
+ scenes: ['host'],
67
+ commercialUse: 'allowed',
68
+ sourceFingerprint: `sha256:${'0'.repeat(64)}`,
69
+ enterpriseExcluded: false,
70
+ },
71
+ },
72
+ };
73
+
74
+ // ── 映射层:DSH tool exec → dashcam capability ────────────────────
75
+ // 口径保守:只映射确知名字与参数形状的工具;未列入表的工具直接放行,
76
+ // 但走「未映射遥测」(console 点名 + 账本 telemetry 记录,同名只记一次),
77
+ // 让覆盖缺口可见、可补——否则 shell 主力通道换了名字(如 dsh-ops 的 ops_bash)
78
+ // 就会静默绕过账本,「先观测后收紧」的底账不完整。
79
+ //
80
+ // dsh-ops(TOOL_PREFIX='ops_')把工具公开名统一加前缀:ops_bash / ops_read_files /
81
+ // ops_glob / ops_grep / ops_write_file / ops_edit_file / ops_replace / ops_copy /
82
+ // ops_move / ops_remove / ops_stat / ops_diff / ops_read_table / ops_read_docx /
83
+ // ops_read_pdf / ops_inspect_local_file / ops_discover_tools / ops_result / ops_batch。
84
+ // 映射按「剥掉 ops_ 前缀的裸名」进行,避免逐个枚举前缀组合。
85
+
86
+ /** shell 系:arguments.command 是整条命令行 → proc.spawn(判定口径=首词)。 */
87
+ const SHELL_TOOLS = new Set(['pwsh', 'powershell', 'bash', 'sh', 'zsh', 'cmd']);
88
+ /** 读文件系:arguments 里的路径字段 → fs.read。glob/grep/stat/diff 只读不写,一并按 fs.read 记账。 */
89
+ const READ_TOOLS = new Set([
90
+ 'read_file', 'read', 'read_files', 'read_table', 'read_docx', 'read_pdf',
91
+ 'read_image', 'inspect_local_file', 'stat', 'diff', 'glob', 'grep',
92
+ ]);
93
+ /** 写/改文件系:→ fs.write(dryRun:true 时降级为 fs.read)。 */
94
+ const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'write', 'edit', 'replace']);
95
+ /** 删除系:→ fs.delete(破坏性落盘,1.10.11 起不再伪装成 fs.write)。 */
96
+ const DELETE_TOOLS = new Set(['remove']);
97
+ /** 复制系:读源 + 写目标(源不再记 fs.write——复制不改写源文件)。 */
98
+ const COPY_TOOLS = new Set(['copy']);
99
+ /** 移动系:读源 + 删源 + 写目标(跨盘 move = copy+unlink,同盘 rename 不读内容;
100
+ * 三项全记是保守口径:enforce 下少授 fs.read 会被拒,fail-closed 可接受)。 */
101
+ const MOVE_TOOLS = new Set(['move']);
102
+ /** 批量操作系:operations[] 逐条展开为 copy/move/remove。 */
103
+ const BATCH_TOOLS = new Set(['batch']);
104
+ /** 出网系:arguments.url → net.outbound。 */
105
+ const FETCH_TOOLS = new Set(['web_fetch', 'fetch']);
106
+ /** 已知无副作用的工具(能力发现、结果引用释放):不映射也不遥测,避免噪音。 */
107
+ const NOOP_TOOLS = new Set(['discover_tools', 'result']);
108
+
109
+ /** 批量展开的单次上限(dsh-ops 原生 worker 上限 512,超出直接视为无法判定)。 */
110
+ const MAX_BATCH_OPERATIONS = 512;
111
+ /** 完整命令行存档的截断长度(full 模式打码文本的截断上限)。 */
112
+ const COMMANDLINE_ARCHIVE_LIMIT = 1024;
113
+ /**
114
+ * L4:目录探测的进程级缓存(带 TTL)。fsScopePath/判断目录需要同步 stat,
115
+ * 每次工具调用都探测会在主线程做 IO(网络盘会阻塞);缓存把重复探测压到
116
+ * 每 path 每 TTL 一次。判定与执行之间的 TOCTOU 依然存在(已知边界,README 有注)。
117
+ *
118
+ * R1(1.10.13 复核):**只缓存正结果(isDir=true)**。负缓存(file→dir)会在
119
+ * 「先删文件、30s 内换成目录再删」的窗口里让 deleteRequests 跳过子树判定,
120
+ * 绕过 M1——那是 fail-open。正缓存方向相反:目录被换成文件后仍多判一条
121
+ * 子树,是 fail-closed,可接受。负结果每次 fresh 探测。
122
+ */
123
+ const DIRECTORY_PROBE_TTL_MS = 30_000;
124
+ const directoryProbeCache = new Map();
125
+
126
+ /** L4:探测某路径当前是否为目录(正结果 30s TTL 缓存;异常一律按「非目录」处理且不缓存)。 */
127
+ function pathIsDirectory(pathValue) {
128
+ const key = String(pathValue);
129
+ const now = Date.now();
130
+ const cached = directoryProbeCache.get(key);
131
+ if (cached && now - cached.at < DIRECTORY_PROBE_TTL_MS) return true; // 正结果才有缓存
132
+ let isDir = false;
133
+ try {
134
+ isDir = existsSync(key) && statSync(key).isDirectory();
135
+ } catch {
136
+ isDir = false; // 探测失败(权限/竞态删除)按非目录处理:保留原路径,fail-closed
137
+ }
138
+ if (isDir) directoryProbeCache.set(key, { at: now, isDir: true });
139
+ else directoryProbeCache.delete(key); // 负结果不缓存:R1,防 30s 窗口绕过 M1
140
+ return isDir;
141
+ }
142
+
143
+ /** L2:allowlist 匹配——全名或前缀通配(mcp__* / memory_*),大小写不敏感。 */
144
+ function matchesAllowlist(name, allowlist) {
145
+ const lowered = String(name).toLowerCase();
146
+ return allowlist.some((entry) => {
147
+ const pattern = String(entry).toLowerCase();
148
+ if (pattern.endsWith('*')) return lowered.startsWith(pattern.slice(0, -1));
149
+ return lowered === pattern;
150
+ });
151
+ }
152
+
153
+ /** DSH 的 exec.arguments 是工具参数 JSON(实测为对象;容错 JSON 字符串写法)。 */
154
+ function normalizeArguments(raw) {
155
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
156
+ if (typeof raw === 'string') {
157
+ try { return JSON.parse(raw); } catch { return {}; }
158
+ }
159
+ return {};
160
+ }
161
+
162
+ function firstString(args, keys) {
163
+ for (const key of keys) {
164
+ const value = args[key];
165
+ if (typeof value === 'string' && value.length > 0) return value;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /** ops_read_files 的 files:[{ path, offset, limit }] 数组形态:取全部 path(P4:逐个判定)。 */
171
+ function pathsFromFiles(args) {
172
+ const files = args.files;
173
+ if (!Array.isArray(files)) return [];
174
+ return files
175
+ .filter((item) => item && typeof item === 'object' && !Array.isArray(item)
176
+ && typeof item.path === 'string' && item.path.length > 0)
177
+ .map((item) => item.path);
178
+ }
179
+
180
+ /**
181
+ * 命令判定口径 = 首词(与 dashcam Bridge 的 firstCommand 同型,含引号形态)。
182
+ * 工具级入口门与进程内 Bridge 必须同一把尺子,否则账本口径漂移。
183
+ * 完整命令行(含 `cd X && rm -f …` 的后续部分)另存存档记录(见 commandlineArchive)。
184
+ */
185
+ function firstCommandWord(command) {
186
+ const trimmed = command.trim();
187
+ const matched = trimmed.match(/^(?:"([^"]+)"|'([^']+)'|(\S+))/);
188
+ return matched ? (matched[1] || matched[2] || matched[3]) : trimmed;
189
+ }
190
+
191
+ /**
192
+ * F3:目录作为判定对象时的 scope 形态修正。
193
+ *
194
+ * SPEC 语义(docs/SPEC.md 77):授权 `dir/**` 只覆盖 `dir/` 之下,不覆盖 `dir` 本身。
195
+ * 但 glob/grep/replace/stat/copy 这些工具的 path 语义就是「扫/写这个子树」——真实
196
+ * 触达的是 dir 之下的内容。按裸 dir 判定会与 `dir/**` 授权错配(F3 实测整片误拒)。
197
+ *
198
+ * 修正:**只读/子树写**语义的判定对象若实测是目录,改用子树形态 `dir/**`。
199
+ * **破坏性操作(remove/move 源)例外**——见 deleteRequests(M1):删的是目录节点
200
+ * 本身,节点必须按节点判,不能被子树授权放大。
201
+ * 注意:`patchBuiltins: true` 时探测本身会过 Bridge(多一条 fs.read 记录),
202
+ * 见 README「与 Bridge 叠加」一节。
203
+ */
204
+ function fsScopePath(pathValue) {
205
+ return pathIsDirectory(pathValue)
206
+ ? `${String(pathValue).replace(/\\/g, '/').replace(/\/+$/, '')}/**`
207
+ : pathValue;
208
+ }
209
+
210
+ /**
211
+ * M1(1.10.12 复核):破坏性删除的判定请求组。
212
+ *
213
+ * `remove(目录)` / `move(目录, …)` 删掉的是**节点本身 + 整棵子树**。若按 F3 只转成
214
+ * `dir/**`,仅授 `dir/**` 时会放行——与 SPEC「`dir/**` 不含 dir 本身」矛盾,等于把
215
+ * 目录节点的删除权塞进了子树授权(复核实测越权)。修正:目录删除 = 节点判定
216
+ * (fs.delete{dir},需要显式授权 dir 本身)**加**子树判定(fs.delete{dir/**}),
217
+ * enforce 下两条都要过;文件删除维持单条 fs.delete{path}。
218
+ */
219
+ function deleteRequests(pathValue) {
220
+ const requests = [{ operation: 'fs.delete', payload: { path: pathValue } }];
221
+ if (pathIsDirectory(pathValue)) {
222
+ requests.push({ operation: 'fs.delete', payload: { path: fsScopePath(pathValue) } });
223
+ }
224
+ return requests;
225
+ }
226
+
227
+ /**
228
+ * F4:命令行里的敏感串打码(best-effort,配合默认 hash-only 使用)。
229
+ * MAC 只防篡改不加密,账本是普通文件——Authorization/Bearer、sk-/ghp-/xox 系
230
+ * token、password/token= 赋值一律替换成 ***。要全文存档必须显式
231
+ * `commandlineArchive: 'full'`(且接受打码后仍可能漏的残余风险)。
232
+ */
233
+ function maskSecrets(text) {
234
+ return String(text)
235
+ .replace(/((?:authorization|proxy-authorization)["'=:\s]*bearer\s+)[^\s"']+/gi, '$1***')
236
+ .replace(/((?:--)?(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|token|password|passwd|secret|passphrase)["'=:\s]+)[^\s"'&]+/gi, '$1***')
237
+ .replace(/\b(sk-[A-Za-z0-9_-]{8,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b/g, '***');
238
+ }
239
+
240
+ /**
241
+ * batch 操作展开(dsh-ops PTC batch:op ∈ {copy, move, remove},path 必填,copy/move 带 to)。
242
+ * 与单工具同口径:copy=读源+写目标,move=读源+删源+写目标,remove=删源;
243
+ * 目录语义的 path/to 经 fsScopePath 转子树形态(F3 同规则);
244
+ * 删除走 deleteRequests(M1:目录节点 + 子树两条都判)。
245
+ */
246
+ function expandBatchOperation(op) {
247
+ if (!op || typeof op !== 'object') return null;
248
+ const { op: kind, path, to } = op;
249
+ if (typeof path !== 'string' || path.length === 0) return null;
250
+ if (kind === 'copy') {
251
+ if (typeof to !== 'string' || to.length === 0) return null;
252
+ return [
253
+ { operation: 'fs.read', payload: { path: fsScopePath(path) } },
254
+ { operation: 'fs.write', payload: { path: fsScopePath(to) } },
255
+ ];
256
+ }
257
+ if (kind === 'move') {
258
+ if (typeof to !== 'string' || to.length === 0) return null;
259
+ return [
260
+ { operation: 'fs.read', payload: { path: fsScopePath(path) } },
261
+ ...deleteRequests(path),
262
+ { operation: 'fs.write', payload: { path: fsScopePath(to) } },
263
+ ];
264
+ }
265
+ if (kind === 'remove') {
266
+ return deleteRequests(path);
267
+ }
268
+ return null;
269
+ }
270
+
271
+ /**
272
+ * 把一次 DSH 工具调用映射成 dashcam 判定请求;映射不了返回 null(放行 + 遥测)。
273
+ *
274
+ * options(L2:per-挂载配置,随调用传入,不再用模块级可变状态):
275
+ * commandlineArchive: 'hash' | 'full' —— 命令行存档模式。
276
+ *
277
+ * 返回形状:
278
+ * null —— 完全未映射(遥测 unmapped-tool)
279
+ * { requests, aux, unjudgeable } —— requests: guard.check 逐条判定并记账;
280
+ * aux: 直接落账本的附加记录(命令行存档 / 遥测);
281
+ * unjudgeable: 无法判定目标的说明文本——
282
+ * advisory 下只落遥测放行,enforce 下 fail-closed 拒绝。
283
+ */
284
+ function mapToolRequests(exec, options = {}) {
285
+ const commandlineArchiveMode = options.commandlineArchive === 'full' ? 'full' : 'hash';
286
+ if (!exec || typeof exec.name !== 'string') return null;
287
+ const name = exec.name.toLowerCase();
288
+ // dsh-ops 公开名带 ops_ 前缀:剥掉后按裸名匹配(ops_bash → bash)。
289
+ const bare = name.startsWith('ops_') ? name.slice(4) : name;
290
+ const args = normalizeArguments(exec.arguments);
291
+ const requests = [];
292
+ const aux = [];
293
+
294
+ if (SHELL_TOOLS.has(bare)) {
295
+ const command = firstString(args, ['command', 'cmd']);
296
+ // pwsh 的 command 里常见 `bash -c "…"` 套娃:入口门判的是首词(与 Bridge
297
+ // 同口径),套娃内部的副作用不在覆盖范围(见文件头「定位边界」)。
298
+ if (command) {
299
+ requests.push({ operation: 'proc.spawn', payload: { command: firstCommandWord(command) } });
300
+ // P6/F4:判定只认首词,但账本要能对出整条命令——默认 hash-only
301
+ //(sha256 + 长度,可离线对账不含明文);显式 commandlineArchive:'full'
302
+ // 才落打码后的文本。decision 由调用结果决定(F5:被拒不记 allow)。
303
+ const trimmed = command.trim();
304
+ if (trimmed !== firstCommandWord(command)) {
305
+ const hash = `sha256:${createHash('sha256').update(trimmed, 'utf8').digest('hex')}`;
306
+ const truncated = trimmed.length > COMMANDLINE_ARCHIVE_LIMIT;
307
+ const scope = commandlineArchiveMode === 'full'
308
+ ? {
309
+ commandLine: maskSecrets(truncated ? trimmed.slice(0, COMMANDLINE_ARCHIVE_LIMIT) : trimmed),
310
+ commandLineTruncated: truncated,
311
+ commandLineMasked: true,
312
+ commandLineHash: hash,
313
+ }
314
+ : {
315
+ commandLineHash: hash,
316
+ commandLineLength: trimmed.length,
317
+ commandLineTruncated: truncated,
318
+ };
319
+ aux.push({
320
+ capability: 'telemetry',
321
+ action: 'proc.commandline',
322
+ scope,
323
+ decision: 'allow', // handler 会按最终处置改写(F5)
324
+ ruleId: 'commandline-audit',
325
+ reason: commandlineArchiveMode === 'full'
326
+ ? '完整命令行存档(打码后文本 + 哈希;判定口径仍为首词)'
327
+ : '完整命令行哈希存档(默认 hash-only,不含明文;判定口径仍为首词)',
328
+ });
329
+ }
330
+ return { requests, aux, unjudgeable: null };
331
+ }
332
+ // ops_bash 的 script_path 形态(命令超过 ~1KB 时要求走脚本文件):
333
+ // 脚本文件本身是执行主体,按路径整串记账(Scope 归一交给 guard)。
334
+ const scriptPath = firstString(args, ['script_path']);
335
+ if (scriptPath) {
336
+ requests.push({ operation: 'proc.spawn', payload: { command: scriptPath } });
337
+ return { requests, aux, unjudgeable: null };
338
+ }
339
+ return null;
340
+ }
341
+
342
+ if (BATCH_TOOLS.has(bare)) {
343
+ // P3:ops_batch 一次可 plan/resume/run 512 条 copy/move/remove——批量破坏性
344
+ // 通道必须展开。operations[] 缺席(plan 校验 / resume 续跑 / operationsRef
345
+ // 引用)时子操作不可见,无法判定:advisory 落遥测,enforce fail-closed。
346
+ const operations = Array.isArray(args.operations) ? args.operations : [];
347
+ if (operations.length === 0 || operations.length > MAX_BATCH_OPERATIONS) {
348
+ return {
349
+ requests,
350
+ aux,
351
+ unjudgeable: operations.length > MAX_BATCH_OPERATIONS
352
+ ? `batch operations 超限:${operations.length} > ${MAX_BATCH_OPERATIONS}`
353
+ : `batch 未携带 operations[](action=${String(args.action || '?')}),子操作不可见`,
354
+ };
355
+ }
356
+ for (const op of operations) {
357
+ const expanded = expandBatchOperation(op);
358
+ if (!expanded) {
359
+ return { requests, aux, unjudgeable: `batch operations 含无法展开的条目:${JSON.stringify(op).slice(0, 200)}` };
360
+ }
361
+ requests.push(...expanded);
362
+ }
363
+ return { requests, aux, unjudgeable: null };
364
+ }
365
+
366
+ if (READ_TOOLS.has(bare)) {
367
+ if (bare === 'read_files') {
368
+ // P4:逐 path 判定记账——只记首个会让 files[1..] 的越界读静默通过。
369
+ const paths = pathsFromFiles(args);
370
+ if (paths.length === 0) return null;
371
+ for (const p of paths) requests.push({ operation: 'fs.read', payload: { path: fsScopePath(p) } });
372
+ return { requests, aux, unjudgeable: null };
373
+ }
374
+ if (bare === 'glob' || bare === 'grep') {
375
+ const basePath = firstString(args, ['path']);
376
+ if (basePath) {
377
+ // F3:glob/grep 的 path 语义是「扫这个子树」,目录时判 dir/**(与 dir/** 授权对齐)。
378
+ requests.push({ operation: 'fs.read', payload: { path: fsScopePath(basePath) } });
379
+ return { requests, aux, unjudgeable: null };
380
+ }
381
+ // P5:缺显式 path 时 DSH 用 session cwd——宿主进程拿不到 session cwd,
382
+ // 猜一个写进账就是假数据。落遥测让缺口可见:advisory 放行,enforce 拒绝。
383
+ return {
384
+ requests,
385
+ aux,
386
+ unjudgeable: `${bare} 未显式给 path(DSH 默认 session cwd,宿主不可见)`,
387
+ };
388
+ }
389
+ const filePath = firstString(args, ['path', 'file_path', 'filePath']);
390
+ if (filePath) {
391
+ // stat/diff 等对目录的只读触达同样按子树形态判(F3)。
392
+ requests.push({ operation: 'fs.read', payload: { path: fsScopePath(filePath) } });
393
+ return { requests, aux, unjudgeable: null };
394
+ }
395
+ return null;
396
+ }
397
+
398
+ if (WRITE_TOOLS.has(bare) || DELETE_TOOLS.has(bare) || COPY_TOOLS.has(bare) || MOVE_TOOLS.has(bare)) {
399
+ const filePath = firstString(args, ['path', 'file_path', 'filePath']);
400
+ if (!filePath) return null;
401
+ // P9:dryRun 只读不写(write_file / edit_file / replace 支持),按 fs.read 记账,
402
+ // 不再让账本高估副作用。
403
+ const dryRun = args.dryRun === true;
404
+ if (dryRun) {
405
+ requests.push({ operation: 'fs.read', payload: { path: fsScopePath(filePath) } });
406
+ return { requests, aux, unjudgeable: null };
407
+ }
408
+ if (WRITE_TOOLS.has(bare)) {
409
+ // P10:replace 以根目录为 scope,glob 批量替换的实际目标文件不逐个展开——
410
+ // 审计粒度到「替换基目录」为止,属已知且接受的粗粒度(dsh-ops replace 语义)。
411
+ // F3:replace 的 path 是目录语义(批量写子树),目录时判 dir/**。
412
+ requests.push({ operation: 'fs.write', payload: { path: fsScopePath(filePath) } });
413
+ return { requests, aux, unjudgeable: null };
414
+ }
415
+ if (DELETE_TOOLS.has(bare)) {
416
+ // M1:remove 删的是节点本身(目录则是节点 + 整棵子树)。目录必须同时判
417
+ // 节点(fs.delete{dir},需显式授权 dir)与子树(fs.delete{dir/**}),
418
+ // 不能被 dir/** 子树授权放大(见 deleteRequests 注释)。
419
+ requests.push(...deleteRequests(filePath));
420
+ return { requests, aux, unjudgeable: null };
421
+ }
422
+ const targetPath = firstString(args, ['to']);
423
+ if (!targetPath) {
424
+ return { requests, aux, unjudgeable: `${bare} 缺目标路径 to` };
425
+ }
426
+ if (COPY_TOOLS.has(bare)) {
427
+ // P1:目标端必须单独判定记账——只判源时,enforce 下「源在域内、目标在域外」
428
+ // 的复制会把数据带出授权域。F3:目标是已存在目录时按 to/** 判(复制进目录)。
429
+ requests.push({ operation: 'fs.read', payload: { path: fsScopePath(filePath) } });
430
+ requests.push({ operation: 'fs.write', payload: { path: fsScopePath(targetPath) } });
431
+ return { requests, aux, unjudgeable: null };
432
+ }
433
+ // move:读源 + 删源(M1:目录源 = 节点 + 子树两条都判)+ 写目标。
434
+ requests.push({ operation: 'fs.read', payload: { path: fsScopePath(filePath) } });
435
+ requests.push(...deleteRequests(filePath));
436
+ requests.push({ operation: 'fs.write', payload: { path: fsScopePath(targetPath) } });
437
+ return { requests, aux, unjudgeable: null };
438
+ }
439
+
440
+ if (FETCH_TOOLS.has(bare)) {
441
+ const url = firstString(args, ['url', 'href']);
442
+ if (url) {
443
+ try {
444
+ const parsed = new URL(url);
445
+ const port = parsed.port
446
+ ? Number(parsed.port)
447
+ : (parsed.protocol === 'https:' ? 443 : 80);
448
+ requests.push({ operation: 'net.outbound', payload: { host: parsed.hostname, port } });
449
+ return { requests, aux, unjudgeable: null };
450
+ } catch {
451
+ return null; // 非法 URL 交回工具层自己的报错路径
452
+ }
453
+ }
454
+ }
455
+ return null;
456
+ }
457
+
458
+ /**
459
+ * F1 防呆辅助:账本文件里是否已有无 mac 的历史行(v2 或缺 mac 字段)。
460
+ * 空文件/不存在 → false(可以直接以 v3 起账)。解析失败的行跳过——
461
+ * 真正的完整性校验交给 dashcam 的 verify。
462
+ */
463
+ function ledgerHasUnmacedHistory(ledgerPath) {
464
+ let raw;
465
+ try {
466
+ raw = readFileSync(ledgerPath, 'utf8');
467
+ } catch {
468
+ return false; // 文件不存在/不可读:不算历史问题,交给 activate 的常规路径
469
+ }
470
+ for (const line of raw.split(/\r?\n/)) {
471
+ const trimmed = line.trim();
472
+ if (!trimmed) continue;
473
+ try {
474
+ const record = JSON.parse(trimmed);
475
+ if (record && (record.v !== 3 || typeof record.mac !== 'string')) return true;
476
+ } catch {
477
+ // 非 JSON 行忽略
478
+ }
479
+ }
480
+ return false;
481
+ }
482
+
483
+ export function apply(ctx, config = {}) {
484
+ const enforce = config.enforce === true;
485
+ const advisory = !enforce;
486
+ // F6:enforce 默认只覆盖映射表(enforce 不是全部工具的总闸);
487
+ // enforceUnmapped:true 把未映射工具也纳入 fail-closed(总闸口径)。
488
+ const enforceUnmapped = config.enforceUnmapped === true;
489
+ // F4:命令行存档模式,'hash'(默认)| 'full'(打码文本)。
490
+ // L2:per-挂载局部常量(随 mapToolRequests 调用传入),不再用模块级可变状态——
491
+ // 同进程多次挂载(测试/多 profile)不会互相串。
492
+ const commandlineArchiveMode = config.commandlineArchive === 'full' ? 'full' : 'hash';
493
+ // L6:enforceUnmapped 的豁免清单(全名或前缀通配,如 mcp__* / memory_*)。
494
+ // R3(1.10.13 复核):非数组 fail-fast——静默按空数组会把本想豁免的
495
+ // compress/present 等全拦掉且无告警(写成逗号分隔字符串是人之常情)。
496
+ if (config.unmappedAllowlist !== undefined
497
+ && config.unmappedAllowlist !== null
498
+ && !Array.isArray(config.unmappedAllowlist)) {
499
+ console.error(
500
+ '[dsh-dashcam] 配置错误:unmappedAllowlist 必须是数组(如 ["mcp__demo__*", "memory_*"]),'
501
+ + `当前是 ${typeof config.unmappedAllowlist}——fail-closed 不挂载,防止静默拦掉全部未映射工具`,
502
+ );
503
+ return;
504
+ }
505
+ const unmappedAllowlist = Array.isArray(config.unmappedAllowlist)
506
+ ? config.unmappedAllowlist.map((entry) => String(entry))
507
+ : [];
508
+ // 1.11.0 改名迁移:默认路径下若只有旧名(dsh-covenant)的账本,原地搬到新目录。
509
+ // 不合并、不改内容、不改哈希链;显式配置 ledgerPath 时完全不动。
510
+ const LEDGER_DIR = join(homedir(), '.dsh', 'dsh-dashcam');
511
+ const DEFAULT_LEDGER_PATH = join(LEDGER_DIR, 'ledger.jsonl');
512
+ const LEGACY_LEDGER_PATH = join(homedir(), '.dsh', 'dsh-covenant', 'ledger.jsonl');
513
+ function migrateLegacyLedger() {
514
+ try {
515
+ if (existsSync(DEFAULT_LEDGER_PATH)) return DEFAULT_LEDGER_PATH;
516
+ if (!existsSync(LEGACY_LEDGER_PATH)) return DEFAULT_LEDGER_PATH;
517
+ mkdirSync(dirname(DEFAULT_LEDGER_PATH), { recursive: true });
518
+ renameSync(LEGACY_LEDGER_PATH, DEFAULT_LEDGER_PATH);
519
+ const legacyLog = LEGACY_LEDGER_PATH + '.guard.log';
520
+ if (existsSync(legacyLog)) renameSync(legacyLog, DEFAULT_LEDGER_PATH + '.guard.log');
521
+ console.warn(`[dsh-dashcam] 账本迁移:${LEGACY_LEDGER_PATH} → ${DEFAULT_LEDGER_PATH}`);
522
+ } catch (error) {
523
+ console.warn(`[dsh-dashcam] 账本迁移失败(沿用旧位置):${error.message}`);
524
+ }
525
+ return existsSync(DEFAULT_LEDGER_PATH) ? DEFAULT_LEDGER_PATH : LEGACY_LEDGER_PATH;
526
+ }
527
+ // N1:账本是单写者契约,多个 DSH profile 同时开宿主时共用 ledger.jsonl 会
528
+ // 互相判「并发写入」。配置 config.profile 后默认路径分实例:
529
+ // ~/.dsh/dsh-dashcam/ledger-<profile>.jsonl(各实例独立成链,互不干扰)。
530
+ let ledgerPath;
531
+ if (config.ledgerPath) {
532
+ ledgerPath = config.ledgerPath;
533
+ } else if (typeof config.profile === 'string' && config.profile.trim()) {
534
+ const profileName = config.profile.trim();
535
+ if (!/^[\w.-]+$/.test(profileName)) {
536
+ console.warn(`[dsh-dashcam] 配置错误:profile 只允许字母数字与 . _ -,当前是 ${JSON.stringify(profileName)}——fail-closed 不挂载`);
537
+ return;
538
+ }
539
+ ledgerPath = join(LEDGER_DIR, `ledger-${profileName}.jsonl`);
540
+ } else {
541
+ ledgerPath = migrateLegacyLedger();
542
+ }
543
+
544
+ // L3:防呆拒绝挂载也要留痕——此时账本不能写,落到旁边 side 日志
545
+ //(<ledgerPath>.guard.log),桌面版看不到 stdout 也能事后查原因。
546
+ function appendGuardLog(stage, detail) {
547
+ try {
548
+ appendFileSync(
549
+ `${ledgerPath}.guard.log`,
550
+ `${new Date().toISOString()} stage=${stage} version=${PLUGIN_VERSION} ${detail}\n`,
551
+ 'utf8',
552
+ );
553
+ } catch {
554
+ // side 日志写不进去就只剩 console——不因此阻断
555
+ }
556
+ }
557
+
558
+ // L1:先区分「取不到密钥」与「历史行无 mac」两种 fail-closed,别让 env 未设置
559
+ // 的报错冒充「不能原地启用」。
560
+ if (config.ledgerMacKeyEnv && !process.env[config.ledgerMacKeyEnv]) {
561
+ const message = `环境变量 ${config.ledgerMacKeyEnv} 未设置,取不到 MAC 密钥(fail-closed 不挂载)——`
562
+ + `请设置该环境变量,或改用 ledgerMacKey / ledgerMacKeyFile,或去掉 ledgerMacKeyEnv 配置`;
563
+ console.error(`[dsh-dashcam] 配置错误:${message}`);
564
+ appendGuardLog('mac-env-missing', message);
565
+ return;
566
+ }
567
+
568
+ // F1:MAC 不能在既有 v2 账本上原地启用——verify 会把全部历史判成 mac_missing、
569
+ // ok=false,metrics 先校验后统计直接非零退出(复核实测生产账本 190 行全中)。
570
+ // 防呆:配置了 MAC 密钥且账本里已有无 mac 的历史行 → 拒绝挂载并给出迁移路径。
571
+ // 迁移方案(详见宿主 README「MAC 启用与账本迁移」):换新账本文件、旧账归档,
572
+ // 不要原地开关。
573
+ const macKeyConfigured = Boolean(
574
+ config.ledgerMacKey || config.ledgerMacKeyFile || config.ledgerMacKeyEnv,
575
+ );
576
+ if (macKeyConfigured && ledgerHasUnmacedHistory(ledgerPath)) {
577
+ const message = `检测到账本 ${ledgerPath} 存在无 MAC 的历史记录(v2),不能原地启用账本 MAC`
578
+ + `——verify 会把全部历史判成 mac_missing。\n`
579
+ + `迁移步骤:1) mv ledger.jsonl ledger-v2-archive.jsonl(归档,标注 v2 无 mac);`
580
+ + `2) 保留 ledgerMacKey 配置重启,判定链会在新文件上从 v3 开始;`
581
+ + `3) 归档文件用无密钥 Ledger.verify()(ok=false 是预期,历史本就无 mac)。`
582
+ + `详见 dsh-dashcam-host/README.md「MAC 启用与账本迁移」。`;
583
+ console.error(`[dsh-dashcam] 配置错误:${message}`);
584
+ appendGuardLog('mac-v2-history', message.replace(/\n/g, ' | '));
585
+ return;
586
+ }
587
+
588
+ // ── fail-fast:配置前置校验(问题 1,1.10.8)─────────────────
589
+ // mount 时就把写错的 config.requests 拦下来,而不是等到运行时判成 not_declared。
590
+ // activate 内部直接吃 manifest,不走 parseManifest,所以宿主必须自己做 normalize。
591
+ let normalizedRequests = [];
592
+ try {
593
+ const raw = Array.isArray(config.requests) ? config.requests : [];
594
+ normalizedRequests = raw.map((item, index) => dashcam.normalizeRequest(item, { home: homedir() }));
595
+ } catch (error) {
596
+ console.error(
597
+ `[dsh-dashcam] 配置错误:config.requests 非法——${error.message}`,
598
+ `(正确格式见 README 3.4:每个 request 是 { capability, scope, reason } 对象,`
599
+ + `字符串数组会静默变成「未声明」)`,
600
+ );
601
+ // 不装判定链,让宿主在启动期就显式看到配置有问题,而不是默默跑起来再运行期拒一切。
602
+ return;
603
+ }
604
+ // grants 也顺便校验一下,写错了同样 fail-fast(契约要求是对象数组)。
605
+ const rawGrants = Array.isArray(config.grants) ? config.grants : [];
606
+ let normalizedGrants = [];
607
+ try {
608
+ normalizedGrants = rawGrants.map((item, index) => dashcam.normalizeGrant(item, { home: homedir() }, index));
609
+ } catch (error) {
610
+ console.error(`[dsh-dashcam] 配置错误:config.grants 非法——${error.message}`);
611
+ return;
612
+ }
613
+
614
+ // Two-Key 成对原则:enforce 模式下宿主既要授 grants(钥匙),也要在 manifest
615
+ // 里声明 requests(能力清单)。advisory 模式两者皆空,只记账。
616
+ // 注意两处都要放:运行时 two-key 读 manifest.dashcam.requests,
617
+ // DSH 插件清单格式是 dashcam.capabilities.requests——保持同源不漂移。
618
+ const manifest = {
619
+ ...MANIFEST,
620
+ dashcam: {
621
+ ...MANIFEST.dashcam,
622
+ requests: normalizedRequests,
623
+ capabilities: { requests: normalizedRequests },
624
+ },
625
+ };
626
+
627
+ // 归属标注(v4 账本字段):宿主侧的 profiled / manifestHash 与守卫链同源——
628
+ // 有契约时每条记录都能对出「当时声明了什么」;零契约时 profiled=false、
629
+ // manifestHash=null,本身就是「装上时没给契约」的证明。
630
+ const profiledRequests = normalizedRequests.length > 0;
631
+ const manifestHash = profiledRequests ? sha256(stableStringify(normalizedRequests)) : null;
632
+
633
+ // R2 façade 化之后 activate() 不再外借 Ledger(防插件顺藤摸瓜拿走审计密钥)。
634
+ // 宿主自建实例经 host.ledger 传入:activate 优先使用注入的账本,mac 密钥解析
635
+ // 与 activate 内部同函数同参数,行为完全一致;进程内共享账本状态(H6)保证
636
+ // 两个实例的 seq/size 基准一致。
637
+ let ledger;
638
+ try {
639
+ const resolvedMac = dashcam.resolveLedgerMacKey({
640
+ ledgerMacKey: config.ledgerMacKey || null,
641
+ ledgerMacKeyFile: config.ledgerMacKeyFile || null,
642
+ ledgerMacKeyEnv: config.ledgerMacKeyEnv || null,
643
+ });
644
+ ledger = new dashcam.Ledger(ledgerPath, { macKey: resolvedMac.key });
645
+ } catch (error) {
646
+ console.warn(`[dsh-dashcam] activate failed: ${error.message}`);
647
+ return;
648
+ }
649
+
650
+ let runtime;
651
+ try {
652
+ runtime = dashcam.activate({
653
+ manifest,
654
+ grants: normalizedGrants,
655
+ policy: config.policy || {},
656
+ ledger,
657
+ // Bridge 是进程级补丁,默认必须关(宿主是 Electron 主进程,一装就接管
658
+ // 应用自身的 fs/net);需要真实拦截时显式 config.patchBuiltins: true。
659
+ patchBuiltins: config.patchBuiltins === true,
660
+ // 判定不再经由 registerPreExecute 直挂事件:waterfall 契约要求
661
+ // (exec, next) 包装与映射层,check 在下面的 handler 里手动调用。
662
+ });
663
+ } catch (error) {
664
+ // 装载失败只标记本插件,不拖垮宿主其余插件树。
665
+ console.warn(`[dsh-dashcam] activate failed: ${error.message}`);
666
+ return;
667
+ }
668
+ // activate() 返回最小 façade:guard 只有 check/enforce/coverage 只读面。
669
+ const guard = runtime.guard;
670
+
671
+ /** 附加审计记录(遥测 / 命令行存档 / mount)直接落账本;失败只警告不阻断放行。 */
672
+ function appendAuxRecord(record) {
673
+ if (!ledger) return;
674
+ try {
675
+ ledger.append({
676
+ plugin: MANIFEST.id,
677
+ pluginVersion: PLUGIN_VERSION,
678
+ profiled: profiledRequests,
679
+ manifestHash,
680
+ ...record,
681
+ });
682
+ } catch (error) {
683
+ console.warn(`[dsh-dashcam] 附加审计记录写入失败:${error.message}`);
684
+ }
685
+ }
686
+
687
+ // 零契约模式(行车记录仪出厂态):没配置 config.requests(且不在 enforce)时,
688
+ // 不产生 Two-Key 判定——对着不存在的契约判「未声明」,账本会塞满没人看的
689
+ // not_declared 噪音。改为把每次被观测的工具调用按 unprofiled 如实记录。
690
+ const zeroContract = !enforce && normalizedRequests.length === 0;
691
+
692
+ // 遗留 P4(1.10.10 报告):挂载事件落账本——桌面版看不到 stdout,
693
+ // 「判定链是否挂载」从此可以直接从账本文件判断。
694
+ // N2:宿主可通过 config.trustFile 声明信任根,整体哈希进 mount 记录——
695
+ // 「这台机器按哪份信任表办入职」从账本可查,对的是文件内容不是文件名。
696
+ let trustInfo = null;
697
+ if (config.trustFile) {
698
+ try {
699
+ trustInfo = { file: basename(config.trustFile), hash: sha256(readFileSync(config.trustFile, 'utf8')) };
700
+ } catch (error) {
701
+ console.warn(`[dsh-dashcam] 信任表读取失败(mount 记录不携带 trust 字段):${error.message}`);
702
+ }
703
+ }
704
+ appendAuxRecord({
705
+ capability: 'telemetry',
706
+ action: 'mount',
707
+ scope: {
708
+ mode: enforce ? 'enforce' : (zeroContract ? 'unprofiled' : 'advisory'),
709
+ contract: zeroContract ? 'unprofiled' : 'declared',
710
+ enforceUnmapped,
711
+ commandlineArchive: commandlineArchiveMode,
712
+ patchBuiltins: config.patchBuiltins === true,
713
+ requests: normalizedRequests.length,
714
+ grants: normalizedGrants.length,
715
+ ledgerPath,
716
+ ...(trustInfo ? { trust: trustInfo } : {}),
717
+ },
718
+ decision: 'allow',
719
+ ruleId: 'mount',
720
+ reason: zeroContract
721
+ ? 'dsh-dashcam-host 判定链挂载(零契约模式:全部观测活动按 unprofiled 记录)'
722
+ : 'dsh-dashcam-host 判定链挂载',
723
+ });
724
+
725
+ // ingress 审计:请求出口 + 消息入口的元数据。事件名可配;接不上必须留痕,
726
+ // 不能让「没记录」看起来像「没有异常」。
727
+ const ingress = attachIngress(ctx, {
728
+ ledger,
729
+ policy: { ingress: config.ingress || {} },
730
+ plugin: MANIFEST.id,
731
+ pluginVersion: PLUGIN_VERSION,
732
+ config: config.ingress || {},
733
+ log: (message) => console.warn(message),
734
+ });
735
+ appendAuxRecord({
736
+ capability: 'telemetry',
737
+ action: ingress.enabled ? 'ingress-mount' : 'ingress-disabled',
738
+ scope: {
739
+ enabled: ingress.enabled,
740
+ requestEvent: ingress.requestEvent,
741
+ inboundEvent: ingress.inboundEvent,
742
+ headBytes: ingress.recorder ? ingress.recorder.settings.headBytes : null,
743
+ },
744
+ decision: 'allow',
745
+ ruleId: ingress.enabled ? 'ingress-mount' : 'ingress-disabled',
746
+ reason: ingress.enabled ? 'ingress 审计接线' : 'ingress 审计未启用(配置关闭或账本不可用)',
747
+ });
748
+
749
+ // 面板只读 API(1.13.0):webServer 可用时挂 /api/dsh-dashcam/* 路由,供注入
750
+ // DSH Web 的浏览器面板(lib/client.js)消费。optional-safe——webServer 缺席或
751
+ // 挂载失败只丢面板入口,不影响判定链与账本;config.panel.enabled=false 可关。
752
+ if (config.panel && config.panel.enabled === false) {
753
+ console.info('[dsh-dashcam] 面板 API 已通过 config.panel.enabled=false 关闭');
754
+ } else {
755
+ try {
756
+ if (typeof ctx.inject === 'function') {
757
+ ctx.inject(['webServer'], (hostCtx) => {
758
+ hostCtx.effect(() => {
759
+ const webServer =
760
+ typeof hostCtx.get === 'function' ? hostCtx.get('webServer') : hostCtx.webServer;
761
+ try {
762
+ const dispose = registerPanelRoutes(webServer, {
763
+ ledgerPath,
764
+ version: PLUGIN_VERSION,
765
+ log: (message) => console.info(message),
766
+ });
767
+ console.info(`[dsh-dashcam] 面板 API 已挂载(/api/dsh-dashcam/*,ledger=${ledgerPath})`);
768
+ return dispose;
769
+ } catch (error) {
770
+ console.warn(`[dsh-dashcam] 面板 API 挂载失败:${error.message}`);
771
+ return () => {};
772
+ }
773
+ }, 'dsh-dashcam: panel routes');
774
+ });
775
+ } else {
776
+ console.info('[dsh-dashcam] 当前宿主未提供 ctx.inject,面板 API 不挂载');
777
+ }
778
+ } catch (error) {
779
+ console.warn(`[dsh-dashcam] 面板 API 不可用:${error.message}`);
780
+ }
781
+ }
782
+
783
+ // tools/pre-execute waterfall:判定链的真正入口(旧版监听 'pre-execute',
784
+ // DSH 核心只发 tools/pre-execute——判定链因此一次都没跑过)。
785
+ // 未映射遥测:console 点名 + 账本 telemetry 记录同名只记一次(seenUnmapped
786
+ // 去重——**只去重日志/遥测**)。
787
+ // P1(1.10.13 复核):enforceUnmapped 的拒绝逻辑必须对**每次**未映射调用执行,
788
+ // 不能被去重短路——否则同名工具第 2 次起 fail-open,对 agent 反复调用同一工具
789
+ // 的形态等于没拦。
790
+ // L6:unmappedAllowlist 支持全名与前缀通配(mcp__* / memory_*)——enforceUnmapped
791
+ // 是总闸,不开 allowlist 会把 compress/workflow/subagent/mcp__* 等宿主工具全打死。
792
+ const seenUnmapped = new Set();
793
+ ctx.on('tools/pre-execute', async (exec, next) => {
794
+ const mapped = mapToolRequests(exec, { commandlineArchive: commandlineArchiveMode });
795
+ if (!mapped) {
796
+ if (exec && typeof exec.name === 'string') {
797
+ const lowered = exec.name.toLowerCase();
798
+ const bare = lowered.startsWith('ops_') ? lowered.slice(4) : lowered;
799
+ if (!NOOP_TOOLS.has(bare)) {
800
+ const allowlisted = matchesAllowlist(exec.name, unmappedAllowlist);
801
+ const denyUnmapped = enforce && enforceUnmapped && !allowlisted;
802
+ if (zeroContract && !denyUnmapped) {
803
+ // 零契约:每一次未映射调用都逐次记账(观测记录,不去重)——
804
+ // 否则默认出厂路径下大部分活动根本不进账本,「装上就能用」
805
+ // 变成「装上就装了」。参数预览只截 512 字符并过打码。
806
+ const inputText = JSON.stringify(exec.arguments ?? '') ?? '';
807
+ appendAuxRecord({
808
+ capability: 'telemetry',
809
+ action: 'unmapped-tool',
810
+ scope: {
811
+ tool: exec.name,
812
+ allowlisted,
813
+ inputLength: inputText.length,
814
+ inputPreview: maskSecrets(inputText.slice(0, 512)),
815
+ },
816
+ decision: 'allow',
817
+ intercepted: false,
818
+ ruleId: 'unprofiled',
819
+ reason: '零契约模式:未映射工具按观测逐次记录(无声明清单,不做 Two-Key 判定)',
820
+ });
821
+ }
822
+ if (!seenUnmapped.has(lowered)) {
823
+ // 去重只作用于日志与 allow 遥测;拦截判定每次都做(P1)。
824
+ // 零契约下逐次 unprofiled 记录已覆盖这件事,allow 遥测不再重复落。
825
+ seenUnmapped.add(lowered);
826
+ console.info(
827
+ `[dsh-dashcam] 未映射工具(${denyUnmapped ? 'enforceUnmapped 拒绝' : allowlisted ? 'allowlist 豁免放行' : zeroContract ? '放行,按 unprofiled 记账' : '放行,不记账'}):`
828
+ + `${exec.name}——如需纳管请补充映射表`,
829
+ );
830
+ if (!denyUnmapped && !zeroContract) {
831
+ appendAuxRecord({
832
+ capability: 'telemetry',
833
+ action: 'unmapped-tool',
834
+ scope: { tool: exec.name, allowlisted },
835
+ decision: 'allow',
836
+ intercepted: false,
837
+ ruleId: 'unmapped',
838
+ reason: allowlisted
839
+ ? '未映射工具命中 unmappedAllowlist 豁免放行(不产生能力判定)'
840
+ : '未映射工具放行(不产生能力判定),如需纳管请补充映射表',
841
+ });
842
+ }
843
+ }
844
+ if (denyUnmapped) {
845
+ // R2(1.10.13 复核):拒绝**每次**都落账——「拒绝必落账」不因遥测去重而缩水;
846
+ // console 点名仍只一次。telemetryDenies 因此逐次可见。
847
+ appendAuxRecord({
848
+ capability: 'telemetry',
849
+ action: 'unmapped-tool',
850
+ scope: { tool: exec.name, allowlisted },
851
+ decision: 'deny',
852
+ intercepted: true,
853
+ ruleId: 'unmapped',
854
+ reason: 'enforceUnmapped 开启:未映射工具按 fail-closed 拒绝(不在映射表内且不在 allowlist)',
855
+ });
856
+ return {
857
+ kind: 'deny',
858
+ reason: `[dsh-dashcam] 未映射工具(enforceUnmapped):${exec.name} 不在映射表内,按 fail-closed 拒绝`,
859
+ };
860
+ }
861
+ }
862
+ }
863
+ return next();
864
+ }
865
+
866
+ // 无法判定目标:审计先于执行(F2)——拒绝也要落账。
867
+ // advisory:telemetry allow + 放行;enforce:telemetry deny(intercepted) + 拒绝。
868
+ if (mapped.unjudgeable) {
869
+ appendAuxRecord({
870
+ capability: 'telemetry',
871
+ action: 'unjudgeable-tool',
872
+ scope: { tool: exec && exec.name, detail: mapped.unjudgeable },
873
+ decision: enforce ? 'deny' : 'allow',
874
+ intercepted: enforce,
875
+ ruleId: 'unjudgeable',
876
+ reason: enforce
877
+ ? `fail-closed 拒绝(无法判定):${mapped.unjudgeable}`
878
+ : `放行(无法判定,advisory):${mapped.unjudgeable}`,
879
+ });
880
+ if (enforce) {
881
+ return {
882
+ kind: 'deny',
883
+ reason: `[dsh-dashcam] 无法判定调用目标,按 fail-closed 拒绝:${mapped.unjudgeable}`,
884
+ };
885
+ }
886
+ return next();
887
+ }
888
+
889
+ // 零契约:映射到的请求也只记录、不判定——判定要对着声明清单,而这里没有。
890
+ // 记录粒度与映射层一致(copy=读源+写目标、move=读+删源+写目标……),
891
+ // capability 取操作前缀;读路径命中凭证特征时与 derive 同源升级为 cred.*,
892
+ // scope 保存原始目标(路径/主机/命令首词)。
893
+ // 命令行存档等附加记录照常落账(零契约下没有 deny 处置可改写,保持 allow)。
894
+ if (zeroContract) {
895
+ for (const req of mapped.requests) {
896
+ let capability = String(req.operation || '').split('.')[0] || 'unknown';
897
+ if (req.operation === 'fs.read' && req.payload && typeof req.payload.path === 'string') {
898
+ const upgraded = credentialCapabilityForPath(req.payload.path, 'fs.read');
899
+ if (upgraded !== 'fs.read') capability = upgraded;
900
+ }
901
+ appendAuxRecord({
902
+ capability,
903
+ action: req.operation,
904
+ scope: req.payload,
905
+ decision: 'allow',
906
+ intercepted: false,
907
+ ruleId: 'unprofiled',
908
+ reason: '零契约模式:映射请求按观测记录(无声明清单,不做 Two-Key 判定)',
909
+ });
910
+ }
911
+ for (const auxRecord of mapped.aux) appendAuxRecord(auxRecord);
912
+ return next();
913
+ }
914
+
915
+ const decisions = [];
916
+ for (const req of mapped.requests) {
917
+ let decision;
918
+ try {
919
+ // guard.check 自带账本落盘(含 deny 明细与 ledgerSeq):审计先于放行/拦截,
920
+ // 与 dashcam 的 fail-closed 记账语义一致。
921
+ // advisory 模式:告诉 guard 「我会自行放行」,让账本 decision=deny 但
922
+ // intercepted=false——这样 metrics 与审计不会把「would-deny 放行」误算
923
+ // 成真正的拦截(问题 2,1.10.8)。
924
+ decision = guard.check(req.operation, req.payload, { advisory });
925
+ } catch (error) {
926
+ // check 正常不抛(derive 失败已折算成拒绝结果);真抛了按模式兜底。
927
+ console.warn(`[dsh-dashcam] 判定异常:${error.message}`);
928
+ if (enforce) return { kind: 'deny', reason: `[dsh-dashcam] 判定异常:${error.message}` };
929
+ return next();
930
+ }
931
+ decisions.push(decision);
932
+ }
933
+ // 附加审计记录(命令行存档)在任何处置之前落账(F5:decision 随最终处置改写,
934
+ // 被拒的调用不再记 allow)。
935
+ const denied = decisions.find((decision) => !decision.allowed);
936
+ for (const auxRecord of mapped.aux) {
937
+ if (auxRecord.ruleId === 'commandline-audit') {
938
+ auxRecord.decision = denied && enforce ? 'deny' : 'allow';
939
+ auxRecord.intercepted = Boolean(denied && enforce);
940
+ }
941
+ appendAuxRecord(auxRecord);
942
+ }
943
+
944
+ // enforce:多请求任一不允许即拒绝整次工具调用(整批按最严处置)。
945
+ if (denied && enforce) {
946
+ return { kind: 'deny', reason: `[dsh-dashcam] ${denied.reason}` };
947
+ }
948
+ // advisory 模式:deny 也放行——账本已如实落了 decision=deny + intercepted=false,
949
+ // 收紧交给后续决策。allow 路径 decision.intercepted 本来就是 false,账本照原样落。
950
+ return next();
951
+ });
952
+
953
+ ctx.effect(() => () => {
954
+ try {
955
+ ingress.dispose();
956
+ } catch (error) {
957
+ console.warn(`[dsh-dashcam] ingress dispose failed: ${error.message}`);
958
+ }
959
+ try {
960
+ runtime.deactivate();
961
+ } catch (error) {
962
+ console.warn(`[dsh-dashcam] deactivate failed: ${error.message}`);
963
+ }
964
+ });
965
+ console.info(
966
+ `[dsh-dashcam] 判定链已挂载(event=tools/pre-execute,mode=${enforce ? 'enforce' : (zeroContract ? 'unprofiled' : 'advisory')},`
967
+ + `enforceUnmapped=${enforceUnmapped},commandlineArchive=${commandlineArchiveMode},`
968
+ + `patchBuiltins=${config.patchBuiltins === true},ledger=${ledgerPath},`
969
+ + `requests=${normalizedRequests.length},grants=${normalizedGrants.length})`,
970
+ );
971
+ }