@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.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- 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
|
+
}
|