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

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 (147) 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/APPROVAL-DRILL.md +42 -0
  12. package/docs/CONTRACT-INTEROP.md +77 -0
  13. package/docs/CRASH-SAFETY.md +69 -0
  14. package/docs/DECISION-RELAY.md +46 -0
  15. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  16. package/docs/FORK-SEED.md +64 -0
  17. package/docs/MAIN-CITY-GATE.md +58 -0
  18. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  19. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  20. package/docs/PM-DECISION-POLICY.md +61 -0
  21. package/docs/WORKFLOW-UI.md +86 -0
  22. package/icon.svg +8 -0
  23. package/locale/en.json +6 -0
  24. package/locale/zh.json +6 -0
  25. package/package.json +71 -4
  26. package/sdk/README.md +65 -0
  27. package/sdk/examples/status-badge/README.md +16 -0
  28. package/sdk/examples/status-badge/client.js +60 -0
  29. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  30. package/sdk/examples/status-badge/index.js +12 -0
  31. package/sdk/examples/status-badge/package.json +24 -0
  32. package/sdk/templates/plugin/README.md +36 -0
  33. package/sdk/templates/plugin/client.js +35 -0
  34. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  35. package/sdk/templates/plugin/icon.svg +5 -0
  36. package/sdk/templates/plugin/index.js +23 -0
  37. package/sdk/templates/plugin/locale/en.json +6 -0
  38. package/sdk/templates/plugin/package.json +36 -0
  39. package/src/compose.mjs +67 -0
  40. package/src/contract/VENDOR.json +16 -0
  41. package/src/contract/client.js +93 -0
  42. package/src/contract/constants.js +94 -0
  43. package/src/contract/envelope.js +83 -0
  44. package/src/contract/index.js +20 -0
  45. package/src/contract/negotiate.js +106 -0
  46. package/src/contract/relay.js +223 -0
  47. package/src/contract/server.js +77 -0
  48. package/src/contract/transports.js +319 -0
  49. package/src/index.mjs +11 -0
  50. package/src/kernel/core.mjs +100 -0
  51. package/src/kernel/extensions.mjs +61 -0
  52. package/src/kernel/fsx.mjs +349 -0
  53. package/src/kernel/index.mjs +21 -0
  54. package/src/kernel/ledger.mjs +94 -0
  55. package/src/kernel/paths.mjs +182 -0
  56. package/src/kernel/role.mjs +68 -0
  57. package/src/kernel/rounds.mjs +172 -0
  58. package/src/kernel/schema.mjs +5 -0
  59. package/src/kernel/types.mjs +266 -0
  60. package/src/kernel/vendor/cosmokit.mjs +421 -0
  61. package/src/kernel/vendor/schemastery.mjs +638 -0
  62. package/src/lib/adapter-client.mjs +383 -0
  63. package/src/lib/adapter-protocol.mjs +243 -0
  64. package/src/lib/ai-secret-store.mjs +202 -0
  65. package/src/lib/approval-drill.mjs +111 -0
  66. package/src/lib/boot-guard.mjs +186 -0
  67. package/src/lib/branch-store.mjs +84 -0
  68. package/src/lib/branch.mjs +74 -0
  69. package/src/lib/bridge-client.mjs +133 -0
  70. package/src/lib/broker-adapter.mjs +67 -0
  71. package/src/lib/broker.mjs +45 -0
  72. package/src/lib/business-producers.mjs +109 -0
  73. package/src/lib/command-cache.mjs +209 -0
  74. package/src/lib/compat.mjs +170 -0
  75. package/src/lib/composer-journal.mjs +128 -0
  76. package/src/lib/container/index.mjs +806 -0
  77. package/src/lib/decision-events.mjs +129 -0
  78. package/src/lib/decision-ingest-client.mjs +41 -0
  79. package/src/lib/decision-outbox.mjs +129 -0
  80. package/src/lib/decision-policy.mjs +52 -0
  81. package/src/lib/default-essence.mjs +61 -0
  82. package/src/lib/desktop-launch.mjs +138 -0
  83. package/src/lib/essence.mjs +1211 -0
  84. package/src/lib/fork-client.mjs +410 -0
  85. package/src/lib/fork-config-lock.mjs +166 -0
  86. package/src/lib/fork-seed.mjs +304 -0
  87. package/src/lib/fork-sessions.mjs +183 -0
  88. package/src/lib/fork-supervision-settings.mjs +58 -0
  89. package/src/lib/fork-supervisor.mjs +122 -0
  90. package/src/lib/fs-guard.mjs +28 -0
  91. package/src/lib/http-auth.mjs +162 -0
  92. package/src/lib/idle-audit.mjs +178 -0
  93. package/src/lib/interaction-poke.mjs +61 -0
  94. package/src/lib/main-gate.mjs +582 -0
  95. package/src/lib/mirror-etag.mjs +92 -0
  96. package/src/lib/mirror-official-source.mjs +93 -0
  97. package/src/lib/mirror.mjs +478 -0
  98. package/src/lib/mother-link.mjs +156 -0
  99. package/src/lib/mutex.mjs +31 -0
  100. package/src/lib/permissions.mjs +115 -0
  101. package/src/lib/pnpm-store.mjs +189 -0
  102. package/src/lib/preferences.mjs +212 -0
  103. package/src/lib/producer-contract.mjs +162 -0
  104. package/src/lib/reflow.mjs +41 -0
  105. package/src/lib/remote.mjs +408 -0
  106. package/src/lib/restart-contract.mjs +752 -0
  107. package/src/lib/roots.mjs +30 -0
  108. package/src/lib/rule-registry.mjs +1127 -0
  109. package/src/lib/runtime-capture.mjs +307 -0
  110. package/src/lib/semaphore.mjs +34 -0
  111. package/src/lib/session-bundle.mjs +138 -0
  112. package/src/lib/session-format.mjs +672 -0
  113. package/src/lib/sse-parser.mjs +102 -0
  114. package/src/lib/ssrf-guard.mjs +170 -0
  115. package/src/lib/status-store.mjs +257 -0
  116. package/src/lib/task-handle.mjs +622 -0
  117. package/src/lib/ui-actions.mjs +55 -0
  118. package/src/lib/umbrella.mjs +1661 -0
  119. package/src/lib/upgrade-coordinator.mjs +107 -0
  120. package/src/lib/upgrade-status.mjs +43 -0
  121. package/src/plugins/adapter.mjs +1468 -0
  122. package/src/plugins/approver.mjs +1210 -0
  123. package/src/plugins/auditor.mjs +330 -0
  124. package/src/plugins/buffer-keeper.mjs +522 -0
  125. package/src/plugins/decision-center.mjs +2581 -0
  126. package/src/plugins/forker.mjs +1610 -0
  127. package/src/plugins/healer.mjs +702 -0
  128. package/src/plugins/hot-reloader.mjs +274 -0
  129. package/src/plugins/imports.mjs +247 -0
  130. package/src/plugins/orchestrator.mjs +3979 -0
  131. package/src/plugins/restart-guard.mjs +1022 -0
  132. package/src/plugins/router.mjs +714 -0
  133. package/src/plugins/scorer.mjs +661 -0
  134. package/src/plugins/session-bridge.mjs +166 -0
  135. package/src/plugins/session-catalog.mjs +771 -0
  136. package/src/plugins/session-router.mjs +125 -0
  137. package/src/plugins/session-sync.mjs +171 -0
  138. package/src/plugins/umbrella.mjs +419 -0
  139. package/tools/approval-drill.mjs +21 -0
  140. package/tools/boot-guard.mjs +95 -0
  141. package/tools/decision-ingest.mjs +53 -0
  142. package/tools/desktop-launch.mjs +71 -0
  143. package/tools/fangctl.mjs +425 -0
  144. package/tools/gate/main-city.mjs +204 -0
  145. package/tools/mcp/server.mjs +121 -0
  146. package/tools/mcp/tools.mjs +65 -0
  147. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,1661 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang v2.7.6 保护伞(Umbrella)— 一个证据管道,两个评分出口。
