dsh-rule-engine 0.3.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/lib/index.js ADDED
@@ -0,0 +1,648 @@
1
+ // dsh-rule-engine —— DSH 规则执行引擎 v3(host 插件,纯 Node)
2
+ // 容器:解析 AGENTS.md → 理解器 → 匹配机 → 执行框架。
3
+ // 执行框架:ctx.tools.guard() 硬拦 + session/event 文本纠察 + 审计台账 + /guard 命令。
4
+ import { readFileSync, watch, writeFileSync } from "node:fs";
5
+ import { audit, readAuditLog } from "./core/audit.js";
6
+ import { loadPluginConfig } from "./core/config.js";
7
+ import { guardDecision } from "./core/guard-core.js";
8
+ import {
9
+ activateForAssistant,
10
+ activateForToolCall,
11
+ activateForUserMessage
12
+ } from "./core/matcher.js";
13
+ import {
14
+ auditOutputFailed,
15
+ auditOutputPassed,
16
+ backupPathsFromTool,
17
+ isAssemblyMutationTool,
18
+ isAuditCommand,
19
+ isBackupCommand,
20
+ isBackupTool,
21
+ isGetDateCommand,
22
+ isManualReadTool,
23
+ setWorkspaceRoot
24
+ } from "./core/patterns.js";
25
+ import {
26
+ askQuestionText,
27
+ askResultApproved,
28
+ inferPathPrefixFromText,
29
+ inferTypeFromText,
30
+ isAuthMessage,
31
+ isDirectiveMessage,
32
+ isQuestionMessage
33
+ } from "./core/authorization.js";
34
+ import {
35
+ getSessionState,
36
+ maybeReloadIfChanged,
37
+ recordAuthorization,
38
+ recordBackup,
39
+ reloadRules,
40
+ resetTurn
41
+ } from "./core/state.js";
42
+ import { state } from "./core/runtime.js";
43
+ import { detectViolations, extractAssistantText } from "./core/text-detect.js";
44
+ import { writeUnderstanding } from "./core/understanding-store.js";
45
+ import { agentsFilePath, auditFilePath } from "./core/paths.js";
46
+ import { isVersionedFile, validateEditedFile } from "./core/version-guard.js";
47
+ import { computeMountSignature, profileNameFromArgs } from "./core/mount-signature.js";
48
+ import { detectSilentError, extractToolOutput } from "./core/silent-error.js";
49
+ import { enrichRulesWithLlm } from "./core/llm-understander.js";
50
+
51
+ export const name = "dsh-rule-engine";
52
+ export const inject = ["tools", "commands", "agents", "workspaceRegistry", "skills", "llm"];
53
+
54
+ const pluginConfig = loadPluginConfig();
55
+ state.enabled = pluginConfig.enabled;
56
+ reloadRules(state);
57
+ writeUnderstanding(state.configs);
58
+
59
+ // ── 工具函数 ────────────────────────────────────────────────────────────────
60
+
61
+ function summarizeArgs(args) {
62
+ try {
63
+ const s = JSON.stringify(args ?? {});
64
+ return s.length > 300 ? s.slice(0, 300) + "..." : s;
65
+ } catch {
66
+ return String(args);
67
+ }
68
+ }
69
+
70
+ function remainMs(until) {
71
+ return Math.max(0, until - Date.now());
72
+ }
73
+
74
+ function fmtRemain(ms) {
75
+ if (ms <= 0) return "无";
76
+ const min = Math.floor(ms / 60000);
77
+ const sec = Math.floor((ms % 60000) / 1000);
78
+ return min > 0 ? `${min} 分 ${sec} 秒` : `${sec} 秒`;
79
+ }
80
+
81
+ function parseArgs(raw) {
82
+ if (typeof raw === "string") {
83
+ try {
84
+ return JSON.parse(raw);
85
+ } catch {
86
+ return { raw };
87
+ }
88
+ }
89
+ return raw || {};
90
+ }
91
+
92
+ function extractUserText(message) {
93
+ if (!message) return "";
94
+ if (typeof message === "string") return message;
95
+ const content = message.content;
96
+ if (typeof content === "string") return content;
97
+ if (Array.isArray(content)) {
98
+ return content
99
+ .map((b) => (b && typeof b === "object" && b.type === "text" ? b.text : ""))
100
+ .join("\n");
101
+ }
102
+ return "";
103
+ }
104
+
105
+ // ── session/event 处理 ──────────────────────────────────────────────────────
106
+
107
+ function maybeInject(ctx, sessionId, violation) {
108
+ if (!pluginConfig.correctInject) return;
109
+ const key = `${sessionId}:${violation.ruleId}`;
110
+ const count = state.injectCounts.get(key) || 0;
111
+ if (count >= pluginConfig.injectLimitPerRulePerSession) return;
112
+ state.injectCounts.set(key, count + 1);
113
+ try {
114
+ const agent = ctx.agents.get(sessionId);
115
+ if (agent && typeof agent.inject === "function") {
116
+ agent.inject({
117
+ content: [
118
+ {
119
+ type: "text",
120
+ text: `[规则引擎] ${violation.reason}(规则 ${violation.ruleId},已记入 /guard log;下次回复请自证/纠正)`
121
+ }
122
+ ],
123
+ source: { kind: "plugin", plugin: name }
124
+ });
125
+ }
126
+ } catch {
127
+ // 注入失败不影响审计
128
+ }
129
+ }
130
+
131
+ async function refreshSkills(ctx) {
132
+ try {
133
+ const list = await ctx.skills.list();
134
+ state.skillNames = new Set((list || []).map((s) => s && s.name).filter(Boolean));
135
+ } catch {
136
+ // 技能目录不可用时保留旧缓存,不阻断
137
+ }
138
+ }
139
+
140
+ function handleSessionEvent(ctx, session, event) {
141
+ maybeReloadIfChanged(state);
142
+ const sid = session?.id || "global";
143
+ const s = getSessionState(state, sid);
144
+ const d = event.data || {};
145
+ if (event.type === "turn/start") {
146
+ resetTurn(state, sid, d.turn);
147
+ return;
148
+ }
149
+ if (event.type === "user/message") {
150
+ const text = extractUserText(d.message);
151
+ s.lastUserText = text;
152
+ s.turn.userText = text;
153
+ s.turn.questionOnly = isQuestionMessage(text);
154
+ if (isAuthMessage(text) || isDirectiveMessage(text)) {
155
+ recordAuthorization(state, sid, {
156
+ type: inferTypeFromText(text),
157
+ pathPrefix: inferPathPrefixFromText(text),
158
+ source: "user-message"
159
+ });
160
+ audit({ kind: "auth", rule: "12D", name: "用户消息授权", event: "user/message", reason: `记录用户消息授权:${text.slice(0, 120)}`, session: sid });
161
+ }
162
+ const active = activateForUserMessage(state.configs, text);
163
+ if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: "用户消息命中触发词" }));
164
+ return;
165
+ }
166
+ if (event.type === "tool/call") {
167
+ const toolName = String(d.name || "");
168
+ const args = parseArgs(d.arguments);
169
+ s.turn.toolCount++;
170
+ if (s.turn.toolCount === 1) s.turn.firstToolName = toolName;
171
+ if (toolName === "ask_user_question") {
172
+ s.turn.askSeen = true;
173
+ s.turn.pendingAsk = { callId: d.callId, questions: args?.questions || [] };
174
+ }
175
+ const pendingCall = { name: toolName, args };
176
+ const targetPath = args?.file_path ?? args?.path;
177
+ if ((toolName === "edit" || toolName === "write" || (toolName === "str_replace_editor" && args?.command !== "view")) && targetPath && isVersionedFile(targetPath)) {
178
+ try {
179
+ pendingCall.originalContent = readFileSync(targetPath, "utf8");
180
+ } catch {
181
+ pendingCall.originalContent = null;
182
+ }
183
+ }
184
+ s.turn.pendingToolCalls.set(d.callId, pendingCall);
185
+ if (toolName === "pwsh" || toolName === "bash") {
186
+ const cmd = args?.command || args?.code || "";
187
+ if (isGetDateCommand(cmd)) s.turn.getDateSeen = true;
188
+ const bp = backupPathsFromTool(toolName, args);
189
+ if (bp) {
190
+ if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
191
+ pendingCall.backupPaths.push(bp);
192
+ }
193
+ }
194
+ if (toolName !== "pwsh" && toolName !== "bash") {
195
+ const bpTool = backupPathsFromTool(toolName, args);
196
+ if (bpTool) {
197
+ if (!pendingCall.backupPaths) pendingCall.backupPaths = [];
198
+ pendingCall.backupPaths.push(bpTool);
199
+ }
200
+ }
201
+ if (isManualReadTool(toolName, args)) s.manualReadSeen = true;
202
+ if (toolName === "skill" && args?.name) s.turn.skillNames.push(args.name);
203
+ s.turn.toolNames.push(toolName);
204
+ const active = activateForToolCall(state.configs, toolName, args);
205
+ if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: `工具 ${toolName} 命中` }));
206
+ return;
207
+ }
208
+ if (event.type === "tool/result") {
209
+ const resultBlock = Array.isArray(d.message?.content)
210
+ ? d.message.content.find((b) => b && b.type === "tool-result")
211
+ : null;
212
+ const callId = resultBlock?.toolCallId ?? d.callId;
213
+ const isError = Boolean(d.error) || resultBlock?.isError === true;
214
+ const pendingCall = s.turn.pendingToolCalls.get(callId);
215
+ if (pendingCall) {
216
+ const key = `${pendingCall.name}:${JSON.stringify(pendingCall.args || {})}`;
217
+ if (isError) {
218
+ state.retryCounts.set(key, (state.retryCounts.get(key) || 0) + 1);
219
+ } else {
220
+ state.retryCounts.delete(key);
221
+ }
222
+ s.turn.pendingToolCalls.delete(callId);
223
+ }
224
+
225
+ // 备份证据只在工具调用成功后才记录;被拦截/失败的调用不产生备份记录
226
+ if (!isError && pendingCall?.backupPaths?.length) {
227
+ for (const bp of pendingCall.backupPaths) {
228
+ recordBackup(state, sid, bp.targetPath, bp.backupPath);
229
+ }
230
+ }
231
+
232
+ // 规则 27:装配变更成功后全局 revision +1;审计命令输出通过/失败后更新本会话审计 revision
233
+ if (pendingCall) {
234
+ if (!isError && isAssemblyMutationTool(pendingCall.name, pendingCall.args)) {
235
+ state.mountRevision += 1;
236
+ state.mountSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
237
+ audit({
238
+ kind: "mount-dirty",
239
+ rule: "27",
240
+ name: "插件装配变更",
241
+ event: "tool/result",
242
+ reason: `装配已变更(mountRevision=${state.mountRevision},哈希=${state.mountSignature.slice(0, 8)}),继续装配/重启前需先跑全量审计`,
243
+ session: sid,
244
+ tool: pendingCall.name,
245
+ args: summarizeArgs(pendingCall.args)
246
+ });
247
+ }
248
+ const auditCmd = pendingCall.args?.command || pendingCall.args?.code || "";
249
+ if (isAuditCommand(auditCmd)) {
250
+ const output = extractToolOutput(d);
251
+ if (isError || auditOutputFailed(output)) {
252
+ audit({
253
+ kind: "mount-audit-fail",
254
+ rule: "27",
255
+ name: "全量审计未通过",
256
+ event: "tool/result",
257
+ reason: isError ? "审计脚本执行失败" : "审计脚本发现 DUPLICATES FOUND",
258
+ session: sid,
259
+ tool: pendingCall.name
260
+ });
261
+ maybeInject(ctx, sid, { ruleId: "27", reason: "规则 27:全量审计未通过,先移除多余挂载再重跑审计" });
262
+ } else if (auditOutputPassed(output)) {
263
+ s.mountAuditRevision = state.mountRevision;
264
+ s.mountAuditSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
265
+ audit({
266
+ kind: "mount-audit-pass",
267
+ rule: "27",
268
+ name: "全量审计通过",
269
+ event: "tool/result",
270
+ reason: `audit-mount-consistency 输出 MOUNT CONSISTENT(mountRevision=${state.mountRevision},装配哈希=${s.mountAuditSignature.slice(0, 8)})`,
271
+ session: sid,
272
+ tool: pendingCall.name
273
+ });
274
+ }
275
+ }
276
+ }
277
+
278
+ // 版本文件写后自检:失败自动回滚 + 审计
279
+ const versionTarget = pendingCall?.args?.file_path ?? pendingCall?.args?.path;
280
+ if (!isError && pendingCall && pendingCall.originalContent != null && versionTarget && isVersionedFile(versionTarget)) {
281
+ try {
282
+ const current = readFileSync(versionTarget, "utf8");
283
+ const check = validateEditedFile(
284
+ pendingCall.originalContent,
285
+ current,
286
+ pendingCall.args.old_string || "",
287
+ pendingCall.args.new_string || ""
288
+ );
289
+ if (!check.ok) {
290
+ writeFileSync(versionTarget, pendingCall.originalContent, "utf8");
291
+ const reason = `edit 工具显示成功,但内容已被 version-guard 回滚:${check.errors.join(";")}`;
292
+ audit({
293
+ kind: "rollback",
294
+ rule: "__version-guard",
295
+ name: "版本文件写后自检",
296
+ event: "tool/result",
297
+ reason,
298
+ session: sid,
299
+ file: versionTarget
300
+ });
301
+ maybeInject(ctx, sid, { ruleId: "__version-guard", reason });
302
+ }
303
+ } catch (error) {
304
+ audit({
305
+ kind: "rollback-error",
306
+ rule: "__version-guard",
307
+ name: "版本文件写后自检",
308
+ event: "tool/result",
309
+ reason: error instanceof Error ? error.message : String(error),
310
+ session: sid
311
+ });
312
+ }
313
+ }
314
+
315
+ // 命令输出静默错误检测(不阻断,只审计 + 注入提醒)
316
+ if (!isError && pendingCall && (pendingCall.name === "pwsh" || pendingCall.name === "bash")) {
317
+ const output = extractToolOutput(d);
318
+ const det = detectSilentError(output, s.lastCommandOutput);
319
+ if (det.suspicious) {
320
+ audit({
321
+ kind: "silent-error",
322
+ rule: "__silent-error",
323
+ name: "命令输出静默错误",
324
+ event: "tool/result",
325
+ reason: det.reason,
326
+ session: sid,
327
+ tool: pendingCall.name
328
+ });
329
+ maybeInject(ctx, sid, { ruleId: "__silent-error", reason: det.reason });
330
+ }
331
+ if (output) s.lastCommandOutput = output;
332
+ }
333
+
334
+ const pending = s.turn.pendingAsk;
335
+ if (pending) {
336
+ const result = d.result ?? d.value ?? d;
337
+ if (askResultApproved(result)) {
338
+ const qText = askQuestionText(pending.questions);
339
+ const pathPrefix = inferPathPrefixFromText(qText);
340
+ // ask 问题文本措辞不可靠,授权记录为宽泛类型 any + 路径前缀,避免类型错位
341
+ // 无路径的全局 any 授权缩短 TTL,降低安全边界风险
342
+ const authRecord = {
343
+ type: "any",
344
+ pathPrefix,
345
+ source: "ask"
346
+ };
347
+ const sessionWide = /本会话|剩余|全部|会话内/i.test(qText);
348
+ if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
349
+ else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
350
+ recordAuthorization(state, sid, authRecord);
351
+ audit({
352
+ kind: "auth",
353
+ rule: "12D",
354
+ name: "ask_user_question 授权",
355
+ event: "tool/result",
356
+ reason: `记录授权范围:${qText.slice(0, 120)}`,
357
+ session: sid
358
+ });
359
+ } else {
360
+ audit({
361
+ kind: "auth-reject",
362
+ rule: "12D",
363
+ name: "ask_user_question 未授权",
364
+ event: "tool/result",
365
+ reason: "用户未批准该授权请求",
366
+ session: sid
367
+ });
368
+ }
369
+ s.turn.pendingAsk = null;
370
+ }
371
+ return;
372
+ }
373
+ if (event.type === "assistant/chunk") {
374
+ const chunk = d.chunk;
375
+ if (chunk && chunk.type === "reasoning-delta" && typeof chunk.text === "string") {
376
+ s.turn.reasoningText += chunk.text;
377
+ }
378
+ return;
379
+ }
380
+ if (event.type === "assistant/message") {
381
+ const text = extractAssistantText(d.message);
382
+ const violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
383
+ for (const v of violations) {
384
+ audit({
385
+ kind: v.kind,
386
+ rule: v.ruleId,
387
+ name: v.title,
388
+ event: "assistant/message",
389
+ reason: v.reason,
390
+ session: sid
391
+ });
392
+ maybeInject(ctx, sid, v);
393
+ }
394
+ if (violations.length) {
395
+ state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
396
+ } else {
397
+ const active = activateForAssistant(state.configs, text);
398
+ if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: "assistant 文本进入 B/D 检测" }));
399
+ }
400
+ return;
401
+ }
402
+ }
403
+
404
+ // ── /guard 命令 ─────────────────────────────────────────────────────────────
405
+
406
+ const USAGE = [
407
+ "用法:",
408
+ " /guard status 引擎状态",
409
+ " /guard rules 规则清单 + 理解产物",
410
+ " /guard active 最近激活的规则",
411
+ " /guard log [N] 最近 N 条审计(默认 10)",
412
+ " /guard unlock [N] 解锁配置写保护 N 分钟(默认 10,仅用户)",
413
+ " /guard bypass [N] 临时整体放行 N 分钟(默认 5,仅用户)",
414
+ " /guard lock 立即恢复全部守卫(取消解锁/放行)",
415
+ " /guard revoke 撤销全部授权记录",
416
+ " /guard reload 强制重解析 AGENTS.md",
417
+ "",
418
+ "说明:",
419
+ " - 守卫 = 硬拦截:违反规则的工具调用直接拒绝,模型无法自行绕过;",
420
+ " - 解锁/放行只能由你(用户)在对话框输入命令执行,助手无法代替;",
421
+ " - 每次拦截/纠察都会记录到 " + auditFilePath() + "(/guard log 可查)。"
422
+ ].join("\n");
423
+
424
+ function parseCommand(rawInput) {
425
+ const text = (rawInput || "").trim();
426
+ if (!text || /^(status|state)$/i.test(text)) return { kind: "status" };
427
+ if (/^(rules|list|ls)$/i.test(text)) return { kind: "rules" };
428
+ if (/^(active)$/i.test(text)) return { kind: "active" };
429
+ if (/^(help|\?)$/i.test(text)) return { kind: "help" };
430
+ let m = text.match(/^unlock\s*(\d+)?$/i);
431
+ if (m) return { kind: "unlock", minutes: m[1] ? Number(m[1]) : 10 };
432
+ m = text.match(/^bypass\s*(\d+)?$/i);
433
+ if (m) return { kind: "bypass", minutes: m[1] ? Number(m[1]) : 5 };
434
+ if (/^lock$/i.test(text)) return { kind: "lock" };
435
+ if (/^revoke$/i.test(text)) return { kind: "revoke" };
436
+ m = text.match(/^log\s*(\d+)?$/i);
437
+ if (m) return { kind: "log", n: m[1] ? Number(m[1]) : 10 };
438
+ if (/^reload$/i.test(text)) return { kind: "reload" };
439
+ return { kind: "invalid" };
440
+ }
441
+
442
+ async function executeGuard(ctx, invocation) {
443
+ const command = parseCommand(invocation.rawInput);
444
+ switch (command.kind) {
445
+ case "help":
446
+ return { kind: "success", text: USAGE };
447
+ case "status": {
448
+ const conf = loadPluginConfig();
449
+ state.enabled = conf.enabled;
450
+ const high = state.configs.filter((c) => c.confidence === "high").length;
451
+ const medium = state.configs.filter((c) => c.confidence === "medium").length;
452
+ const low = state.configs.filter((c) => c.confidence === "low").length;
453
+ const parts = [
454
+ "【规则引擎状态】",
455
+ ` 总开关:${state.enabled ? "开启" : "已关闭"}`,
456
+ ` 规则容器:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}`,
457
+ ` 理解置信度:high ${high} / medium ${medium} / low ${low}`,
458
+ ` 配置加载:${conf.ok ? "正常" : "⚠ " + conf.error}`,
459
+ ` 解锁剩余:${fmtRemain(remainMs(state.unlockUntil))}(配置写保护豁免)`,
460
+ ` 放行剩余:${fmtRemain(remainMs(state.bypassUntil))}(全部守卫暂停)`,
461
+ ` 授权存储:内存态(重启失效)`,
462
+ ` 审计日志:${auditFilePath()}`,
463
+ ` 最近激活:${state.lastActive.length ? state.lastActive.map((a) => a.ruleId).join("、") : "无"}`,
464
+ ` 提示:审批策略 never 只关系统审批弹窗,不影响对话内 ask_user_question 授权;规则 12A 仍会硬拦需要授权的操作。`
465
+ ];
466
+ return { kind: "success", text: parts.join("\n") };
467
+ }
468
+ case "rules": {
469
+ if (state.configs.length === 0) return { kind: "success", text: "当前没有可执行规则(AGENTS.md 为空或缺失)。" };
470
+ const parts = [`共 ${state.configs.length} 条规则:`, ""];
471
+ for (const c of state.configs) {
472
+ const flag = c.disabled ? "(已禁用)" : "";
473
+ const actions = (c.actions || []).join("/");
474
+ parts.push(` [${c.ruleId}] ${c.title} ${flag}`);
475
+ parts.push(` 等级 ${c.level || "?"}|动作 ${actions}|置信 ${c.confidence}|handler ${c.handler || "generic"}`);
476
+ }
477
+ return { kind: "success", text: parts.join("\n") };
478
+ }
479
+ case "active": {
480
+ if (state.lastActive.length === 0) return { kind: "success", text: "暂无激活规则。" };
481
+ const parts = ["最近激活规则:", ""];
482
+ for (const a of state.lastActive) {
483
+ parts.push(` [${a.ruleId}] ${a.title}`);
484
+ if (a.reason) parts.push(` 原因:${a.reason}`);
485
+ }
486
+ return { kind: "success", text: parts.join("\n") };
487
+ }
488
+ case "unlock": {
489
+ const minutes = Math.min(Math.max(1, command.minutes), 60);
490
+ state.unlockUntil = Date.now() + minutes * 60000;
491
+ return {
492
+ kind: "success",
493
+ text: `已解锁「配置写保护」${minutes} 分钟。现在可以让助手修改 rule-engine.json / rule-understanding.json / AGENTS.md;改完请执行 /guard lock 或等待自动恢复。`
494
+ };
495
+ }
496
+ case "bypass": {
497
+ const minutes = Math.min(Math.max(1, command.minutes), 60);
498
+ state.bypassUntil = Date.now() + minutes * 60000;
499
+ return {
500
+ kind: "success",
501
+ text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
502
+ };
503
+ }
504
+ case "lock": {
505
+ state.unlockUntil = 0;
506
+ state.bypassUntil = 0;
507
+ return { kind: "success", text: "守卫已全部恢复:解锁与放行均已取消。" };
508
+ }
509
+ case "revoke": {
510
+ let count = 0;
511
+ for (const s of state.sessions.values()) {
512
+ count += s.authorizations.length;
513
+ s.authorizations = [];
514
+ }
515
+ return { kind: "success", text: `已撤销全部授权记录(共 ${count} 条)。` };
516
+ }
517
+ case "log": {
518
+ const n = Math.min(Math.max(1, command.n), 200);
519
+ const entries = readAuditLog(n);
520
+ if (entries.length === 0) return { kind: "success", text: "暂无审计记录。" };
521
+ const parts = [`最近 ${entries.length} 条审计:`, ""];
522
+ for (const e of entries) {
523
+ if (e.raw) {
524
+ parts.push(` ${e.raw}`);
525
+ continue;
526
+ }
527
+ const t = (e.ts || "").replace("T", " ").slice(0, 19);
528
+ parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}`);
529
+ if (e.reason) parts.push(` 原因:${e.reason}`);
530
+ if (e.tool) parts.push(` 工具:${e.tool}|参数:${e.args || ""}`);
531
+ }
532
+ return { kind: "success", text: parts.join("\n") };
533
+ }
534
+ case "reload": {
535
+ reloadRules(state);
536
+ const saved = writeUnderstanding(state.configs);
537
+ return {
538
+ kind: "success",
539
+ text: `已重解析 AGENTS.md:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}` +
540
+ (saved.ok ? `\n理解产物已写入:${saved.path}` : `\n理解产物写入失败:${saved.error}`)
541
+ };
542
+ }
543
+ default:
544
+ return { kind: "error", text: USAGE };
545
+ }
546
+ }
547
+
548
+ // ── 插件主体 ────────────────────────────────────────────────────────────────
549
+
550
+ export function apply(ctx) {
551
+ // 0. 从 workspaceRegistry 获取真实工作区根目录,避免 process.cwd() 误判
552
+ try {
553
+ const ws = ctx.workspaceRegistry?.list?.()?.[0]?.path;
554
+ setWorkspaceRoot(ws || process.env.DSH_WORKSPACE || "");
555
+ } catch {
556
+ setWorkspaceRoot(process.env.DSH_WORKSPACE || "");
557
+ }
558
+
559
+ // 0.1 技能目录实时联动
560
+ refreshSkills(ctx);
561
+ ctx.on("skills/change", () => {
562
+ refreshSkills(ctx);
563
+ });
564
+
565
+ // 0.2 LLM 增量理解(非 high 置信规则,失败自动回退模式库)
566
+ enrichRulesWithLlm(ctx, state);
567
+
568
+ // 0.3 AGENTS.md 文件监听(fs.watch 即时触发,stat 轮询保留为兜底)
569
+ ctx.effect(
570
+ function* () {
571
+ let timer = null;
572
+ let watcher = null;
573
+ try {
574
+ watcher = watch(agentsFilePath(), { persistent: false }, () => {
575
+ clearTimeout(timer);
576
+ timer = setTimeout(() => {
577
+ reloadRules(state);
578
+ writeUnderstanding(state.configs);
579
+ }, 200);
580
+ });
581
+ } catch {
582
+ // 文件暂不可监听时由 stat 轮询兜底
583
+ }
584
+ yield () => {
585
+ if (timer) clearTimeout(timer);
586
+ if (watcher) watcher.close();
587
+ };
588
+ },
589
+ "dsh-rule-engine watch"
590
+ );
591
+
592
+ // 1. 单调守卫:工具调用先裁决,命中即物理拒绝
593
+ ctx.effect(
594
+ function* () {
595
+ yield ctx.tools.guard((exec) => {
596
+ const hit = guardDecision(state, exec, Date.now());
597
+ if (hit) {
598
+ audit({
599
+ kind: "deny",
600
+ rule: hit.ruleId,
601
+ name: hit.title,
602
+ event: "tool/guard",
603
+ tool: exec?.name,
604
+ args: summarizeArgs(exec?.arguments),
605
+ reason: hit.reason
606
+ });
607
+ state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
608
+ return hit.reason;
609
+ }
610
+ return undefined;
611
+ });
612
+ },
613
+ "dsh-rule-engine guard"
614
+ );
615
+
616
+ // 2. session/event 监听:文本纠察 + 时序状态
617
+ ctx.on("session/event", (session, event) => {
618
+ try {
619
+ handleSessionEvent(ctx, session, event);
620
+ } catch (error) {
621
+ ctx.logger?.warn?.("[dsh-rule-engine] session/event handler error", error);
622
+ }
623
+ });
624
+
625
+ // 3. /guard 命令(仅用户可执行;模型无命令工具)
626
+ ctx.effect(
627
+ function* () {
628
+ yield ctx.commands.register({
629
+ name: "guard",
630
+ description: "规则执行引擎:查看状态/规则/激活/审计,解锁配置修改,临时放行,强制重载",
631
+ input: { hint: "[status|rules|active|log <N>|unlock <分钟>|bypass <分钟>|reload]" },
632
+ handler: async (invocation) => {
633
+ try {
634
+ return await executeGuard(ctx, invocation);
635
+ } catch (error) {
636
+ return {
637
+ kind: "error",
638
+ text: `执行出错:${error instanceof Error ? error.message : String(error)}`
639
+ };
640
+ }
641
+ }
642
+ });
643
+ },
644
+ "dsh-rule-engine commands"
645
+ );
646
+
647
+ ctx.logger?.info?.("[dsh-rule-engine] 已加载,AGENTS.md 规则数:" + state.configs.length);
648
+ }