@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.
- 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/APPROVAL-DRILL.md +42 -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/approval-drill.mjs +111 -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 +582 -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 +3979 -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/approval-drill.mjs +21 -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 +425 -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,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
|
+
}
|