@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,702 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
|
|
3
|
+
// dsh-Fang 2.3 修复子插件 — 自检 / 证据 / G1–G12 门禁 / 补丁建议(契约:docs/CONTRACTS.md 5.5)。
|
|
4
|
+
//
|
|
5
|
+
// 硬边界:**Healer 不放行,也不写盘**。本插件只读取插件目录并产出结构化证据与
|
|
6
|
+
// 建议补丁;放行权在 approver(见 CONTRACTS.md §1 硬约束 5)。
|
|
7
|
+
//
|
|
8
|
+
// 门禁设计原则(移植自 dsh-hot-update/packages/ci-gate):能确定性判定的才叫门禁;
|
|
9
|
+
// 拿不到证据的判 UNKNOWN 并**默认阻断**(fail-closed,可用 Config.blockingOnUnknown 关闭)。
|
|
10
|
+
//
|
|
11
|
+
// G1–G12 分配(v1.0.0):
|
|
12
|
+
// G1 manifest 字段 name/version/type=module
|
|
13
|
+
// G2 入口存在 可解析的 entry 文件
|
|
14
|
+
// G3 无 eval/new Function 静态源码扫描
|
|
15
|
+
// G4 无裸 child_process import 层扫描(局限:不做调用层)
|
|
16
|
+
// G5 依赖 allow-list dependencies ⊆ { @deepseek-ai/* } ∪ config.allowlist
|
|
17
|
+
// G6 资源闭合 裸定时器必须与 ctx.effect 同时出现(近似判定)
|
|
18
|
+
// G7 豁免诚实性 acceptRisk=true 必须带 {gate,reason} 豁免清单
|
|
19
|
+
// G8 构建脚本闭合 安装期 script 必须列入 dsh.buildApproved
|
|
20
|
+
// G9 秘密卫生 硬编码凭据模式
|
|
21
|
+
// G10 bundle patch 存在 dsh.bundle.patch 声明与文件一致(非 bundle 则 SKIP)
|
|
22
|
+
// G11 激活证据 facts.application === 'applied' 且证据不旧于入口
|
|
23
|
+
// G12 分数可复算 facts.signals/score 重算一致
|
|
24
|
+
// ↑ G11/G12 的 host facts 只在 load 之后才存在:静态预检(stage='load',默认)按 ci-gate
|
|
25
|
+
// G7 先例判 deferred/SKIP;stage='promote' 时必须给 facts,否则 UNKNOWN 阻断(fail-closed)。
|
|
26
|
+
//
|
|
27
|
+
// 证据来源:目录读取全部走 node:fs 只读;无任何写操作(suggestFix 也不写)。
|
|
28
|
+
import fs from 'node:fs'
|
|
29
|
+
import path from 'node:path'
|
|
30
|
+
import { SERVICE, AUDIT, z, readJson, listFiles, sha256, exists } from '../kernel/index.mjs'
|
|
31
|
+
import { scanSource, importsToSignals } from './imports.mjs'
|
|
32
|
+
import { scoreScan, familyOf, entryCandidates, resolvePluginFile } from './scorer.mjs'
|
|
33
|
+
import { roleGate } from '../kernel/index.mjs'
|
|
34
|
+
|
|
35
|
+
export const name = 'fang-healer'
|
|
36
|
+
export const inject = []
|
|
37
|
+
|
|
38
|
+
export const Config = z.object({
|
|
39
|
+
blockingOnUnknown: z.boolean().default(true)
|
|
40
|
+
.description('UNKNOWN 是否阻断(默认 true;关闭后拿不到证据的门禁不阻断),与 ci-gate 语义一致'),
|
|
41
|
+
reservedIds: z.array(z.string()).default([])
|
|
42
|
+
.description('保留插件 id(G5 之外的命名冲突检查;v1.0.0 只记录在 policy)'),
|
|
43
|
+
allowlist: z.array(z.string()).default([])
|
|
44
|
+
.description('额外允许的依赖名(默认放行 @deepseek-ai/*)'),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** 安装期构建脚本(与 ci-gate 一致)。 */
|
|
48
|
+
export const BUILD_SCRIPTS = ['preinstall', 'install', 'postinstall', 'prepare']
|
|
49
|
+
|
|
50
|
+
/** 默认放行的依赖前缀:宿主提供的 @deepseek-ai/* 包。 */
|
|
51
|
+
export const DEFAULT_ALLOWLIST_PREFIXES = ['@deepseek-ai/']
|
|
52
|
+
|
|
53
|
+
/** 硬编码凭据模式(与 ci-gate 一致)。 */
|
|
54
|
+
export const SECRET_PATTERNS = [
|
|
55
|
+
{ id: 'aws-access-key', re: /AKIA[0-9A-Z]{16}/ },
|
|
56
|
+
{ id: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{20,}/ },
|
|
57
|
+
{ id: 'openai-key', re: /sk-[A-Za-z0-9]{20,}/ },
|
|
58
|
+
{ id: 'pem-private-key', re: /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/ },
|
|
59
|
+
{ id: 'slack-token', re: /xox[baprs]-[A-Za-z0-9-]{10,}/ },
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const CHILD_PROCESS = new Set(['child_process', 'node:child_process'])
|
|
63
|
+
const TIMER_RE = /(^|[^.\w$])(setInterval|setTimeout|setImmediate)\s*\(/g
|
|
64
|
+
const EVAL_RES = [/(^|[^.\w$])eval\s*\(/g, /\bnew\s+Function\s*\(/g]
|
|
65
|
+
const EFFECT_RE = /ctx\.effect\s*\(/
|
|
66
|
+
const PROVIDE_RE = /ctx\.provide\s*\(/
|
|
67
|
+
const INJECT_RE = /export\s+const\s+inject\s*=\s*(\[[^\]]*\])/
|
|
68
|
+
|
|
69
|
+
/** v3.3.0:主城门禁 blocker → 可执行的修复动作(只给建议,不写盘)。 */
|
|
70
|
+
export const GATE_REPAIR_HINTS = Object.freeze({
|
|
71
|
+
'source: semver': 'fix package.json version to a semver x.y.z',
|
|
72
|
+
'source: kernel FANG_VERSION': 'update src/kernel/types.mjs FANG_VERSION to the package version',
|
|
73
|
+
'source: CHANGELOG section': 'add a "## v<version>" section to docs/CHANGELOG.md',
|
|
74
|
+
'source: AUDIT file': 'add docs/AUDIT-v<version>.md with the release evidence',
|
|
75
|
+
'source: required guard files': 'restore the missing crash-safety files before building',
|
|
76
|
+
'source: vendored contract bytes': 're-sync src/contract from the pinned @twinsearth/dsh-fang-contract version',
|
|
77
|
+
'source: gate wired into build': 'call tools/gate/main-city.mjs from tools/release/build.mjs',
|
|
78
|
+
'source: gate wired into publish': 'call tools/gate/main-city.mjs from tools/release/publish-npm.mjs',
|
|
79
|
+
'package: client half': 'restore dsh.client.platform=web in package.json',
|
|
80
|
+
'package: bundle manifest': 'restore dsh.bundle.patch and the referenced patch file',
|
|
81
|
+
'artifact: tarball sha256': 'rebuild the artifact; never publish a tarball whose sha256 differs from manifest',
|
|
82
|
+
'artifact: sha256 sidecar': 'rebuild so the .sha256 sidecar is regenerated with the tarball',
|
|
83
|
+
'artifact: required files inside tarball': 'add the missing guard/gate files to package.json files and rebuild',
|
|
84
|
+
'publish: tag matches version': 'recreate the annotated tag as v<package version>',
|
|
85
|
+
'publish: registry version not already used': 'bump the version; published versions are immutable',
|
|
86
|
+
'profile: inside home/profiles': 'point --home/--profile at $DSH_HOME/profiles; never write outside it',
|
|
87
|
+
'profile: no duplicate bundles': 'remove duplicate entries from dsh.profile.bundles',
|
|
88
|
+
'profile: patch parses': 'repair cordis.patch.yml indentation or restore the previous patch from .gate-backups',
|
|
89
|
+
'profile: backup available': 'run gate --repair (creates .gate-backups/<stamp>/) before changing the profile',
|
|
90
|
+
'runtime: no live host on this profile': 'fully exit the DSH client before install/deploy',
|
|
91
|
+
'runtime: restart safe-mode clear': 'investigate restart safe-mode; clear only via fangRestart.clearSafeMode({by,note})',
|
|
92
|
+
'runtime: no active crash loop': 'inspect runtime-crash.jsonl and the restart guard; do not redeploy until the loop is understood',
|
|
93
|
+
'audit: hash chain verifies': 'restore audit.jsonl from backup and investigate tampering; never append to a broken chain',
|
|
94
|
+
'disk: free space available': 'free disk space before creating profiles or installing packages',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
export function gateRepairPlan(findings) {
|
|
98
|
+
return (Array.isArray(findings) ? findings : [])
|
|
99
|
+
.filter((item) => item && item.ok !== true)
|
|
100
|
+
.map((item) => ({
|
|
101
|
+
id: item.id,
|
|
102
|
+
severity: item.severity ?? 'blocker',
|
|
103
|
+
hint: GATE_REPAIR_HINTS[item.id] ?? item.repair ?? 'inspect the gate report and fix the underlying cause; do not bypass',
|
|
104
|
+
}))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const GATE_IDS = ['G1', 'G2', 'G3', 'G4', 'G5', 'G6', 'G7', 'G8', 'G9', 'G10', 'G11', 'G12']
|
|
108
|
+
|
|
109
|
+
function safeGet(ctx, key) {
|
|
110
|
+
try { return ctx?.get?.(key) } catch { return undefined }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function lineOf(src, index) {
|
|
114
|
+
return String(src ?? '').slice(0, index).split('\n').length
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function findMatches(src, re) {
|
|
118
|
+
const out = []
|
|
119
|
+
if (typeof src !== 'string') return out
|
|
120
|
+
re.lastIndex = 0
|
|
121
|
+
let m
|
|
122
|
+
while ((m = re.exec(src)) !== null) {
|
|
123
|
+
out.push({ index: m.index, line: lineOf(src, m.index), text: m[0] })
|
|
124
|
+
if (m[0].length === 0) re.lastIndex += 1
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 裸定时器调用点(近似:只看文本,不建 AST)。 */
|
|
130
|
+
export function findTimers(src) {
|
|
131
|
+
return findMatches(src, TIMER_RE).map(m => ({ fn: m.text.trim().replace(/\s*\($/, ''), line: m.line }))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** eval()/new Function() 调用点。 */
|
|
135
|
+
export function findEvalSites(src) {
|
|
136
|
+
const sites = []
|
|
137
|
+
for (const re of EVAL_RES) {
|
|
138
|
+
for (const m of findMatches(src, re)) sites.push({ line: m.line, text: m.text.trim() })
|
|
139
|
+
}
|
|
140
|
+
return sites.sort((a, b) => a.line - b.line)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function findSecrets(src) {
|
|
144
|
+
const text = typeof src === 'string' ? src : ''
|
|
145
|
+
return SECRET_PATTERNS.filter(p => p.re.test(text)).map(p => p.id)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseInject(src) {
|
|
149
|
+
const m = INJECT_RE.exec(String(src ?? ''))
|
|
150
|
+
if (!m) return { declared: false, empty: false, raw: null }
|
|
151
|
+
const raw = m[1]
|
|
152
|
+
const empty = raw.replace(/\s+/g, '') === '[]'
|
|
153
|
+
return { declared: true, empty, raw }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function depsOf(pkg) {
|
|
157
|
+
const deps = pkg?.dependencies
|
|
158
|
+
return deps && typeof deps === 'object' ? Object.keys(deps) : []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function depAllowed(dep, cfg) {
|
|
162
|
+
const name = String(dep)
|
|
163
|
+
if (name.startsWith('node:') || name === 'fs' || name === 'path') return true // 防御性:builtin 不应出现在 dependencies
|
|
164
|
+
if (DEFAULT_ALLOWLIST_PREFIXES.some(p => name.startsWith(p))) return true
|
|
165
|
+
return Array.isArray(cfg.allowlist) && cfg.allowlist.includes(name)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 读取插件目录模型(只读)。
|
|
170
|
+
* @returns {{abs:string, pkg:object|null, manifest:object|null, entry:object|null, files:string[], scan:object|null}}
|
|
171
|
+
*/
|
|
172
|
+
export function readModel(dir, opts = {}) {
|
|
173
|
+
const abs = path.resolve(String(dir ?? ''))
|
|
174
|
+
const pkgFile = resolvePluginFile(abs, 'package.json')
|
|
175
|
+
const pkg = pkgFile ? readJson(pkgFile, null) : null
|
|
176
|
+
const manifestFile = opts.manifest === undefined ? resolvePluginFile(abs, 'manifest.json') : null
|
|
177
|
+
const manifest = opts.manifest ?? (manifestFile ? readJson(manifestFile, null) : null)
|
|
178
|
+
let entry = null
|
|
179
|
+
for (const rel of entryCandidates(pkg ?? {}, manifest)) {
|
|
180
|
+
const file = resolvePluginFile(abs, rel)
|
|
181
|
+
if (!file) continue
|
|
182
|
+
try {
|
|
183
|
+
const st = fs.statSync(file)
|
|
184
|
+
if (!st.isFile()) continue
|
|
185
|
+
const src = fs.readFileSync(file, 'utf8')
|
|
186
|
+
entry = { rel, file, src, bytes: st.size, mtimeMs: st.mtimeMs, sha256: sha256(src) }
|
|
187
|
+
break
|
|
188
|
+
} catch { /* 试下一个候选 */ }
|
|
189
|
+
}
|
|
190
|
+
let scan = null
|
|
191
|
+
if (entry) {
|
|
192
|
+
try { scan = scanSource(entry.src) } catch { scan = { imports: [], warnings: [{ reason: 'scan-error' }], error: true } }
|
|
193
|
+
}
|
|
194
|
+
return { abs, pkg, manifest, entry, files: listFiles(abs), scan }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 目录的静态评分等级(复用 scorer 的同一套数学)。 */
|
|
198
|
+
function gradeOfModel(model) {
|
|
199
|
+
if (!model.entry) return { grade: 'E', S: 100, veto: false, degraded: true, reasons: ['no-entry-file'], signals: [] }
|
|
200
|
+
const mapped = importsToSignals(model.scan, model.pkg ?? {})
|
|
201
|
+
const scored = scoreScan(mapped.signals, { ...(model.pkg ?? {}), __parseErrors: (model.scan?.warnings ?? []).length })
|
|
202
|
+
return { ...scored, signals: mapped.signals.map(s => ({ id: s.id, weight: s.weight })) }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─────────────────────────────── selfCheck ───────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 静态自检(契约 5.5):ctx.effect 包裹 / 依赖 allow-list / inject 最小化 /
|
|
209
|
+
* 无裸定时器 / 入口存在。只读、同步、不写盘。
|
|
210
|
+
*/
|
|
211
|
+
export function selfCheckOf(model, cfg) {
|
|
212
|
+
const findings = []
|
|
213
|
+
const add = (id, level, message, evidence = null) => findings.push({ id, level, message, evidence })
|
|
214
|
+
const src = model.entry?.src ?? ''
|
|
215
|
+
const effect = EFFECT_RE.test(src)
|
|
216
|
+
const provide = PROVIDE_RE.test(src)
|
|
217
|
+
|
|
218
|
+
if (!model.entry) {
|
|
219
|
+
add('entry-missing', 'block', `no resolvable entry file under ${model.abs}`)
|
|
220
|
+
} else {
|
|
221
|
+
add('entry-ok', 'info', `entry=${model.entry.rel}`, { bytes: model.entry.bytes, sha256: model.entry.sha256 })
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!model.pkg) {
|
|
225
|
+
add('manifest-missing', 'block', 'package.json missing or unreadable')
|
|
226
|
+
} else {
|
|
227
|
+
const deps = depsOf(model.pkg)
|
|
228
|
+
const bad = deps.filter(d => !depAllowed(d, cfg))
|
|
229
|
+
if (bad.length > 0) {
|
|
230
|
+
add('deps-not-allowed', 'block', `dependencies outside allow-list: ${bad.join(', ')}`, { deps: bad })
|
|
231
|
+
} else {
|
|
232
|
+
add('deps-ok', 'info', deps.length > 0 ? `dependencies allow-listed: ${deps.join(', ')}` : 'no runtime dependencies', { deps })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!effect) add('no-effect-wrap', 'block', 'ctx.effect(...) not found — services/timers must be disposable')
|
|
237
|
+
else add('effect-ok', 'info', 'ctx.effect(...) present')
|
|
238
|
+
if (!provide) add('no-provide', 'block', 'ctx.provide(...) not found — plugin provides no service')
|
|
239
|
+
|
|
240
|
+
const inject = parseInject(src)
|
|
241
|
+
if (!inject.declared) add('inject-missing', 'block', 'module must export inject (hard deps only; middle-tier: inject = [])')
|
|
242
|
+
else if (!inject.empty) add('inject-not-minimal', 'warn', `inject is not empty (${inject.raw}) — declare only hard deps`, { inject: inject.raw })
|
|
243
|
+
else add('inject-minimal', 'info', 'inject = []')
|
|
244
|
+
|
|
245
|
+
const timers = findTimers(src)
|
|
246
|
+
if (timers.length === 0) add('timer-clean', 'info', 'no bare timers')
|
|
247
|
+
else if (!effect) add('bare-timer', 'block', `timers without any ctx.effect cleanup: ${timers.map(t => `${t.fn}@${t.line}`).join(',')}`, { timers })
|
|
248
|
+
else add('bare-timer-unwrapped', 'warn', 'timers found; ctx.effect present but static scan cannot prove each timer is registered inside it', { timers })
|
|
249
|
+
|
|
250
|
+
if (model.pkg?.dsh?.hotReload === false) {
|
|
251
|
+
add('hotreload-veto', 'warn', 'package.json dsh.hotReload === false → static grade is E (veto)')
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const ok = !findings.some(f => f.level === 'block')
|
|
255
|
+
return { ok, grade: gradeOfModel(model).grade, dir: model.abs, findings }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─────────────────────────────── gates ───────────────────────────────
|
|
259
|
+
|
|
260
|
+
function makeGates(model, cfg, opts = {}) {
|
|
261
|
+
const gates = []
|
|
262
|
+
const facts = opts.facts ?? null
|
|
263
|
+
// 'load' = 装载前静态预检(默认,host facts 尚不存在);'promote' = 晋升裁决(必须 host facts)。
|
|
264
|
+
const stage = opts.stage === 'promote' ? 'promote' : 'load'
|
|
265
|
+
const blockingOnUnknown = cfg.blockingOnUnknown !== false
|
|
266
|
+
const add = (id, gate, verdict, detail, extra = {}) => {
|
|
267
|
+
const ok = verdict === 'pass'
|
|
268
|
+
|| verdict === 'skip'
|
|
269
|
+
|| (verdict === 'unknown' && !blockingOnUnknown)
|
|
270
|
+
gates.push({ id, gate, verdict, ok, detail, ...extra })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const pkg = model.pkg
|
|
274
|
+
const src = model.entry?.src ?? ''
|
|
275
|
+
const hasSrc = typeof model.entry?.src === 'string'
|
|
276
|
+
|
|
277
|
+
// G1 manifest 字段
|
|
278
|
+
if (!pkg || typeof pkg !== 'object') {
|
|
279
|
+
add('G1', 'manifest-fields', 'fail', 'package.json missing/unreadable')
|
|
280
|
+
} else if (typeof pkg.name !== 'string' || pkg.name.trim() === '') {
|
|
281
|
+
add('G1', 'manifest-fields', 'fail', 'missing package.json name')
|
|
282
|
+
} else if (typeof pkg.version !== 'string' || pkg.version.trim() === '') {
|
|
283
|
+
add('G1', 'manifest-fields', 'fail', `${pkg.name}: missing version`)
|
|
284
|
+
} else if (pkg.type !== undefined && pkg.type !== 'module') {
|
|
285
|
+
add('G1', 'manifest-fields', 'fail', `${pkg.name}@${pkg.version}: type must be "module" (got ${JSON.stringify(pkg.type)})`)
|
|
286
|
+
} else {
|
|
287
|
+
add('G1', 'manifest-fields', 'pass', `${pkg.name}@${pkg.version} (type=${pkg.type ?? 'module'})`)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// G2 入口存在
|
|
291
|
+
if (model.entry) add('G2', 'entry-exists', 'pass', `${model.entry.rel} (${model.entry.bytes}B)`)
|
|
292
|
+
else add('G2', 'entry-exists', 'fail', 'no entry file matched exports/module/main/index.js')
|
|
293
|
+
|
|
294
|
+
// G3 无 eval/new Function
|
|
295
|
+
if (!hasSrc) add('G3', 'static-danger', 'unknown', 'no entry source to inspect')
|
|
296
|
+
else {
|
|
297
|
+
const sites = findEvalSites(src)
|
|
298
|
+
if (sites.length > 0) add('G3', 'static-danger', 'fail', `eval()/new Function() at line(s) ${sites.map(s => s.line).join(',')}`, { sites })
|
|
299
|
+
else add('G3', 'static-danger', 'pass', 'no eval()/new Function() found')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// G4 无裸 child_process(import 层;不做调用层)
|
|
303
|
+
if (!model.scan) add('G4', 'no-bare-child-process', 'unknown', 'no import scan (entry unreadable)')
|
|
304
|
+
else {
|
|
305
|
+
const hits = model.scan.imports.filter(i => CHILD_PROCESS.has(i.specifier))
|
|
306
|
+
if (hits.length > 0) {
|
|
307
|
+
add('G4', 'no-bare-child-process', 'fail', `child_process imported at line(s) ${hits.map(h => h.line).join(',')}`, { imports: hits })
|
|
308
|
+
} else {
|
|
309
|
+
add('G4', 'no-bare-child-process', 'pass', 'no child_process import (import-layer only)')
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// G5 依赖 allow-list
|
|
314
|
+
if (!pkg || typeof pkg !== 'object') {
|
|
315
|
+
add('G5', 'deps-allowlist', 'unknown', 'no manifest to read dependencies from')
|
|
316
|
+
} else {
|
|
317
|
+
const deps = depsOf(pkg)
|
|
318
|
+
const bad = deps.filter(d => !depAllowed(d, cfg))
|
|
319
|
+
add('G5', 'deps-allowlist', bad.length ? 'fail' : 'pass',
|
|
320
|
+
bad.length
|
|
321
|
+
? `not allow-listed: ${bad.join(', ')} (allowed: @deepseek-ai/* + config.allowlist)`
|
|
322
|
+
: (deps.length ? `allow-listed: ${deps.join(', ')}` : 'no runtime dependencies'),
|
|
323
|
+
{ deps, disallowed: bad })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// G6 资源闭合(裸定时器必须有 ctx.effect;静态近似)
|
|
327
|
+
if (!hasSrc) add('G6', 'resource-containment', 'unknown', 'no entry source to inspect')
|
|
328
|
+
else {
|
|
329
|
+
const timers = findTimers(src)
|
|
330
|
+
const effect = EFFECT_RE.test(src)
|
|
331
|
+
if (timers.length === 0) add('G6', 'resource-containment', 'pass', 'no bare timers')
|
|
332
|
+
else if (!effect) add('G6', 'resource-containment', 'fail', `timers without ctx.effect: ${timers.map(t => `${t.fn}@${t.line}`).join(',')}`, { timers })
|
|
333
|
+
else add('G6', 'resource-containment', 'pass', `timer(s) present with ctx.effect (approximate): ${timers.map(t => `@${t.line}`).join(',')}`, { approximate: true, timers })
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// G7 豁免诚实性
|
|
337
|
+
if (pkg?.dsh?.acceptRisk === true) {
|
|
338
|
+
const exempted = pkg?.dsh?.exempted
|
|
339
|
+
const honest = Array.isArray(exempted) && exempted.length > 0
|
|
340
|
+
&& exempted.every(e => e && typeof e.gate === 'string' && String(e.reason ?? '').trim() !== '')
|
|
341
|
+
add('G7', 'exemption-honesty', honest ? 'pass' : 'fail',
|
|
342
|
+
honest ? `${exempted.length} explicit exemption(s)` : 'dsh.acceptRisk=true without explicit {gate,reason} exemptions')
|
|
343
|
+
} else {
|
|
344
|
+
add('G7', 'exemption-honesty', 'pass', 'no acceptRisk claim')
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// G8 构建脚本闭合
|
|
348
|
+
const scripts = pkg?.scripts ?? {}
|
|
349
|
+
const present = BUILD_SCRIPTS.filter(s => typeof scripts[s] === 'string')
|
|
350
|
+
if (present.length === 0) add('G8', 'build-scripts', 'pass', 'no install-time build scripts')
|
|
351
|
+
else {
|
|
352
|
+
const approved = Array.isArray(pkg?.dsh?.buildApproved) ? pkg.dsh.buildApproved : []
|
|
353
|
+
const missing = present.filter(s => !approved.includes(s))
|
|
354
|
+
add('G8', 'build-scripts', missing.length ? 'fail' : 'pass',
|
|
355
|
+
missing.length ? `unapproved install scripts: ${missing.join(', ')}` : `approved: ${present.join(', ')}`,
|
|
356
|
+
{ present, missing })
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// G9 秘密卫生
|
|
360
|
+
if (!hasSrc) add('G9', 'secret-hygiene', 'unknown', 'no entry source to inspect')
|
|
361
|
+
else {
|
|
362
|
+
const hits = findSecrets(src)
|
|
363
|
+
add('G9', 'secret-hygiene', hits.length ? 'fail' : 'pass', hits.length ? `hardcoded secret pattern(s): ${hits.join(', ')}` : 'clean', { hits })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// G10 bundle patch 声明与文件一致
|
|
367
|
+
const patchRel = pkg?.dsh?.bundle?.patch
|
|
368
|
+
if (typeof patchRel !== 'string' || patchRel === '') {
|
|
369
|
+
add('G10', 'bundle-patch', 'skip', 'not a bundle (no dsh.bundle.patch)')
|
|
370
|
+
} else {
|
|
371
|
+
// The declared patch is plugin-relative and must stay inside the plugin root.
|
|
372
|
+
const patchFile = resolvePluginFile(model.abs, patchRel)
|
|
373
|
+
const ok = patchFile !== null
|
|
374
|
+
add('G10', 'bundle-patch', ok ? 'pass' : 'fail',
|
|
375
|
+
ok ? patchRel : `declared bundle patch missing/unsafe: ${patchRel}`, { patch: patchRel })
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// G11 激活证据:静态预检查看不了激活(load 之后才可观测)→ deferred/SKIP(ci-gate G7 同构);
|
|
379
|
+
// stage='promote' 时必须给出 host facts,否则 UNKNOWN 阻断(fail-closed)。
|
|
380
|
+
if (stage === 'load' && !facts) {
|
|
381
|
+
add('G11', 'activation-evidence', 'skip', 'deferred: activation is observed only after load')
|
|
382
|
+
} else if (!facts || typeof facts !== 'object') {
|
|
383
|
+
add('G11', 'activation-evidence', 'unknown', 'no host facts (activation not observed)')
|
|
384
|
+
} else if (facts.application !== 'applied') {
|
|
385
|
+
add('G11', 'activation-evidence', 'fail', `facts.application=${JSON.stringify(facts.application ?? null)} (expected "applied")`)
|
|
386
|
+
} else if (!Number.isFinite(facts.observedAt) || facts.observedAt <= 0) {
|
|
387
|
+
add('G11', 'activation-evidence', 'unknown', 'facts.observedAt missing/invalid; freshness cannot be established')
|
|
388
|
+
} else if (model.entry && facts.observedAt < model.entry.mtimeMs) {
|
|
389
|
+
add('G11', 'activation-evidence', 'fail', `facts stale: observedAt=${facts.observedAt} < entry.mtimeMs=${model.entry.mtimeMs}`)
|
|
390
|
+
} else {
|
|
391
|
+
add('G11', 'activation-evidence', 'pass', 'facts.application=applied and fresh enough')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// G12 分数可复算:同样只在有 host facts 时才可判定。
|
|
395
|
+
if (stage === 'load' && (!facts || !Array.isArray(facts.signals) || typeof facts.score !== 'number')) {
|
|
396
|
+
add('G12', 'score-reproducible', 'skip', 'deferred: persisted facts exist only after load')
|
|
397
|
+
} else if (!facts || !Array.isArray(facts.signals) || typeof facts.score !== 'number') {
|
|
398
|
+
add('G12', 'score-reproducible', 'unknown', 'no persisted facts.signals/score')
|
|
399
|
+
} else {
|
|
400
|
+
const recomputed = scoreScan(facts.signals, { dsh: pkg?.dsh }).S
|
|
401
|
+
add('G12', 'score-reproducible', recomputed === facts.score ? 'pass' : 'fail',
|
|
402
|
+
`stored=${facts.score} recomputed=${recomputed}`)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const count = (v) => gates.filter(g => g.verdict === v).length
|
|
406
|
+
const summary = {
|
|
407
|
+
total: gates.length,
|
|
408
|
+
pass: count('pass'),
|
|
409
|
+
fail: count('fail'),
|
|
410
|
+
unknown: count('unknown'),
|
|
411
|
+
skip: count('skip'),
|
|
412
|
+
}
|
|
413
|
+
return {
|
|
414
|
+
pass: gates.every(g => g.ok),
|
|
415
|
+
gates,
|
|
416
|
+
summary,
|
|
417
|
+
blockingOnUnknown,
|
|
418
|
+
dir: model.abs,
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ─────────────────────────────── evidence ───────────────────────────────
|
|
423
|
+
|
|
424
|
+
function evidenceOf(model, cfg) {
|
|
425
|
+
const scan = model.scan ?? { imports: [], warnings: [] }
|
|
426
|
+
const mapped = model.entry ? importsToSignals(scan, model.pkg ?? {}) : { signals: [] }
|
|
427
|
+
const self = selfCheckOf(model, cfg)
|
|
428
|
+
const gs = makeGates(model, cfg, {})
|
|
429
|
+
return {
|
|
430
|
+
plugin: model.pkg
|
|
431
|
+
? {
|
|
432
|
+
name: model.pkg.name ?? null,
|
|
433
|
+
version: model.pkg.version ?? null,
|
|
434
|
+
type: model.pkg.type ?? null,
|
|
435
|
+
dsh: model.pkg.dsh ?? null,
|
|
436
|
+
dependencies: model.pkg.dependencies ?? {},
|
|
437
|
+
}
|
|
438
|
+
: null,
|
|
439
|
+
entry: model.entry
|
|
440
|
+
? { file: model.entry.file, rel: model.entry.rel, bytes: model.entry.bytes, sha256: model.entry.sha256, mtimeMs: model.entry.mtimeMs }
|
|
441
|
+
: null,
|
|
442
|
+
files: model.files,
|
|
443
|
+
imports: {
|
|
444
|
+
count: scan.imports.length,
|
|
445
|
+
items: scan.imports.map(i => ({ specifier: i.specifier, kind: i.kind, line: i.line })),
|
|
446
|
+
warnings: scan.warnings,
|
|
447
|
+
coverage: 'import-only',
|
|
448
|
+
},
|
|
449
|
+
resources: {
|
|
450
|
+
timers: model.entry ? findTimers(model.entry.src) : [],
|
|
451
|
+
evalSites: model.entry ? findEvalSites(model.entry.src) : [],
|
|
452
|
+
secrets: model.entry ? findSecrets(model.entry.src) : [],
|
|
453
|
+
hasEffect: model.entry ? EFFECT_RE.test(model.entry.src) : false,
|
|
454
|
+
hasProvide: model.entry ? PROVIDE_RE.test(model.entry.src) : false,
|
|
455
|
+
deps: depsOf(model.pkg),
|
|
456
|
+
signals: mapped.signals.map(s => ({ id: s.id, weight: s.weight })),
|
|
457
|
+
},
|
|
458
|
+
checks: {
|
|
459
|
+
selfCheck: self.findings.map(f => ({ id: f.id, level: f.level })),
|
|
460
|
+
gates: gs.gates.map(g => ({ id: g.id, gate: g.gate, verdict: g.verdict, ok: g.ok })),
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─────────────────────────────── suggestFix ───────────────────────────────
|
|
466
|
+
|
|
467
|
+
function patch(target, yaml, why, extra = {}) {
|
|
468
|
+
return { target, yaml, why, ...extra }
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function suggestFixOf(model, cfg, opts = {}) {
|
|
472
|
+
const self = selfCheckOf(model, cfg)
|
|
473
|
+
const gs = makeGates(model, cfg, opts)
|
|
474
|
+
const gate = (id) => gs.gates.find(g => g.id === id)
|
|
475
|
+
const finding = (id) => self.findings.find(f => f.id === id)
|
|
476
|
+
const entryRel = model.entry?.rel ?? 'index.js'
|
|
477
|
+
const patches = []
|
|
478
|
+
const seen = new Set()
|
|
479
|
+
const add = (p) => {
|
|
480
|
+
const key = `${p.target}\u0000${p.why}`
|
|
481
|
+
if (seen.has(key)) return
|
|
482
|
+
seen.add(key)
|
|
483
|
+
patches.push(p)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const g1 = gate('G1')
|
|
487
|
+
if (g1 && !g1.ok && g1.verdict === 'fail') {
|
|
488
|
+
add(patch('package.json',
|
|
489
|
+
JSON.stringify({ name: model.pkg?.name ?? '@local/<plugin-name>', version: '1.0.0', type: 'module' }, null, 2),
|
|
490
|
+
`G1 manifest-fields: ${g1.detail} — merge these fields into the existing package.json; do not replace the file`,
|
|
491
|
+
{ apply: 'merge', mergeInto: 'package.json' }))
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const g2 = gate('G2')
|
|
495
|
+
if (g2 && !g2.ok) {
|
|
496
|
+
add(patch('package.json',
|
|
497
|
+
JSON.stringify({ exports: { '.': './index.js' } }, null, 2),
|
|
498
|
+
`G2 entry-exists: ${g2.detail} — create the entry file and merge this exports field`,
|
|
499
|
+
{ apply: 'merge', mergeInto: 'package.json' }))
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const g3 = gate('G3')
|
|
503
|
+
if (g3 && g3.verdict === 'fail') {
|
|
504
|
+
add(patch(entryRel,
|
|
505
|
+
'# remove eval()/new Function(): use a static branch, a schema, or a declared capability service\n# (healer only suggests; the code change is a manual edit)',
|
|
506
|
+
`G3 static-danger: ${g3.detail}`))
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const g4 = gate('G4')
|
|
510
|
+
if (g4 && g4.verdict === 'fail') {
|
|
511
|
+
add(patch(entryRel,
|
|
512
|
+
'# drop the child_process import: delegate process work to the declared capability\n# (fangForker mother-side) or run this code in the container zone',
|
|
513
|
+
`G4 no-bare-child-process: ${g4.detail}`))
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const g5 = gate('G5')
|
|
517
|
+
if (g5 && g5.verdict === 'fail') {
|
|
518
|
+
const bad = g5.disallowed ?? []
|
|
519
|
+
const yaml = [
|
|
520
|
+
'# merge into the plugin row config in cordis.patch.yml',
|
|
521
|
+
'config:',
|
|
522
|
+
' allowlist:',
|
|
523
|
+
...bad.map(d => ` - ${JSON.stringify(d)}`),
|
|
524
|
+
].join('\n')
|
|
525
|
+
add(patch('cordis.patch.yml', yaml, `G5 deps-allowlist: ${g5.detail}`))
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const g6 = gate('G6')
|
|
529
|
+
if (g6 && g6.verdict === 'fail') {
|
|
530
|
+
add(patch(entryRel,
|
|
531
|
+
'# register every timer through ctx.effect so it is disposed with the fiber:\n# ctx.effect(() => { const t = setInterval(fn, ms); return () => clearInterval(t) })',
|
|
532
|
+
`G6 resource-containment: ${g6.detail}`))
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const g8 = gate('G8')
|
|
536
|
+
if (g8 && g8.verdict === 'fail') {
|
|
537
|
+
// Never auto-approve install scripts: that would turn a security finding into a
|
|
538
|
+
// one-line bypass. The placeholder requires a human review step.
|
|
539
|
+
add(patch('package.json',
|
|
540
|
+
JSON.stringify({
|
|
541
|
+
dsh: {
|
|
542
|
+
buildApproved: ['<script-name-after-review>'],
|
|
543
|
+
},
|
|
544
|
+
}, null, 2),
|
|
545
|
+
`G8 build-scripts: ${g8.detail} — review each install script; only merge names you explicitly approve. An auto-filled buildApproved list would bypass this gate.`,
|
|
546
|
+
{ apply: 'manual-review', mergeInto: 'package.json', requiresHumanReview: true }))
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const g9 = gate('G9')
|
|
550
|
+
if (g9 && g9.verdict === 'fail') {
|
|
551
|
+
add(patch(entryRel,
|
|
552
|
+
'# remove the hardcoded credential; read it from ctx.config / environment at runtime',
|
|
553
|
+
`G9 secret-hygiene: ${g9.detail}`))
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const g10 = gate('G10')
|
|
557
|
+
if (g10 && g10.verdict === 'fail') {
|
|
558
|
+
const declared = typeof g10.patch === 'string' ? g10.patch : ''
|
|
559
|
+
const safeTarget = declared && !path.isAbsolute(declared) && !declared.split(/[\\/]/).includes('..')
|
|
560
|
+
? declared
|
|
561
|
+
: 'cordis.patch.yml'
|
|
562
|
+
add(patch(safeTarget,
|
|
563
|
+
'# create the declared bundle patch file; the declaration must stay inside the plugin root',
|
|
564
|
+
`G10 bundle-patch: ${g10.detail}`,
|
|
565
|
+
{ apply: 'manual-create', declaredPatch: declared || null }))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const g7 = gate('G7')
|
|
569
|
+
if (g7 && g7.verdict === 'fail') {
|
|
570
|
+
add(patch('package.json',
|
|
571
|
+
JSON.stringify({ dsh: { exempted: [{ gate: '<gate-id>', reason: 'reviewed: <why this risk is acceptable>' }] } }, null, 2),
|
|
572
|
+
`G7 exemption-honesty: ${g7.detail} — merge only after a real review; a placeholder exemption must not be copied blindly`,
|
|
573
|
+
{ apply: 'manual-review', mergeInto: 'package.json', requiresHumanReview: true }))
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const fx = finding('inject-missing')
|
|
577
|
+
if (fx) {
|
|
578
|
+
add(patch(entryRel,
|
|
579
|
+
'# add at module top level (middle-tier plugins declare no hard deps):\n# export const inject = []',
|
|
580
|
+
`module shape: ${fx.message}`))
|
|
581
|
+
}
|
|
582
|
+
const fe = finding('no-effect-wrap')
|
|
583
|
+
if (fe) {
|
|
584
|
+
add(patch(entryRel,
|
|
585
|
+
'# wrap service registration: ctx.effect(() => ctx.provide(SERVICE.<key>, service))',
|
|
586
|
+
`module shape: ${fe.message}`))
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const before = gradeOfModel(model)
|
|
590
|
+
let afterGrade = before.grade
|
|
591
|
+
const projectedGates = []
|
|
592
|
+
if (model.entry) {
|
|
593
|
+
const addressed = new Set()
|
|
594
|
+
if (g4 && g4.verdict === 'fail') {
|
|
595
|
+
addressed.add('S1')
|
|
596
|
+
projectedGates.push('G4')
|
|
597
|
+
}
|
|
598
|
+
const kept = before.signals.filter(s => !addressed.has(familyOf(s.id)))
|
|
599
|
+
const projected = scoreScan(kept, {
|
|
600
|
+
...(model.pkg ?? {}),
|
|
601
|
+
__parseErrors: (model.scan?.warnings ?? []).length,
|
|
602
|
+
})
|
|
603
|
+
afterGrade = projected.grade
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
patches,
|
|
608
|
+
before: { grade: before.grade, S: before.S },
|
|
609
|
+
after: {
|
|
610
|
+
grade: afterGrade,
|
|
611
|
+
note: projectedGates.length > 0
|
|
612
|
+
? 'projected only by removing G4/S1 signals; G3/G5/G6/G7/G8/G9/G10 are not represented and host-fact gates (G11/G12) are unaffected'
|
|
613
|
+
: 'static projection only; no score-affecting patch was applied and host-fact gates (G11/G12) are unaffected',
|
|
614
|
+
projectedGates,
|
|
615
|
+
},
|
|
616
|
+
dir: model.abs,
|
|
617
|
+
applied: false,
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ─────────────────────────────── plugin ───────────────────────────────
|
|
622
|
+
|
|
623
|
+
export function apply(ctx, config) {
|
|
624
|
+
const gate = roleGate('healer', { cfgRole: config?.role, logger: ctx.logger })
|
|
625
|
+
// v2 deployment matrix: this plugin is not deployed in this role — register nothing.
|
|
626
|
+
if (!gate.active) return
|
|
627
|
+
const cfg = {
|
|
628
|
+
blockingOnUnknown: true,
|
|
629
|
+
reservedIds: [],
|
|
630
|
+
allowlist: [],
|
|
631
|
+
...(config ?? {}),
|
|
632
|
+
}
|
|
633
|
+
const normalized = {
|
|
634
|
+
blockingOnUnknown: cfg.blockingOnUnknown !== false,
|
|
635
|
+
reservedIds: Array.isArray(cfg.reservedIds) ? cfg.reservedIds.map(String) : [],
|
|
636
|
+
allowlist: Array.isArray(cfg.allowlist) ? cfg.allowlist.map(String) : [],
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const audit = (kind, data) => {
|
|
640
|
+
try { safeGet(ctx, SERVICE.auditor)?.append?.(kind, data) } catch { /* 审计缺失不得影响自检 */ }
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const service = {
|
|
644
|
+
policy: { ...normalized },
|
|
645
|
+
|
|
646
|
+
/** 自检:只读,返回 {ok, grade, findings[]}(契约 5.5)。 */
|
|
647
|
+
selfCheck(dir, opts = {}) {
|
|
648
|
+
const model = readModel(dir, opts)
|
|
649
|
+
const out = selfCheckOf(model, normalized)
|
|
650
|
+
audit(AUDIT.SCORE, {
|
|
651
|
+
component: name,
|
|
652
|
+
check: 'self-check',
|
|
653
|
+
dir: out.dir,
|
|
654
|
+
ok: out.ok,
|
|
655
|
+
grade: out.grade,
|
|
656
|
+
findings: out.findings.length,
|
|
657
|
+
})
|
|
658
|
+
return out
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
/** 结构化证据 JSON(契约 5.5):{plugin, entry, files, imports, resources, checks}。 */
|
|
662
|
+
evidence(dir, opts = {}) {
|
|
663
|
+
return evidenceOf(readModel(dir, opts), normalized)
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* G1–G12 门禁(契约 5.5):{pass, gates:[{id, ok, detail, verdict}], summary}。
|
|
668
|
+
* opts.stage: 'load'(默认,静态预检,G11/G12 deferred/SKIP)| 'promote'(G11/G12 必须有 facts,否则 UNKNOWN 阻断)。
|
|
669
|
+
* opts.facts: host 证据 {application, observedAt, signals, score}(见 CONTRACTS 5.5)。
|
|
670
|
+
*/
|
|
671
|
+
gates(dir, opts = {}) {
|
|
672
|
+
const out = makeGates(readModel(dir, opts), normalized, opts)
|
|
673
|
+
audit(AUDIT.SCORE, {
|
|
674
|
+
component: name,
|
|
675
|
+
check: 'gates',
|
|
676
|
+
dir: out.dir,
|
|
677
|
+
pass: out.pass,
|
|
678
|
+
fail: out.summary.fail,
|
|
679
|
+
unknown: out.summary.unknown,
|
|
680
|
+
})
|
|
681
|
+
return out
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
/** 补丁建议:{patches:[{target,yaml,why}], before, after};**不写盘**(契约 5.5)。 */
|
|
685
|
+
suggestFix(dir, opts = {}) {
|
|
686
|
+
const out = suggestFixOf(readModel(dir, opts), normalized, opts)
|
|
687
|
+
audit(AUDIT.SCORE, {
|
|
688
|
+
component: name,
|
|
689
|
+
check: 'suggest-fix',
|
|
690
|
+
dir: out.dir,
|
|
691
|
+
patches: out.patches.length,
|
|
692
|
+
before: out.before.grade,
|
|
693
|
+
after: out.after.grade,
|
|
694
|
+
})
|
|
695
|
+
return out
|
|
696
|
+
},
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
ctx.effect(() => ctx.provide(SERVICE.healer, service))
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export const _internals = { selfCheckOf, makeGates, evidenceOf, suggestFixOf, readModel, gradeOfModel }
|