@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4

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.
Files changed (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,1127 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ //
4
+ // dsh-Fang v2.8.0 — 可配置的动作规则表(YAML 叠加层)。
5
+ //
6
+ // 约束:本仓库零第三方运行时依赖(CI 与 profile 安装都依赖这一点),所以这里手写
7
+ // * YAML **子集**解析器(块映射 / 序列 / 引号标量 / 简单 flow 列表 / 注释;不做锚点、多文档、块标量)
8
+ // * 配置 schema 校验(手写,错误逐条给出)
9
+ // * 声明式 matcher/evidence 编译器(只允许内置 kind,不能写任意 JS)
10
+ // * RuleRegistry(内置规则 + YAML 叠加,事务化应用)
11
+ // * 文件加载 + fs.watch 热加载(失败保留旧规则)
12
+ //
13
+ // 设计边界(刻意的):
14
+ // YAML 是「配置」不是「脚本」。match 是任意 JS 谓词,不可能安全地塞进 YAML;
15
+ // 因此 YAML 只能 (a) 开关内置规则、(b) 覆盖 light/priority/reason/tags/category、
16
+ // (c) 给内置规则追加正则、(d) 用内置声明式 matcher 新增简单规则。
17
+ // 复杂谓词仍然写在代码里,用 id 引用。
18
+
19
+ import fs from 'node:fs'
20
+ import path from 'node:path'
21
+ import { ACTION_CATEGORIES, assertActionRule, DEFAULT_ACTION_RULES } from './umbrella.mjs'
22
+ import { LIGHT_RANK } from '../kernel/index.mjs'
23
+
24
+ /** 内置规则的默认优先级(越小越先匹配;同优先级按内置顺序)。 */
25
+ export const DEFAULT_RULE_PRIORITIES = Object.freeze({
26
+ 'dsh-lifecycle': 10,
27
+ 'destructive-command': 20,
28
+ 'credential-access': 30,
29
+ 'network-allowlisted': 40,
30
+ 'network-egress': 50,
31
+ 'dynamic-import': 60,
32
+ 'write-in-buffer': 70,
33
+ 'write-in-allowlist': 80,
34
+ 'write-production': 90,
35
+ 'write-outside-buffer': 100,
36
+ 'exec-read-only': 110,
37
+ 'exec-side-effect': 120,
38
+ 'read-in-buffer': 130,
39
+ 'read-outside-buffer': 140,
40
+ 'unknown-tool': 9999,
41
+ })
42
+
43
+ export const RULE_CONFIG_VERSION = 1
44
+ export const MATCHER_KINDS = [
45
+ 'command-pattern', 'path-pattern', 'path-outside',
46
+ 'url-host-in', 'url-host-not-in', 'tool-in', 'pid-equal', 'always', 'never',
47
+ ]
48
+ export const EVIDENCE_KINDS = ['pattern-match', 'list-match', 'static']
49
+ const MAX_PATTERN_LENGTH = 500
50
+ const MAX_PATTERNS = 50
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // 1. YAML 子集解析
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+
56
+ function stripComment(line) {
57
+ let inSingle = false
58
+ let inDouble = false
59
+ for (let i = 0; i < line.length; i += 1) {
60
+ const ch = line[i]
61
+ if (ch === "'" && !inDouble) inSingle = !inSingle
62
+ else if (ch === '"' && !inSingle) inDouble = !inDouble
63
+ else if (ch === '#' && !inSingle && !inDouble) return line.slice(0, i)
64
+ }
65
+ return line
66
+ }
67
+
68
+ function unescapeDouble(text) {
69
+ return text.replace(/\\(["\\/nrt])/g, (_m, c) => (c === 'n' ? '\n' : c === 'r' ? '\r' : c === 't' ? '\t' : c))
70
+ }
71
+
72
+ /** 按顶层逗号切分 flow 内容(尊重引号与嵌套 [] {})。 */
73
+ function splitFlowParts(inner) {
74
+ const parts = []
75
+ let current = ''
76
+ let inSingle = false
77
+ let inDouble = false
78
+ let depth = 0
79
+ for (const ch of inner) {
80
+ if (ch === "'" && !inDouble) inSingle = !inSingle
81
+ else if (ch === '"' && !inSingle) inDouble = !inDouble
82
+ else if (!inSingle && !inDouble) {
83
+ if (ch === '[' || ch === '{') depth += 1
84
+ else if (ch === ']' || ch === '}') depth -= 1
85
+ else if (ch === ',' && depth === 0) {
86
+ parts.push(current)
87
+ current = ''
88
+ continue
89
+ }
90
+ }
91
+ current += ch
92
+ }
93
+ if (current.trim().length > 0) parts.push(current)
94
+ return parts
95
+ }
96
+
97
+ /** flow 映射里第一个顶层冒号位置(引号/嵌套外);没有则 -1。 */
98
+ function findFlowColon(part) {
99
+ let inSingle = false
100
+ let inDouble = false
101
+ let depth = 0
102
+ for (let index = 0; index < part.length; index += 1) {
103
+ const ch = part[index]
104
+ if (ch === "'" && !inDouble) inSingle = !inSingle
105
+ else if (ch === '"' && !inSingle) inDouble = !inDouble
106
+ else if (!inSingle && !inDouble) {
107
+ if (ch === '[' || ch === '{') depth += 1
108
+ else if (ch === ']' || ch === '}') depth -= 1
109
+ else if (ch === ':' && depth === 0) return index
110
+ }
111
+ }
112
+ return -1
113
+ }
114
+
115
+ function unquoteFlowKey(raw) {
116
+ const text = String(raw ?? '').trim()
117
+ if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
118
+ return text.startsWith('"') ? unescapeDouble(text.slice(1, -1)) : text.slice(1, -1)
119
+ }
120
+ return text
121
+ }
122
+
123
+ function parseScalar(raw) {
124
+ const text = String(raw ?? '').trim()
125
+ if (text.length === 0) return null
126
+ if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) return unescapeDouble(text.slice(1, -1))
127
+ if (text.length >= 2 && text.startsWith("'") && text.endsWith("'")) return text.slice(1, -1).replace(/''/g, "'")
128
+ if ((text.startsWith('[') && text.endsWith(']')) || (text.startsWith('{') && text.endsWith('}'))) {
129
+ const isList = text.startsWith('[')
130
+ const inner = text.slice(1, -1).trim()
131
+ if (inner.length === 0) return isList ? [] : {}
132
+ const parts = splitFlowParts(inner)
133
+ if (isList) return parts.map((part) => parseScalar(part))
134
+ const mapping = {}
135
+ for (const part of parts) {
136
+ const colon = findFlowColon(part)
137
+ if (colon < 0) {
138
+ mapping[unquoteFlowKey(part)] = null
139
+ continue
140
+ }
141
+ mapping[unquoteFlowKey(part.slice(0, colon))] = parseScalar(part.slice(colon + 1))
142
+ }
143
+ return mapping
144
+ }
145
+ if (text === 'true') return true
146
+ if (text === 'false') return false
147
+ if (text === 'null' || text === '~') return null
148
+ if (/^-?\d+$/.test(text)) return Number(text)
149
+ if (/^-?\d+\.\d+$/.test(text)) return Number(text)
150
+ return text
151
+ }
152
+
153
+ /**
154
+ * 解析 YAML 子集。返回 { value, errors }。
155
+ * 不支持的语法(锚点/别名/多文档/块标量/tab 缩进)会给出明确错误,由调用方 fail-safe 处理。
156
+ */
157
+ export function parseYamlSubset(text) {
158
+ const errors = []
159
+ const tokens = []
160
+ const lines = String(text ?? '').split(/\r?\n/)
161
+ for (let index = 0; index < lines.length; index += 1) {
162
+ const noComment = stripComment(lines[index])
163
+ if (noComment.trim().length === 0) continue
164
+ if (noComment.startsWith('---') || noComment.startsWith('...')) {
165
+ errors.push(`line ${index + 1}: multi-document YAML is not supported`)
166
+ continue
167
+ }
168
+ const indentMatch = /^[ \t]*/.exec(noComment)[0]
169
+ if (indentMatch.includes('\t')) {
170
+ errors.push(`line ${index + 1}: tab indentation is not supported`)
171
+ continue
172
+ }
173
+ tokens.push({ indent: indentMatch.length, content: noComment.trim(), line: index + 1 })
174
+ }
175
+ if (errors.length > 0) return { value: null, errors }
176
+
177
+ const isSequenceLine = (token) => token.content === '-' || token.content.startsWith('- ')
178
+
179
+ const parseBlock = (startIndex, indent) => {
180
+ const first = tokens[startIndex]
181
+ if (first === undefined) return [null, startIndex]
182
+ if (isSequenceLine(first)) return parseSequence(startIndex, indent)
183
+ return parseMapping(startIndex, indent)
184
+ }
185
+
186
+ const parseSequence = (startIndex, indent) => {
187
+ const items = []
188
+ let index = startIndex
189
+ while (index < tokens.length) {
190
+ const token = tokens[index]
191
+ if (token.indent !== indent || !isSequenceLine(token)) break
192
+ const rest = token.content === '-' ? '' : token.content.slice(2).trim()
193
+ if (rest.length === 0) {
194
+ const next = tokens[index + 1]
195
+ if (next !== undefined && next.indent > indent) {
196
+ const [child, nextIndex] = parseBlock(index + 1, next.indent)
197
+ items.push(child)
198
+ index = nextIndex
199
+ } else {
200
+ items.push(null)
201
+ index += 1
202
+ }
203
+ continue
204
+ }
205
+ const inlineKey = /^([A-Za-z0-9_$.-]+):(?:\s|$)/.exec(rest)
206
+ if (inlineKey !== null) {
207
+ const virtualIndent = indent + 2
208
+ const key = inlineKey[1]
209
+ const restValue = rest.slice(inlineKey[0].length).trim()
210
+ const mapped = {}
211
+ if (restValue.length > 0) {
212
+ const [folded, nextIndex] = foldContinuations({ content: restValue }, index + 1, virtualIndent)
213
+ mapped[key] = parseScalar(folded)
214
+ index = nextIndex - 1
215
+ } else {
216
+ const next = tokens[index + 1]
217
+ if (next !== undefined && next.indent > virtualIndent) {
218
+ const [child, nextIndex] = parseBlock(index + 1, next.indent)
219
+ mapped[key] = child
220
+ index = nextIndex - 1
221
+ } else {
222
+ mapped[key] = null
223
+ }
224
+ }
225
+ // 同时解析续行的同级键(`- key: v` 后面按 indent+2 继续)
226
+ index += 1
227
+ while (index < tokens.length && tokens[index].indent === virtualIndent && !isSequenceLine(tokens[index])) {
228
+ const line = tokens[index]
229
+ const match = /^([A-Za-z0-9_$.-]+):(?:\s*(.*))?$/.exec(line.content)
230
+ if (match === null) {
231
+ errors.push(`line ${line.line}: expected "key: value" inside sequence item`)
232
+ index += 1
233
+ continue
234
+ }
235
+ const childKey = match[1]
236
+ const childRest = String(match[2] ?? '').trim()
237
+ if (childRest.length > 0) {
238
+ const [folded, nextIndex] = foldContinuations({ content: childRest }, index + 1, virtualIndent)
239
+ mapped[childKey] = parseScalar(folded)
240
+ index = nextIndex
241
+ continue
242
+ }
243
+ const nextToken = tokens[index + 1]
244
+ if (nextToken !== undefined && nextToken.indent > virtualIndent) {
245
+ const [child, nextIndex] = parseBlock(index + 1, nextToken.indent)
246
+ mapped[childKey] = child
247
+ index = nextIndex
248
+ } else {
249
+ mapped[childKey] = null
250
+ index += 1
251
+ }
252
+ }
253
+ items.push(mapped)
254
+ continue
255
+ }
256
+ items.push(parseScalar(rest))
257
+ index += 1
258
+ }
259
+ return [items, index]
260
+ }
261
+
262
+ // v3.1.31:折行纯量(plain scalar continuation)。
263
+ // DSH 会把超长 description 折成多行,续行缩进更深、且既不是 "key:" 也不是 "- item"。
264
+ // 结构行(映射键/序列项)一律不吸收,避免吞掉兄弟节点。
265
+ const KEY_LINE = /^[A-Za-z0-9_$.-]+:(?:\s|$)/
266
+ const isStructural = (token) => isSequenceLine(token) || KEY_LINE.test(token.content)
267
+ const foldContinuations = (valueToken, startIndex, parentIndent) => {
268
+ const parts = []
269
+ let index = startIndex
270
+ while (index < tokens.length) {
271
+ const token = tokens[index]
272
+ if (token.indent <= parentIndent) break
273
+ if (isStructural(token)) break
274
+ parts.push(token.content)
275
+ index += 1
276
+ }
277
+ if (parts.length === 0) return [valueToken.content, startIndex]
278
+ const first = valueToken.content
279
+ const joined = [first, ...parts].join(" ")
280
+ return [joined, index]
281
+ }
282
+ const parseMapping = (startIndex, indent) => {
283
+ const mapping = {}
284
+ let index = startIndex
285
+ while (index < tokens.length) {
286
+ const token = tokens[index]
287
+ if (token.indent !== indent || isSequenceLine(token)) break
288
+ const match = /^([A-Za-z0-9_$.-]+):(?:\s*(.*))?$/.exec(token.content)
289
+ if (match === null) {
290
+ errors.push(`line ${token.line}: expected "key: value" or "- item"`)
291
+ index += 1
292
+ continue
293
+ }
294
+ const key = match[1]
295
+ const rest = String(match[2] ?? '').trim()
296
+ if (rest.length > 0) {
297
+ // v3.1.31:先吸收折行续行,再按纯量解析。
298
+ const [folded, nextIndex] = foldContinuations({ content: rest }, index + 1, indent)
299
+ mapping[key] = parseScalar(folded)
300
+ index = nextIndex
301
+ continue
302
+ }
303
+ const next = tokens[index + 1]
304
+ if (next !== undefined && next.indent > indent) {
305
+ const [child, nextIndex] = parseBlock(index + 1, next.indent)
306
+ mapping[key] = child
307
+ index = nextIndex
308
+ } else {
309
+ mapping[key] = null
310
+ index += 1
311
+ }
312
+ }
313
+ return [mapping, index]
314
+ }
315
+
316
+ const [value, endIndex] = parseBlock(0, tokens[0].indent)
317
+ if (endIndex < tokens.length) {
318
+ const leftover = tokens[endIndex]
319
+ errors.push(`line ${leftover.line}: unexpected indentation / content`)
320
+ }
321
+ return { value, errors }
322
+ }
323
+
324
+ // ─────────────────────────────────────────────────────────────────────────────
325
+ // 2. schema 校验(手写;错误逐条给出,不做部分接受)
326
+ // ─────────────────────────────────────────────────────────────────────────────
327
+
328
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
329
+
330
+ function checkPatternList(list, field, errors) {
331
+ if (!Array.isArray(list)) {
332
+ errors.push(`${field}: must be a list`)
333
+ return
334
+ }
335
+ if (list.length === 0 || list.length > MAX_PATTERNS) {
336
+ errors.push(`${field}: must contain 1..${MAX_PATTERNS} patterns`)
337
+ return
338
+ }
339
+ for (const [index, pattern] of list.entries()) {
340
+ if (!isPlainObject(pattern)) {
341
+ errors.push(`${field}[${index}]: must be an object { re, label? }`)
342
+ continue
343
+ }
344
+ if (typeof pattern.re !== 'string' || pattern.re.length === 0) {
345
+ errors.push(`${field}[${index}].re: required non-empty string`)
346
+ continue
347
+ }
348
+ if (pattern.re.length > MAX_PATTERN_LENGTH) {
349
+ errors.push(`${field}[${index}].re: longer than ${MAX_PATTERN_LENGTH} chars`)
350
+ }
351
+ if (pattern.label !== undefined && (typeof pattern.label !== 'string' || pattern.label.length > 200)) {
352
+ errors.push(`${field}[${index}].label: string ≤200 chars`)
353
+ }
354
+ }
355
+ }
356
+
357
+ function validateMatcher(match, field, errors) {
358
+ if (!isPlainObject(match)) {
359
+ errors.push(`${field}: must be an object with a "kind"`)
360
+ return
361
+ }
362
+ const kind = match.kind
363
+ if (!MATCHER_KINDS.includes(kind)) {
364
+ errors.push(`${field}.kind: unknown matcher "${String(kind)}" (allowed: ${MATCHER_KINDS.join(', ')})`)
365
+ return
366
+ }
367
+ if (kind === 'command-pattern') checkPatternList(match.patterns, `${field}.patterns`, errors)
368
+ if (kind === 'path-pattern') {
369
+ checkPatternList(match.patterns, `${field}.patterns`, errors)
370
+ if (match.scope !== undefined && !['target', 'read', 'write', 'all'].includes(match.scope)) {
371
+ errors.push(`${field}.scope: must be target|read|write|all`)
372
+ }
373
+ }
374
+ if (kind === 'path-outside') {
375
+ if (!['$bufferRoot', '$productionRoot', 'literal'].includes(match.root)) {
376
+ errors.push(`${field}.root: must be $bufferRoot|$productionRoot|literal`)
377
+ }
378
+ if (match.root === 'literal' && typeof match.literal !== 'string') {
379
+ errors.push(`${field}.literal: required when root=literal`)
380
+ }
381
+ }
382
+ if (kind === 'url-host-in' || kind === 'url-host-not-in') {
383
+ if (!Array.isArray(match.hosts) || match.hosts.length === 0 || match.hosts.length > 100) {
384
+ errors.push(`${field}.hosts: list with 1..100 entries`)
385
+ }
386
+ }
387
+ if (kind === 'tool-in') {
388
+ if (!Array.isArray(match.tools) || match.tools.length === 0 || match.tools.length > 50) {
389
+ errors.push(`${field}.tools: list with 1..50 entries`)
390
+ }
391
+ }
392
+ if (kind === 'pid-equal') {
393
+ const ok = match.pid === '$currentPid' || (typeof match.pid === 'number' && Number.isInteger(match.pid))
394
+ if (!ok) errors.push(`${field}.pid: must be $currentPid or an integer`)
395
+ }
396
+ }
397
+
398
+ function validateEvidence(evidence, field, errors) {
399
+ if (evidence === undefined) return
400
+ if (!isPlainObject(evidence)) {
401
+ errors.push(`${field}: must be an object`)
402
+ return
403
+ }
404
+ if (!EVIDENCE_KINDS.includes(evidence.kind)) {
405
+ errors.push(`${field}.kind: unknown evidence "${String(evidence.kind)}" (allowed: ${EVIDENCE_KINDS.join(', ')})`)
406
+ return
407
+ }
408
+ if (evidence.kind === 'static' && !isPlainObject(evidence.payload)) {
409
+ errors.push(`${field}.payload: object required for kind=static`)
410
+ }
411
+ }
412
+
413
+ /** 校验单个规则条目(overlay 或 declarative)。 */
414
+ export function validateRuleEntry(entry, index, errors) {
415
+ const field = `rules[${index}]`
416
+ if (!isPlainObject(entry)) {
417
+ errors.push(`${field}: must be an object`)
418
+ return
419
+ }
420
+ if (typeof entry.id !== 'string' || entry.id.length === 0 || entry.id.length > 100) {
421
+ errors.push(`${field}.id: required string (1..100 chars)`)
422
+ } else if (!/^[a-z0-9][a-z0-9-]*$/.test(entry.id)) {
423
+ errors.push(`${field}.id: must match ^[a-z0-9][a-z0-9-]*$`)
424
+ }
425
+ if (entry.enabled !== undefined && typeof entry.enabled !== 'boolean') errors.push(`${field}.enabled: boolean`)
426
+ if (entry.light !== undefined && !Object.prototype.hasOwnProperty.call(LIGHT_RANK, entry.light)) {
427
+ errors.push(`${field}.light: must be green|blue|yellow|red`)
428
+ }
429
+ if (entry.priority !== undefined && (!Number.isInteger(entry.priority) || entry.priority < 0 || entry.priority > 10000)) {
430
+ errors.push(`${field}.priority: integer 0..10000`)
431
+ }
432
+ if (entry.reason !== undefined && (typeof entry.reason !== 'string' || entry.reason.length > 500)) {
433
+ errors.push(`${field}.reason: string ≤500 chars`)
434
+ }
435
+ if (entry.tags !== undefined) {
436
+ if (!Array.isArray(entry.tags) || entry.tags.length > 20 || entry.tags.some((t) => typeof t !== 'string')) {
437
+ errors.push(`${field}.tags: list of ≤20 strings`)
438
+ }
439
+ }
440
+ if (entry.category !== undefined && !ACTION_CATEGORIES.includes(entry.category)) {
441
+ errors.push(`${field}.category: must be one of ${ACTION_CATEGORIES.join(', ')}`)
442
+ }
443
+ if (entry.match !== undefined) validateMatcher(entry.match, `${field}.match`, errors)
444
+ if (entry.evidence !== undefined) validateEvidence(entry.evidence, `${field}.evidence`, errors)
445
+ if (entry.pathPatterns !== undefined) checkPatternList(entry.pathPatterns, `${field}.pathPatterns`, errors)
446
+ if (entry.commandPatterns !== undefined) checkPatternList(entry.commandPatterns, `${field}.commandPatterns`, errors)
447
+ }
448
+
449
+ /**
450
+ * 校验整个配置。返回 { config, errors };errors 非空时调用方必须回退(fail-safe)。
451
+ */
452
+ export function validateRuleConfig(raw) {
453
+ const errors = []
454
+ if (!isPlainObject(raw)) return { config: null, errors: ['config: top level must be a mapping'] }
455
+ if (raw.version !== RULE_CONFIG_VERSION) {
456
+ errors.push(`version: must be ${RULE_CONFIG_VERSION}`)
457
+ }
458
+ if (raw.inherit !== undefined && typeof raw.inherit !== 'boolean') errors.push('inherit: boolean')
459
+ const settings = isPlainObject(raw.settings) ? raw.settings : {}
460
+ for (const key of ['failOnUnknownRule', 'failOnInvalidPattern', 'rejectUnsafeRegex']) {
461
+ if (settings[key] !== undefined && typeof settings[key] !== 'boolean') errors.push(`settings.${key}: boolean`)
462
+ }
463
+ const rules = raw.rules ?? []
464
+ if (!Array.isArray(rules)) {
465
+ errors.push('rules: must be a list')
466
+ } else {
467
+ for (const [index, entry] of rules.entries()) validateRuleEntry(entry, index, errors)
468
+ }
469
+ if (errors.length > 0) return { config: null, errors }
470
+ return {
471
+ config: {
472
+ version: RULE_CONFIG_VERSION,
473
+ inherit: raw.inherit !== false,
474
+ settings: {
475
+ failOnUnknownRule: settings.failOnUnknownRule === true,
476
+ failOnInvalidPattern: settings.failOnInvalidPattern === true,
477
+ rejectUnsafeRegex: settings.rejectUnsafeRegex !== false,
478
+ },
479
+ rules,
480
+ },
481
+ errors: [],
482
+ }
483
+ }
484
+
485
+ // ─────────────────────────────────────────────────────────────────────────────
486
+ // 3. 正则安全启发式(不是完整 ReDoS 检测,够挡住明显灾难回溯)
487
+ // ─────────────────────────────────────────────────────────────────────────────
488
+
489
+ export function isSafePatternSource(source) {
490
+ const text = String(source ?? '')
491
+ if (text.length === 0 || text.length > MAX_PATTERN_LENGTH) return false
492
+ // 禁止嵌套量词:(a+)+ / (.*)* / (\w+)* 这类灾难回溯形状
493
+ if (/\([^()]*[+*]\)[+*]/.test(text)) return false
494
+ if (/\([^()]*\{\d+,\}\)[+*]/.test(text)) return false
495
+ // 超过 200 的定长重复基本没有正当用途
496
+ if (/\{\s*\d{3,}/.test(text)) return false
497
+ return true
498
+ }
499
+
500
+ function compilePatterns(list, errors, labelPrefix) {
501
+ const out = []
502
+ for (const [index, pattern] of list.entries()) {
503
+ if (!isSafePatternSource(pattern.re)) {
504
+ errors.push(`${labelPrefix}[${index}]: unsafe or too long regex rejected`)
505
+ continue
506
+ }
507
+ try {
508
+ out.push({
509
+ re: new RegExp(pattern.re, 'i'),
510
+ id: pattern.id ?? pattern.label ?? String(pattern.re).slice(0, 40),
511
+ label: pattern.label ?? String(pattern.re).slice(0, 40),
512
+ })
513
+ } catch (e) {
514
+ errors.push(`${labelPrefix}[${index}]: invalid regex (${e?.message ?? e})`)
515
+ }
516
+ }
517
+ return out
518
+ }
519
+
520
+ // ─────────────────────────────────────────────────────────────────────────────
521
+ // 4. 声明式 matcher / evidence 编译
522
+ // ─────────────────────────────────────────────────────────────────────────────
523
+
524
+ function hostMatches(host, pattern) {
525
+ const p = String(pattern).toLowerCase()
526
+ const h = String(host).toLowerCase()
527
+ if (p.startsWith('*.')) return h.endsWith(p.slice(1))
528
+ return h === p
529
+ }
530
+
531
+ function pathInside(child, parent) {
532
+ if (typeof child !== 'string' || typeof parent !== 'string' || parent.length === 0) return false
533
+ const rel = path.relative(path.resolve(parent), path.resolve(child))
534
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))
535
+ }
536
+
537
+ /** 编译声明式规则;返回 { rule, errors }(errors 非空时 rule 可能仍可用,调用方决定)。 */
538
+ export function compileDeclarativeRule(entry, { rejectUnsafeRegex = true } = {}) {
539
+ const errors = []
540
+ const patterns = {}
541
+ const compile = (list, key) => {
542
+ if (list === undefined) return []
543
+ const compiled = compilePatterns(list, errors, `rules.${entry.id}.${key}`)
544
+ if (rejectUnsafeRegex === false) {
545
+ // 调用方显式关闭安全检查时,把因安全被拒的模式按原样重编译(仍要求语法合法)
546
+ for (const [index, pattern] of list.entries()) {
547
+ if (compiled.some((c) => c.source === pattern.re)) continue
548
+ try {
549
+ compiled.push({ re: new RegExp(pattern.re, 'i'), id: pattern.id ?? `p${index}`, label: pattern.label ?? pattern.re })
550
+ } catch { /* 已记录为错误 */ }
551
+ }
552
+ }
553
+ patterns[key] = compiled
554
+ return compiled
555
+ }
556
+
557
+ const match = entry.match
558
+ let matchFn = null
559
+ const evidenceKind = entry.evidence?.kind ?? 'pattern-match'
560
+
561
+ if (match !== undefined) {
562
+ switch (match.kind) {
563
+ case 'command-pattern': {
564
+ const compiled = compile(match.patterns, 'commandPatterns')
565
+ matchFn = (_ev, ctx) => compiled.some(({ re }) => re.test(ctx.commandText))
566
+ break
567
+ }
568
+ case 'path-pattern': {
569
+ const compiled = compile(match.patterns, 'pathPatterns')
570
+ const scope = match.scope ?? 'all'
571
+ matchFn = (_ev, ctx) => {
572
+ const candidates = []
573
+ if (scope === 'target' || scope === 'all') if (ctx.targetPath !== null) candidates.push(ctx.targetPath)
574
+ if (scope === 'write' || scope === 'all') candidates.push(...ctx.writeTargets)
575
+ if (scope === 'read' || scope === 'all') candidates.push(...ctx.readTargets)
576
+ return candidates.some((p) => compiled.some(({ re }) => re.test(p)))
577
+ }
578
+ break
579
+ }
580
+ case 'path-outside': {
581
+ const rootOf = (ev) => {
582
+ if (match.root === '$bufferRoot') return ev?.context?.bufferRoot ?? null
583
+ if (match.root === '$productionRoot') return ev?.context?.productionRoot ?? null
584
+ return match.literal ?? null
585
+ }
586
+ const checkAllowPaths = match.checkAllowPaths !== false
587
+ matchFn = (ev, ctx) => {
588
+ if (ctx.targetPath === null) return false
589
+ const root = rootOf({ context: { ...ev?.context, bufferRoot: ctx.policy.bufferRoot, productionRoot: ctx.policy.productionRoot } })
590
+ if (root === null) return false
591
+ if (pathInside(ctx.targetPath, root)) return false
592
+ if (checkAllowPaths && ctx.policy.allowPaths.some((a) => pathInside(ctx.targetPath, a))) return false
593
+ return true
594
+ }
595
+ break
596
+ }
597
+ case 'url-host-in': {
598
+ matchFn = (_ev, ctx) => ctx.url !== null && match.hosts.some((h) => hostMatches(ctx.url.host, h))
599
+ break
600
+ }
601
+ case 'url-host-not-in': {
602
+ matchFn = (_ev, ctx) => ctx.url !== null && !match.hosts.some((h) => hostMatches(ctx.url.host, h))
603
+ break
604
+ }
605
+ case 'tool-in': {
606
+ const set = new Set(match.tools.map(String))
607
+ matchFn = (ev) => set.has(String(ev?.tool ?? ''))
608
+ break
609
+ }
610
+ case 'pid-equal': {
611
+ matchFn = (_ev, ctx) => (match.pid === '$currentPid' ? ctx.targetPid === process.pid : ctx.targetPid === match.pid)
612
+ break
613
+ }
614
+ case 'always': matchFn = () => true; break
615
+ case 'never': matchFn = () => false; break
616
+ default: errors.push(`rules.${entry.id}.match.kind: unsupported "${String(match.kind)}"`)
617
+ }
618
+ }
619
+
620
+ let evidenceFn = null
621
+ switch (evidenceKind) {
622
+ case 'pattern-match': {
623
+ evidenceFn = (_ev, ctx) => {
624
+ const hits = [...(patterns.commandPatterns ?? []), ...(patterns.pathPatterns ?? [])]
625
+ .filter(({ re }) => re.test(ctx.commandText) || (ctx.targetPath !== null && re.test(ctx.targetPath)) || ctx.writeTargets.some((t) => re.test(t)))
626
+ .map((p) => p.id)
627
+ return { matchedPattern: hits.length > 0 ? hits[0] : 'declarative-pattern', patternIds: hits }
628
+ }
629
+ break
630
+ }
631
+ case 'list-match': {
632
+ evidenceFn = (_ev, ctx) => ({
633
+ matchedPattern: 'declarative-list',
634
+ writeTargets: ctx.writeTargets,
635
+ readTargets: ctx.readTargets,
636
+ })
637
+ break
638
+ }
639
+ case 'static': {
640
+ const payload = entry.evidence?.payload ?? {}
641
+ evidenceFn = () => ({ ...payload })
642
+ break
643
+ }
644
+ default:
645
+ errors.push(`rules.${entry.id}.evidence.kind: unsupported "${String(evidenceKind)}"`)
646
+ }
647
+
648
+ if (matchFn === null || evidenceFn === null) return { rule: null, errors }
649
+
650
+ const light = entry.light
651
+ const category = entry.category
652
+ const reason = entry.reason
653
+ return {
654
+ rule: {
655
+ id: entry.id,
656
+ category,
657
+ light,
658
+ reason,
659
+ tags: entry.tags ?? [],
660
+ match: matchFn,
661
+ evidence: evidenceFn,
662
+ source: 'yaml',
663
+ priority: entry.priority,
664
+ },
665
+ errors,
666
+ }
667
+ }
668
+
669
+ // ─────────────────────────────────────────────────────────────────────────────
670
+ // 5. RuleRegistry:内置 + YAML 叠加(事务化)
671
+ // ─────────────────────────────────────────────────────────────────────────────
672
+
673
+ /**
674
+ * @param {object[]} builtinRules 默认 DEFAULT_ACTION_RULES
675
+ * @param {{priorities?: Record<string, number>}} [options]
676
+ */
677
+ /** 不可协商标签:带这些标签的规则不可被关闭、不可被降低灯色。 */
678
+ export const NON_NEGOTIABLE_TAGS = Object.freeze(['hard-deny', 'non-negotiable'])
679
+
680
+ export function isLockedRule(rule) {
681
+ return Array.isArray(rule?.tags) && rule.tags.some((tag) => NON_NEGOTIABLE_TAGS.includes(tag))
682
+ }
683
+
684
+ /**
685
+ * 分层规则注册表:内置规则 → YAML 层(基线 → 项目 → 环境,后层按 id 覆盖前层)。
686
+ * 事务化:任何一层被判定 fatal 时整次 rebuild 被拒绝,effective 保持上一次有效值。
687
+ *
688
+ * @param {object[]} builtinRules
689
+ * @param {{priorities?: Record<string, number>}} [options]
690
+ */
691
+ export function createRuleRegistry(builtinRules = DEFAULT_ACTION_RULES, options = {}) {
692
+ const builtin = [...builtinRules]
693
+ const defaultPriorities = { ...DEFAULT_RULE_PRIORITIES, ...(options.priorities ?? {}) }
694
+ let effective = []
695
+ let layers = []
696
+ let status = { source: 'builtin', appliedAt: null, warnings: [], errors: [], configVersion: null, layers: 0, strictMode: null }
697
+
698
+ const priorityBase = (rule, priorities) => {
699
+ const explicit = priorities[rule.id]
700
+ if (Number.isFinite(explicit)) return explicit
701
+ const index = builtin.findIndex((r) => r.id === rule.id)
702
+ return index >= 0 ? index * 10 : 5000
703
+ }
704
+
705
+ const sortRules = (rules, priorities) => rules
706
+ .map((rule, index) => ({ rule, index }))
707
+ .sort((a, b) => {
708
+ const pa = priorityBase(a.rule, priorities)
709
+ const pb = priorityBase(b.rule, priorities)
710
+ if (pa !== pb) return pa - pb
711
+ return a.index - b.index
712
+ })
713
+ .map(({ rule }) => rule)
714
+
715
+ const resetToBuiltin = () => {
716
+ layers = []
717
+ effective = sortRules(builtin, defaultPriorities)
718
+ status = { source: 'builtin', appliedAt: Date.now(), warnings: [], errors: [], configVersion: null, layers: 0, strictMode: null }
719
+ return effective
720
+ }
721
+ resetToBuiltin()
722
+
723
+ /**
724
+ * 纯计算:从内置 + 给定层序列构建候选规则表。**不修改** effective/status,
725
+ * 由调用方在 applied 时提交——失败时三者全部保持上一次成功值。
726
+ */
727
+ const computeRebuild = (layerList) => {
728
+ const warnings = []
729
+ const errors = []
730
+ const layerRecords = []
731
+ const priorities = { ...defaultPriorities }
732
+ let byId = new Map(builtin.map((rule) => [rule.id, rule]))
733
+ let order = builtin.map((rule) => rule.id)
734
+ const locked = new Set()
735
+ const builtinById = new Map(builtin.map((rule) => [rule.id, rule]))
736
+
737
+ // inherit:false = 严格模式:丢弃此前所有可调规则,只保留硬规则 + fallback,
738
+ // 然后从该层开始叠加。硬规则与 fallback 不能被任何配置拆掉。
739
+ let startIndex = 0
740
+ for (let index = layerList.length - 1; index >= 0; index -= 1) {
741
+ if (layerList[index].inherit === false) { startIndex = index; break }
742
+ }
743
+ const strictMode = startIndex > 0 || (layerList.length > 0 && layerList[0].inherit === false)
744
+ let strictModeInfo = null
745
+ if (strictMode) {
746
+ const preserved = builtin.filter((rule) => isLockedRule(rule) || rule.category === 'fallback')
747
+ byId = new Map(preserved.map((rule) => [rule.id, rule]))
748
+ order = preserved.map((rule) => rule.id)
749
+ for (const id of Object.keys(priorities)) if (!byId.has(id)) delete priorities[id]
750
+ strictModeInfo = {
751
+ activatedAt: Date.now(),
752
+ layerIndex: startIndex,
753
+ preservedHardRules: preserved.filter((rule) => isLockedRule(rule)).map((rule) => rule.id),
754
+ preservedFallback: preserved.filter((rule) => rule.category === 'fallback').map((rule) => rule.id),
755
+ discardedRuleCount: builtin.length - preserved.length,
756
+ }
757
+ }
758
+ let fatal = false
759
+ let lockViolation = false
760
+
761
+ for (let layerIndex = startIndex; layerIndex < layerList.length; layerIndex += 1) {
762
+ const layer = layerList[layerIndex]
763
+ const layerErrors = []
764
+ for (const entry of layer.rules) {
765
+ const isDeclarative = entry.match !== undefined
766
+ if (isDeclarative) {
767
+ if (byId.has(entry.id)) warnings.push(`declarative rule "${entry.id}" shadows an existing rule`)
768
+ const compiled = compileDeclarativeRule(entry, { rejectUnsafeRegex: layer.settings.rejectUnsafeRegex })
769
+ layerErrors.push(...compiled.errors)
770
+ if (compiled.rule !== null) {
771
+ try {
772
+ assertActionRule(compiled.rule)
773
+ byId.set(compiled.rule.id, compiled.rule)
774
+ if (!order.includes(compiled.rule.id)) order.push(compiled.rule.id)
775
+ if (entry.priority !== undefined) priorities[compiled.rule.id] = entry.priority
776
+ if (isLockedRule(compiled.rule)) locked.add(compiled.rule.id)
777
+ } catch (e) {
778
+ layerErrors.push(`rules.${entry.id}: ${e.message}`)
779
+ }
780
+ }
781
+ continue
782
+ }
783
+
784
+ // 严格模式丢弃过内置规则后,后续层仍可按 id 引用它(重新引入内置定义再覆盖),
785
+ // 这样 inherit:false 之后的 inherit:true 层是"叠加"而不是"必须重写谓词"。
786
+ const base = byId.get(entry.id) ?? builtinById.get(entry.id)
787
+ if (base === undefined) {
788
+ const message = `unknown rule id "${entry.id}"`
789
+ if (layer.settings.failOnUnknownRule) layerErrors.push(message)
790
+ else warnings.push(message)
791
+ continue
792
+ }
793
+
794
+ // 硬规则锁定:不可关闭、不可降低灯色(允许提升灯色 / 追加 pattern / 改 reason)
795
+ if (isLockedRule(base) || locked.has(base.id)) {
796
+ if (entry.enabled === false) {
797
+ layerErrors.push(`rule "${entry.id}" is non-negotiable and cannot be disabled`)
798
+ lockViolation = true
799
+ continue
800
+ }
801
+ if (entry.light !== undefined && !(LIGHT_RANK[entry.light] >= LIGHT_RANK[base.light])) {
802
+ layerErrors.push(`rule "${entry.id}" is non-negotiable; light cannot be lowered from ${base.light} to ${entry.light}`)
803
+ lockViolation = true
804
+ continue
805
+ }
806
+ if (entry.tags !== undefined) {
807
+ const requiredTags = (base.tags ?? []).filter((tag) => NON_NEGOTIABLE_TAGS.includes(tag))
808
+ const missingTags = requiredTags.filter((tag) => !Array.isArray(entry.tags) || !entry.tags.includes(tag))
809
+ if (missingTags.length > 0) {
810
+ layerErrors.push(`rule "${entry.id}" is non-negotiable; locked tags cannot be removed: ${missingTags.join(',')}`)
811
+ lockViolation = true
812
+ continue
813
+ }
814
+ }
815
+ if (entry.priority !== undefined) {
816
+ const currentPriority = priorities[base.id] ?? defaultPriorities[base.id] ?? 500
817
+ if (entry.priority > currentPriority) {
818
+ layerErrors.push(`rule "${entry.id}" is non-negotiable; priority cannot move later than ${currentPriority}`)
819
+ lockViolation = true
820
+ continue
821
+ }
822
+ }
823
+ }
824
+
825
+ if (entry.enabled === false) {
826
+ byId.delete(entry.id)
827
+ locked.delete(entry.id)
828
+ continue
829
+ }
830
+
831
+ if (!byId.has(entry.id)) {
832
+ if (!order.includes(entry.id)) order.push(entry.id)
833
+ if (priorities[entry.id] === undefined) priorities[entry.id] = defaultPriorities[entry.id] ?? 500
834
+ }
835
+ let patched = {
836
+ ...base,
837
+ light: entry.light ?? base.light,
838
+ reason: entry.reason ?? base.reason,
839
+ tags: entry.tags ?? base.tags,
840
+ category: entry.category ?? base.category,
841
+ }
842
+ if (entry.pathPatterns !== undefined || entry.commandPatterns !== undefined) {
843
+ const patternErrors = []
844
+ const pathPatterns = entry.pathPatterns === undefined
845
+ ? []
846
+ : compilePatterns(entry.pathPatterns, patternErrors, `rules.${entry.id}.pathPatterns`)
847
+ const commandPatterns = entry.commandPatterns === undefined
848
+ ? []
849
+ : compilePatterns(entry.commandPatterns, patternErrors, `rules.${entry.id}.commandPatterns`)
850
+ layerErrors.push(...patternErrors)
851
+ const originalMatch = base.match
852
+ patched = {
853
+ ...patched,
854
+ match: (ev, ctx) => {
855
+ if (originalMatch(ev, ctx)) return true
856
+ if (commandPatterns.some(({ re }) => re.test(ctx.commandText))) return true
857
+ const candidates = [ctx.targetPath, ...ctx.writeTargets, ...ctx.readTargets].filter((x) => typeof x === 'string')
858
+ return candidates.some((p) => pathPatterns.some(({ re }) => re.test(p)))
859
+ },
860
+ }
861
+ }
862
+ if (entry.priority !== undefined) priorities[entry.id] = entry.priority
863
+ const merged = { ...patched, source: 'yaml' }
864
+ byId.set(entry.id, merged)
865
+ if (isLockedRule(merged)) locked.add(entry.id)
866
+ }
867
+ errors.push(...layerErrors)
868
+ if (layer.settings.failOnInvalidPattern && layerErrors.length > 0) fatal = true
869
+ if (layer.settings.failOnUnknownRule && layerErrors.some((e) => e.startsWith('unknown rule id'))) fatal = true
870
+ layerRecords.push({ index: layerIndex, inherit: layer.inherit !== false, version: layer.version, rules: layer.rules.length, errors: layerErrors.length })
871
+ }
872
+
873
+ if (lockViolation) fatal = true
874
+ const merged = order.map((id) => byId.get(id)).filter((rule) => rule !== undefined)
875
+ const sorted = sortRules(merged, priorities)
876
+ // 安全网:任何 rebuild 结果都必须有 fallback(未知动作的兜底)
877
+ if (!sorted.some((rule) => rule.category === 'fallback')) {
878
+ warnings.push('fallback rule missing after rebuild')
879
+ fatal = true
880
+ }
881
+ const source = strictMode ? 'strict-replaced' : (layerList.length > 0 ? 'layered' : 'builtin')
882
+ if (fatal) {
883
+ // 失败:不提交任何状态(effective / status / strictMode 保持上一次成功值)
884
+ return {
885
+ applied: false,
886
+ ruleCount: effective.length,
887
+ warnings,
888
+ errors,
889
+ source,
890
+ layers: layerRecords,
891
+ strictMode: null,
892
+ effective,
893
+ status,
894
+ }
895
+ }
896
+ const nextStatus = {
897
+ source,
898
+ appliedAt: Date.now(),
899
+ warnings,
900
+ errors,
901
+ configVersion: layerList.at(-1)?.version ?? null,
902
+ layers: layerList.length,
903
+ strictMode: strictModeInfo,
904
+ }
905
+ return {
906
+ applied: true,
907
+ ruleCount: sorted.length,
908
+ warnings,
909
+ errors,
910
+ source,
911
+ layers: layerRecords,
912
+ strictMode: strictModeInfo,
913
+ effective: sorted,
914
+ status: nextStatus,
915
+ }
916
+ }
917
+
918
+ const find = (evidence, ctx) => effective.find((rule) => rule.match(evidence, ctx)) ?? effective[effective.length - 1] ?? null
919
+
920
+ // 运行时注册(测试 / 嵌宿主用;YAML 只允许声明式)
921
+ const register = (rule, placement = {}) => {
922
+ assertActionRule(rule)
923
+ if (rule.id !== undefined && effective.some((r) => r.id === rule.id)) {
924
+ throw new Error(`action rule already registered: ${rule.id}(覆盖请用 override())`)
925
+ }
926
+ const before = typeof placement.before === 'string' ? effective.findIndex((r) => r.id === placement.before) : -1
927
+ const after = typeof placement.after === 'string' ? effective.findIndex((r) => r.id === placement.after) : -1
928
+ const next = [...effective]
929
+ if (before >= 0) next.splice(before, 0, rule)
930
+ else if (after >= 0) next.splice(after + 1, 0, rule)
931
+ else throw new Error(`action rule ${rule.id}: register() requires before/after naming an existing rule`)
932
+ effective = next
933
+ return rule
934
+ }
935
+ const override = (id, patch = {}) => {
936
+ const index = effective.findIndex((r) => r.id === id)
937
+ if (index < 0) throw new Error(`action rule not found: ${id}`)
938
+ const merged = assertActionRule({ ...effective[index], ...patch, id })
939
+ effective = [...effective.slice(0, index), merged, ...effective.slice(index + 1)]
940
+ return merged
941
+ }
942
+ const remove = (id) => {
943
+ const index = effective.findIndex((r) => r.id === id)
944
+ if (index < 0) return false
945
+ effective = [...effective.slice(0, index), ...effective.slice(index + 1)]
946
+ return true
947
+ }
948
+
949
+ return {
950
+ builtin,
951
+ get rules() { return [...effective] },
952
+ ids: () => effective.map((r) => r.id),
953
+ find,
954
+ register,
955
+ override,
956
+ remove,
957
+ reset: resetToBuiltin,
958
+ resetToBuiltin,
959
+ /** 用一份配置替换所有层(向后兼容 v2.8.0 的单文件语义)。 */
960
+ applyConfig(config) {
961
+ const out = computeRebuild([config])
962
+ if (out.applied) {
963
+ layers = [config]
964
+ effective = out.effective
965
+ status = out.status
966
+ return out
967
+ }
968
+ return { ...out, revertedToLayerIndex: layers.length - 1 }
969
+ },
970
+ /**
971
+ * 追加一层(基线 → 项目 → 环境)。
972
+ * 事务化:失败时 layers / effective / status / strictMode 全部保持上一次成功值,
973
+ * 失败层对后续层完全不可见(否则坏层会污染后续所有 rebuild)。
974
+ * 失败结果带 revertedToLayerIndex(-1 = 内置全集),供审计回答"回退到了哪一层"。
975
+ */
976
+ applyLayer(config) {
977
+ const candidate = [...layers, config]
978
+ const out = computeRebuild(candidate)
979
+ if (out.applied) {
980
+ layers = candidate
981
+ effective = out.effective
982
+ status = out.status
983
+ return out
984
+ }
985
+ return { ...out, revertedToLayerIndex: layers.length - 1 }
986
+ },
987
+ clearLayers() {
988
+ const out = computeRebuild([])
989
+ if (out.applied) {
990
+ layers = []
991
+ effective = out.effective
992
+ status = out.status
993
+ }
994
+ return out
995
+ },
996
+ layerCount: () => layers.length,
997
+ status: () => ({ ...status, ruleCount: effective.length }),
998
+ priorities: () => ({ ...defaultPriorities }),
999
+ }
1000
+ }
1001
+
1002
+ // ─────────────────────────────────────────────────────────────────────────────
1003
+ // 6. 文件加载 + 热加载
1004
+ // ─────────────────────────────────────────────────────────────────────────────
1005
+
1006
+ /** 读取并校验规则文件。文件不存在 = {config:null, errors:[], found:false}(用纯内置)。 */
1007
+ export function loadRuleConfigFile(file) {
1008
+ let text
1009
+ try {
1010
+ text = fs.readFileSync(file, 'utf8')
1011
+ } catch (e) {
1012
+ if (e?.code === 'ENOENT') return { config: null, errors: [], found: false }
1013
+ return { config: null, errors: [`read failed: ${e?.message ?? e}`], found: true }
1014
+ }
1015
+ const parsed = parseYamlSubset(text)
1016
+ if (parsed.errors.length > 0) return { config: null, errors: parsed.errors, found: true }
1017
+ const validated = validateRuleConfig(parsed.value)
1018
+ return { config: validated.config, errors: validated.errors, found: true }
1019
+ }
1020
+
1021
+ export function watchRuleConfigFiles(files, onChange = () => {}, options = {}) {
1022
+ const debounceMs = Number(options.debounceMs) > 0 ? Number(options.debounceMs) : 200
1023
+ const logger = options.logger ?? null
1024
+ const list = [...new Set((Array.isArray(files) ? files : [files])
1025
+ .filter((file) => typeof file === 'string' && file.length > 0))]
1026
+ const watchers = []
1027
+ let timer = null
1028
+ let stopped = false
1029
+
1030
+ const schedule = () => {
1031
+ if (stopped) return
1032
+ if (timer !== null) clearTimeout(timer)
1033
+ timer = setTimeout(() => {
1034
+ timer = null
1035
+ if (stopped) return
1036
+ try { onChange() } catch (error) {
1037
+ try { logger?.warn?.(`[umbrella-rules] reload callback failed: ${error?.message ?? error}`) } catch { /* ignore */ }
1038
+ }
1039
+ }, debounceMs)
1040
+ }
1041
+
1042
+ for (const file of list) {
1043
+ try {
1044
+ watchers.push(fs.watch(file, schedule))
1045
+ continue
1046
+ } catch { /* missing file: watch its directory */ }
1047
+ try {
1048
+ const dir = path.dirname(file)
1049
+ const base = path.basename(file)
1050
+ watchers.push(fs.watch(dir, (_event, name) => {
1051
+ if (!name || String(name) === base) schedule()
1052
+ }))
1053
+ } catch (error) {
1054
+ try { logger?.warn?.(`[umbrella-rules] cannot watch ${file}: ${error?.message ?? error}`) } catch { /* ignore */ }
1055
+ }
1056
+ }
1057
+
1058
+ return {
1059
+ files: list,
1060
+ reload: schedule,
1061
+ stop() {
1062
+ stopped = true
1063
+ if (timer !== null) clearTimeout(timer)
1064
+ timer = null
1065
+ for (const watcher of watchers) {
1066
+ try { watcher.close() } catch { /* ignore */ }
1067
+ }
1068
+ watchers.length = 0
1069
+ },
1070
+ }
1071
+ }
1072
+
1073
+ /**
1074
+ * 热加载:文件变化 → 防抖 → 重新读取 → 应用。失败保留旧规则。
1075
+ * @returns {{ stop: () => void }}
1076
+ */
1077
+ export function watchRuleConfigFile(file, registry, onResult = () => {}, options = {}) {
1078
+ const debounceMs = Number(options.debounceMs) > 0 ? Number(options.debounceMs) : 200
1079
+ const logger = options.logger ?? null
1080
+ let timer = null
1081
+ let watcher = null
1082
+ let stopped = false
1083
+
1084
+ const reload = () => {
1085
+ if (stopped) return
1086
+ const loaded = loadRuleConfigFile(file)
1087
+ if (loaded.errors.length > 0) {
1088
+ try { logger?.warn?.(`[umbrella-rules] reload failed, keeping previous rules: ${loaded.errors.join('; ')}`) } catch { /* ignore */ }
1089
+ onResult({ applied: false, errors: loaded.errors, warnings: [], reason: 'load-failed' })
1090
+ return
1091
+ }
1092
+ if (loaded.config === null) {
1093
+ registry.resetToBuiltin()
1094
+ onResult({ applied: true, errors: [], warnings: [], reason: 'no-config', ruleCount: registry.rules.length })
1095
+ return
1096
+ }
1097
+ const applied = registry.applyConfig(loaded.config)
1098
+ if (!applied.applied) {
1099
+ try { logger?.warn?.(`[umbrella-rules] apply rejected, keeping previous rules: ${applied.errors.join('; ')}`) } catch { /* ignore */ }
1100
+ }
1101
+ onResult({ ...applied, reason: applied.applied ? 'applied' : 'rejected' })
1102
+ }
1103
+
1104
+ try {
1105
+ watcher = fs.watch(file, () => {
1106
+ if (timer !== null) clearTimeout(timer)
1107
+ timer = setTimeout(() => {
1108
+ timer = null
1109
+ reload()
1110
+ }, debounceMs)
1111
+ })
1112
+ watcher.on?.('error', () => { /* watch 失败不致命:仍保留启动时加载的规则 */ })
1113
+ } catch {
1114
+ watcher = null
1115
+ }
1116
+
1117
+ return {
1118
+ reload,
1119
+ stop() {
1120
+ stopped = true
1121
+ if (timer !== null) clearTimeout(timer)
1122
+ timer = null
1123
+ try { watcher?.close() } catch { /* ignore */ }
1124
+ watcher = null
1125
+ },
1126
+ }
1127
+ }