4
+ //
5
+ // 证据层:插件源码(交给 fang-scorer 的 import 层扫描,只算一次)+ 动作元数据
6
+ // 评分层:PluginScorer → A/B/C/D/E(静态,按指纹缓存)
7
+ // ActionScorer → green/blue/yellow/red(动态,每次动作重算)
8
+ // 耦合层:effectiveLight = max(baseLight, gradeFloor(grade)) —— 只升不降
9
+ //
10
+ // 本模块是纯逻辑(除缓存指纹要 stat/读文件外无副作用),插件形态在
11
+ // `src/plugins/umbrella.mjs`。契约与取舍见 `docs/UMBRELLA.md`。
12
+ //
13
+ // 三条写死的原则:
14
+ // 1. 失败一律往坏里判:拿不到评分 → E;认不出的工具 → yellow;认不出的灯色 → yellow。
15
+ // 2. 插件等级只能**抬高**动作灯色,不能降低。A 级插件做红灯动作仍然是红灯。
16
+ // 3. 动作评分不缓存:args 的语义等价性无法可靠判断(见 docs/UMBRELLA.md §6)。
17
+ import fs from 'node:fs'
18
+ import path from 'node:path'
19
+ import crypto from 'node:crypto'
20
+ import { AUDIT, GRADE_COLOR, LIGHT, LIGHT_RANK, SERVICE } from '../kernel/index.mjs'
21
+
22
+ export { LIGHT, LIGHT_RANK, AUDIT, SERVICE }
23
+
24
+ /** 四个决策出口(保护伞的输出词汇)。 */
25
+ export const DECISION = {
26
+ ALLOW: 'allow',
27
+ MARK: 'mark',
28
+ ASK: 'ask',
29
+ DELEGATE: 'delegate',
30
+ DENY: 'deny',
31
+ }
32
+
33
+ export const SEVERITY = { NORMAL: 'normal', CRITICAL: 'critical' }
34
+
35
+ /** 委托目标(黄色/红色 + DSH 生命周期动作)。 */
36
+ export const DELEGATE_TARGET = { RESTART_GUARD: 'restartGuard' }
37
+
38
+ /**
39
+ * 默认下限表(设计稿 §3.3 原样):A/B 绿,C 蓝,D 黄,E 红。
40
+ *
41
+ * 注意它与 kernel 的 `GRADE_COLOR`(A 绿 / B 蓝 / C 黄 / D、E 红)回答的是
42
+ * **两个不同的问题**:GRADE_COLOR 是「这个插件自己在面板上什么颜色」,
43
+ * gradeFloor 是「这个等级的插件跑动作时,灯色下限是多少」。二者在 B 级上不同,
44
+ * 这是刻意的;`strict` 表把下限对齐 GRADE_COLOR,供偏好一致的部署选用。
45
+ */
46
+ export const GRADE_FLOOR = { A: 'green', B: 'green', C: 'blue', D: 'yellow', E: 'red' }
47
+
48
+ /** 与 kernel `GRADE_COLOR` 完全一致的下限表(可选预设)。 */
49
+ export const GRADE_FLOOR_STRICT = { A: 'green', B: 'blue', C: 'yellow', D: 'red', E: 'red' }
50
+
51
+ export const FLOOR_TABLES = { umbrella: GRADE_FLOOR, strict: GRADE_FLOOR_STRICT }
52
+ export const DEFAULT_FLOOR_TABLE = 'umbrella'
53
+
54
+ /** 内置凭据路径模式(正则可配置;默认值见 docs/UMBRELLA.md §三.3)。 */
55
+ export const DEFAULT_CREDENTIAL_PATTERNS = [
56
+ String.raw`(^|[/\\])\.ssh([/\\]|$)`,
57
+ String.raw`(^|[/\\])id_(rsa|ed25519|ecdsa|dsa)(\.|$)`,
58
+ String.raw`(^|[/\\])\.aws([/\\](credentials|config))?`,
59
+ String.raw`(^|[/\\])\.gnupg([/\\]|$)`,
60
+ String.raw`(^|[/\\])\.netrc$`,
61
+ String.raw`(^|[/\\])\.git-credentials$`,
62
+ String.raw`(^|[/\\])\.kube([/\\]config)?`,
63
+ String.raw`(^|[/\\])\.docker[/\\]config\.json$`,
64
+ String.raw`(^|[/\\])config[/\\](SAM|SECURITY|SYSTEM)(\s|$)`,
65
+ String.raw`(^|[/\\])Login Data$`,
66
+ String.raw`(^|[/\\])Cookies$`,
67
+ ]
68
+
69
+ /** 内置破坏性命令模式(可配置)。顺序无关,命中即红灯。 */
70
+ export const DEFAULT_DESTRUCTIVE_PATTERNS = [
71
+ String.raw`\brm\s+(-[a-z]*[rf][a-z]*\s+)+`,
72
+ String.raw`\bRemove-Item\b.*-(Recurse|Force)`,
73
+ String.raw`\bdel\s+/[fsq]`,
74
+ String.raw`\brmdir\s+/s`,
75
+ String.raw`\bmkfs(\.|\s)`,
76
+ String.raw`\bdd\s+if=`,
77
+ String.raw`\bchmod\s+777\b`,
78
+ String.raw`\bchmod\s+-R\b`,
79
+ String.raw`\bgit\s+push\b.*--force`,
80
+ String.raw`\bgit\s+reset\s+--hard`,
81
+ String.raw`\bgit\s+clean\s+-[a-z]*[dfx]`,
82
+ String.raw`\bDROP\s+(TABLE|DATABASE)\b`,
83
+ String.raw`\bformat\s+[a-z]:`,
84
+ String.raw`\btaskkill\b`,
85
+ String.raw`(^|[;&|]\s*)kill\s+(-[a-z]+\s+)*\d`,
86
+ String.raw`\b(takeown|icacls)\b`,
87
+ String.raw`\breg\s+delete\b`,
88
+ ]
89
+
90
+ /** 内置 DSH 生命周期命令模式:命中即红灯,并**委托**给 restart-guard(不直接问用户)。 */
91
+ export const DEFAULT_LIFECYCLE_PATTERNS = [
92
+ String.raw`\bdsh\b.*\b(restart|kill|stop|shutdown)\b`,
93
+ String.raw`\btaskkill\b.*\/im\s+dsh`,
94
+ String.raw`\bStop-Process\b`,
95
+ ]
96
+
97
+ export const DEFAULT_LIFECYCLE_TOOLS = ['restart', 'supervisor', 'kill-process', 'dsh-restart']
98
+
99
+ /** 工具名 → 动作模式。未登记的工具一律 'unknown'(→ yellow)。 */
100
+ export const TOOL_MODE = {
101
+ read: 'read', grep: 'read', glob: 'read', ls: 'read', stat: 'read', list: 'read',
102
+ write: 'write', edit: 'write', 'multi-edit': 'write', append: 'write', patch: 'write', apply_patch: 'write',
103
+ bash: 'exec', bash_pwsh: 'exec', pwsh: 'exec', powershell: 'exec', shell: 'exec', exec: 'exec',
104
+ fetch: 'network', web_fetch: 'network', web_search: 'network', http: 'network', request: 'network',
105
+ import: 'import', require: 'import', 'dynamic-import': 'import',
106
+ }
107
+
108
+ /** 只读 shell 命令的前缀白名单(保守:还要不含重定向才算只读)。 */
109
+ export const READ_ONLY_EXEC_PATTERNS = [
110
+ String.raw`^\s*(dir|ls|cat|type|head|tail|wc|pwd|where|which|echo|node\s+--version|node\s+-v)\b`,
111
+ String.raw`^\s*Get-(Content|ChildItem|Item|Location|Command|Process)\b`,
112
+ String.raw`^\s*git\s+(status|log|diff|show|branch|rev-parse)\b`,
113
+ String.raw`^\s*npm\s+(ls|view)\b`,
114
+ ]
115
+
116
+ const WRITE_REDIRECT_RE = /(^|[^>])>{1,2}(?!&)/
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // 归一化
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+
122
+ /** 任意输入 → 合法灯色;认不出的按 yellow(保守但不制造红灯风暴)。 */
123
+ export function normalizeLight(value, fallback = LIGHT.YELLOW) {
124
+ const v = String(value ?? '').trim().toLowerCase()
125
+ return Object.prototype.hasOwnProperty.call(LIGHT_RANK, v) ? v : fallback
126
+ }
127
+
128
+ /** 任意输入 → 合法等级;认不出的按 E(失败往坏里判)。 */
129
+ export function normalizeGrade(value) {
130
+ const v = String(value ?? '').trim().toUpperCase()
131
+ return Object.prototype.hasOwnProperty.call(GRADE_FLOOR, v) ? v : 'E'
132
+ }
133
+
134
+ /** 灯色严重度。 */
135
+ export function rankOf(light) {
136
+ return LIGHT_RANK[normalizeLight(light)] ?? LIGHT_RANK.yellow
137
+ }
138
+
139
+ /** 取更危险的一个(相等返回后者,值相同所以无所谓)。 */
140
+ export function maxLight(a, b) {
141
+ return rankOf(a) >= rankOf(b) ? normalizeLight(a) : normalizeLight(b)
142
+ }
143
+
144
+ /** a 是否至少和 b 一样危险。 */
145
+ export function lightAtLeast(a, b) {
146
+ return rankOf(a) >= rankOf(b)
147
+ }
148
+
149
+ function asStringList(value) {
150
+ if (Array.isArray(value)) return value.map(v => String(v)).filter(v => v.length > 0)
151
+ if (typeof value === 'string' && value.length > 0) return [value]
152
+ return []
153
+ }
154
+
155
+ function asPatternList(value, fallback) {
156
+ // 显式传空数组 = 「一条规则都不要」;只有缺省(undefined/null)才回退内置值。
157
+ if (value === undefined || value === null) return Array.isArray(fallback) ? [...fallback] : []
158
+ return asStringList(value)
159
+ }
160
+
161
+ function compilePatterns(patterns) {
162
+ const out = []
163
+ for (const source of patterns ?? []) {
164
+ try {
165
+ out.push({ source: String(source), re: new RegExp(String(source), 'i') })
166
+ } catch {
167
+ /* 写错的正则不得让保护伞失效:跳过并在 matchesPattern 里如实返回 false */
168
+ }
169
+ }
170
+ return out
171
+ }
172
+
173
+ /** 指针:`[/\\]` 已覆盖两种分隔符,`resolve` 之后这里只做大小写与包含判断。 */
174
+ /** Resolve a path through the nearest existing ancestor so symlinked parents are real. */
175
+ function realPathWithMissing(value) {
176
+ let current = path.resolve(String(value))
177
+ const suffix = []
178
+ for (;;) {
179
+ try {
180
+ const real = fs.realpathSync(current)
181
+ return suffix.length === 0 ? real : path.join(real, ...suffix)
182
+ } catch {
183
+ const parent = path.dirname(current)
184
+ if (parent === current) return path.resolve(String(value))
185
+ suffix.unshift(path.basename(current))
186
+ current = parent
187
+ }
188
+ }
189
+ }
190
+
191
+ /** 路径 containment:realpath 后再比较,避免 symlink/junction 逃出 root。 */
192
+ function withinPath(child, parent) {
193
+ if (!child || !parent) return false
194
+ const c = process.platform === 'win32' ? realPathWithMissing(child).toLowerCase() : realPathWithMissing(child)
195
+ const p = process.platform === 'win32' ? realPathWithMissing(parent).toLowerCase() : realPathWithMissing(parent)
196
+ if (c === p) return true
197
+ const withSep = p.endsWith(path.sep) ? p : p + path.sep
198
+ return c.startsWith(withSep)
199
+ }
200
+
201
+ function resolvePathValue(value, root) {
202
+ if (typeof value !== 'string' || value.length === 0) return null
203
+ try {
204
+ const abs = path.isAbsolute(value) ? value : path.resolve(root, value)
205
+ return path.normalize(abs)
206
+ } catch {
207
+ return null
208
+ }
209
+ }
210
+
211
+ /**
212
+ * 评分策略归一化:相对路径按 `root` 解析,缺省值补齐。
213
+ * `allowPaths`/`credentialPatterns` 等传空数组 = 显式「没有」,只有缺省才用内置值。
214
+ */
215
+ export function normalizePolicy(input = {}) {
216
+ const src = input && typeof input === 'object' ? input : {}
217
+ const root = resolvePathValue(src.root, process.cwd()) ?? process.cwd()
218
+ return {
219
+ root,
220
+ bufferRoot: resolvePathValue(src.bufferRoot, root),
221
+ productionRoot: resolvePathValue(src.productionRoot, root),
222
+ allowPaths: asStringList(src.allowPaths).map(v => resolvePathValue(v, root)).filter(Boolean),
223
+ networkAllowlist: asStringList(src.networkAllowlist),
224
+ credentialPatterns: asPatternList(src.credentialPatterns, DEFAULT_CREDENTIAL_PATTERNS),
225
+ destructivePatterns: asPatternList(src.destructivePatterns, DEFAULT_DESTRUCTIVE_PATTERNS),
226
+ lifecyclePatterns: asPatternList(src.lifecyclePatterns, DEFAULT_LIFECYCLE_PATTERNS),
227
+ lifecycleTools: src.lifecycleTools === undefined ? [...DEFAULT_LIFECYCLE_TOOLS] : asStringList(src.lifecycleTools),
228
+ readOnlyExecPatterns: asPatternList(src.readOnlyExecPatterns, READ_ONLY_EXEC_PATTERNS),
229
+ }
230
+ }
231
+
232
+ /** 下限表解析:接受名字或表对象;未知回退默认并给出 degraded 标记。 */
233
+ export function resolveFloorTable(table) {
234
+ if (typeof table === 'string') {
235
+ const key = table.trim().toLowerCase()
236
+ if (Object.prototype.hasOwnProperty.call(FLOOR_TABLES, key)) {
237
+ return { name: key, table: { ...FLOOR_TABLES[key] }, degraded: false }
238
+ }
239
+ return { name: DEFAULT_FLOOR_TABLE, table: { ...GRADE_FLOOR }, degraded: true }
240
+ }
241
+ if (table && typeof table === 'object') {
242
+ const out = {}
243
+ for (const g of ['A', 'B', 'C', 'D', 'E']) out[g] = normalizeLight(table[g], GRADE_FLOOR[g])
244
+ return { name: 'custom', table: out, degraded: false }
245
+ }
246
+ return { name: DEFAULT_FLOOR_TABLE, table: { ...GRADE_FLOOR }, degraded: false }
247
+ }
248
+
249
+ // ─────────────────────────────────────────────────────────────────────────────
250
+ // 插件等级 → 动作下限(耦合规则)
251
+ // ─────────────────────────────────────────────────────────────────────────────
252
+
253
+ /** 等级 → 灯色下限。认不出的等级按 E(红)。 */
254
+ export function gradeFloor(grade, options = {}) {
255
+ const table = resolveFloorTable(options.table ?? options.floorTable ?? DEFAULT_FLOOR_TABLE).table
256
+ const g = normalizeGrade(grade)
257
+ return table[g] ?? LIGHT.RED
258
+ }
259
+
260
+ /**
261
+ * 工作灯色 = max(基础灯色, 插件等级下限)。
262
+ * 只会抬高,永不降低:A 级插件做红灯动作仍是红灯。
263
+ */
264
+ export function effectiveLight(baseLight, grade, options = {}) {
265
+ const base = normalizeLight(baseLight)
266
+ const floor = gradeFloor(grade, options)
267
+ const light = maxLight(base, floor)
268
+ return {
269
+ light,
270
+ baseLight: base,
271
+ gradeFloor: floor,
272
+ pluginGrade: normalizeGrade(grade),
273
+ raised: rankOf(light) > rankOf(base),
274
+ }
275
+ }
276
+
277
+ // ─────────────────────────────────────────────────────────────────────────────
278
+ // 动作证据 → 基础灯色
279
+ // ─────────────────────────────────────────────────────────────────────────────
280
+
281
+ /** 工具名/显式 mode → 动作模式。 */
282
+ export function modeOf(evidence = {}) {
283
+ const explicit = String(evidence?.mode ?? '').trim().toLowerCase()
284
+ if (explicit) return explicit
285
+ const tool = String(evidence?.tool ?? '').trim().toLowerCase()
286
+ return TOOL_MODE[tool] ?? 'unknown'
287
+ }
288
+
289
+ /** 目标三元组归一化:url / command / path(含 pid)。 */
290
+ export function targetOf(evidence = {}) {
291
+ const args = evidence?.args && typeof evidence.args === 'object' ? evidence.args : {}
292
+ const declared = evidence?.target && typeof evidence.target === 'object' ? evidence.target : {}
293
+ const kind = String(declared.kind ?? '').trim().toLowerCase()
294
+ const declaredValue = typeof declared.value === 'string' ? declared.value : ''
295
+ const command = declaredValue || stringArg(args, ['command', 'cmd', 'script', 'argv'])
296
+ const url = stringArg(args, ['url', 'uri', 'href'])
297
+ const filePath = declaredValue || stringArg(args, ['path', 'file', 'filePath', 'target', 'cwd', 'dir'])
298
+ if (kind === 'url' || (!declaredValue && url && /^[a-z][a-z0-9+.-]*:\/\//i.test(url))) {
299
+ return { kind: 'url', value: url || declaredValue }
300
+ }
301
+ if (kind === 'command' || (!declaredValue && command)) return { kind: 'command', value: command }
302
+ if (kind === 'pid') return { kind: 'pid', value: declaredValue || String(args.pid ?? '') }
303
+ if (kind === 'path' || filePath) return { kind: 'path', value: filePath }
304
+ if (declaredValue) return { kind: kind || 'path', value: declaredValue }
305
+ return { kind: 'none', value: '' }
306
+ }
307
+
308
+ function stringArg(args, keys) {
309
+ for (const key of keys) {
310
+ const v = args?.[key]
311
+ if (typeof v === 'string' && v.length > 0) return v
312
+ if (Array.isArray(v) && v.length > 0) return v.map(String).join(' ')
313
+ }
314
+ return ''
315
+ }
316
+
317
+ function matchesAny(patterns, text) {
318
+ if (!text) return false
319
+ for (const p of patterns) if (p.re.test(text)) return true
320
+ return false
321
+ }
322
+
323
+ /** 动作作用域:session | system | process(system/process 视为 DSH 生命周期)。 */
324
+ export function scopeOf(evidence = {}) {
325
+ const raw = evidence?.scope ?? evidence?.args?.scope
326
+ const scope = String(raw ?? '').trim().toLowerCase()
327
+ return scope === 'session' || scope === 'system' || scope === 'process' ? scope : null
328
+ }
329
+
330
+ export function isDestructiveCommand(evidence = {}, policy = normalizePolicy()) {
331
+ const mode = modeOf(evidence)
332
+ if (mode !== 'exec' && mode !== 'unknown') return false
333
+ const target = targetOf(evidence)
334
+ const text = target.kind === 'command' ? target.value : String(evidence?.args?.command ?? '')
335
+ return matchesAny(compilePatterns(policy.destructivePatterns), text)
336
+ }
337
+
338
+ export function isDshLifecycleAction(evidence = {}, policy = normalizePolicy()) {
339
+ const scope = scopeOf(evidence)
340
+ if (scope === 'system' || scope === 'process') return true
341
+ const tool = String(evidence?.tool ?? '').trim().toLowerCase()
342
+ const lifecycleTools = asStringList(policy.lifecycleTools).map(v => v.toLowerCase())
343
+ if (lifecycleTools.includes(tool)) return true
344
+ const target = targetOf(evidence)
345
+ const text = target.kind === 'command' ? target.value : String(evidence?.args?.command ?? '')
346
+ return matchesAny(compilePatterns(policy.lifecyclePatterns), text)
347
+ }
348
+
349
+ export function isCredentialAccess(evidence = {}, policy = normalizePolicy()) {
350
+ const target = targetOf(evidence)
351
+ const text = [target.value, String(evidence?.args?.command ?? ''), String(evidence?.args?.path ?? '')]
352
+ .filter(v => typeof v === 'string' && v.length > 0)
353
+ .join('\n')
354
+ return matchesAny(compilePatterns(policy.credentialPatterns), text)
355
+ }
356
+
357
+ export function isNetworkEgress(evidence = {}) {
358
+ if (modeOf(evidence) === 'network') return true
359
+ return targetOf(evidence).kind === 'url'
360
+ }
361
+
362
+ export function isDynamicImport(evidence = {}) {
363
+ return modeOf(evidence) === 'import'
364
+ }
365
+
366
+ export function isReadOnly(evidence = {}) {
367
+ return modeOf(evidence) === 'read'
368
+ }
369
+
370
+ function networkAllowed(hostText, policy) {
371
+ if (!hostText) return false
372
+ const rawText = String(hostText)
373
+ let host = ''
374
+ let hostPort = ''
375
+ let parsedUrl = null
376
+ try {
377
+ parsedUrl = new URL(rawText)
378
+ host = parsedUrl.hostname.toLowerCase()
379
+ hostPort = `${parsedUrl.hostname}:${parsedUrl.port || (parsedUrl.protocol === 'https:' ? '443' : '80')}`.toLowerCase()
380
+ } catch {
381
+ host = rawText.toLowerCase()
382
+ hostPort = host
383
+ }
384
+ for (const raw of asStringList(policy.networkAllowlist)) {
385
+ const rawEntry = raw.trim()
386
+ const entry = rawEntry.toLowerCase()
387
+ if (!entry) continue
388
+ // URL-prefix entries must match an origin/path boundary; startsWith on the raw URL
389
+ // lets https://example.com allowlist https://example.com.evil.
390
+ if (rawEntry.includes('://')) {
391
+ let allow = null
392
+ try { allow = new URL(rawEntry) } catch { continue }
393
+ if (parsedUrl === null) continue
394
+ if (allow.protocol !== parsedUrl.protocol || allow.hostname.toLowerCase() !== host) continue
395
+ const allowPort = allow.port || (allow.protocol === 'https:' ? '443' : '80')
396
+ const targetPort = parsedUrl.port || (parsedUrl.protocol === 'https:' ? '443' : '80')
397
+ if (allowPort !== targetPort) continue
398
+ const allowPath = allow.pathname || '/'
399
+ const targetPath = parsedUrl.pathname || '/'
400
+ if (allowPath === '/' || targetPath === allowPath) return true
401
+ if (targetPath.startsWith(allowPath)
402
+ && (allowPath.endsWith('/') || ['/', '?', '#'].includes(targetPath[allowPath.length]))) return true
403
+ continue
404
+ }
405
+ if (entry === host || entry === hostPort) return true
406
+ if (entry.startsWith('*.') && host.endsWith(entry.slice(1))) return true
407
+ }
408
+ return false
409
+ }
410
+
411
+ function resolveEvidencePath(evidence, policy) {
412
+ const target = targetOf(evidence)
413
+ if (target.kind !== 'path') return null
414
+ return resolvePathValue(target.value, policy.root)
415
+ }
416
+
417
+ function inAllowList(absPath, policy) {
418
+ if (!absPath) return false
419
+ return asStringList(policy.allowPaths).some(prefix => withinPath(absPath, prefix))
420
+ }
421
+
422
+ function execLooksReadOnly(evidence, policy) {
423
+ const target = targetOf(evidence)
424
+ const text = target.kind === 'command' ? target.value : String(evidence?.args?.command ?? '')
425
+ if (!text) return false
426
+ if (WRITE_REDIRECT_RE.test(text)) return false
427
+ // A read-only prefix only applies to a single simple command. Any command separator,
428
+ // pipeline, substitution or backtick can smuggle a side-effect into "cat x; rm ...".
429
+ const lex = tokenizeShell(text)
430
+ if (lex.errors.length > 0) return false
431
+ if (lex.tokens.some((token) => token.type === 'op' && SHELL_SEPARATORS.has(token.value))) return false
432
+ if (text.includes('`') || /\$\(/.test(text)) return false
433
+ return matchesAny(compilePatterns(policy.readOnlyExecPatterns), text)
434
+ }
435
+
436
+ // ─────────────────────────────────────────────────────────────────────────────
437
+ // 动作规则上下文(每条规则共享一份解析结果,避免重复解析命令/路径/URL)
438
+ // ─────────────────────────────────────────────────────────────────────────────
439
+
440
+ const SHELL_TOOLS = new Set(['bash', 'bash_pwsh', 'pwsh', 'powershell', 'shell', 'exec'])
441
+ const WRITE_TOOL_NAMES = new Set(['write', 'edit', 'multi-edit', 'append', 'patch', 'apply_patch'])
442
+ const READ_TOOL_NAMES = new Set(['read', 'grep', 'glob', 'ls', 'stat', 'list'])
443
+
444
+ // ─────────────────────────────────────────────────────────────────────────────
445
+ // shell 词法 / 语法(AST-lite):重定向目标的唯一来源
446
+ //
447
+ // 为什么不用 shell-quote:它只把字符串切成 token,不区分操作符与命令边界
448
+ // (`2>&1` 会被当成「写目标 &1」),给不了重定向语义。
449
+ // 为什么不用 @aliou/sh:它是真 AST 解析器(v0.3.4,MIT AND BSD-3-Clause,零运行时依赖,
450
+ // unpacked ~315 KB),但**引入第三方运行时依赖会破坏本仓库的零依赖约束**(CI 与安装都依赖这一点)。
451
+ // 所以这里手写一个只覆盖「重定向目标提取」的 AST-lite 扫描器:
452
+ // * 引号(单/双/反斜杠转义)、$VAR / $(...) / 反引号 / 通配 → 动态,不可静态解析
453
+ // * fd 前缀(2>、3>>)、>| &> &>> >& <& <> << <<- <<<
454
+ // * heredoc 正文整段跳过(正文里的 `>` 是数据,不是重定向)
455
+ // * 命令分隔符(换行 ; && || | & 括号)划分命令段,段内识别 tee / dd / Set-Content / Get-Content 这类隐式文件命令
456
+ // 任何无法确定的情况一律 fail-closed:parseFailed = true,由规则判黄,绝不猜。
457
+ // ─────────────────────────────────────────────────────────────────────────────
458
+
459
+ const SHELL_SEPARATORS = new Set(['newline', ';', ';;', '&&', '||', '|', '&', '(', ')'])
460
+
461
+ /** 重定向操作符匹配(最长优先)。返回 { op, len } 或 null。 */
462
+ function matchRedirectOp(text, i) {
463
+ const ch = text[i]
464
+ const two = text.slice(i, i + 2)
465
+ const three = text.slice(i, i + 3)
466
+ if (ch === '&') {
467
+ if (three === '&>>') return { op: '&>>', len: 3 }
468
+ if (two === '&>') return { op: '&>', len: 2 }
469
+ return null
470
+ }
471
+ if (ch === '>') {
472
+ if (three === '>>|') return null
473
+ if (two === '>>') return { op: '>>', len: 2 }
474
+ if (two === '>|') return { op: '>|', len: 2 }
475
+ if (two === '>&') return { op: '>&', len: 2 }
476
+ if (two === '>=') return null
477
+ return { op: '>', len: 1 }
478
+ }
479
+ if (ch === '<') {
480
+ if (three === '<<<') return { op: '<<<', len: 3 }
481
+ if (three === '<<-') return { op: '<<-', len: 3 }
482
+ if (two === '<<') return { op: '<<', len: 2 }
483
+ if (two === '<&') return { op: '<&', len: 2 }
484
+ if (two === '<>') return { op: '<>', len: 2 }
485
+ if (two === '<=') return null
486
+ return { op: '<', len: 1 }
487
+ }
488
+ return null
489
+ }
490
+
491
+ /** 跳过 `$( ... )`:计数括号,忽略引号内的括号。返回结束后一位。 */
492
+ function skipParenGroup(text, i) {
493
+ let depth = 0
494
+ let j = i
495
+ while (j < text.length) {
496
+ const ch = text[j]
497
+ if (ch === '\\') { j += 2; continue }
498
+ if (ch === '(') depth += 1
499
+ else if (ch === ')') { depth -= 1; if (depth === 0) return j + 1 }
500
+ j += 1
501
+ }
502
+ return -1
503
+ }
504
+
505
+ /**
506
+ * 扫描一个 word(不含分隔符)。返回 { value, dynamic, end, error }。
507
+ * dynamic = true 表示包含参数展开/命令替换/通配,无法作为字面量路径。
508
+ */
509
+ function scanWord(text, i) {
510
+ let value = ''
511
+ let dynamic = false
512
+ let j = i
513
+ while (j < text.length) {
514
+ const ch = text[j]
515
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') break
516
+ if (SHELL_SEPARATORS.has(ch) || ch === '>' || ch === '<' || ch === ';') break
517
+ if (ch === '\\') {
518
+ const next = text[j + 1]
519
+ if (next === undefined) return { value, dynamic, end: j + 1, error: 'trailing-backslash' }
520
+ if (next === '\n') { j += 2; continue }
521
+ value += next
522
+ j += 2
523
+ continue
524
+ }
525
+ if (ch === "'") {
526
+ const close = text.indexOf("'", j + 1)
527
+ if (close < 0) return { value, dynamic, end: text.length, error: 'unterminated-single-quote' }
528
+ value += text.slice(j + 1, close)
529
+ j = close + 1
530
+ continue
531
+ }
532
+ if (ch === '"') {
533
+ let k = j + 1
534
+ let closed = false
535
+ while (k < text.length) {
536
+ const c = text[k]
537
+ if (c === '\\') {
538
+ const next = text[k + 1]
539
+ if (next === undefined) break
540
+ if (next === '"' || next === '\\' || next === '$' || next === '`') value += next
541
+ else value += `\\${next}`
542
+ k += 2
543
+ continue
544
+ }
545
+ if (c === '"') { closed = true; k += 1; break }
546
+ if (c === '$' || c === '`') dynamic = true
547
+ value += c
548
+ k += 1
549
+ }
550
+ if (!closed) return { value, dynamic, end: text.length, error: 'unterminated-double-quote' }
551
+ j = k
552
+ continue
553
+ }
554
+ if (ch === '$') {
555
+ dynamic = true
556
+ const next = text[j + 1]
557
+ if (next === '(') {
558
+ const end = skipParenGroup(text, j + 1)
559
+ if (end < 0) return { value, dynamic, end: text.length, error: 'unterminated-substitution' }
560
+ j = end
561
+ continue
562
+ }
563
+ if (next === '{') {
564
+ const close = text.indexOf('}', j + 2)
565
+ if (close < 0) return { value, dynamic, end: text.length, error: 'unterminated-brace-expansion' }
566
+ j = close + 1
567
+ continue
568
+ }
569
+ // $'...' / $VAR
570
+ if (next === "'") {
571
+ const close = text.indexOf("'", j + 2)
572
+ if (close < 0) return { value, dynamic, end: text.length, error: 'unterminated-ansi-c' }
573
+ value += text.slice(j + 2, close)
574
+ j = close + 1
575
+ continue
576
+ }
577
+ j += 1
578
+ while (j < text.length && /[A-Za-z0-9_]/.test(text[j])) j += 1
579
+ continue
580
+ }
581
+ if (ch === '`') {
582
+ dynamic = true
583
+ const close = text.indexOf('`', j + 1)
584
+ if (close < 0) return { value, dynamic, end: text.length, error: 'unterminated-backtick' }
585
+ j = close + 1
586
+ continue
587
+ }
588
+ if (ch === '*' || ch === '?' || ch === '[' || ch === '{') dynamic = true
589
+ value += ch
590
+ j += 1
591
+ }
592
+ return { value, dynamic, end: j, error: null }
593
+ }
594
+
595
+ /**
596
+ * 词法扫描:产出 word / redirect / op token,并把 heredoc 正文整段吞掉。
597
+ * @returns {{tokens: object[], errors: string[], heredocs: number}}
598
+ */
599
+ export function tokenizeShell(commandText) {
600
+ const text = String(commandText ?? '')
601
+ const tokens = []
602
+ const errors = []
603
+ let heredocs = 0
604
+ let pendingHeredocs = []
605
+ let i = 0
606
+ let lastEnd = -1
607
+
608
+ const consumeHeredocBodies = () => {
609
+ for (const hd of pendingHeredocs) {
610
+ heredocs += 1
611
+ let k = i
612
+ let closed = false
613
+ while (k <= text.length) {
614
+ const lineEnd = text.indexOf('\n', k)
615
+ const line = text.slice(k, lineEnd < 0 ? text.length : lineEnd)
616
+ const compare = hd.strip ? line.replace(/^\t+/, '') : line
617
+ k = lineEnd < 0 ? text.length : lineEnd + 1
618
+ if (compare === hd.delimiter) { closed = true; break }
619
+ if (lineEnd < 0) break
620
+ }
621
+ if (!closed) errors.push('unterminated-heredoc')
622
+ i = k
623
+ }
624
+ pendingHeredocs = []
625
+ }
626
+
627
+ while (i < text.length) {
628
+ const ch = text[i]
629
+ if (ch === ' ' || ch === '\t' || ch === '\r') { i += 1; continue }
630
+ if (ch === '\\' && text[i + 1] === '\n') { i += 2; continue }
631
+ if (ch === '\n') {
632
+ tokens.push({ type: 'op', value: 'newline' })
633
+ i += 1
634
+ if (pendingHeredocs.length > 0) consumeHeredocBodies()
635
+ continue
636
+ }
637
+ // 重定向(可能带 fd 前缀)
638
+ const redir = matchRedirectOp(text, i)
639
+ if (redir !== null) {
640
+ let fd = null
641
+ const prev = tokens[tokens.length - 1]
642
+ if (prev !== undefined && prev.type === 'word' && prev.end === i && /^\d+$/.test(prev.value) && !prev.dynamic) {
643
+ fd = Number(prev.value)
644
+ tokens.pop()
645
+ }
646
+ i += redir.len
647
+ while (i < text.length && (text[i] === ' ' || text[i] === '\t')) i += 1
648
+ let target = null
649
+ const next = text[i]
650
+ const targetIsWord = next !== undefined
651
+ && next !== '\n' && next !== ';' && next !== '|' && next !== '&' && next !== '>' && next !== '<'
652
+ if (targetIsWord) {
653
+ const word = scanWord(text, i)
654
+ if (word.error !== null) errors.push(word.error)
655
+ target = { value: word.value, dynamic: word.dynamic }
656
+ i = word.end
657
+ }
658
+ tokens.push({ type: 'redirect', op: redir.op, fd, target })
659
+ if ((redir.op === '<<' || redir.op === '<<-') && target !== null) {
660
+ if (target.dynamic) errors.push('dynamic-heredoc-delimiter')
661
+ else pendingHeredocs.push({ delimiter: target.value, strip: redir.op === '<<-' })
662
+ }
663
+ continue
664
+ }
665
+ // 命令分隔 / 管道 / 括号
666
+ if (ch === ';' || ch === '&' || ch === '|' || ch === '(' || ch === ')') {
667
+ const two = text.slice(i, i + 2)
668
+ let value = ch
669
+ if (two === ';;' || two === '&&' || two === '||') value = two
670
+ tokens.push({ type: 'op', value })
671
+ i += value.length
672
+ continue
673
+ }
674
+ const word = scanWord(text, i)
675
+ if (word.error !== null) errors.push(word.error)
676
+ tokens.push({ type: 'word', value: word.value, dynamic: word.dynamic, start: i, end: word.end })
677
+ if (word.end <= i) { i += 1; continue }
678
+ i = word.end
679
+ lastEnd = word.end
680
+ }
681
+ return { tokens, errors, heredocs, lastEnd }
682
+ }
683
+
684
+ const IMPLICIT_WRITE_COMMANDS = new Set(['tee', 'tee-object', 'out-file', 'set-content', 'add-content'])
685
+ const IMPLICIT_READ_COMMANDS = new Set(['cat', 'type', 'less', 'head', 'tail', 'more', 'get-content', 'gc'])
686
+
687
+ /** 段内隐式文件命令(tee / dd / Set-Content / Get-Content …)的目标。 */
688
+ function collectImplicitTargets(argv, writeTargets, readTargets) {
689
+ const first = argv[0]
690
+ if (first === undefined || first.dynamic) return
691
+ const name = String(first.value).split(/[\\/]/).pop()?.toLowerCase() ?? ''
692
+ const rest = argv.slice(1)
693
+ const positional = []
694
+ for (let index = 0; index < rest.length; index += 1) {
695
+ const word = rest[index]
696
+ if (word.dynamic) continue
697
+ const value = String(word.value)
698
+ if (value === '--') continue
699
+ if (value.startsWith('-')) continue
700
+ positional.push({ value, previous: rest[index - 1]?.value })
701
+ }
702
+ if (name === 'tee') {
703
+ for (const item of positional) writeTargets.push(item.value)
704
+ return
705
+ }
706
+ if (name === 'dd') {
707
+ for (const word of rest) {
708
+ if (word.dynamic) continue
709
+ const value = String(word.value)
710
+ if (value.startsWith('of=')) writeTargets.push(value.slice(3))
711
+ else if (value.startsWith('if=')) readTargets.push(value.slice(3))
712
+ }
713
+ return
714
+ }
715
+ if (IMPLICIT_WRITE_COMMANDS.has(name)) {
716
+ const flagged = positional.find((item) => String(item.previous ?? '').toLowerCase() === '-path' || String(item.previous ?? '').toLowerCase() === '-filepath')
717
+ if (flagged !== undefined) writeTargets.push(flagged.value)
718
+ else if (positional.length > 0) writeTargets.push(String(positional[0].value))
719
+ return
720
+ }
721
+ if (IMPLICIT_READ_COMMANDS.has(name)) {
722
+ for (const item of positional) readTargets.push(item.value)
723
+ }
724
+ }
725
+
726
+ /**
727
+ * 从命令文本提取写/读重定向目标(AST-lite)。
728
+ * @returns {{writeTargets:string[], readTargets:string[], parseFailed:boolean,
729
+ * parseError:string|null, heredocs:number, warnings:string[]}}
730
+ */
731
+ export function parseShellRedirects(commandText, policyInput = {}) {
732
+ const policy = policyInput && policyInput.__normalized === true ? policyInput : normalizePolicy(policyInput ?? {})
733
+ const text = String(commandText ?? '')
734
+ const empty = { writeTargets: [], readTargets: [], parseFailed: false, parseError: null, heredocs: 0, warnings: [] }
735
+ if (text.trim().length === 0) return empty
736
+
737
+ const lex = tokenizeShell(text)
738
+ const writeRaw = []
739
+ const readRaw = []
740
+ const warnings = []
741
+ let argv = []
742
+
743
+ const flushCommand = () => {
744
+ if (argv.length > 0) collectImplicitTargets(argv, writeRaw, readRaw)
745
+ argv = []
746
+ }
747
+
748
+ for (const token of lex.tokens) {
749
+ if (token.type === 'op') {
750
+ if (SHELL_SEPARATORS.has(token.value)) flushCommand()
751
+ continue
752
+ }
753
+ if (token.type === 'word') { argv.push({ value: token.value, dynamic: token.dynamic }); continue }
754
+ if (token.type === 'redirect') {
755
+ const target = token.target
756
+ const op = token.op
757
+ if (op === '<<' || op === '<<-' || op === '<<<') continue // heredoc / herestring 的内容不是文件路径
758
+ if (target === null) { warnings.push(`${op}-missing-target`); continue }
759
+ if (target.dynamic) { warnings.push(`${op}-dynamic-target`); continue }
760
+ const value = String(target.value)
761
+ if (op === '>' || op === '>>' || op === '>|' || op === '&>' || op === '&>>') {
762
+ writeRaw.push(value)
763
+ } else if (op === '<') {
764
+ readRaw.push(value)
765
+ } else if (op === '<>') {
766
+ readRaw.push(value)
767
+ writeRaw.push(value)
768
+ } else if (op === '>&' || op === '<&') {
769
+ // 纯数字目标是 fd 复制(2>&1),不是文件
770
+ if (/^\d+$/.test(value)) continue
771
+ if (op === '>&') writeRaw.push(value)
772
+ else readRaw.push(value)
773
+ }
774
+ }
775
+ }
776
+ flushCommand()
777
+
778
+ const resolve = (list) => {
779
+ const out = []
780
+ for (const raw of list) {
781
+ const abs = resolvePathValue(String(raw), policy.root)
782
+ if (abs !== null && !out.includes(abs)) out.push(abs)
783
+ }
784
+ return out
785
+ }
786
+ const parseFailed = lex.errors.length > 0
787
+ return {
788
+ writeTargets: resolve(writeRaw),
789
+ readTargets: resolve(readRaw),
790
+ parseFailed,
791
+ parseError: parseFailed ? lex.errors[0] : null,
792
+ heredocs: lex.heredocs,
793
+ warnings,
794
+ }
795
+ }
796
+
797
+ /** URL → { raw, host, hostPort };坏 URL 返回 null(由 network-egress 判黄兜住)。 */
798
+ export function parseUrlParts(raw) {
799
+ const text = String(raw ?? '').trim()
800
+ const m = /^([a-z][a-z0-9+.-]*):\/\/([^/?#]+)/i.exec(text)
801
+ if (m === null) return null
802
+ const scheme = String(m[1]).toLowerCase()
803
+ const authority = String(m[2])
804
+ const hostPart = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority
805
+ let host = hostPart
806
+ let port = ''
807
+ const colon = hostPart.lastIndexOf(':')
808
+ if (colon > 0) {
809
+ host = hostPart.slice(0, colon)
810
+ port = hostPart.slice(colon + 1)
811
+ }
812
+ if (host.length === 0) return null
813
+ const defaultPort = scheme === 'https' ? '443' : scheme === 'http' ? '80' : ''
814
+ const effectivePort = port.length > 0 ? port : defaultPort
815
+ return {
816
+ raw: text,
817
+ host: host.toLowerCase(),
818
+ hostPort: effectivePort.length > 0 ? `${host.toLowerCase()}:${effectivePort}` : host.toLowerCase(),
819
+ }
820
+ }
821
+
822
+ /**
823
+ * 规则上下文:一次解析,供 15 条规则共享。
824
+ * @returns {{mode:string, target:object, commandText:string, targetPath:string|null,
825
+ * url:{raw:string,host:string,hostPort:string}|null, targetPid:number|null,
826
+ * writeTargets:string[], readTargets:string[], policy:object}}
827
+ */
828
+ export function buildActionContext(evidence = {}, policyInput = {}) {
829
+ const policy = policyInput && policyInput.__normalized === true ? policyInput : normalizePolicy(policyInput ?? {})
830
+ const mode = modeOf(evidence)
831
+ const target = targetOf(evidence)
832
+ const commandText = target.kind === 'command' ? target.value : String(evidence?.args?.command ?? '')
833
+ const redirects = parseShellRedirects(commandText, policy)
834
+ const targetPath = target.kind === 'path' ? resolvePathValue(target.value, policy.root) : null
835
+ const url = target.kind === 'url' ? parseUrlParts(target.value) : null
836
+ const targetPid = target.kind === 'pid' ? Number(target.value) : null
837
+ return {
838
+ mode,
839
+ target,
840
+ commandText,
841
+ targetPath,
842
+ url,
843
+ targetPid: Number.isFinite(targetPid) ? targetPid : null,
844
+ writeTargets: redirects.writeTargets,
845
+ readTargets: redirects.readTargets,
846
+ parseFailed: redirects.parseFailed,
847
+ parseError: redirects.parseError,
848
+ heredocs: redirects.heredocs,
849
+ warnings: redirects.warnings,
850
+ trusted: evidence?.trusted === true,
851
+ policy,
852
+ }
853
+ }
854
+
855
+ // ─────────────────────────────────────────────────────────────────────────────
856
+ // 声明式动作规则表:id / category / light / reason / tags / match / evidence
857
+ //
858
+ // 与 AST 规则表(S1–S7 / C2–C5)同一个粒度:每条规则可独立喂证据、独立断言证据形状。
859
+ // **首个命中胜出**(数组顺序即优先级),不是"取最高灯色"——灯色是排他性的,
860
+ // 而且凭据访问必须无条件压过"沙箱内只读"的绿规则。
861
+ // ─────────────────────────────────────────────────────────────────────────────
862
+
863
+ export const ACTION_CATEGORIES = [
864
+ 'lifecycle', 'destructive', 'credential', 'network', 'import', 'write', 'exec', 'read', 'fallback',
865
+ ]
866
+
867
+ function firstPattern(patterns, text) {
868
+ for (const source of patterns ?? []) {
869
+ let re
870
+ try {
871
+ re = new RegExp(String(source), 'i')
872
+ } catch {
873
+ continue
874
+ }
875
+ if (re.test(String(text ?? ''))) return String(source)
876
+ }
877
+ return null
878
+ }
879
+
880
+ function shellCommandOf(ctx) {
881
+ return ctx.commandText
882
+ }
883
+
884
+ /** 写动作的目标路径集合:write 工具取 target 路径,shell 取重定向目标。 */
885
+ function writeTargetsOf(ctx) {
886
+ const out = []
887
+ if (ctx.mode === 'write' && ctx.targetPath !== null) out.push(ctx.targetPath)
888
+ for (const t of ctx.writeTargets) if (!out.includes(t)) out.push(t)
889
+ return out
890
+ }
891
+
892
+ /** 读动作的目标路径集合:read 工具取 target 路径,shell 取读命令目标。 */
893
+ function readTargetsOf(ctx) {
894
+ const out = []
895
+ if (ctx.mode === 'read' && ctx.targetPath !== null) out.push(ctx.targetPath)
896
+ for (const t of ctx.readTargets) if (!out.includes(t)) out.push(t)
897
+ return out
898
+ }
899
+
900
+ const NO_TARGET = { kind: 'none', value: '' }
901
+
902
+ /** 默认规则表(v2.7.8 起声明式;id 与 v2.7.6 的 15 条一一对应)。 */
903
+ export const DEFAULT_ACTION_RULES = Object.freeze([
904
+ {
905
+ id: 'dsh-lifecycle',
906
+ category: 'lifecycle',
907
+ light: LIGHT.RED,
908
+ reason: 'DSH 生命周期动作,交给 restart-guard',
909
+ tags: ['hard-deny', 'delegate-to-restart-guard'],
910
+ match: (ev, ctx) => isDshLifecycleAction(ev, ctx.policy),
911
+ evidence: (ev, ctx) => {
912
+ const scope = scopeOf(ev)
913
+ const tool = String(ev?.tool ?? '').trim().toLowerCase()
914
+ const policy = ctx.policy
915
+ const lifecycleTools = policy.lifecycleTools.map((v) => v.toLowerCase())
916
+ let matchedPattern = 'unknown'
917
+ if (scope === 'system' || scope === 'process') matchedPattern = `scope:${scope}`
918
+ else if (lifecycleTools.includes(tool)) matchedPattern = `tool:${tool}`
919
+ else {
920
+ const hit = firstPattern(policy.lifecyclePatterns.map((p) => p.source ?? p), shellCommandOf(ctx))
921
+ matchedPattern = hit === null ? 'pattern' : `pattern:${hit}`
922
+ }
923
+ return { matchedPattern, tool: String(ev?.tool ?? ''), targetPid: ctx.targetPid, commandSnippet: shellCommandOf(ctx).slice(0, 200) }
924
+ },
925
+ },
926
+ {
927
+ id: 'destructive-command',
928
+ category: 'destructive',
929
+ light: LIGHT.RED,
930
+ reason: '命令命中破坏性模式(不可逆)',
931
+ tags: ['hard-deny'],
932
+ match: (ev, ctx) => isDestructiveCommand(ev, ctx.policy),
933
+ evidence: (ev, ctx) => {
934
+ const hit = firstPattern(ctx.policy.destructivePatterns.map((p) => p.source ?? p), shellCommandOf(ctx))
935
+ return { matchedPattern: hit === null ? 'unknown' : `pattern:${hit}`, commandSnippet: shellCommandOf(ctx).slice(0, 200) }
936
+ },
937
+ },
938
+ {
939
+ id: 'credential-access',
940
+ category: 'credential',
941
+ light: LIGHT.RED,
942
+ reason: '命中凭据路径,硬拒绝',
943
+ tags: ['hard-deny'],
944
+ match: (ev, ctx) => isCredentialAccess(ev, ctx.policy),
945
+ evidence: (ev, ctx) => {
946
+ const candidates = []
947
+ if (ctx.targetPath !== null) candidates.push(ctx.targetPath)
948
+ for (const p of [...ctx.readTargets, ...ctx.writeTargets]) candidates.push(p)
949
+ const text = [ctx.target.value, shellCommandOf(ctx), String(ev?.args?.path ?? '')].filter(Boolean).join('\n')
950
+ const hit = firstPattern(ctx.policy.credentialPatterns.map((p) => p.source ?? p), text)
951
+ const matchedPaths = candidates.filter((p) => firstPattern(ctx.policy.credentialPatterns.map((x) => x.source ?? x), p) !== null)
952
+ return { matchedPattern: hit === null ? 'unknown' : `pattern:${hit}`, paths: matchedPaths, target: ctx.target }
953
+ },
954
+ },
955
+ {
956
+ id: 'network-allowlisted',
957
+ category: 'network',
958
+ light: LIGHT.BLUE,
959
+ reason: '外连命中允许列表',
960
+ tags: ['allow-with-audit'],
961
+ match: (ev, ctx) => isNetworkEgress(ev) && networkAllowed(ctx.target.value, ctx.policy),
962
+ evidence: (ev, ctx) => ({ matchedPattern: 'allowlisted-host', url: ctx.url?.raw ?? ctx.target.value, host: ctx.url?.host ?? null, allowlist: ctx.policy.networkAllowlist }),
963
+ },
964
+ {
965
+ id: 'network-egress',
966
+ category: 'network',
967
+ light: LIGHT.YELLOW,
968
+ reason: '外连未在允许列表',
969
+ tags: ['needs-approval'],
970
+ match: (ev, ctx) => isNetworkEgress(ev) && !networkAllowed(ctx.target.value, ctx.policy),
971
+ evidence: (ev, ctx) => ({ matchedPattern: ctx.url === null ? 'unparsable-url' : 'non-allowlisted-host', url: ctx.target.value, host: ctx.url?.host ?? null, allowlist: ctx.policy.networkAllowlist }),
972
+ },
973
+ {
974
+ id: 'dynamic-import',
975
+ category: 'import',
976
+ light: LIGHT.YELLOW,
977
+ reason: '动态加载未评分模块',
978
+ tags: ['needs-approval'],
979
+ match: (ev) => isDynamicImport(ev),
980
+ evidence: (ev, ctx) => ({ matchedPattern: 'import-tool', tool: String(ev?.tool ?? ''), specifier: String(ev?.args?.specifier ?? ev?.args?.module ?? ctx.target.value ?? '') }),
981
+ },
982
+ {
983
+ id: 'write-in-buffer',
984
+ category: 'write',
985
+ light: LIGHT.BLUE,
986
+ reason: '写落在沙箱缓冲区内',
987
+ tags: ['allow-with-audit'],
988
+ match: (ev, ctx) => {
989
+ if (ctx.mode !== 'write' && ctx.mode !== 'exec') return false
990
+ const root = ctx.policy.bufferRoot
991
+ if (root === null) return false
992
+ const targets = writeTargetsOf(ctx)
993
+ return targets.length > 0 && targets.every((t) => withinPath(t, root))
994
+ },
995
+ evidence: (ev, ctx) => ({ matchedPattern: 'in-buffer', targets: writeTargetsOf(ctx), bufferRoot: ctx.policy.bufferRoot }),
996
+ },
997
+ {
998
+ id: 'write-in-allowlist',
999
+ category: 'write',
1000
+ light: LIGHT.BLUE,
1001
+ reason: '写命中允许列表',
1002
+ tags: ['allow-with-audit'],
1003
+ match: (ev, ctx) => {
1004
+ if (ctx.mode !== 'write') return false
1005
+ const targets = writeTargetsOf(ctx)
1006
+ return targets.length > 0 && targets.every((t) => inAllowList(t, ctx.policy))
1007
+ },
1008
+ evidence: (ev, ctx) => ({ matchedPattern: 'in-allowlist', targets: writeTargetsOf(ctx), allowPaths: ctx.policy.allowPaths }),
1009
+ },
1010
+ {
1011
+ id: 'write-production',
1012
+ category: 'write',
1013
+ light: LIGHT.YELLOW,
1014
+ reason: '写落在生产区',
1015
+ tags: ['needs-approval'],
1016
+ match: (ev, ctx) => {
1017
+ if (ctx.mode !== 'write') return false
1018
+ const root = ctx.policy.productionRoot
1019
+ if (root === null) return false
1020
+ const targets = writeTargetsOf(ctx)
1021
+ return targets.length > 0 && targets.every((t) => withinPath(t, root))
1022
+ },
1023
+ evidence: (ev, ctx) => ({ matchedPattern: 'production-root', targets: writeTargetsOf(ctx), productionRoot: ctx.policy.productionRoot }),
1024
+ },
1025
+ {
1026
+ id: 'write-outside-buffer',
1027
+ category: 'write',
1028
+ light: LIGHT.YELLOW,
1029
+ reason: '写越出沙箱',
1030
+ tags: ['needs-approval'],
1031
+ match: (ev, ctx) => {
1032
+ // fail-closed:命令里出现重定向符号但语法解析失败 → 不猜目标,直接判黄(需审批)
1033
+ if (ctx.parseFailed === true && /[>]/.test(shellCommandOf(ctx))) return true
1034
+ if (ctx.mode !== 'write') return false
1035
+ const targets = writeTargetsOf(ctx)
1036
+ if (targets.length === 0) return true
1037
+ return targets.some((t) => !withinPath(t, ctx.policy.bufferRoot) && !inAllowList(t, ctx.policy))
1038
+ },
1039
+ evidence: (ev, ctx) => ({
1040
+ matchedPattern: ctx.parseFailed === true ? 'unparsed-redirect-fail-closed' : 'outside-buffer-and-allowlist',
1041
+ targets: writeTargetsOf(ctx),
1042
+ bufferRoot: ctx.policy.bufferRoot,
1043
+ allowPaths: ctx.policy.allowPaths,
1044
+ parseFailed: ctx.parseFailed === true,
1045
+ parseError: ctx.parseError ?? null,
1046
+ }),
1047
+ },
1048
+ {
1049
+ id: 'exec-read-only',
1050
+ category: 'exec',
1051
+ light: LIGHT.BLUE,
1052
+ reason: '只读 shell 命令(无重定向)',
1053
+ tags: ['allow-with-audit'],
1054
+ // 解析失败时不宣称只读:宁可判黄,也不把没看懂的语法放成蓝/绿
1055
+ match: (ev, ctx) => ctx.mode === 'exec' && ctx.parseFailed !== true && execLooksReadOnly(ev, ctx.policy),
1056
+ evidence: (ev, ctx) => ({ matchedPattern: 'read-only-prefix', commandSnippet: shellCommandOf(ctx).slice(0, 200) }),
1057
+ },
1058
+ {
1059
+ id: 'exec-side-effect',
1060
+ category: 'exec',
1061
+ light: LIGHT.YELLOW,
1062
+ reason: 'shell 命令有副作用,不在只读白名单',
1063
+ tags: ['needs-approval'],
1064
+ match: (ev, ctx) => ctx.mode === 'exec',
1065
+ evidence: (ev, ctx) => ({
1066
+ matchedPattern: ctx.parseFailed === true ? 'unparsed-command-fail-closed' : 'side-effect',
1067
+ commandSnippet: shellCommandOf(ctx).slice(0, 200),
1068
+ writeTargets: ctx.writeTargets,
1069
+ parseFailed: ctx.parseFailed === true,
1070
+ parseError: ctx.parseError ?? null,
1071
+ }),
1072
+ },
1073
+ {
1074
+ id: 'read-in-buffer',
1075
+ category: 'read',
1076
+ light: LIGHT.GREEN,
1077
+ reason: '沙箱内只读',
1078
+ tags: ['auto-allow'],
1079
+ match: (ev, ctx) => {
1080
+ if (ctx.mode !== 'read') return false
1081
+ const root = ctx.policy.bufferRoot
1082
+ if (root === null) return false
1083
+ const targets = readTargetsOf(ctx)
1084
+ return targets.length > 0 && targets.every((t) => withinPath(t, root))
1085
+ },
1086
+ evidence: (ev, ctx) => ({ matchedPattern: 'in-buffer', targets: readTargetsOf(ctx), bufferRoot: ctx.policy.bufferRoot }),
1087
+ },
1088
+ {
1089
+ id: 'read-outside-buffer',
1090
+ category: 'read',
1091
+ light: LIGHT.BLUE,
1092
+ reason: '沙箱外只读',
1093
+ tags: ['allow-with-audit'],
1094
+ match: (ev, ctx) => ctx.mode === 'read',
1095
+ evidence: (ev, ctx) => ({ matchedPattern: 'outside-buffer', targets: readTargetsOf(ctx), bufferRoot: ctx.policy.bufferRoot }),
1096
+ },
1097
+ {
1098
+ id: 'unknown-tool',
1099
+ category: 'fallback',
1100
+ light: LIGHT.YELLOW,
1101
+ reason: '未登记的工具,保守判黄',
1102
+ tags: ['conservative'],
1103
+ match: () => true,
1104
+ evidence: (ev, ctx) => ({ matchedPattern: 'fallback', tool: String(ev?.tool ?? ''), mode: ctx.mode, hasTarget: ctx.target.kind !== 'none' }),
1105
+ },
1106
+ ])
1107
+
1108
+ /** 规则形状校验:注册时立刻失败,而不是在评分路径上静默漏判。 */
1109
+ export function assertActionRule(rule) {
1110
+ if (!rule || typeof rule !== 'object') throw new TypeError('action rule must be an object')
1111
+ if (typeof rule.id !== 'string' || rule.id.length === 0) throw new TypeError('action rule.id must be a non-empty string')
1112
+ if (!ACTION_CATEGORIES.includes(rule.category)) throw new TypeError(`action rule ${rule.id}: unknown category ${String(rule.category)}`)
1113
+ if (!Object.prototype.hasOwnProperty.call(LIGHT_RANK, rule.light)) throw new TypeError(`action rule ${rule.id}: unknown light ${String(rule.light)}`)
1114
+ if (typeof rule.match !== 'function') throw new TypeError(`action rule ${rule.id}: match() is required`)
1115
+ if (typeof rule.evidence !== 'function') throw new TypeError(`action rule ${rule.id}: evidence() is required`)
1116
+ return rule
1117
+ }
1118
+
1119
+ /**
1120
+ * 可注册/可覆盖的规则集。注册是**插入到正确优先级位置**,不是追加到末尾——
1121
+ * 追加一条红灯规则到表尾等于永远不命中(前面有 fallback)。
1122
+ */
1123
+ export function createActionRuleSet(initial = DEFAULT_ACTION_RULES) {
1124
+ const rules = (initial ?? []).map((rule) => assertActionRule(rule))
1125
+ const findIndex = (id) => rules.findIndex((r) => r.id === id)
1126
+
1127
+ function register(rule, opts = {}) {
1128
+ assertActionRule(rule)
1129
+ if (typeof rule.id === 'string' && findIndex(rule.id) >= 0) {
1130
+ throw new Error(`action rule already registered: ${rule.id}(覆盖请用 override())`)
1131
+ }
1132
+ const before = typeof opts.before === 'string' ? findIndex(opts.before) : -1
1133
+ const after = typeof opts.after === 'string' ? findIndex(opts.after) : -1
1134
+ if (before >= 0) rules.splice(before, 0, rule)
1135
+ else if (after >= 0) rules.splice(after + 1, 0, rule)
1136
+ else throw new Error(`action rule ${rule.id}: register() requires before/after naming an existing rule`)
1137
+ return rule
1138
+ }
1139
+
1140
+ function override(id, patch = {}) {
1141
+ const index = findIndex(id)
1142
+ if (index < 0) throw new Error(`action rule not found: ${id}`)
1143
+ const merged = assertActionRule({ ...rules[index], ...patch, id })
1144
+ rules[index] = merged
1145
+ return merged
1146
+ }
1147
+
1148
+ function remove(id) {
1149
+ const index = findIndex(id)
1150
+ if (index < 0) return false
1151
+ rules.splice(index, 1)
1152
+ return true
1153
+ }
1154
+
1155
+ function reset() {
1156
+ rules.length = 0
1157
+ for (const rule of DEFAULT_ACTION_RULES) rules.push(rule)
1158
+ }
1159
+
1160
+ return {
1161
+ rules,
1162
+ ids: () => rules.map((r) => r.id),
1163
+ register,
1164
+ override,
1165
+ remove,
1166
+ reset,
1167
+ find(evidence, ctx) {
1168
+ return rules.find((rule) => rule.match(evidence, ctx)) ?? rules[rules.length - 1] ?? null
1169
+ },
1170
+ }
1171
+ }
1172
+
1173
+ /** 默认规则集实例(只读用途:baseLight 用它)。 */
1174
+ const DEFAULT_RULE_SET = createActionRuleSet(DEFAULT_ACTION_RULES)
1175
+
1176
+ /**
1177
+ * 基础灯色:只看动作本身,不看插件等级。
1178
+ * 规则按数组顺序首个命中胜出;返回值带 matchedRule / category / tags / 结构化证据。
1179
+ */
1180
+ export function baseLight(evidence = {}, policyInput = {}, ruleSet = DEFAULT_RULE_SET) {
1181
+ const policy = policyInput && policyInput.__normalized === true ? policyInput : normalizePolicy(policyInput ?? {})
1182
+ const ctx = buildActionContext(evidence, policy)
1183
+ const rule = (ruleSet && typeof ruleSet.find === 'function' ? ruleSet.find(evidence, ctx) : null)
1184
+ ?? DEFAULT_ACTION_RULES[DEFAULT_ACTION_RULES.length - 1]
1185
+ const detail = rule.evidence(evidence, ctx) ?? {}
1186
+ return {
1187
+ light: rule.light,
1188
+ matchedRule: rule.id,
1189
+ category: rule.category,
1190
+ tags: [...(rule.tags ?? [])],
1191
+ lifecycle: rule.id === 'dsh-lifecycle',
1192
+ credential: rule.id === 'credential-access',
1193
+ trusted: ctx.trusted === true,
1194
+ target: ctx.target ?? NO_TARGET,
1195
+ path: ctx.targetPath ?? (detail.targets?.[0] ?? null),
1196
+ reason: rule.reason,
1197
+ evidence: detail,
1198
+ }
1199
+ }
1200
+
1201
+ // ─────────────────────────────────────────────────────────────────────────────
1202
+ // 决策矩阵
1203
+ // ─────────────────────────────────────────────────────────────────────────────
1204
+
1205
+ /**
1206
+ * 灯色 + 动作类别 → 决策。纯函数,不读策略、不碰 IO。
1207
+ * @returns {{decision:string, severity:string, reason:string, originLight?:string, target?:string, elevated?:boolean}}
1208
+ */
1209
+ export function decide(assessment = {}) {
1210
+ const light = normalizeLight(assessment.light)
1211
+ const lifecycle = assessment.lifecycle === true || assessment.matchedRule === 'dsh-lifecycle'
1212
+ const credential = assessment.credential === true || assessment.matchedRule === 'credential-access'
1213
+
1214
+ if (light === LIGHT.GREEN) {
1215
+ return { decision: DECISION.ALLOW, severity: SEVERITY.NORMAL, reason: 'green-light' }
1216
+ }
1217
+ if (light === LIGHT.BLUE) {
1218
+ return { decision: DECISION.MARK, severity: SEVERITY.NORMAL, reason: 'blue-light', elevated: true }
1219
+ }
1220
+ if (light === LIGHT.YELLOW) {
1221
+ if (lifecycle) {
1222
+ return {
1223
+ decision: DECISION.DELEGATE,
1224
+ severity: SEVERITY.NORMAL,
1225
+ reason: 'lifecycle-yellow',
1226
+ originLight: LIGHT.YELLOW,
1227
+ target: DELEGATE_TARGET.RESTART_GUARD,
1228
+ }
1229
+ }
1230
+ return { decision: DECISION.ASK, severity: SEVERITY.NORMAL, reason: 'yellow-light' }
1231
+ }
1232
+ // red
1233
+ if (credential) return { decision: DECISION.DENY, severity: SEVERITY.CRITICAL, reason: 'credential-access' }
1234
+ if (lifecycle) {
1235
+ return {
1236
+ decision: DECISION.DELEGATE,
1237
+ severity: SEVERITY.CRITICAL,
1238
+ reason: 'lifecycle-red',
1239
+ originLight: LIGHT.RED,
1240
+ target: DELEGATE_TARGET.RESTART_GUARD,
1241
+ }
1242
+ }
1243
+ return { decision: DECISION.ASK, severity: SEVERITY.CRITICAL, reason: 'red-light' }
1244
+ }
1245
+
1246
+ // ─────────────────────────────────────────────────────────────────────────────
1247
+ // 评分门面
1248
+ // ─────────────────────────────────────────────────────────────────────────────
1249
+
1250
+ function sha1(text) {
1251
+ return crypto.createHash('sha1').update(String(text), 'utf8').digest('hex')
1252
+ }
1253
+
1254
+ function mtimeOf(file) {
1255
+ try {
1256
+ return String(fs.statSync(file).mtimeMs)
1257
+ } catch {
1258
+ return null
1259
+ }
1260
+ }
1261
+
1262
+ function pluginKeyOf(input) {
1263
+ const id = typeof input.pluginId === 'string' && input.pluginId.length > 0 ? input.pluginId : ''
1264
+ const dir = typeof input.dir === 'string' && input.dir.length > 0 ? path.resolve(input.dir) : ''
1265
+ if (id && dir) return `id:${id}|dir:${dir}`
1266
+ if (id) return `id:${id}`
1267
+ if (dir) return `dir:${dir}`
1268
+ if (typeof input.source === 'string') return `src:${sha1(input.source)}`
1269
+ return null
1270
+ }
1271
+
1272
+ /**
1273
+ * 保护伞门面。消费者(Router / Approver / 工具执行器)只接触这一个对象。
1274
+ *
1275
+ * @param {object} options
1276
+ * @param {{scoreSource?:Function, scoreDir?:Function}|null} options.scorer fang-scorer 服务(缺省 = 失败往坏里判)
1277
+ * @param {object} [options.policy] 见 normalizePolicy
1278
+ * @param {string|object} [options.floorTable]
1279
+ * @param {number} [options.cacheSize]
1280
+ * @param {Function} [options.now]
1281
+ * @param {Function|null} [options.audit] (record) => void,每次决策一条
1282
+ * @param {Function|null} [options.onPluginScored] (assessment) => void,插件评分落审计
1283
+ */
1284
+ export function createUmbrella(options = {}) {
1285
+ const {
1286
+ scorer = null,
1287
+ policy: rawPolicy = {},
1288
+ floorTable = DEFAULT_FLOOR_TABLE,
1289
+ cacheSize = 200,
1290
+ now = () => Date.now(),
1291
+ decisionCenter = null, audit = null,
1292
+ onPluginScored = null,
1293
+ rules: initialRules = null,
1294
+ registry = null,
1295
+ } = options
1296
+
1297
+ // 规则集:可传数组(自动建集)或 createActionRuleSet() 的实例(可注册/覆盖)。
1298
+ const ruleSet = initialRules && typeof initialRules.find === 'function' && Array.isArray(initialRules.rules)
1299
+ ? initialRules
1300
+ : createActionRuleSet(initialRules ?? DEFAULT_ACTION_RULES)
1301
+ // v2.8.0:可选的规则注册表(内置 + YAML 叠加)。提供时优先于 ruleSet。
1302
+ const activeRules = registry ?? ruleSet
1303
+
1304
+ // policy 允许是函数:插件在每次评分时都能拿到最新的 buffer root(buffer-keeper 的
1305
+ // root 是 start({dir}) 之后才知道的),而缓存仍按插件指纹失效,不因 root 变化清空。
1306
+ const policySource = typeof rawPolicy === 'function' ? rawPolicy : () => rawPolicy
1307
+ let policyCache = null
1308
+ let policyStamp = null
1309
+ function policyNow() {
1310
+ const raw = policySource() ?? {}
1311
+ const stamp = JSON.stringify(raw)
1312
+ if (policyCache !== null && stamp === policyStamp) return policyCache
1313
+ policyStamp = stamp
1314
+ policyCache = { ...normalizePolicy(raw), __normalized: true }
1315
+ return policyCache
1316
+ }
1317
+
1318
+ const floors = resolveFloorTable(floorTable)
1319
+ const maxCache = Math.max(1, Math.floor(Number(cacheSize) || 200))
1320
+ const cache = new Map()
1321
+ const grades = new Map()
1322
+ const stats = { hits: 0, misses: 0, evictions: 0, invalidations: 0, pluginScans: 0 }
1323
+
1324
+ function auditRecord(record) {
1325
+ if (typeof audit !== 'function') return { ok: true, skipped: true }
1326
+ try {
1327
+ const result = audit(record)
1328
+ if (result && result.ok === false) {
1329
+ record.auditDegraded = true
1330
+ return result
1331
+ }
1332
+ return { ok: true }
1333
+ } catch (error) {
1334
+ record.auditDegraded = true
1335
+ record.auditError = String(error?.message ?? error)
1336
+ return { ok: false, error: record.auditError }
1337
+ }
1338
+ }
1339
+
1340
+ function emitPluginScored(assessment) {
1341
+ if (typeof onPluginScored !== 'function') return
1342
+ try { onPluginScored(assessment) } catch { /* 同上 */ }
1343
+ }
1344
+
1345
+ function rememberGrade(assessment) {
1346
+ if (!assessment?.pluginId) return
1347
+ grades.set(String(assessment.pluginId), assessment.grade)
1348
+ }
1349
+
1350
+ function runScorer(input) {
1351
+ const fail = (reason) => ({
1352
+ grade: 'E', S: 100, color: LIGHT.RED, veto: false, degraded: true,
1353
+ signals: [], reasons: [reason], basis: null, dir: input.dir ?? null, entry: input.entry ?? null,
1354
+ })
1355
+ if (!scorer || (typeof scorer.scoreSource !== 'function' && typeof scorer.scoreDir !== 'function')) {
1356
+ return fail('scorer-unavailable')
1357
+ }
1358
+ const opts = { environment: input.environment }
1359
+ try {
1360
+ if (typeof input.source === 'string' && typeof scorer.scoreSource === 'function') {
1361
+ return scorer.scoreSource(input.source, input.pkg ?? {}, opts) ?? fail('scorer-empty-result')
1362
+ }
1363
+ if (typeof input.dir === 'string' && typeof scorer.scoreDir === 'function') {
1364
+ return scorer.scoreDir(input.dir, input.manifest ?? null, opts) ?? fail('scorer-empty-result')
1365
+ }
1366
+ return fail('no-scoring-input')
1367
+ } catch (e) {
1368
+ return fail(`scorer-error:${e?.message ?? e}`)
1369
+ }
1370
+ }
1371
+
1372
+ function isStale(entry) {
1373
+ if (!entry) return true
1374
+ if (entry.entryFile) {
1375
+ const current = mtimeOf(entry.entryFile)
1376
+ if (current === null) return true
1377
+ if (current !== entry.fingerprint) return true
1378
+ }
1379
+ if (entry.packageFile) {
1380
+ const currentPackage = mtimeOf(entry.packageFile)
1381
+ if (currentPackage !== entry.packageFingerprint) return true
1382
+ }
1383
+ return false
1384
+ }
1385
+
1386
+ function store(key, entry) {
1387
+ if (cache.has(key)) cache.delete(key)
1388
+ cache.set(key, entry)
1389
+ while (cache.size > maxCache) {
1390
+ const oldest = cache.keys().next().value
1391
+ cache.delete(oldest)
1392
+ stats.evictions += 1
1393
+ }
1394
+ }
1395
+
1396
+ /** 插件评分:静态证据只算一次,按入口文件 mtime(或 inline 源码 sha1)失效。 */
1397
+ function assessPlugin(input = {}) {
1398
+ const started = now()
1399
+ const key = pluginKeyOf(input)
1400
+ if (!key) {
1401
+ const failed = {
1402
+ pluginId: 'unknown', version: '0.0.0', grade: 'E', score: 100, color: LIGHT.RED,
1403
+ veto: false, degraded: true, signals: [], reasons: ['no-scoring-input'], basis: null,
1404
+ entry: null, cachedAt: started, fingerprint: null, fromCache: false,
1405
+ }
1406
+ emitPluginScored(failed)
1407
+ return failed
1408
+ }
1409
+
1410
+ if (input.refresh !== true && cache.has(key)) {
1411
+ const hit = cache.get(key)
1412
+ if (!isStale(hit)) {
1413
+ stats.hits += 1
1414
+ store(key, hit) // LRU:命中后挪到队尾
1415
+ return { ...hit.assessment, fromCache: true }
1416
+ }
1417
+ cache.delete(key)
1418
+ }
1419
+
1420
+ stats.misses += 1
1421
+ stats.pluginScans += 1
1422
+ const scored = runScorer(input)
1423
+ const entryFile = scored?.dir && scored?.entry ? path.join(scored.dir, scored.entry) : null
1424
+ const packageFile = scored?.dir ? path.join(scored.dir, 'package.json') : null
1425
+ const fingerprint = entryFile ? mtimeOf(entryFile) : sha1(String(input.source ?? ''))
1426
+ const packageFingerprint = packageFile ? mtimeOf(packageFile) : null
1427
+ const grade = normalizeGrade(scored?.grade)
1428
+ const assessment = {
1429
+ pluginId: String(input.pluginId ?? (input.dir ? path.basename(path.resolve(input.dir)) : 'inline')),
1430
+ version: String(input.version ?? input.pkg?.version ?? '0.0.0'),
1431
+ grade,
1432
+ score: Number.isFinite(Number(scored?.S)) ? Number(scored.S) : 100,
1433
+ color: scored?.color ?? GRADE_COLOR[grade] ?? LIGHT.RED,
1434
+ veto: scored?.veto === true,
1435
+ degraded: scored?.degraded === true || gradedDegraded(scored),
1436
+ signals: Array.isArray(scored?.signals) ? scored.signals.map(s => ({ id: s.id, weight: s.weight })) : [],
1437
+ reasons: Array.isArray(scored?.reasons) ? [...scored.reasons] : [],
1438
+ basis: scored?.basis ?? null,
1439
+ entry: scored?.entry ?? null,
1440
+ dir: scored?.dir ?? input.dir ?? null,
1441
+ cachedAt: started,
1442
+ fingerprint,
1443
+ durationMs: Math.max(0, now() - started),
1444
+ fromCache: false,
1445
+ }
1446
+ rememberGrade(assessment)
1447
+ store(key, { assessment, entryFile, fingerprint, packageFile, packageFingerprint })
1448
+ emitPluginScored(assessment)
1449
+ return assessment
1450
+ }
1451
+
1452
+ function gradedDegraded(scored) {
1453
+ const reasons = Array.isArray(scored?.reasons) ? scored.reasons : []
1454
+ return reasons.some(r => typeof r === 'string' && r.startsWith('scorer-'))
1455
+ }
1456
+
1457
+ function gradeFor(evidence, opts) {
1458
+ if (opts && typeof opts.grade === 'string') return { grade: normalizeGrade(opts.grade), basis: 'explicit-grade' }
1459
+ if (opts && opts.assessment && typeof opts.assessment.grade === 'string') {
1460
+ return { grade: normalizeGrade(opts.assessment.grade), basis: 'assessment' }
1461
+ }
1462
+ if (evidence && typeof evidence.pluginGrade === 'string') {
1463
+ return { grade: normalizeGrade(evidence.pluginGrade), basis: 'evidence' }
1464
+ }
1465
+ const pluginId = String((opts && opts.pluginId) ?? evidence?.pluginId ?? '')
1466
+ if (pluginId && grades.has(pluginId)) return { grade: grades.get(pluginId), basis: 'cached-plugin-grade' }
1467
+ return { grade: 'E', basis: 'unscored-fail-closed' }
1468
+ }
1469
+
1470
+ /** 动作评分:基础灯色 + 等级下限,只升不降。每次调用都重算,不缓存。 */
1471
+ function assessAction(evidence = {}, opts = {}) {
1472
+ const started = now()
1473
+ const base = baseLight(evidence, policyNow(), activeRules)
1474
+ const { grade, basis } = gradeFor(evidence, opts)
1475
+ const eff = effectiveLight(base.light, grade, { table: floors.table })
1476
+ return {
1477
+ light: eff.light,
1478
+ baseLight: eff.baseLight,
1479
+ gradeFloor: eff.gradeFloor,
1480
+ raised: eff.raised,
1481
+ pluginGrade: grade,
1482
+ gradeBasis: basis,
1483
+ matchedRule: base.matchedRule,
1484
+ category: base.category,
1485
+ tags: base.tags,
1486
+ lifecycle: base.lifecycle,
1487
+ credential: base.credential,
1488
+ trusted: base.trusted === true,
1489
+ reason: base.reason,
1490
+ evidence: base.evidence,
1491
+ tool: String(evidence?.tool ?? ''),
1492
+ target: base.target,
1493
+ path: base.path,
1494
+ durationMs: Math.max(0, now() - started),
1495
+ fromCache: false,
1496
+ }
1497
+ }
1498
+
1499
+ /** 动作评分 + 决策矩阵 + 审计(一条 umbrella-decision)。 */
1500
+ function decideAction(evidence = {}, opts = {}) {
1501
+ const started = now()
1502
+ const assessment = assessAction(evidence, opts)
1503
+ const decision = decide(assessment)
1504
+ const record = {
1505
+ kind: AUDIT.UMBRELLA_DECISION,
1506
+ at: now(),
1507
+ tool: assessment.tool || null,
1508
+ target: assessment.target?.value ? { kind: assessment.target.kind, value: assessment.target.value } : null,
1509
+ pluginId: String((opts && opts.pluginId) ?? evidence?.pluginId ?? '') || null,
1510
+ pluginGrade: assessment.pluginGrade,
1511
+ gradeBasis: assessment.gradeBasis,
1512
+ baseLight: assessment.baseLight,
1513
+ light: assessment.light,
1514
+ matchedRule: assessment.matchedRule,
1515
+ category: assessment.category,
1516
+ trusted: assessment.trusted === true,
1517
+ evidence: assessment.evidence,
1518
+ decision: decision.decision,
1519
+ severity: decision.severity,
1520
+ durationMs: Math.max(0, now() - started),
1521
+ }
1522
+ const auditState = auditRecord(record)
1523
+ let decisionTicketId = null
1524
+ let decisionTicketPromise = null
1525
+ // Umbrella's ASK is exactly the yellow/red attention event the decision center
1526
+ // exists for. Keep it best-effort: a missing decision center must never change
1527
+ // the umbrella decision itself.
1528
+ if (decision.decision === 'ask') {
1529
+ try {
1530
+ const center = typeof decisionCenter === 'function' ? decisionCenter() : decisionCenter
1531
+ if (center && typeof center.submit === 'function') {
1532
+ const level = assessment.light === 'red' || decision.severity === 'critical' ? 'red' : 'yellow'
1533
+ const submitted = center.submit({
1534
+ level,
1535
+ title: `保护伞请求确认:${assessment.matchedRule ?? 'unknown-action'}`,
1536
+ description: `${assessment.tool || 'tool'} → ${assessment.target?.kind || 'none'}:${String(assessment.target?.value || '').slice(0, 500)}`,
1537
+ source: 'System',
1538
+ // 同一 action 的重复 ASK 在决策中心 pending 期内合并,避免重复开票。
1539
+ dedupeKey: `umbrella:${assessment.tool ?? ''}:${assessment.target?.kind ?? ''}:${String(assessment.target?.value ?? '').slice(0, 120)}:${assessment.matchedRule ?? ''}`.slice(0, 200),
1540
+ actions: [
1541
+ { id: 'ack', label: '知悉', type: 'default' },
1542
+ { id: 'ignore', label: '忽略', type: 'default' },
1543
+ ],
1544
+ metadata: {
1545
+ tool: assessment.tool || null,
1546
+ target: assessment.target ?? null,
1547
+ light: assessment.light,
1548
+ matchedRule: assessment.matchedRule ?? null,
1549
+ category: assessment.category ?? null,
1550
+ },
1551
+ })
1552
+ const link = (result) => {
1553
+ if (result?.ok === true && result.ticket?.id) {
1554
+ decisionTicketId = result.ticket.id
1555
+ record.decisionTicketId = decisionTicketId
1556
+ }
1557
+ return result ?? null
1558
+ }
1559
+ // fork 代理的 submit 是异步(outbox/HTTP);不能丢 Promise,否则 fork 里
1560
+ // decisionTicketId 永远为 null 且可能出现 unhandled rejection。
1561
+ if (submitted && typeof submitted.then === 'function') {
1562
+ decisionTicketPromise = Promise.resolve(submitted).then(link).catch(() => null)
1563
+ } else {
1564
+ link(submitted)
1565
+ }
1566
+ }
1567
+ } catch { /* decision-center is optional */ }
1568
+ }
1569
+ record.decisionTicketId = decisionTicketId
1570
+ return {
1571
+ ...assessment,
1572
+ decision: decision.decision,
1573
+ severity: decision.severity,
1574
+ decisionTicketId,
1575
+ decisionTicketPromise,
1576
+ decisionRecord: record,
1577
+ auditDegraded: auditState?.ok === false || record.auditDegraded === true,
1578
+ }
1579
+ }
1580
+
1581
+ function cacheStats() {
1582
+ return { ...stats, size: cache.size, capacity: maxCache }
1583
+ }
1584
+
1585
+ function invalidate(keyOrPluginId) {
1586
+ const text = String(keyOrPluginId ?? '')
1587
+ let removed = 0
1588
+ for (const key of [...cache.keys()]) {
1589
+ if (key === text
1590
+ || key.endsWith(`:${text}`)
1591
+ || key.startsWith(`id:${text}|`)
1592
+ || key === `id:${text}`) {
1593
+ cache.delete(key)
1594
+ removed += 1
1595
+ }
1596
+ }
1597
+ if (grades.delete(text)) removed += 1
1598
+ stats.invalidations += removed
1599
+ return removed
1600
+ }
1601
+
1602
+ function clearCache() {
1603
+ const size = cache.size
1604
+ cache.clear()
1605
+ grades.clear()
1606
+ stats.invalidations += size
1607
+ return size
1608
+ }
1609
+
1610
+ return {
1611
+ get policy() {
1612
+ return policyNow()
1613
+ },
1614
+ floorTable: floors.name,
1615
+ floorDegraded: floors.degraded,
1616
+ grades: floors.table,
1617
+ assessPlugin,
1618
+ assessAction,
1619
+ decideAction,
1620
+ baseLight: (evidence) => baseLight(evidence, policyNow(), activeRules),
1621
+ gradeFloor: (grade) => gradeFloor(grade, { table: floors.table }),
1622
+ effectiveLight: (base, grade) => effectiveLight(base, grade, { table: floors.table }),
1623
+ cachedGrade: (pluginId) => grades.get(String(pluginId)) ?? null,
1624
+ cacheStats,
1625
+ invalidate,
1626
+ clearCache,
1627
+ // 规则表:只读视图 + 唯一的三种变更入口(都做形状校验并以 id 定位)
1628
+ get rules() {
1629
+ return activeRules.rules.slice()
1630
+ },
1631
+ get ruleRegistry() {
1632
+ return registry
1633
+ },
1634
+ ruleIds: () => activeRules.ids(),
1635
+ registerRule: (rule, opts) => activeRules.register(rule, opts),
1636
+ overrideRule: (id, patch) => activeRules.override(id, patch),
1637
+ removeRule: (id) => activeRules.remove(id),
1638
+ resetRules: () => activeRules.reset(),
1639
+ }
1640
+ }
1641
+
1642
+ export const _internals = {
1643
+ compilePatterns,
1644
+ withinPath,
1645
+ resolvePathValue,
1646
+ networkAllowed,
1647
+ execLooksReadOnly,
1648
+ resolveEvidencePath,
1649
+ pluginKeyOf,
1650
+ sha1,
1651
+ mtimeOf,
1652
+ firstPattern,
1653
+ writeTargetsOf,
1654
+ readTargetsOf,
1655
+ DEFAULT_CREDENTIAL_PATTERNS,
1656
+ DEFAULT_DESTRUCTIVE_PATTERNS,
1657
+ DEFAULT_LIFECYCLE_PATTERNS,
1658
+ DEFAULT_LIFECYCLE_TOOLS,
1659
+ READ_ONLY_EXEC_PATTERNS,
1660
+ TOOL_MODE,
1661
+ }