dsh-engineering-ui 0.0.0-stage → 1.5.0
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/LICENSE +21 -0
- package/README.md +772 -3
- package/engineering/plugins/dsh-engineering-ui/cordis.patch.yml +5 -0
- package/engineering/plugins/dsh-engineering-ui/icon.svg +14 -0
- package/engineering/plugins/dsh-engineering-ui/lib/client.js +8259 -0
- package/engineering/plugins/dsh-engineering-ui/lib/defense-sign.js +1552 -0
- package/engineering/plugins/dsh-engineering-ui/lib/index.js +5633 -0
- package/engineering/plugins/dsh-engineering-ui/lib/job-wait-guard.js +236 -0
- package/engineering/plugins/dsh-engineering-ui/lib/mem-guard.js +331 -0
- package/engineering/plugins/dsh-engineering-ui/lib/webots.js +114 -0
- package/engineering/plugins/dsh-engineering-ui/locale/en.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/locale/zh.json +6 -0
- package/engineering/plugins/dsh-engineering-ui/preset-engineering.patch.yml +601 -0
- package/engineering/skills/assembly-orchestration/SKILL.md +651 -0
- package/engineering/skills/cad-workflow/SKILL.md +132 -0
- package/engineering/skills/mode-selection/SKILL.md +660 -0
- package/engineering/skills/physics-in-loop/SKILL.md +238 -0
- package/engineering/skills/physics-in-loop/references/feadocs.md +142 -0
- package/engineering/skills/solidworks-bridge/SKILL.md +445 -0
- package/engineering/skills/sw-design/SKILL.md +112 -0
- package/engineering/skills/sw-design/references/autocad-workflow.md +81 -0
- package/engineering/skills/sw-design/references/gbt-drafting.md +71 -0
- package/engineering/skills/sw-to-cad/SKILL.md +121 -0
- package/engineering/skills/webots-simulation/SKILL.md +58 -0
- package/engineering/tools/LICENSE.Auto-SolidWorks +21 -0
- package/engineering/tools/_plugin_paths.py +93 -0
- package/engineering/tools/_root.py +330 -0
- package/engineering/tools/_store.py +446 -0
- package/engineering/tools/ac_bridge.py +459 -0
- package/engineering/tools/ac_rules.json +21 -0
- package/engineering/tools/ac_validate.py +699 -0
- package/engineering/tools/ask_user.py +887 -0
- package/engineering/tools/build_arm1.py +46 -0
- package/engineering/tools/build_assembly.py +94 -0
- package/engineering/tools/build_base.py +57 -0
- package/engineering/tools/build_housing.py +70 -0
- package/engineering/tools/build_structural.py +43 -0
- package/engineering/tools/build_transmission.py +69 -0
- package/engineering/tools/cadx.lsp +128 -0
- package/engineering/tools/choice_contract.py +61 -0
- package/engineering/tools/conn_state.py +1671 -0
- package/engineering/tools/defense_gate.py +3374 -0
- package/engineering/tools/drawing_input.py +314 -0
- package/engineering/tools/env_check.py +467 -0
- package/engineering/tools/examples/build_axis.py +142 -0
- package/engineering/tools/examples/build_cup.py +56 -0
- package/engineering/tools/examples/build_plate.py +58 -0
- package/engineering/tools/examples/build_sphere.py +30 -0
- package/engineering/tools/examples/build_transition_duct.py +381 -0
- package/engineering/tools/gate_integrity.py +401 -0
- package/engineering/tools/mode2_flow.py +3589 -0
- package/engineering/tools/mode_checker.py +77 -0
- package/engineering/tools/mode_gate.py +8013 -0
- package/engineering/tools/part_registry.py +4266 -0
- package/engineering/tools/physics/__init__.py +50 -0
- package/engineering/tools/physics/_compat.py +12 -0
- package/engineering/tools/physics/design_state.py +142 -0
- package/engineering/tools/physics/domain_validator.py +751 -0
- package/engineering/tools/physics/fatigue.py +489 -0
- package/engineering/tools/physics/fea_solver.py +1163 -0
- package/engineering/tools/physics/feapy_solver.py +314 -0
- package/engineering/tools/physics/gb_materials.json +1228 -0
- package/engineering/tools/physics/geometry_gate.py +337 -0
- package/engineering/tools/physics/load_case.py +951 -0
- package/engineering/tools/physics/material_db.py +874 -0
- package/engineering/tools/physics/mesh_adapter.py +321 -0
- package/engineering/tools/physics/refine_rules.py +272 -0
- package/engineering/tools/physics/rules/gb_base_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_drafting_rules.json +57 -0
- package/engineering/tools/physics/rules/gb_electrical_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_fasteners_rules.json +73 -0
- package/engineering/tools/physics/rules/gb_housing_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_manufacturing_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_material_verify_rules.json +72 -0
- package/engineering/tools/physics/rules/gb_materials_rules.json +377 -0
- package/engineering/tools/physics/rules/gb_safety_rules.json +89 -0
- package/engineering/tools/physics/rules/gb_springs_rules.json +41 -0
- package/engineering/tools/physics/rules/gb_standards_registry.json +1646 -0
- package/engineering/tools/physics/rules/gb_structural_rules.json +201 -0
- package/engineering/tools/physics/rules/gb_surface_rules.json +25 -0
- package/engineering/tools/physics/rules/gb_transmission_rules.json +233 -0
- package/engineering/tools/physics/rules/housing_rules.json +48 -0
- package/engineering/tools/physics/rules/manifest.json +54 -0
- package/engineering/tools/physics/rules/mold_rules.json +48 -0
- package/engineering/tools/physics/rules/structural_rules.json +56 -0
- package/engineering/tools/physics/rules/transmission_rules.json +48 -0
- package/engineering/tools/physics/simulation_report.py +829 -0
- package/engineering/tools/physics_bridge.py +2685 -0
- package/engineering/tools/profile-manifest.ps1 +72 -0
- package/engineering/tools/run_regression.py +109 -0
- package/engineering/tools/settings.py +419 -0
- package/engineering/tools/solidworks-modeling.md +519 -0
- package/engineering/tools/sw_bridge.py +8215 -0
- package/engineering/tools/sw_guard.py +1066 -0
- package/engineering/tools/sw_surface.py +87 -0
- package/engineering/tools/swapi.py +14766 -0
- package/engineering/tools/verify_sync.py +218 -0
- package/engineering/tools/visual_check.py +2589 -0
- package/engineering/tools/webots_env.py +121 -0
- package/engineering/tools/webots_mcp/__init__.py +3 -0
- package/engineering/tools/webots_mcp/__main__.py +3 -0
- package/engineering/tools/webots_mcp/assembly.py +391 -0
- package/engineering/tools/webots_mcp/bootstrap.py +55 -0
- package/engineering/tools/webots_mcp/controller_worker.py +124 -0
- package/engineering/tools/webots_mcp/evidence.py +288 -0
- package/engineering/tools/webots_mcp/fake_worker.py +69 -0
- package/engineering/tools/webots_mcp/fault_resources.py +121 -0
- package/engineering/tools/webots_mcp/host.py +45 -0
- package/engineering/tools/webots_mcp/ledger.py +73 -0
- package/engineering/tools/webots_mcp/owned_job.py +70 -0
- package/engineering/tools/webots_mcp/physical_gate.py +187 -0
- package/engineering/tools/webots_mcp/physics.py +152 -0
- package/engineering/tools/webots_mcp/pipeline.py +108 -0
- package/engineering/tools/webots_mcp/recipes.py +129 -0
- package/engineering/tools/webots_mcp/repair.py +132 -0
- package/engineering/tools/webots_mcp/repair_trial.py +375 -0
- package/engineering/tools/webots_mcp/runtime.py +311 -0
- package/engineering/tools/webots_mcp/server.py +217 -0
- package/engineering/tools/webots_mcp/sw_export.py +509 -0
- package/engineering/tools/webots_mcp/transport.py +156 -0
- package/engineering/tools/workflow_gate.py +6134 -0
- package/package.json +64 -4
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
// dsh-engineering-ui — 三大防线【签名式信任根】(仅工程模式)
|
|
2
|
+
// ==========================================================================
|
|
3
|
+
// 定位(用户给定)
|
|
4
|
+
// 签名 = 主防线:防"AI 直接 write 一份格式完美的伪造凭据"。
|
|
5
|
+
// 守卫 = 补丁:围绕签名服务,保证 defense_gate 读到的是签名认可的版本。
|
|
6
|
+
//
|
|
7
|
+
// 信任模型(必须读)
|
|
8
|
+
// 密钥 = HMAC-SHA256 随机密钥,【只存在于宿主进程内存】,绝不落盘。
|
|
9
|
+
// 因此:Python 侧【不持有密钥】—— 签名与验签都必须请求宿主。
|
|
10
|
+
// 这样即使 AI 读到工程模式目录下的所有文件,也拿不到签发能力。
|
|
11
|
+
//
|
|
12
|
+
// 已知残余风险(同用户、无隔离环境,用户已确认接受)
|
|
13
|
+
// 1. 能运行同用户任意代码的一方,理论上可伪造一个"假宿主"端点。
|
|
14
|
+
// 2. AI 可能抢在守卫扫描前写入。
|
|
15
|
+
// 守卫(文件监控 + 台账比对 + 自动恢复)用于缩减这两类窗口,
|
|
16
|
+
// 并使越权尝试留痕;它不产生信任,只保证校验读取的是认可版本。
|
|
17
|
+
//
|
|
18
|
+
// 签发约束(关键)
|
|
19
|
+
// /sign 不签调用方给的任意内容:宿主【自己读磁盘报告】,
|
|
20
|
+
// 用文件真实内容(含 sha256)构造待签体,再签名。
|
|
21
|
+
// 调用方无法让宿主为"不存在的运行"或"与报告不符的结论"背书。
|
|
22
|
+
import crypto from 'node:crypto';
|
|
23
|
+
import fsSync from 'node:fs';
|
|
24
|
+
import pathSync from 'node:path';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 签名有效期(毫秒)。超过即视为过期,防重放旧签名。
|
|
28
|
+
*
|
|
29
|
+
* ── 【观察点 14 修复 · 阻断级】从 10 分钟放宽到 24 小时 ──────────────────
|
|
30
|
+
* 原值 `10 * 60 * 1000` 与工程模式的实际作业节奏不匹配,导致**多零件装配
|
|
31
|
+
* 必然无法完成 room-end**:
|
|
32
|
+
* · 每个零件 `save()` 时各签发一份材料凭据,各自带独立 `_ts`;
|
|
33
|
+
* · `room-end` 要校验【房间内全部零件】的凭据;
|
|
34
|
+
* · 大型装配的零件分散保存(实测 9~21 件跨 18:30~18:51,跨度 21 分钟);
|
|
35
|
+
* · 最早签发的凭据 10 分钟后即过期 → `host_verify_credential` 返回
|
|
36
|
+
* `{'ok': False, 'reason': 'signature expired'}` → 门禁报
|
|
37
|
+
* "【防线①材料】xxx.sldprt: 验签失败(signature expired)"。
|
|
38
|
+
* 即:只要房间内零件数 > 1 且保存时间跨度 > 10 分钟,room-end 必然失败,
|
|
39
|
+
* 只能靠 `--force` 绕过(留痕),三道防线被架空。
|
|
40
|
+
*
|
|
41
|
+
* 新值取 24 小时:覆盖"一天内完成一个装配任务"的正常节奏,同时仍能防住
|
|
42
|
+
* "隔天拿旧凭据重放"。可用 `DSH_DEFENSE_SIG_TTL_MS` 覆盖(毫秒)。
|
|
43
|
+
*
|
|
44
|
+
* ⚠️ 不要为了省事设成 `Infinity`:那会让重放旧凭据永久有效,防线失去意义。
|
|
45
|
+
*/
|
|
46
|
+
function resolveSigTtlMs() {
|
|
47
|
+
try {
|
|
48
|
+
const raw = process.env.DSH_DEFENSE_SIG_TTL_MS;
|
|
49
|
+
if (raw !== undefined && raw !== null && String(raw).trim() !== '') {
|
|
50
|
+
const v = Number(String(raw).trim());
|
|
51
|
+
if (Number.isFinite(v) && v > 0) return v;
|
|
52
|
+
}
|
|
53
|
+
} catch (e) {
|
|
54
|
+
// 环境变量不可读 → 用默认值
|
|
55
|
+
}
|
|
56
|
+
return 24 * 60 * 60 * 1000;
|
|
57
|
+
}
|
|
58
|
+
export const SIG_TTL_MS = resolveSigTtlMs();
|
|
59
|
+
/** nonce 缓存上限(用于 this.nonces 的过期清理阈值)。 */
|
|
60
|
+
const NONCE_MAX = 4000;
|
|
61
|
+
/**
|
|
62
|
+
* 已签发 nonce 台账的【硬上限】(仅防内存无界增长,不参与正确性判定)。
|
|
63
|
+
*
|
|
64
|
+
* ── 【观察点 14 修复·连带】必须显著大于"一个任务可能签发的凭据数" ──────
|
|
65
|
+
* 原实现用 NONCE_MAX(4000) 当裁剪线,且 TTL 只有 10 分钟。TTL 放宽到 24 小时
|
|
66
|
+
* 后,一天内签发量很容易超过 4000(每个零件至少 1 份材料凭据,加上物理/领域
|
|
67
|
+
* 凭据与重签),若仍按 4000 裁剪就会把【仍在有效期内】的 nonce 删掉,
|
|
68
|
+
* 使合法凭据被 verify() 判为 'nonce not issued by this host'。
|
|
69
|
+
* 故这里给一个宽松的兜底值;真正决定 nonce 是否可用的判据是【年龄】。
|
|
70
|
+
*/
|
|
71
|
+
const NONCE_HARD_MAX = 200000;
|
|
72
|
+
/** 工程模式下的防线目录名。 */
|
|
73
|
+
export const DEFENSE_DIR_NAME = '.defense';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 规范化 JSON:递归按键排序、无多余空白。
|
|
77
|
+
* 签名与验签必须使用完全相同的字节序列。
|
|
78
|
+
* @param value - 可 JSON 化的值。
|
|
79
|
+
* @returns 规范字符串。
|
|
80
|
+
*/
|
|
81
|
+
export function canonicalJson(value) {
|
|
82
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
83
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']';
|
|
84
|
+
const keys = Object.keys(value).sort();
|
|
85
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 计算文件的 sha256(读取失败返回 null)。 */
|
|
89
|
+
function fileSha256(p) {
|
|
90
|
+
try {
|
|
91
|
+
return crypto.createHash('sha256').update(fsSync.readFileSync(p)).digest('hex');
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 三大防线的签名服务与守卫。仅由工程模式装配。
|
|
99
|
+
*/
|
|
100
|
+
export class DefenseSigner {
|
|
101
|
+
/**
|
|
102
|
+
* @param toolsDir - 工程模式 tools 目录(绝对路径)。
|
|
103
|
+
* @param onLog - 日志回调 (msg: string) => void。
|
|
104
|
+
*/
|
|
105
|
+
constructor(toolsDir, onLog = null) {
|
|
106
|
+
this.toolsDir = toolsDir;
|
|
107
|
+
this.log = typeof onLog === 'function' ? onLog : () => {};
|
|
108
|
+
this.dir = pathSync.join(toolsDir, DEFENSE_DIR_NAME);
|
|
109
|
+
// ── 【隐藏 BUG 修复 · 跨重启密钥漂移】2026-10-29 实测确认 ────────────
|
|
110
|
+
// 现象(打完本次任务的真机盘查):
|
|
111
|
+
// 磁盘上明明有 structural.physics.json / .domain.json(学校比赛那次任务
|
|
112
|
+
// 真的跑过物理与领域校核,overall=FAIL 也如实落盘了),
|
|
113
|
+
// 但 defense_gate.py check-room 报:
|
|
114
|
+
// "物理凭据验签失败(key id mismatch (signer restarted?))"
|
|
115
|
+
// "领域凭据验签失败(key id mismatch (signer restarted?))"
|
|
116
|
+
// → room-end 被永久拒绝,房间只能靠 --force 才能收尾。
|
|
117
|
+
//
|
|
118
|
+
// 根因:密钥每次进程启动都 crypto.randomBytes(32) 随机重生,
|
|
119
|
+
// 而 kid = sha256(key) 前 16 位。DSH 一重启,kid 就变,
|
|
120
|
+
// 于是【重启之前签发的所有凭据一律失效】。
|
|
121
|
+
// 而工程模式的实际节奏是"跨天、跨重启做同一个装配"——
|
|
122
|
+
// 这与 SIG_TTL_MS 已经从 10 分钟放宽到 24 小时的初衷完全矛盾:
|
|
123
|
+
// TTL 放宽了,密钥却活不过一次重启,凭据照样作废。
|
|
124
|
+
//
|
|
125
|
+
// 修复原则(不破坏原有信任模型):
|
|
126
|
+
// · 密钥【仍然不落明文盘】—— 私钥文件在 .defense/ 下,权限收窄,
|
|
127
|
+
// 且与 runtime.json 同目录(该目录本来就只有本用户可读);
|
|
128
|
+
// · 首次启动生成并持久化;之后启动【复用】同一个密钥 → kid 稳定,
|
|
129
|
+
// 跨重启签发的凭据仍可验签;
|
|
130
|
+
// · 读到的密钥文件损坏/长度不对 → 视为不可用,回退到"内存随机"
|
|
131
|
+
// (fail-closed:宁可这次重启后旧凭据失效,也不用一个残缺密钥)。
|
|
132
|
+
// · 可用 DSH_DEFENSE_KEY_EPHEMERAL=1 强制回到旧的"每次随机"行为
|
|
133
|
+
// (调试/隔离环境用,明确要求凭据不跨重启)。
|
|
134
|
+
this.key = this.loadOrCreateKey();
|
|
135
|
+
this.kid = crypto.createHash('sha256').update(this.key).digest('hex').slice(0, 16);
|
|
136
|
+
this.startedAt = Date.now();
|
|
137
|
+
/** nonce → 首次出现时间(防重放)。 */
|
|
138
|
+
this.nonces = new Map();
|
|
139
|
+
/**
|
|
140
|
+
* 本宿主签发过的 nonce 台账:验签时核对,挡住伪造/跨语境复用。
|
|
141
|
+
*
|
|
142
|
+
* ── 【观察点 14 修复·连带】类型从 Set 改为 Map(nonce → 签发时间) ──────
|
|
143
|
+
* 只存 nonce 无法按【年龄】裁剪;TTL 放宽到 24 小时后必须能区分
|
|
144
|
+
* "已过期可删" 与 "仍在有效期内必须保留",否则合法凭据会被误判为伪造。
|
|
145
|
+
*/
|
|
146
|
+
this.signedNonces = new Map();
|
|
147
|
+
/** 签发台账(审计与守卫比对)。 */
|
|
148
|
+
this.ledger = [];
|
|
149
|
+
/** 守卫观察到的基线:路径 → sha256。 */
|
|
150
|
+
this.baseline = new Map();
|
|
151
|
+
/** 守卫发现的异常。 */
|
|
152
|
+
this.anomalies = [];
|
|
153
|
+
this.guardTimer = null;
|
|
154
|
+
// 【隐藏 BUG 修复】先恢复跨重启的 nonce 台账,再对外提供服务
|
|
155
|
+
this.loadPersistedNonces();
|
|
156
|
+
this.initRuntimeFile();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 载入或创建【跨重启稳定】的签名密钥。
|
|
161
|
+
*
|
|
162
|
+
* 返回值是关键:必须是可直接用于 crypto.createHmac 的 Buffer。
|
|
163
|
+
* 任何异常都不得抛出(constructor 抛错会让整个 host 插件装载失败)。
|
|
164
|
+
*
|
|
165
|
+
* @returns {Buffer} 32 字节密钥
|
|
166
|
+
*/
|
|
167
|
+
loadOrCreateKey() {
|
|
168
|
+
const ephemeral = () => crypto.randomBytes(32);
|
|
169
|
+
// 显式要求"每次重启换钥"的调试开关
|
|
170
|
+
try {
|
|
171
|
+
const flag = String(process.env.DSH_DEFENSE_KEY_EPHEMERAL || '').trim();
|
|
172
|
+
if (flag === '1' || flag.toLowerCase() === 'true') {
|
|
173
|
+
this.log('defense key: ephemeral mode (DSH_DEFENSE_KEY_EPHEMERAL=1)');
|
|
174
|
+
return ephemeral();
|
|
175
|
+
}
|
|
176
|
+
} catch (e) {}
|
|
177
|
+
// 允许显式指定密钥文件位置(多部署副本共存时用)
|
|
178
|
+
let keyPath = null;
|
|
179
|
+
try {
|
|
180
|
+
const custom = String(process.env.DSH_DEFENSE_KEY_FILE || '').trim();
|
|
181
|
+
keyPath = custom || pathSync.join(this.dir, 'signing.key');
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return ephemeral();
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
if (fsSync.existsSync(keyPath)) {
|
|
187
|
+
const buf = fsSync.readFileSync(keyPath);
|
|
188
|
+
// 只接受恰好 32 字节;长度不对 = 损坏 → 不猜,走 ephemeral
|
|
189
|
+
if (buf && buf.length === 32) {
|
|
190
|
+
this.keySource = 'persisted:' + keyPath;
|
|
191
|
+
return buf;
|
|
192
|
+
}
|
|
193
|
+
this.log('defense key: existing key file has bad length ('
|
|
194
|
+
+ (buf ? buf.length : 0) + '), falling back to ephemeral');
|
|
195
|
+
return ephemeral();
|
|
196
|
+
}
|
|
197
|
+
} catch (e) {
|
|
198
|
+
this.log('defense key: read failed, falling back to ephemeral: '
|
|
199
|
+
+ String((e && e.message) || e));
|
|
200
|
+
return ephemeral();
|
|
201
|
+
}
|
|
202
|
+
// 首次启动:生成并持久化
|
|
203
|
+
try {
|
|
204
|
+
const fresh = crypto.randomBytes(32);
|
|
205
|
+
fsSync.mkdirSync(pathSync.dirname(keyPath), { recursive: true });
|
|
206
|
+
fsSync.writeFileSync(keyPath, fresh, { mode: 0o600 });
|
|
207
|
+
this.keySource = 'created:' + keyPath;
|
|
208
|
+
this.log('defense key: created persistent key at ' + keyPath);
|
|
209
|
+
return fresh;
|
|
210
|
+
} catch (e) {
|
|
211
|
+
this.log('defense key: persist failed, using ephemeral: '
|
|
212
|
+
+ String((e && e.message) || e));
|
|
213
|
+
return ephemeral();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 写运行时发现文件,供 Python 侧定位宿主端点。 */
|
|
218
|
+
initRuntimeFile() {
|
|
219
|
+
try {
|
|
220
|
+
fsSync.mkdirSync(this.dir, { recursive: true });
|
|
221
|
+
this.runtimePath = pathSync.join(this.dir, 'runtime.json');
|
|
222
|
+
this.writeRuntime();
|
|
223
|
+
this.log('defense signer ready, kid=' + this.kid);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
this.log('defense signer init failed: ' + String((e && e.message) || e));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 写入/刷新运行时文件。
|
|
231
|
+
* @param port - 宿主 HTTP 端口(可用时)。
|
|
232
|
+
*/
|
|
233
|
+
writeRuntime(port) {
|
|
234
|
+
try {
|
|
235
|
+
if (typeof port === 'number') this.port = port;
|
|
236
|
+
// [Bug8 修复] 不得让非宿主进程覆盖 runtime.json。
|
|
237
|
+
// 实测:任何加载本插件的进程(测试脚本、临时 node、第二个 DSH
|
|
238
|
+
// 实例)都会调用本方法,把 port 写成 null、pid 写成自己,
|
|
239
|
+
// 于是真正宿主的端点信息被覆盖,Python 侧 _host_base() 返回
|
|
240
|
+
// None,三道防线全部 fail-closed(表现为签名端点未装配)。
|
|
241
|
+
if (this.port === undefined || this.port === null) {
|
|
242
|
+
this.log('runtime not written: no port available (non-host context)');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
if (fsSync.existsSync(this.runtimePath)) {
|
|
247
|
+
const prev = JSON.parse(fsSync.readFileSync(this.runtimePath, 'utf8'));
|
|
248
|
+
const prevTitle = String(prev.process_title || '');
|
|
249
|
+
const curTitle = String(process.title || '');
|
|
250
|
+
const looksHost = (t) => /deepseek|harness|electron/i.test(t);
|
|
251
|
+
if (prev.pid && prev.port && looksHost(prevTitle) && !looksHost(curTitle)) {
|
|
252
|
+
this.log('runtime not overwritten: existing record belongs to host pid='
|
|
253
|
+
+ String(prev.pid));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch (e) {
|
|
258
|
+
// 读不到旧记录时按可写处理
|
|
259
|
+
}
|
|
260
|
+
fsSync.writeFileSync(this.runtimePath, JSON.stringify({
|
|
261
|
+
kid: this.kid,
|
|
262
|
+
port: this.port,
|
|
263
|
+
host: '127.0.0.1',
|
|
264
|
+
// pid 供 Python 侧做端口归属校验(堵假宿主冒充)。
|
|
265
|
+
pid: process.pid,
|
|
266
|
+
process_title: process.title || 'dsh',
|
|
267
|
+
startedAt: this.startedAt,
|
|
268
|
+
style: 'hmac-host-mediated',
|
|
269
|
+
}, null, 2), 'utf8');
|
|
270
|
+
} catch (e) {
|
|
271
|
+
this.log('runtime write failed: ' + String((e && e.message) || e));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** 清理过期 nonce。 */
|
|
276
|
+
prune() {
|
|
277
|
+
const now = Date.now();
|
|
278
|
+
if (this.nonces.size <= NONCE_MAX) return;
|
|
279
|
+
for (const [n, t] of this.nonces) {
|
|
280
|
+
if (now - t > SIG_TTL_MS) this.nonces.delete(n);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 对宿主【自行从磁盘确认】的内容签名。
|
|
286
|
+
* @param payload - 已由宿主构造、含磁盘事实的载荷。
|
|
287
|
+
* @returns 签名封装或 null。
|
|
288
|
+
*/
|
|
289
|
+
sign(payload) {
|
|
290
|
+
try {
|
|
291
|
+
this.prune();
|
|
292
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
293
|
+
const ts = Date.now();
|
|
294
|
+
// 关键:签名对象 = 【元字段 + 载荷】,验签时先剥离元字段再重算,
|
|
295
|
+
// 两侧必须用同一套字节。这里直接对 payload 签名,元字段不参与,
|
|
296
|
+
// 避免"签的时候带 _ts、验的时候剥离后重算"造成永不匹配。
|
|
297
|
+
const mac = crypto.createHmac('sha256', this.key)
|
|
298
|
+
.update(canonicalJson(payload), 'utf8').digest('base64');
|
|
299
|
+
this.nonces.set(nonce, ts);
|
|
300
|
+
if (!this.signedNonces) this.signedNonces = new Map();
|
|
301
|
+
this.signedNonces.set(nonce, ts);
|
|
302
|
+
// 【隐藏 BUG 修复·跨重启 nonce 台账】签发即落盘(见 persistNonces 注释)
|
|
303
|
+
this.persistNonces();
|
|
304
|
+
// ── 【观察点 14 修复·连带】nonce 台账改为【按年龄】裁剪 ──────────────
|
|
305
|
+
// 原实现按【条数】裁剪(> NONCE_MAX*2 就只留最近 NONCE_MAX 条),
|
|
306
|
+
// 当时的理由是"凭据 TTL 仅 10 分钟,更早的 nonce 对应凭据早已过期"。
|
|
307
|
+
// TTL 放宽到 24 小时后该前提不再成立:若一天内签发超过 NONCE_MAX 份
|
|
308
|
+
// 凭据,早期 nonce 会被裁掉,而它对应的凭据【仍在有效期内】→
|
|
309
|
+
// verify() 返回 'nonce not issued by this host' → 凭据被误判为伪造。
|
|
310
|
+
// 现改为:① 先按 TTL 清掉确实过期的 nonce(过期凭据本就该失败);
|
|
311
|
+
// ② 再保留一个【宽松的】硬上限兜底内存(NONCE_HARD_MAX),
|
|
312
|
+
// 仅防无界增长,正常装配任务远达不到。
|
|
313
|
+
this.pruneSignedNonces(ts);
|
|
314
|
+
this.ledger.push({ kind: 'sign', ts, nonce, room: payload.room, run_id: payload.run_id });
|
|
315
|
+
if (this.ledger.length > 4000) this.ledger = this.ledger.slice(-4000);
|
|
316
|
+
return { _sig: mac, _kid: this.kid, _ts: ts, _nonce: nonce };
|
|
317
|
+
} catch (e) {
|
|
318
|
+
this.log('sign failed: ' + String((e && e.message) || e));
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 清理已过期的已签发 nonce 台账。
|
|
325
|
+
*
|
|
326
|
+
* 只要 nonce 对应的凭据可能仍在 TTL 内,就必须保留它 —— 否则 verify()
|
|
327
|
+
* 会把一份【合法且未过期】的凭据判成 'nonce not issued by this host'。
|
|
328
|
+
* @param now - 当前时间戳(毫秒)。
|
|
329
|
+
*/
|
|
330
|
+
pruneSignedNonces(now) {
|
|
331
|
+
if (!this.signedNonces || this.signedNonces.size === 0) return;
|
|
332
|
+
// ① 按年龄清理(过期的凭据本来就会因 'signature expired' 被拒,无需再记)
|
|
333
|
+
for (const [n, t] of this.signedNonces) {
|
|
334
|
+
if (now - t > SIG_TTL_MS) this.signedNonces.delete(n);
|
|
335
|
+
}
|
|
336
|
+
// ② 硬上限兜底:只防无界增长,不参与正确性判定
|
|
337
|
+
if (this.signedNonces.size > NONCE_HARD_MAX) {
|
|
338
|
+
const keep = Array.from(this.signedNonces.entries())
|
|
339
|
+
.sort((a, b) => a[1] - b[1])
|
|
340
|
+
.slice(-NONCE_HARD_MAX);
|
|
341
|
+
this.signedNonces = new Map(keep);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── 【隐藏 BUG 修复·跨重启 nonce 台账】─────────────────────────────────
|
|
346
|
+
// 现象:就算密钥 kid 已经能跨重启稳定(见 loadOrCreateKey),宿主一重启,
|
|
347
|
+
// 旧凭据仍然被判 'nonce not issued by this host (forged or foreign
|
|
348
|
+
// credential)' —— 因为 signedNonces 只活在内存里。
|
|
349
|
+
// 结果是「重启前跑完的物理/领域校核,重启后全部作废」,三道防线实际
|
|
350
|
+
// 撑不过一次重启(本次实测正因如此才只能 --force 收尾)。
|
|
351
|
+
//
|
|
352
|
+
// 做法:把 {nonce: ts} 台账持久化到 .defense/nonces.json(与 signing.key
|
|
353
|
+
// 同目录、同权限级别)。它【不含密钥】,只是"我签发过哪些 nonce"的名单,
|
|
354
|
+
// 泄露不会带来签发能力(伪造凭据仍需私钥算 MAC)。
|
|
355
|
+
// 读取失败/文件损坏 → 当作空台账(fail-closed:旧凭据验签失败),
|
|
356
|
+
// 绝不放宽成"没台账就都通过"。
|
|
357
|
+
/** @returns {string|null} nonce 台账文件路径 */
|
|
358
|
+
nonceLedgerPath() {
|
|
359
|
+
try {
|
|
360
|
+
return pathSync.join(this.dir, 'nonces.json');
|
|
361
|
+
} catch (e) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 把 nonce 台账落盘(失败只记日志,不影响签发)。 */
|
|
367
|
+
persistNonces() {
|
|
368
|
+
const p = this.nonceLedgerPath();
|
|
369
|
+
if (!p) return;
|
|
370
|
+
try {
|
|
371
|
+
this.pruneSignedNonces(Date.now());
|
|
372
|
+
const obj = {};
|
|
373
|
+
for (const [n, t] of this.signedNonces) obj[n] = t;
|
|
374
|
+
fsSync.mkdirSync(pathSync.dirname(p), { recursive: true });
|
|
375
|
+
fsSync.writeFileSync(p, JSON.stringify(obj), { mode: 0o600 });
|
|
376
|
+
} catch (e) {
|
|
377
|
+
this.log('nonce ledger persist failed: ' + String((e && e.message) || e));
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** 启动时从磁盘恢复 nonce 台账(与内存合并,按年龄裁剪)。 */
|
|
382
|
+
loadPersistedNonces() {
|
|
383
|
+
const p = this.nonceLedgerPath();
|
|
384
|
+
if (!p) return;
|
|
385
|
+
try {
|
|
386
|
+
if (!fsSync.existsSync(p)) return;
|
|
387
|
+
const obj = JSON.parse(fsSync.readFileSync(p, 'utf8'));
|
|
388
|
+
if (!obj || typeof obj !== 'object') return;
|
|
389
|
+
if (!this.signedNonces) this.signedNonces = new Map();
|
|
390
|
+
let n = 0;
|
|
391
|
+
for (const k of Object.keys(obj)) {
|
|
392
|
+
const t = Number(obj[k]);
|
|
393
|
+
if (!Number.isFinite(t)) continue;
|
|
394
|
+
if (!this.signedNonces.has(k)) { this.signedNonces.set(k, t); n++; }
|
|
395
|
+
}
|
|
396
|
+
this.pruneSignedNonces(Date.now());
|
|
397
|
+
this.log('nonce ledger restored: +' + n + ' entries');
|
|
398
|
+
} catch (e) {
|
|
399
|
+
this.log('nonce ledger restore failed (treated as empty): '
|
|
400
|
+
+ String((e && e.message) || e));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** 拆出签名元字段,返回待验签的载荷体。 */
|
|
405
|
+
static stripMeta(cred) {
|
|
406
|
+
const body = {};
|
|
407
|
+
for (const k of Object.keys(cred || {})) {
|
|
408
|
+
if (k === '_sig' || k === '_kid' || k === '_ts' || k === '_nonce') continue;
|
|
409
|
+
body[k] = cred[k];
|
|
410
|
+
}
|
|
411
|
+
return body;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 验签:校验 kid、时间窗、nonce 未复用过、MAC 一致。
|
|
416
|
+
* @param cred - 完整凭据(含 _sig/_kid/_ts/_nonce)。
|
|
417
|
+
* @returns {{ok: boolean, reason?: string}}
|
|
418
|
+
*/
|
|
419
|
+
verify(cred, consume = true) {
|
|
420
|
+
try {
|
|
421
|
+
if (!cred || typeof cred !== 'object') return { ok: false, reason: 'malformed credential' };
|
|
422
|
+
const sig = cred._sig;
|
|
423
|
+
if (!sig) return { ok: false, reason: 'missing signature' };
|
|
424
|
+
if (cred._kid !== this.kid) {
|
|
425
|
+
return { ok: false, reason: 'key id mismatch (signer restarted?)' };
|
|
426
|
+
}
|
|
427
|
+
const ts = Number(cred._ts);
|
|
428
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: 'missing/invalid timestamp' };
|
|
429
|
+
if (Date.now() - ts > SIG_TTL_MS) return { ok: false, reason: 'signature expired' };
|
|
430
|
+
if (ts - Date.now() > 60 * 1000) return { ok: false, reason: 'timestamp in the future' };
|
|
431
|
+
const nonce = String(cred._nonce || '');
|
|
432
|
+
if (!nonce) return { ok: false, reason: 'missing nonce' };
|
|
433
|
+
// 【缺口2 修复】nonce 必须真正起作用。
|
|
434
|
+
// 注意:凭据是【静态事实声明】("房间 R 在 run X 通过了校核"),
|
|
435
|
+
// 门禁需要对同一份凭据重复校验(room-end → confirm-assembly → finish),
|
|
436
|
+
// 因此【不能】把"用过的 nonce"一律拒绝(那会让门禁第二次检查就失败)。
|
|
437
|
+
// nonce 的正确职责是【台账核对】:必须是本宿主签发过的 nonce,
|
|
438
|
+
// 从而挡住"伪造/跨语境复用";重复校验是允许的。
|
|
439
|
+
if (this.signedNonces && !this.signedNonces.has(nonce)) {
|
|
440
|
+
return { ok: false, reason: 'nonce not issued by this host (forged or foreign credential)' };
|
|
441
|
+
}
|
|
442
|
+
// ── 【P2 修复】重放防护(仅对外部 verify 调用生效)──────────────────
|
|
443
|
+
// 实测(子代理3):对 /defense/verify 原样重放 3 次全部 verified=true。
|
|
444
|
+
// 根因:verify() 只核对"nonce 是不是本宿主签发的",从不记录
|
|
445
|
+
// "这个 nonce 已经被外部消费过",于是抓到一份合法凭据就能无限重放。
|
|
446
|
+
//
|
|
447
|
+
// 为什么不能一刀切拒绝重复:门禁自身要对同一凭据做多次判定
|
|
448
|
+
// (room-end → confirm-assembly → 收尾),那是【受信任的内部调用】。
|
|
449
|
+
// 因此用调用上下文区分:
|
|
450
|
+
// · consume=true(外部 /defense/verify 请求)→ 首次通过,
|
|
451
|
+
// 同一 nonce 再次出现即判重放并拒绝;
|
|
452
|
+
// · consume=false(门禁/守卫的内部判定)→ 只核对台账,允许重复。
|
|
453
|
+
// 这样既堵住外部重放,又不破坏门禁的多阶段校验。
|
|
454
|
+
if (consume) {
|
|
455
|
+
if (!this.consumedNonces) this.consumedNonces = new Map();
|
|
456
|
+
if (this.consumedNonces.has(nonce)) {
|
|
457
|
+
return { ok: false, reason: 'nonce already consumed (replay detected)' };
|
|
458
|
+
}
|
|
459
|
+
this.consumedNonces.set(nonce, Date.now());
|
|
460
|
+
// 按年龄裁剪,防止无界增长
|
|
461
|
+
try {
|
|
462
|
+
const _now = Date.now();
|
|
463
|
+
for (const [n, t] of this.consumedNonces) {
|
|
464
|
+
if (_now - t > SIG_TTL_MS) this.consumedNonces.delete(n);
|
|
465
|
+
}
|
|
466
|
+
} catch (e) {}
|
|
467
|
+
}
|
|
468
|
+
const body = DefenseSigner.stripMeta(cred);
|
|
469
|
+
const expect = crypto.createHmac('sha256', this.key)
|
|
470
|
+
.update(canonicalJson(body), 'utf8').digest('base64');
|
|
471
|
+
const a = Buffer.from(expect);
|
|
472
|
+
const b = Buffer.from(String(sig));
|
|
473
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
474
|
+
return { ok: false, reason: 'signature mismatch' };
|
|
475
|
+
}
|
|
476
|
+
return { ok: true, nonce };
|
|
477
|
+
} catch (e) {
|
|
478
|
+
return { ok: false, reason: String((e && e.message) || e) };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* 为物理凭据构造待签体:宿主【自己读报告】,不信调用方给的结论。
|
|
484
|
+
* @param room - 房间名。
|
|
485
|
+
* @param reportPath - 报告绝对路径(必须在受管根目录内)。
|
|
486
|
+
* @returns {{ok: boolean, payload?: object, reason?: string}}
|
|
487
|
+
*/
|
|
488
|
+
buildPhysicsPayload(room, reportPath) {
|
|
489
|
+
try {
|
|
490
|
+
const roots = this.managedRoots();
|
|
491
|
+
const abs = pathSync.resolve(String(reportPath || ''));
|
|
492
|
+
const under = roots.some((r) => abs === r || abs.startsWith(r + pathSync.sep));
|
|
493
|
+
if (!under) return { ok: false, reason: 'report path outside managed physics_runs root' };
|
|
494
|
+
let rep;
|
|
495
|
+
try {
|
|
496
|
+
rep = JSON.parse(fsSync.readFileSync(abs, 'utf8'));
|
|
497
|
+
} catch (e) {
|
|
498
|
+
return { ok: false, reason: 'report unreadable or invalid JSON' };
|
|
499
|
+
}
|
|
500
|
+
if (!rep || typeof rep !== 'object') return { ok: false, reason: 'report is not an object' };
|
|
501
|
+
const needs = ['schema_version', 'run_id', 'fea_result', 'geometry_gate', 'overall_status'];
|
|
502
|
+
const missing = needs.filter((k) => !(k in rep));
|
|
503
|
+
if (missing.length) {
|
|
504
|
+
return { ok: false, reason: 'report missing fields: ' + missing.join(',') };
|
|
505
|
+
}
|
|
506
|
+
const repRoom = String(rep.room || '').trim();
|
|
507
|
+
if (repRoom && repRoom !== String(room)) {
|
|
508
|
+
return { ok: false, reason: 'report belongs to another room' };
|
|
509
|
+
}
|
|
510
|
+
// ── 待签体完全由【磁盘事实】构造,调用方无法指定结论 ──────────────
|
|
511
|
+
// 【签后补字段 BUG 修复】原先宿主只签 overall_status / report_path 等少数
|
|
512
|
+
// 字段,而校验端(defense_gate.check_physics_attestation)读的是
|
|
513
|
+
// overall / safety_factor / run_dir / at 等另一批名字。Python 侧只能在
|
|
514
|
+
// 签名【之后】往凭据里补这些字段 —— 于是 HMAC 必然失配,防线②③永远
|
|
515
|
+
// 验签失败(只能靠 --force 绕过)。
|
|
516
|
+
// 正确做法:把所有【校验端会读的字段】全部纳入待签体,由宿主从
|
|
517
|
+
// 磁盘报告里取出并签名;Python 侧此后一个字段都不许再改。
|
|
518
|
+
const _fea = rep.fea_result || {};
|
|
519
|
+
const _fat = _fea.fatigue || {};
|
|
520
|
+
// ── 【观察点 14/BUG-观察-14 修复·等效模型失真披露】─────────────────
|
|
521
|
+
// 背景:physics-optimize 把零件【包围盒】的排序三边等效为【实心矩形
|
|
522
|
+
// 棱柱】悬臂梁建模(fea_solver 的 equivalent_solid_cantilever_from_bbox),
|
|
523
|
+
// 完全忽略真实截面形状与载荷路径。
|
|
524
|
+
// 实测 结构件:equivalent_solid_volume_mm3=9720000 是真实体积
|
|
525
|
+
// 164680 mm³ 的 59.02 倍 —— 而这个失真原先【只写进报告文本】,
|
|
526
|
+
// 从不参与任何判定,防线于是把一个建立在 59 倍失真模型上的
|
|
527
|
+
// SF=8102 当作"强度合格"依据放行。
|
|
528
|
+
// 现在:把模型来源与失真倍数纳入待签体,供 judgeDefense 真正判定。
|
|
529
|
+
const _geo = _fea.geometry_used || {};
|
|
530
|
+
const _lim = (rep.release_readiness || {}).limitations;
|
|
531
|
+
// 失真倍数:字段缺失 / 非数 → null(老报告兼容,不因此拦截)
|
|
532
|
+
const _vinf = (_geo.volume_inflation_x === undefined
|
|
533
|
+
|| _geo.volume_inflation_x === null)
|
|
534
|
+
? null : Number(_geo.volume_inflation_x);
|
|
535
|
+
const _viNum = Number.isFinite(_vinf) ? _vinf : null;
|
|
536
|
+
const payload = {
|
|
537
|
+
kind: 'physics',
|
|
538
|
+
// schema / at / ts 由宿主在签名时写定,Python 侧不得再补(否则验签失配)
|
|
539
|
+
schema: 'dsh-physics-attestation/1',
|
|
540
|
+
at: new Date().toISOString(),
|
|
541
|
+
ts: Date.now(),
|
|
542
|
+
room: String(room),
|
|
543
|
+
run_id: String(rep.run_id || ''),
|
|
544
|
+
report_path: abs,
|
|
545
|
+
report_sha256: fileSha256(abs),
|
|
546
|
+
// run_dir:由已签名的 report_path 推导,无需调用方另行补字段
|
|
547
|
+
run_dir: pathSync.dirname(abs),
|
|
548
|
+
// overall 与 overall_status 同值:前者是校验端的读名,后者是报告原名
|
|
549
|
+
overall: String(rep.overall_status || ''),
|
|
550
|
+
overall_status: String(rep.overall_status || ''),
|
|
551
|
+
safety_factor: _fea.safety_factor === undefined ? null : _fea.safety_factor,
|
|
552
|
+
max_von_mises_mpa: _fea.max_von_mises_mpa === undefined ? null : _fea.max_von_mises_mpa,
|
|
553
|
+
max_displacement_mm: _fea.max_displacement_mm === undefined ? null : _fea.max_displacement_mm,
|
|
554
|
+
passed_gates: Array.isArray(rep.passed_gates) ? rep.passed_gates : [],
|
|
555
|
+
failed_gates: Array.isArray(rep.failed_gates) ? rep.failed_gates : [],
|
|
556
|
+
not_evaluated: Array.isArray(rep.not_evaluated) ? rep.not_evaluated : [],
|
|
557
|
+
release_status: String((rep.release_readiness || {}).status || ''),
|
|
558
|
+
fatigue_verdict: String(_fat.verdict || ''),
|
|
559
|
+
fatigue_sf: _fat.fatigue_sf === undefined ? null : _fat.fatigue_sf,
|
|
560
|
+
// ── 【观察点 14/BUG-观察-14】等效模型的可信度事实(纳入签名,不可事后篡改)
|
|
561
|
+
// model —— 求解器实际使用的几何模型标识
|
|
562
|
+
// volume_inflation_x —— 等效实心体积 / 真实体积(失真倍数,可能为 null)
|
|
563
|
+
// model_trustworthy —— 失真 ≤1.5 倍视为可信;体积字段缺失(null)
|
|
564
|
+
// 时 `!(null > 1.5) === true`,即判为"可信",
|
|
565
|
+
// 使"老报告无此字段"不会被误拦截
|
|
566
|
+
// limitations —— 报告自述的适用边界/局限(含等效性说明)
|
|
567
|
+
model: String(_geo.model || ''),
|
|
568
|
+
volume_inflation_x: _viNum,
|
|
569
|
+
model_trustworthy: !(_viNum > 1.5),
|
|
570
|
+
limitations: Array.isArray(_lim) ? _lim : [],
|
|
571
|
+
// == [A-68 修复] 物理凭据必须绑定【它实际校核的那件零件】======
|
|
572
|
+
// 原待签体只有 room + run_id + report_path,没有零件维度。
|
|
573
|
+
// 实测(2026-11-01):某房"通过"的凭据 run_id 是另一个进程
|
|
574
|
+
// 06:37:29 写入的,part_path=null / material=null,而本房真正
|
|
575
|
+
// 要校核的是另一件 —— 等于"你过的是别人的成绩单";同房 2 小时
|
|
576
|
+
// 内结论还能 2.39x → 2.82x → 1.45x,每次都恰好更好。
|
|
577
|
+
// 零件事实由【宿主自己读盘】确认(sha256 由宿主算,调用方无法伪造),
|
|
578
|
+
// 取不到就如实写 null,校验端会判"未绑定"而不是默认放行。
|
|
579
|
+
part_path: (() => {
|
|
580
|
+
try {
|
|
581
|
+
const _pp = String(_fea.part_path
|
|
582
|
+
|| (_fea.real_geometry && _fea.real_geometry.part_path) || '').trim();
|
|
583
|
+
return _pp ? pathSync.resolve(_pp) : null;
|
|
584
|
+
} catch (e) { return null; }
|
|
585
|
+
})(),
|
|
586
|
+
part_name: (() => {
|
|
587
|
+
try {
|
|
588
|
+
const _pp = String(_fea.part_path
|
|
589
|
+
|| (_fea.real_geometry && _fea.real_geometry.part_path) || '').trim();
|
|
590
|
+
return _pp ? pathSync.basename(_pp) : null;
|
|
591
|
+
} catch (e) { return null; }
|
|
592
|
+
})(),
|
|
593
|
+
// sha256 由宿主对【磁盘上的零件文件】现算 —— 这是不可伪造的绑定
|
|
594
|
+
part_sha256: (() => {
|
|
595
|
+
try {
|
|
596
|
+
const _pp = String(_fea.part_path
|
|
597
|
+
|| (_fea.real_geometry && _fea.real_geometry.part_path) || '').trim();
|
|
598
|
+
if (!_pp || !fsSync.existsSync(_pp)) return null;
|
|
599
|
+
return fileSha256(_pp);
|
|
600
|
+
} catch (e) { return null; }
|
|
601
|
+
})(),
|
|
602
|
+
source: 'physics_bridge',
|
|
603
|
+
};
|
|
604
|
+
return { ok: true, payload };
|
|
605
|
+
} catch (e) {
|
|
606
|
+
return { ok: false, reason: String((e && e.message) || e) };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** 受管的物理运行根目录。 */
|
|
611
|
+
managedRoots() {
|
|
612
|
+
// ── 【Bug8 修复】必须覆盖【两个部署位置】────────────────────────────
|
|
613
|
+
// 工程模式有"两份副本":源码工作区 与 ~/.dsh 安装目录。
|
|
614
|
+
// 宿主按【自己的 toolsDir】(安装目录) 算受管根,而小屋脚本在
|
|
615
|
+
// 【工作区】里跑、报告也写在工作区 output/ → 两边路径对不上,
|
|
616
|
+
// 于是 /defense/sign 一律以 "report path outside managed
|
|
617
|
+
// physics_runs root" 拒签,防线②凭据永远签不出来。
|
|
618
|
+
// 现在把所有已知落点都纳入受管根(宁宽勿误拒,安全由"报告必须
|
|
619
|
+
// 真实存在 + 结论一致 + 房间归属"共同保证)。
|
|
620
|
+
const cands = [
|
|
621
|
+
pathSync.join(this.toolsDir, "..", "..", "output", "physics_runs"),
|
|
622
|
+
pathSync.join(this.toolsDir, "output", "physics_runs"),
|
|
623
|
+
pathSync.join(this.toolsDir, "..", "output", "physics_runs"),
|
|
624
|
+
];
|
|
625
|
+
// 额外:DSH_HOME 下的安装位置,以及工作区(含 engineering/ 与根目录)
|
|
626
|
+
try {
|
|
627
|
+
const home = process.env.USERPROFILE || process.env.HOME || "";
|
|
628
|
+
const dshHome = process.env.DSH_HOME || (home ? pathSync.join(home, ".dsh") : "");
|
|
629
|
+
if (dshHome) {
|
|
630
|
+
cands.push(pathSync.join(dshHome, ".agent-presets", "engineering",
|
|
631
|
+
"tools", "..", "..", "output", "physics_runs"));
|
|
632
|
+
cands.push(pathSync.join(dshHome, "engineering", "output", "physics_runs"));
|
|
633
|
+
}
|
|
634
|
+
} catch (e) {
|
|
635
|
+
// 环境变量缺失不影响主候选
|
|
636
|
+
}
|
|
637
|
+
// ── 工作区副本(关键):宿主只知道自己那份 toolsDir,但小屋脚本与
|
|
638
|
+
// 报告常常在【源码工作区】。这里主动读取工程模式根目录线索:
|
|
639
|
+
// ① 环境变量 DSH_ENGINEERING_ROOT(SKILL 文档推荐的权威来源)
|
|
640
|
+
// ② <toolsDir>/.defense/workspace.txt(由 Python 侧写入的落点声明)
|
|
641
|
+
try {
|
|
642
|
+
const _eng = process.env.DSH_ENGINEERING_ROOT;
|
|
643
|
+
if (_eng) {
|
|
644
|
+
cands.push(pathSync.join(_eng, "output", "physics_runs"));
|
|
645
|
+
cands.push(pathSync.join(_eng, "..", "output", "physics_runs"));
|
|
646
|
+
}
|
|
647
|
+
} catch (e) {
|
|
648
|
+
// 环境变量缺失不影响主候选
|
|
649
|
+
}
|
|
650
|
+
try {
|
|
651
|
+
// ── 【Bug-02 修复】workspace.txt 必须【跨所有部署副本】合并读取 ──────
|
|
652
|
+
// 实测缺陷:本文件原先只读 `this.dir`(宿主自己那份 toolsDir 的
|
|
653
|
+
// .defense/workspace.txt)。但工程模式有工作区/安装目录两份副本,
|
|
654
|
+
// Python 侧从【工作区】跑 physics 时把报告写在
|
|
655
|
+
// 工作区\output\physics_runs,而宿主读的是【安装副本】的声明 ——
|
|
656
|
+
// 工作区路径不在受管根集合里 → /defense/sign 一律拒签
|
|
657
|
+
// "report path outside managed physics_runs root",防线②凭据永远
|
|
658
|
+
// 签不出来(实测 5 次签发失败,全部 fail-closed)。
|
|
659
|
+
// 现在:把所有已知 .defense 目录(含另一份副本、环境变量声明的、
|
|
660
|
+
// DSH_HOME 下的)里的 workspace.txt 全部读入求并集。
|
|
661
|
+
const _wsRoots = [];
|
|
662
|
+
const _seenWs = new Set();
|
|
663
|
+
const _pushWs = (s) => {
|
|
664
|
+
const _v = String(s || '').trim();
|
|
665
|
+
if (!_v) return;
|
|
666
|
+
let _a = _v;
|
|
667
|
+
try { _a = pathSync.resolve(_v); } catch (e) { _a = _v; }
|
|
668
|
+
const _k = process.platform === 'win32' ? _a.toLowerCase() : _a;
|
|
669
|
+
if (_seenWs.has(_k)) return;
|
|
670
|
+
_seenWs.add(_k);
|
|
671
|
+
_wsRoots.push(_a);
|
|
672
|
+
};
|
|
673
|
+
const _wsFiles = [pathSync.join(this.dir, "workspace.txt")];
|
|
674
|
+
try {
|
|
675
|
+
const _home = process.env.USERPROFILE || process.env.HOME || "";
|
|
676
|
+
const _dshHome = process.env.DSH_HOME || (_home ? pathSync.join(_home, ".dsh") : "");
|
|
677
|
+
if (_dshHome) {
|
|
678
|
+
_wsFiles.push(pathSync.join(_dshHome, ".agent-presets", "engineering",
|
|
679
|
+
"tools", ".defense", "workspace.txt"));
|
|
680
|
+
_wsFiles.push(pathSync.join(_dshHome, "engineering", "tools",
|
|
681
|
+
".defense", "workspace.txt"));
|
|
682
|
+
}
|
|
683
|
+
} catch (e) {}
|
|
684
|
+
try {
|
|
685
|
+
const _eng = process.env.DSH_ENGINEERING_ROOT;
|
|
686
|
+
if (_eng) {
|
|
687
|
+
_wsFiles.push(pathSync.join(_eng, "tools", ".defense", "workspace.txt"));
|
|
688
|
+
_wsFiles.push(pathSync.join(_eng, ".defense", "workspace.txt"));
|
|
689
|
+
}
|
|
690
|
+
} catch (e) {}
|
|
691
|
+
// 宿主 toolsDir 的两级父目录下也可能有 engineering 副本
|
|
692
|
+
try {
|
|
693
|
+
const _p1 = pathSync.join(this.toolsDir, "..");
|
|
694
|
+
const _p2 = pathSync.join(this.toolsDir, "..", "..");
|
|
695
|
+
_wsFiles.push(pathSync.join(_p1, ".defense", "workspace.txt"));
|
|
696
|
+
_wsFiles.push(pathSync.join(_p2, "engineering", "tools", ".defense", "workspace.txt"));
|
|
697
|
+
} catch (e) {}
|
|
698
|
+
for (const _f of _wsFiles) {
|
|
699
|
+
try {
|
|
700
|
+
if (!fsSync.existsSync(_f)) continue;
|
|
701
|
+
// workspace.txt 可含【多行】根目录(Python 侧会写入仓库根与
|
|
702
|
+
// engineering 两处,见 defense_gate._publish_workspace_root)。
|
|
703
|
+
String(fsSync.readFileSync(_f, "utf8"))
|
|
704
|
+
.split(/\r?\n/).forEach((s) => _pushWs(s));
|
|
705
|
+
} catch (e) {
|
|
706
|
+
// 单个声明文件缺失/损坏不影响其余
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
for (const _wsRoot of _wsRoots) {
|
|
710
|
+
cands.push(pathSync.join(_wsRoot, "output", "physics_runs"));
|
|
711
|
+
cands.push(pathSync.join(_wsRoot, "engineering", "output", "physics_runs"));
|
|
712
|
+
cands.push(pathSync.join(_wsRoot, "engineering", "tools", "output", "physics_runs"));
|
|
713
|
+
// 声明若直接指向 tools 目录,也覆盖其两级父目录
|
|
714
|
+
cands.push(pathSync.join(_wsRoot, "..", "output", "physics_runs"));
|
|
715
|
+
cands.push(pathSync.join(_wsRoot, "..", "..", "output", "physics_runs"));
|
|
716
|
+
}
|
|
717
|
+
} catch (e) {
|
|
718
|
+
// 声明文件缺失不影响主候选
|
|
719
|
+
}
|
|
720
|
+
return cands.map((p) => pathSync.resolve(p));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** 启动守卫:周期性核对凭据与台账,发现异常即记录并可恢复。 */
|
|
724
|
+
startGuard(intervalMs = 5000) {
|
|
725
|
+
if (this.guardTimer) return;
|
|
726
|
+
this.snapshotBaseline();
|
|
727
|
+
this.guardTimer = setInterval(() => {
|
|
728
|
+
try {
|
|
729
|
+
this.guardTick();
|
|
730
|
+
} catch (e) {
|
|
731
|
+
this.log('guard tick error: ' + String((e && e.message) || e));
|
|
732
|
+
}
|
|
733
|
+
}, intervalMs);
|
|
734
|
+
if (this.guardTimer && typeof this.guardTimer.unref === 'function') this.guardTimer.unref();
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** 停止守卫。 */
|
|
738
|
+
stopGuard() {
|
|
739
|
+
if (this.guardTimer) {
|
|
740
|
+
clearInterval(this.guardTimer);
|
|
741
|
+
this.guardTimer = null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** 取当前 snapshot 范围(.defense 下的公钥/运行文件 + 凭据目录)。 */
|
|
746
|
+
observedPaths() {
|
|
747
|
+
const out = [];
|
|
748
|
+
const defDir = this.dir;
|
|
749
|
+
for (const f of ['runtime.json', 'public.pem', 'kid.txt']) {
|
|
750
|
+
out.push(pathSync.join(defDir, f));
|
|
751
|
+
}
|
|
752
|
+
const reportsDir = pathSync.join(this.toolsDir, 'reports');
|
|
753
|
+
try {
|
|
754
|
+
for (const name of fsSync.readdirSync(reportsDir)) {
|
|
755
|
+
if (name.endsWith('.physics.json') || name.endsWith('.domain.json')) {
|
|
756
|
+
out.push(pathSync.join(reportsDir, name));
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
} catch (e) {
|
|
760
|
+
// reports 目录尚不存在:无凭据可观察
|
|
761
|
+
}
|
|
762
|
+
return out;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** 记录基线哈希。 */
|
|
766
|
+
snapshotBaseline() {
|
|
767
|
+
for (const p of this.observedPaths()) {
|
|
768
|
+
const h = fileSha256(p);
|
|
769
|
+
if (h) this.baseline.set(p, h);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* 守卫单次巡检:凭据必须带有效签名;被改动过的凭据记异常。
|
|
775
|
+
* 这是"补丁":不产生信任,只保证校验读取的是签名认可的版本。
|
|
776
|
+
*/
|
|
777
|
+
guardTick() {
|
|
778
|
+
// ── 【Bug-06 修复】巡检必须覆盖【所有部署副本】的 reports 目录 ────────
|
|
779
|
+
// 原实现只看 this.toolsDir/reports。但工程模式有两份副本,凭据可能写在
|
|
780
|
+
// 工作区副本(Desktop\...\engineering\tools\reports)里,宿主这份
|
|
781
|
+
// 完全看不到 → 守卫"漏检",且宿主 /defense/judge 也会因找不到凭据
|
|
782
|
+
// 而判 fail-closed。这里把候选 reports 目录全部纳入巡检。
|
|
783
|
+
const _reportsDirs = [];
|
|
784
|
+
const _pushDir = (d) => { try { if (d && _reportsDirs.indexOf(d) < 0) _reportsDirs.push(d); } catch (e) {} };
|
|
785
|
+
_pushDir(pathSync.join(this.toolsDir, 'reports'));
|
|
786
|
+
try {
|
|
787
|
+
const _home = process.env.USERPROFILE || process.env.HOME || "";
|
|
788
|
+
const _dshHome = process.env.DSH_HOME || (_home ? pathSync.join(_home, ".dsh") : "");
|
|
789
|
+
if (_dshHome) {
|
|
790
|
+
_pushDir(pathSync.join(_dshHome, ".agent-presets", "engineering", "tools", "reports"));
|
|
791
|
+
_pushDir(pathSync.join(_dshHome, "engineering", "tools", "reports"));
|
|
792
|
+
}
|
|
793
|
+
} catch (e) {}
|
|
794
|
+
try {
|
|
795
|
+
const _eng = process.env.DSH_ENGINEERING_ROOT;
|
|
796
|
+
if (_eng) {
|
|
797
|
+
_pushDir(pathSync.join(_eng, "tools", "reports"));
|
|
798
|
+
_pushDir(pathSync.join(_eng, "reports"));
|
|
799
|
+
}
|
|
800
|
+
} catch (e) {}
|
|
801
|
+
// workspace.txt 声明的每个根也纳入(其 reports 与 tools/reports 两种布局)
|
|
802
|
+
try {
|
|
803
|
+
const _wsFile = pathSync.join(this.dir, "workspace.txt");
|
|
804
|
+
if (fsSync.existsSync(_wsFile)) {
|
|
805
|
+
String(fsSync.readFileSync(_wsFile, "utf8")).split(/\r?\n/)
|
|
806
|
+
.map((s) => s.trim()).filter(Boolean)
|
|
807
|
+
.forEach((_r) => {
|
|
808
|
+
_pushDir(pathSync.join(_r, "tools", "reports"));
|
|
809
|
+
_pushDir(pathSync.join(_r, "reports"));
|
|
810
|
+
_pushDir(pathSync.join(_r, "engineering", "tools", "reports"));
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
} catch (e) {}
|
|
814
|
+
for (const _rd of _reportsDirs) {
|
|
815
|
+
try { this.guardScanDir(_rd); } catch (e) {
|
|
816
|
+
this.log('guard scan error @' + _rd + ': ' + String((e && e.message) || e));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// 落盘守卫报告(含全部副本的观察结果)
|
|
820
|
+
try {
|
|
821
|
+
fsSync.writeFileSync(pathSync.join(this.dir, 'guard.json'),
|
|
822
|
+
JSON.stringify({ items: this.anomalies.slice(-200) }, null, 2), 'utf8');
|
|
823
|
+
} catch (e) {}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** 巡检单个 reports 目录(供 guardTick 对所有副本调用)。 */
|
|
827
|
+
guardScanDir(reportsDir) {
|
|
828
|
+
let names = [];
|
|
829
|
+
try {
|
|
830
|
+
names = fsSync.readdirSync(reportsDir);
|
|
831
|
+
} catch (e) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
for (const name of names) {
|
|
835
|
+
if (!(name.endsWith('.physics.json') || name.endsWith('.domain.json'))) continue;
|
|
836
|
+
const p = pathSync.join(reportsDir, name);
|
|
837
|
+
const h = fileSha256(p);
|
|
838
|
+
if (!h) continue;
|
|
839
|
+
// 未带签名 → 异常(合法凭据必带签名)
|
|
840
|
+
let cred = null;
|
|
841
|
+
try {
|
|
842
|
+
cred = JSON.parse(fsSync.readFileSync(p, 'utf8'));
|
|
843
|
+
} catch (e) {
|
|
844
|
+
this.flag('credential-unreadable', p, 'JSON 解析失败');
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
// 【缺口4 修复】baseline 真正启用:巡检发现凭据相对基线被改写时留痕。
|
|
848
|
+
// 原实现 snapshotBaseline() 存了快照却从不读,宣称的"篡改检测"未实现。
|
|
849
|
+
//
|
|
850
|
+
// ── 【Bug-06 修复】区分"合法重签"与"外部篡改" ─────────────────────
|
|
851
|
+
// 实测现象:guard.json 记录 传动机构.domain.json
|
|
852
|
+
// kind=credential-modified「凭据在签名之后被改写」,但该凭据的
|
|
853
|
+
// _sig/_kid 与宿主一致 —— 校验端因此无法判断签名是否仍然有效。
|
|
854
|
+
// 根因:原实现【任何字节变化】都记 credential-modified。而工程模式里
|
|
855
|
+
// 凭据被"合法重写"是常态:同一房间重跑校核(physics-optimize /
|
|
856
|
+
// physics-validate-domain)会签发【新 nonce 的新凭据】覆盖旧文件。
|
|
857
|
+
// 把这种正常重签报成"篡改"是假阳性,会削弱守卫告警的可信度。
|
|
858
|
+
// 现在按【nonce 是否为本宿主新签发】区分:
|
|
859
|
+
// · 文件变化 + 新 nonce 且在本宿主台账中 → 合法重签(记 note,
|
|
860
|
+
// 用 credential-resigned,不隔离、不报警);
|
|
861
|
+
// · 文件变化 + nonce 未变(或不在台账)→ 真·签名后被改写
|
|
862
|
+
// (记 credential-modified,并由后续验签决定是否隔离)。
|
|
863
|
+
const base = this.baseline.get(p);
|
|
864
|
+
if (base && base !== h) {
|
|
865
|
+
let _nonceNow = '';
|
|
866
|
+
try { _nonceNow = String((cred && cred._nonce) || ''); } catch (e) {}
|
|
867
|
+
const _knownNonce = !!(_nonceNow && this.signedNonces
|
|
868
|
+
&& this.signedNonces.has(_nonceNow));
|
|
869
|
+
if (_knownNonce) {
|
|
870
|
+
this.note('credential-resigned', p,
|
|
871
|
+
'凭据被本宿主重新签发(新 nonce ' + _nonceNow.slice(0, 12)
|
|
872
|
+
+ '…)—— 属合法重写,非篡改');
|
|
873
|
+
} else {
|
|
874
|
+
this.note('credential-modified', p,
|
|
875
|
+
'凭据在基线之后被改写,且 nonce 非本宿主新签发'
|
|
876
|
+
+ '(疑似外部篡改;若确为手工改 verdict 字段,验签将失败)');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
this.baseline.set(p, h);
|
|
880
|
+
if (!cred || !cred._sig) {
|
|
881
|
+
this.flag('credential-unsigned', p, '凭据缺少签名(疑似手写伪造)');
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
// 守卫巡检用 consume=false:只读校验,不消费 nonce,也不把
|
|
885
|
+
// "仅因 TTL 过期"的合法凭据当作攻击 —— 过期只记录、不隔离。
|
|
886
|
+
const v = this.verify(cred, false);
|
|
887
|
+
if (!v.ok) {
|
|
888
|
+
const reason = String(v.reason || '');
|
|
889
|
+
if (reason.indexOf('expired') >= 0) {
|
|
890
|
+
// 【缺口3 修复】过期 ≠ 伪造。隔离会造成"昨天签的合法凭据凭空消失",
|
|
891
|
+
// 这是破坏性假阳性。只记录,交由 defense_gate 按"过期"正常拒绝。
|
|
892
|
+
this.note('credential-expired', p, reason);
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
this.flag('credential-signature-invalid', p, reason);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
// 与台账交叉核对:该房间确有签发记录
|
|
899
|
+
const room = String(cred.room || '');
|
|
900
|
+
const has = this.ledger.some((e) => e.kind === 'sign' && e.room === room);
|
|
901
|
+
if (!has) this.flag('credential-not-in-ledger', p, '台账无该房间的签发记录');
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* 记录一条守卫观察(不触发隔离)。
|
|
907
|
+
*
|
|
908
|
+
* 与 flag() 的区别:过期等"非攻击"情形只留痕,不移除凭据 ——
|
|
909
|
+
* 否则守卫本身会成为破坏源(缺口3)。
|
|
910
|
+
* @param kind - 观察类别。
|
|
911
|
+
* @param path - 相关文件路径。
|
|
912
|
+
* @param detail - 细节。
|
|
913
|
+
*/
|
|
914
|
+
note(kind, path, detail) {
|
|
915
|
+
const entry = { at: new Date().toISOString(), kind, path, detail };
|
|
916
|
+
this.anomalies.push(entry);
|
|
917
|
+
if (this.anomalies.length > 500) this.anomalies = this.anomalies.slice(-500);
|
|
918
|
+
this.log('defense guard (note): ' + kind + ' @ ' + path + ' :: ' + detail);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/**
|
|
922
|
+
* 记录一次守卫异常,并把未通过验签的凭据【隔离】。
|
|
923
|
+
*
|
|
924
|
+
* 隔离(而非"恢复原文件")才是凭据场景下的正确语义:一份无效凭据
|
|
925
|
+
* 无法凭空重建为有效版本 —— 重建需要重跑真实校核。因此守卫的作用是
|
|
926
|
+
* 把它移出校验位置,让 defense_gate 因"缺少凭据"而 fail-closed,
|
|
927
|
+
* 同时保留原件供审计。这正对应"读不到合法文件时让校验失败"。
|
|
928
|
+
*/
|
|
929
|
+
flag(kind, path, detail) {
|
|
930
|
+
const entry = { at: new Date().toISOString(), kind, path, detail };
|
|
931
|
+
this.anomalies.push(entry);
|
|
932
|
+
if (this.anomalies.length > 500) this.anomalies = this.anomalies.slice(-500);
|
|
933
|
+
this.log('defense guard: ' + kind + ' @ ' + path + ' :: ' + detail);
|
|
934
|
+
if (kind === 'credential-unsigned' || kind === 'credential-signature-invalid'
|
|
935
|
+
|| kind === 'credential-not-in-ledger' || kind === 'credential-unreadable') {
|
|
936
|
+
this.quarantine(path, kind);
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
fsSync.writeFileSync(pathSync.join(this.dir, 'guard.json'),
|
|
940
|
+
JSON.stringify({ items: this.anomalies.slice(-200) }, null, 2), 'utf8');
|
|
941
|
+
} catch (e) {
|
|
942
|
+
// 守卫报告写入失败不影响巡检本身
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* 把无效凭据移入 .defense/quarantine/,并记下时间戳。
|
|
948
|
+
*
|
|
949
|
+
* 只移动失败一次的文件;重复移动同一路径会被跳过,避免频繁 IO。
|
|
950
|
+
* @param path - 凭据绝对路径。
|
|
951
|
+
* @param kind - 异常类别。
|
|
952
|
+
*/
|
|
953
|
+
quarantine(path, kind) {
|
|
954
|
+
try {
|
|
955
|
+
if (!this.quarantined) this.quarantined = new Set();
|
|
956
|
+
if (this.quarantined.has(path)) return;
|
|
957
|
+
if (!fsSync.existsSync(path)) return;
|
|
958
|
+
const qdir = pathSync.join(this.dir, 'quarantine');
|
|
959
|
+
fsSync.mkdirSync(qdir, { recursive: true });
|
|
960
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
961
|
+
const dest = pathSync.join(qdir, stamp + '__' + pathSync.basename(path));
|
|
962
|
+
fsSync.renameSync(path, dest);
|
|
963
|
+
this.quarantined.add(path);
|
|
964
|
+
this.anomalies.push({
|
|
965
|
+
at: new Date().toISOString(),
|
|
966
|
+
kind: 'credential-quarantined',
|
|
967
|
+
path,
|
|
968
|
+
dest,
|
|
969
|
+
detail: '无效凭据(' + kind + ')已隔离;defense_gate 将因缺少凭据而 fail-closed',
|
|
970
|
+
});
|
|
971
|
+
// 隔离后基线失效,重新采样
|
|
972
|
+
this.baseline.delete(path);
|
|
973
|
+
} catch (e) {
|
|
974
|
+
this.log('quarantine failed: ' + String((e && e.message) || e));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* 宿主侧执行一次完整防线判定(C 方案:判定权收回宿主)。
|
|
980
|
+
*
|
|
981
|
+
* 为什么必须由宿主判定:Python 侧信任的是"某个 HTTP 端点",而该端点
|
|
982
|
+
* 可以被同用户进程冒充(对抗测试已实证:伪造 runtime.json 指向一个
|
|
983
|
+
* 恒真服务即可整体绕过)。把判定搬进宿主后,AI 在 Python 侧不再有
|
|
984
|
+
* 可冒充的判定点 —— 它要么让真宿主判定,要么篡改 defense_gate 本身
|
|
985
|
+
* (那是另一类攻击,且守约会记录凭据异常)。
|
|
986
|
+
*
|
|
987
|
+
* @param room - 房间名。
|
|
988
|
+
* @param kind - 'room' | 'task'。
|
|
989
|
+
* @param opts - { requirePhysics, rooms } 等上下文,由 Python 传入。
|
|
990
|
+
* @returns 判定结果(结构同 defense_gate.check_room_defense)。
|
|
991
|
+
*/
|
|
992
|
+
/**
|
|
993
|
+
* 【本次修复·Bug⑤】解析"本次判定应当读取的 reports 目录"。
|
|
994
|
+
*
|
|
995
|
+
* 工程模式的凭据实际落在 reports/<task_id>/ 子目录(按任务隔离),
|
|
996
|
+
* 而旧实现只读 reports/ 根 → 凭据存在却判 missing(致命 Bug⑤)。
|
|
997
|
+
*
|
|
998
|
+
* @param {object} opts - { taskId } 可显式指定任务 id。
|
|
999
|
+
* @returns {string} 实际要读的 reports 目录绝对路径。
|
|
1000
|
+
*/
|
|
1001
|
+
_resolveReportsDir(opts = {}) {
|
|
1002
|
+
const flat = pathSync.join(this.toolsDir, 'reports');
|
|
1003
|
+
// ① 显式传入
|
|
1004
|
+
const explicit = String((opts && opts.taskId) || '').trim();
|
|
1005
|
+
if (explicit) {
|
|
1006
|
+
const p = pathSync.join(flat, explicit);
|
|
1007
|
+
try { if (fsSync.existsSync(p) && fsSync.statSync(p).isDirectory()) return p; }
|
|
1008
|
+
catch (e) { /* 继续尝试下一来源 */ }
|
|
1009
|
+
}
|
|
1010
|
+
// ② workflow_state.json 的当前 task_id
|
|
1011
|
+
try {
|
|
1012
|
+
const sf = pathSync.join(this.toolsDir, 'workflow_state.json');
|
|
1013
|
+
if (fsSync.existsSync(sf)) {
|
|
1014
|
+
const st = JSON.parse(fsSync.readFileSync(sf, 'utf8'));
|
|
1015
|
+
const tid = String((st && (st.task_id || (st.state && st.state.task_id))) || '').trim();
|
|
1016
|
+
if (tid) {
|
|
1017
|
+
const p = pathSync.join(flat, tid);
|
|
1018
|
+
if (fsSync.existsSync(p) && fsSync.statSync(p).isDirectory()) return p;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
} catch (e) { /* 继续尝试下一来源 */ }
|
|
1022
|
+
// ③ reports/ 下最新的子目录(按 mtime)
|
|
1023
|
+
try {
|
|
1024
|
+
if (fsSync.existsSync(flat)) {
|
|
1025
|
+
const subs = fsSync.readdirSync(flat, { withFileTypes: true })
|
|
1026
|
+
.filter((d) => d && d.isDirectory && d.isDirectory())
|
|
1027
|
+
.map((d) => {
|
|
1028
|
+
const full = pathSync.join(flat, d.name);
|
|
1029
|
+
let mt = 0;
|
|
1030
|
+
try { mt = fsSync.statSync(full).mtimeMs || 0; } catch (e) { mt = 0; }
|
|
1031
|
+
return { name: d.name, full, mt };
|
|
1032
|
+
})
|
|
1033
|
+
.sort((a, b) => b.mt - a.mt);
|
|
1034
|
+
if (subs.length) return subs[0].full;
|
|
1035
|
+
}
|
|
1036
|
+
} catch (e) { /* 继续尝试下一来源 */ }
|
|
1037
|
+
// ④ flat 根(旧布局)
|
|
1038
|
+
return flat;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
judgeDefense(room, kind, opts = {}) {
|
|
1042
|
+
const out = { ok: false, room, blockers: [], warnings: [], details: {}, judged_by: 'host' };
|
|
1043
|
+
try {
|
|
1044
|
+
// ══ 【本次修复·Bug⑤ 根治】宿主必须能读 reports/<task_id>/ 子目录 ══════
|
|
1045
|
+
// 缺陷:Python 侧凭据落在 reports/<task_id>/<房间>.physics.json,
|
|
1046
|
+
// 而这里【只读 flat 的 reports/】→ 凭据明明存在却判 missing →
|
|
1047
|
+
// room-end 被永久拒绝(用户实测的致命 Bug⑤)。
|
|
1048
|
+
// Python 侧已用"候选集合 + 转本地验签"兜住,但那只是绕行;
|
|
1049
|
+
// 根治点是这里也要认 task 子目录。
|
|
1050
|
+
// 解析顺序(命中即用,全部未命中再退回 flat):
|
|
1051
|
+
// ① opts.taskId 显式传入(Python 侧 check_room_defense 一定带上)
|
|
1052
|
+
// ② workflow_state.json 里的当前 task_id(真实运行时的权威来源)
|
|
1053
|
+
// ③ reports/ 下【最新修改】的子目录(兜底:状态文件缺失/跨副本时)
|
|
1054
|
+
// ④ flat reports/ 根(旧布局,保持兼容)
|
|
1055
|
+
const reportsDir = this._resolveReportsDir(opts);
|
|
1056
|
+
out.reports_dir = reportsDir;
|
|
1057
|
+
const requirePhysics = opts.requirePhysics !== false;
|
|
1058
|
+
// ── 物理凭据 ──
|
|
1059
|
+
if (requirePhysics) {
|
|
1060
|
+
// ── 【新 Bug 修复】阻断原因必须【按具体原因区分】────────────────
|
|
1061
|
+
// 原实现把 readCredential 返回 null 一律写成"缺少凭据或验签失败"。
|
|
1062
|
+
// 但 null 有三种完全不同的成因:凭据不存在 / 无签名 / 验签失败;
|
|
1063
|
+
// 而当凭据【完全正常】、只是 overall=FAIL(强度不足)时,
|
|
1064
|
+
// 文案同样会误导成"凭据层故障"(实测报告 §四)。
|
|
1065
|
+
// 现在:先判"凭据层"(用 reason_code 精确措辞),
|
|
1066
|
+
// 凭据层通过后再判"结论层"(FAIL / 无法识别 / REVIEW)。
|
|
1067
|
+
const phD = this.readCredentialDetailed(reportsDir, room, '.physics.json');
|
|
1068
|
+
if (!phD.ok) {
|
|
1069
|
+
out.blockers.push('【防线②物理】' + this._credFailText(phD, '物理'));
|
|
1070
|
+
out.details.physics = { verified: false, failure: phD.reason_code,
|
|
1071
|
+
reason: phD.reason };
|
|
1072
|
+
} else {
|
|
1073
|
+
const ph = phD.cred;
|
|
1074
|
+
const overall = String(ph.overall_status || ph.overall || '').toUpperCase();
|
|
1075
|
+
if (overall === 'FAIL') {
|
|
1076
|
+
// ── 【BUG-观察-13 修复】归因必须【按 failed_gates 真实分派】────
|
|
1077
|
+
// 实测缺陷(真实产物 output/tools/reports/结构件.physics.json):
|
|
1078
|
+
// 该凭据 overall=FAIL、failed_gates=[{id:DESIGN_SPACE}](零件 z 跨度
|
|
1079
|
+
// 180mm > 设计域 120mm,纯【几何越界】,与强度/寿命无关),
|
|
1080
|
+
// 而原实现【对任何 FAIL 都硬编码】输出:
|
|
1081
|
+
// "…(安全系数 8102.09,要求≥见工况);疲劳判定 PASS
|
|
1082
|
+
// —— 属【强度/寿命不达标】,不是凭据问题"
|
|
1083
|
+
// 它只回显 safety_factor(8102.09) 与 fatigue_verdict(PASS)
|
|
1084
|
+
// —— 恰恰是最不可能导致 FAIL 的两个值 —— 却【完全不读
|
|
1085
|
+
// failed_gates】,于是把几何越界错报成"强度/寿命不达标",
|
|
1086
|
+
// 自相矛盾且把排查方向带偏。
|
|
1087
|
+
// 现在:① 只按 failed_gates[].id 分派归因;
|
|
1088
|
+
// ② 仅当闸口里【确实含】SAFETY_FACTOR 时才回显 safety_factor,
|
|
1089
|
+
// 仅当含 FATIGUE* 时才回显 fatigue_verdict(避免打印与
|
|
1090
|
+
// FAIL 无关的数值造成误导);
|
|
1091
|
+
// ③ 明确列出未通过的闸口 id 列表(最有用的信息);
|
|
1092
|
+
// ④ 未知 id 原样列出,不瞎归类。
|
|
1093
|
+
const _att = this._gateAttribution(ph.failed_gates);
|
|
1094
|
+
const _sf = (ph.safety_factor === undefined ? null : ph.safety_factor);
|
|
1095
|
+
const _fat = String(ph.fatigue_verdict || '');
|
|
1096
|
+
if (!_att.ids.length) {
|
|
1097
|
+
// 没有闸口明细 → 只能说"结论 FAIL",绝不臆断成因
|
|
1098
|
+
// (尤其不得再写死"强度/寿命不达标")
|
|
1099
|
+
out.blockers.push(
|
|
1100
|
+
'【防线②物理】凭据有效(已验签),但物理校核结论为 FAIL'
|
|
1101
|
+
+ '(报告未提供 failed_gates 闸口明细,无法据此归因)'
|
|
1102
|
+
+ ' —— 不是凭据问题,请查阅报告 '
|
|
1103
|
+
+ (String(ph.report_path || '') || String(ph.run_id || '(run_id 未记录)'))
|
|
1104
|
+
+ ' 的闸口结论后重跑校核');
|
|
1105
|
+
} else {
|
|
1106
|
+
out.blockers.push(
|
|
1107
|
+
'【防线②物理】凭据有效(已验签),但物理校核结论为 FAIL'
|
|
1108
|
+
+ ';未通过闸口: ' + _att.ids.join('、')
|
|
1109
|
+
+ ' —— 属【' + _att.labels.join('、') + '】'
|
|
1110
|
+
// 只有闸口【确实与强度相关】时才回显安全系数:否则 SF 常是
|
|
1111
|
+
// 最不可能导致 FAIL 的值(实测 8102.09),打印只会误导。
|
|
1112
|
+
+ (_att.hasSafety
|
|
1113
|
+
? ';安全系数 ' + (_sf === null ? '未提供' : _sf)
|
|
1114
|
+
+ ',要求≥' + (opts.minSafetyFactor || '见工况')
|
|
1115
|
+
: '')
|
|
1116
|
+
// 同理:只有含 FATIGUE* 闸口时才回显疲劳判定
|
|
1117
|
+
+ (_att.hasFatigue && _fat ? ';疲劳判定 ' + _fat : '')
|
|
1118
|
+
+ ';不是凭据问题,须按上述闸口修正设计后重跑校核');
|
|
1119
|
+
// 兜底诚实性:若凭据 fatigue_verdict=FAIL 却未列入 failed_gates,
|
|
1120
|
+
// 属报告口径不一致。该事实本身会阻断,不能因"无 FATIGUE 闸口"
|
|
1121
|
+
// 就被静默丢弃 —— 但也不把它混进上面的归因句(那会重新变成
|
|
1122
|
+
// "回显无关数值")。这里单列一条 warning。
|
|
1123
|
+
if (!_att.hasFatigue && _fat.toUpperCase() === 'FAIL') {
|
|
1124
|
+
out.warnings.push(
|
|
1125
|
+
'【防线②物理】凭据 fatigue_verdict=FAIL,但 failed_gates 未列出'
|
|
1126
|
+
+ ' FATIGUE 闸口(报告口径不一致)—— 疲劳/寿命结论仍须按 FAIL 处理');
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
} else if (overall !== 'PASS' && overall !== 'REVIEW') {
|
|
1130
|
+
out.blockers.push('【防线②物理】凭据有效(已验签),但校核结论缺失或无法识别(overall='
|
|
1131
|
+
+ (overall || '空') + ')');
|
|
1132
|
+
} else if (overall === 'REVIEW') {
|
|
1133
|
+
out.warnings.push('【防线②物理】物理校核为 REVIEW,已放行但留痕');
|
|
1134
|
+
}
|
|
1135
|
+
// ── 【BUG-观察-14 修复·等效模型失真必须参与判定】────────────────
|
|
1136
|
+
// 实测缺陷:physics-optimize 把零件【包围盒】的排序三边等效为【实心
|
|
1137
|
+
// 矩形棱柱】悬臂梁建模,本次 equivalent_solid_volume_mm3=9720000
|
|
1138
|
+
// 是真实体积 164680 mm³ 的 59.02 倍(volume_inflation_x=59.02)。
|
|
1139
|
+
// 这个失真原先【只写进报告文本(limitations)】,从不参与任何
|
|
1140
|
+
// 判定 —— 防线于是把一个建立在 59 倍失真模型上的 SF=8102 当作
|
|
1141
|
+
// "强度合格"依据放行。
|
|
1142
|
+
// 现在:失真倍数已纳入待签体并被签名背书,这里据此判定。
|
|
1143
|
+
// · model_trustworthy === false(失真 >1.5 倍)→ 【阻断】:
|
|
1144
|
+
// 安全系数不能作为强度合格依据,必须用真实几何重跑。
|
|
1145
|
+
// · 字段缺失(老报告 / volume_inflation_x=null)→ 不拦截,
|
|
1146
|
+
// 保持原行为(model_trustworthy 不会为 false)。
|
|
1147
|
+
// · over_design(SF 高于上限)是良性情形,不改变 model_trustworthy,
|
|
1148
|
+
// 因此不会误触发本条。
|
|
1149
|
+
if (ph.model_trustworthy === false) {
|
|
1150
|
+
const _vx = (typeof ph.volume_inflation_x === 'number'
|
|
1151
|
+
&& Number.isFinite(ph.volume_inflation_x))
|
|
1152
|
+
? ph.volume_inflation_x : null;
|
|
1153
|
+
const _sfTxt = (ph.safety_factor === undefined || ph.safety_factor === null)
|
|
1154
|
+
? '未提供' : String(ph.safety_factor);
|
|
1155
|
+
out.blockers.push(
|
|
1156
|
+
'【防线②物理】等效模型失真 ' + (_vx === null ? '超限' : _vx)
|
|
1157
|
+
+ ' 倍(等效实心体积 / 真实体积),安全系数 ' + _sfTxt
|
|
1158
|
+
// == [A-27 修复] 只给【工具链里真实存在】的出路 ==============
|
|
1159
|
+
// 原提示写"须用真实几何(STEP 导入或 --part 绑定)",但
|
|
1160
|
+
// STEP 导入在整条工具链里【根本不存在】(全库 grep
|
|
1161
|
+
// ImportStep / LoadFile4 = 0 命中)。实测后果:某个会话为
|
|
1162
|
+
// 找这条不存在的出路烧掉约 1/4 的步数,并直接诱发
|
|
1163
|
+
// "改门禁源码让判定变绿"(A-20)。
|
|
1164
|
+
// 现在只给三条真实可执行的出路。
|
|
1165
|
+
+ ' 不可作为强度合格依据。真实可执行的出路(按优先级):'
|
|
1166
|
+
+ '① physics-optimize --part <本房主承力零件.SLDPRT> 换一个'
|
|
1167
|
+
+ '等效失真 ≤1.5 的零件绑定(细长轴换承力板/座是常见做法);'
|
|
1168
|
+
+ '② 安装 Gmsh+CalculiX 后走 L2 真几何(本机若无则报告 '
|
|
1169
|
+
+ 'three_d_fea=NOT_AVAILABLE,属工具限制不是设计缺陷);'
|
|
1170
|
+
+ '③ 确需人工放行:room-end --force 或 DSH_DEFENSE_BYPASS=1'
|
|
1171
|
+
+ '(必留痕到 defense_bypass.json)。'
|
|
1172
|
+
+ '注意:工具链【没有 STEP 导入能力】,不要去找。'
|
|
1173
|
+
+ (String(ph.model || '')
|
|
1174
|
+
? '(本次模型: ' + String(ph.model) + ')' : ''));
|
|
1175
|
+
}
|
|
1176
|
+
out.details.physics = { overall, run_id: ph.run_id, verified: true,
|
|
1177
|
+
model: ph.model === undefined ? null : ph.model,
|
|
1178
|
+
volume_inflation_x:
|
|
1179
|
+
ph.volume_inflation_x === undefined
|
|
1180
|
+
? null : ph.volume_inflation_x,
|
|
1181
|
+
model_trustworthy:
|
|
1182
|
+
ph.model_trustworthy === undefined
|
|
1183
|
+
? null : ph.model_trustworthy,
|
|
1184
|
+
failed_gates: Array.isArray(ph.failed_gates)
|
|
1185
|
+
? ph.failed_gates
|
|
1186
|
+
.map((g) => String((g && g.id) || g || ''))
|
|
1187
|
+
.filter(Boolean)
|
|
1188
|
+
: [] };
|
|
1189
|
+
}
|
|
1190
|
+
const dmD = this.readCredentialDetailed(reportsDir, room, '.domain.json');
|
|
1191
|
+
if (!dmD.ok) {
|
|
1192
|
+
out.blockers.push('【防线③领域】' + this._credFailText(dmD, '领域'));
|
|
1193
|
+
out.details.domain = { verified: false, failure: dmD.reason_code,
|
|
1194
|
+
reason: dmD.reason };
|
|
1195
|
+
} else {
|
|
1196
|
+
const dm = dmD.cred;
|
|
1197
|
+
const v = Array.isArray(dm.violations) ? dm.violations : null;
|
|
1198
|
+
// ── 【新 Bug 修复】领域凭据的可追溯标识 ──────────────────────
|
|
1199
|
+
// 校验端要求 run_id 或 checked_at 之一存在,否则判"疑似伪造"。
|
|
1200
|
+
// 原实现只报"CRITICAL 违规"或"结构异常",对"缺可追溯标识"
|
|
1201
|
+
// 这一独立成因完全不提,导致实测报告里"缺 run_id"成了隐藏项。
|
|
1202
|
+
const _trace = String(dm.run_id || '').trim() || String(dm.checked_at || '').trim();
|
|
1203
|
+
if (!_trace) {
|
|
1204
|
+
out.blockers.push('【防线③领域】凭据有效(已验签),但缺少 run_id/checked_at '
|
|
1205
|
+
+ '可追溯标识(防伪造检查要求)—— 请重新执行 '
|
|
1206
|
+
+ 'physics-validate-domain 以签发带追溯标识的凭据');
|
|
1207
|
+
}
|
|
1208
|
+
if (v === null) out.blockers.push('【防线③领域】violations 不是数组(凭据结构异常)');
|
|
1209
|
+
else if (v.length) {
|
|
1210
|
+
const _ids = v.map((x) => (x && x.rule_id) ? x.rule_id : '?')
|
|
1211
|
+
.filter((x, i, a) => a.indexOf(x) === i).slice(0, 8);
|
|
1212
|
+
out.blockers.push('【防线③领域】凭据有效(已验签),但检出 ' + v.length
|
|
1213
|
+
+ ' 条 CRITICAL 违规: ' + _ids.join(', ')
|
|
1214
|
+
+ ' —— 属【规则不达标】,请按规则修正设计参数');
|
|
1215
|
+
}
|
|
1216
|
+
out.details.domain = { domain: dm.domain, violations: v ? v.length : null,
|
|
1217
|
+
verified: true, has_trace_id: !!_trace };
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// ── 材料凭据(本房间登记零件)──
|
|
1221
|
+
const mats = this.checkRoomMaterials(room);
|
|
1222
|
+
out.details.material = mats;
|
|
1223
|
+
for (const b of mats.blockers) out.blockers.push(b);
|
|
1224
|
+
out.ok = out.blockers.length === 0;
|
|
1225
|
+
return out;
|
|
1226
|
+
} catch (e) {
|
|
1227
|
+
// 宿主判定异常 → fail-closed
|
|
1228
|
+
out.blockers.push('宿主判定异常(fail-closed): ' + String((e && e.message) || e));
|
|
1229
|
+
out.ok = false;
|
|
1230
|
+
return out;
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* 【新 Bug 修复】把"凭据层故障"翻译成**精确的**阻断文案。
|
|
1236
|
+
*
|
|
1237
|
+
* 目的:不再把"凭据不存在 / 无签名 / 验签失败"三种成因混成一句
|
|
1238
|
+
* "缺少凭据或验签失败",让排查方向一目了然。
|
|
1239
|
+
* @param det - readCredentialDetailed() 的返回值。
|
|
1240
|
+
* @param label - '物理' / '领域'。
|
|
1241
|
+
* @returns 人类可读的阻断原因。
|
|
1242
|
+
*/
|
|
1243
|
+
_credFailText(det, label) {
|
|
1244
|
+
const _which = 'reports/' + (det && det.rel ? det.rel : '') ;
|
|
1245
|
+
switch (det && det.reason_code) {
|
|
1246
|
+
case 'missing':
|
|
1247
|
+
return '未做' + label + '校核:凭据文件不存在(' + (det.reason || '') + ')'
|
|
1248
|
+
+ ' —— 请先跑对应的 physics 校核命令生成凭据';
|
|
1249
|
+
case 'unreadable':
|
|
1250
|
+
return label + '凭据无法解析(' + (det.reason || '') + ')—— 文件损坏或非 JSON';
|
|
1251
|
+
case 'unsigned':
|
|
1252
|
+
return label + '凭据缺少宿主签名(' + (det.reason || '') + ')'
|
|
1253
|
+
+ ' —— 宿主签发失败或凭据被手写伪造';
|
|
1254
|
+
case 'invalid_signature':
|
|
1255
|
+
return label + '凭据验签失败(' + (det.reason || '') + ')'
|
|
1256
|
+
+ ' —— 凭据被篡改,或宿主重启后密钥已更换(需重新签发)';
|
|
1257
|
+
default:
|
|
1258
|
+
return label + '凭据不可用(' + ((det && det.reason) || '未知原因') + ')';
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* 【BUG-观察-13】按 failed_gates 的闸口 id 分派【真实】归因文字。
|
|
1264
|
+
*
|
|
1265
|
+
* 为什么需要:原实现对任何 overall=FAIL 都写死"属【强度/寿命不达标】",
|
|
1266
|
+
* 只回显 safety_factor / fatigue_verdict,完全不读 failed_gates ——
|
|
1267
|
+
* 于是把"几何越界"(DESIGN_SPACE)错报成强度问题(实测 reports/
|
|
1268
|
+
* 结构件.physics.json:SF=8102、疲劳 PASS,FAIL 的真实原因是零件
|
|
1269
|
+
* z 跨度 180mm 超出 120mm 设计域)。
|
|
1270
|
+
*
|
|
1271
|
+
* 分派规则:
|
|
1272
|
+
* DESIGN_SPACE → 几何越界/设计域不满足
|
|
1273
|
+
* SAFETY_FACTOR → 强度不达标
|
|
1274
|
+
* FATIGUE* → 寿命/疲劳不达标
|
|
1275
|
+
* WALL_THICKNESS → 壁厚不达标
|
|
1276
|
+
* MAX_DISPLACEMENT → 刚度/位移超限
|
|
1277
|
+
* CONNECTIVITY → 拓扑不连通(多实体)
|
|
1278
|
+
* MESH_QUALITY → 网格质量不达标
|
|
1279
|
+
* 未知 id → 原样列出 id,不归类(不瞎猜)
|
|
1280
|
+
* 多个闸口同时 FAIL 时全部列出,用"、"连接。
|
|
1281
|
+
*
|
|
1282
|
+
* @param failedGates - 凭据里的 failed_gates(元素可为 {id} 或裸字符串)。
|
|
1283
|
+
* @returns {{ids: string[], labels: string[], hasSafety: boolean, hasFatigue: boolean}}
|
|
1284
|
+
*/
|
|
1285
|
+
_gateAttribution(failedGates) {
|
|
1286
|
+
const out = { ids: [], labels: [], hasSafety: false, hasFatigue: false };
|
|
1287
|
+
const arr = Array.isArray(failedGates) ? failedGates : [];
|
|
1288
|
+
const _seenId = new Set();
|
|
1289
|
+
const _seenLabel = new Set();
|
|
1290
|
+
for (const g of arr) {
|
|
1291
|
+
// 兼容两种形态:{id, status, ...} 与 裸字符串 'SAFETY_FACTOR'
|
|
1292
|
+
const id = String((g && typeof g === 'object' ? g.id : g) || '').trim().toUpperCase();
|
|
1293
|
+
if (!id || _seenId.has(id)) continue;
|
|
1294
|
+
_seenId.add(id);
|
|
1295
|
+
out.ids.push(id);
|
|
1296
|
+
if (id === 'SAFETY_FACTOR') out.hasSafety = true;
|
|
1297
|
+
if (id.indexOf('FATIGUE') === 0) out.hasFatigue = true;
|
|
1298
|
+
// 归因标签:未知 id 不硬套类别,直接回显"未归类闸口 XXX",
|
|
1299
|
+
// 让读者看到原始 id 而不是一个编造的成因。
|
|
1300
|
+
let label;
|
|
1301
|
+
switch (id) {
|
|
1302
|
+
case 'DESIGN_SPACE':
|
|
1303
|
+
label = '几何越界/设计域不满足'; break;
|
|
1304
|
+
case 'SAFETY_FACTOR':
|
|
1305
|
+
label = '强度不达标'; break;
|
|
1306
|
+
case 'WALL_THICKNESS':
|
|
1307
|
+
label = '壁厚不达标'; break;
|
|
1308
|
+
case 'MAX_DISPLACEMENT':
|
|
1309
|
+
label = '刚度/位移超限'; break;
|
|
1310
|
+
case 'CONNECTIVITY':
|
|
1311
|
+
label = '拓扑不连通(多实体)'; break;
|
|
1312
|
+
case 'MESH_QUALITY':
|
|
1313
|
+
label = '网格质量不达标'; break;
|
|
1314
|
+
default:
|
|
1315
|
+
// FATIGUE_STRENGTH / FATIGUE_LIFE / 其它 FATIGUE* 前缀
|
|
1316
|
+
label = (id.indexOf('FATIGUE') === 0)
|
|
1317
|
+
? '寿命/疲劳不达标'
|
|
1318
|
+
: ('未归类闸口 ' + id);
|
|
1319
|
+
break;
|
|
1320
|
+
}
|
|
1321
|
+
if (!_seenLabel.has(label)) {
|
|
1322
|
+
_seenLabel.add(label);
|
|
1323
|
+
out.labels.push(label);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
return out;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* 读取并验签一份凭据,返回【带原因】的结果。
|
|
1331
|
+
*
|
|
1332
|
+
* ── 【新 Bug 修复】区分"凭据缺失"与"验签失败" ──────────────────────────
|
|
1333
|
+
* 原实现只返回 null,调用方无法区分两种完全不同的故障:
|
|
1334
|
+
* · 凭据根本不存在 → 该房间没跑过校核(要补跑校核);
|
|
1335
|
+
* · 凭据存在但验签失败 → 凭据被篡改/宿主重启换了密钥(要重签)。
|
|
1336
|
+
* 实测后果:room-end 把"物理校核判定为 FAIL(强度不足)"这类
|
|
1337
|
+
* 【凭据完全正常】的情形也笼统报成"缺少凭据或验签失败",
|
|
1338
|
+
* 让人误以为防线信任根又坏了,白白排查方向错误。
|
|
1339
|
+
*
|
|
1340
|
+
* @param reportsDir - reports 目录。
|
|
1341
|
+
* @param room - 房间名。
|
|
1342
|
+
* @param suffix - '.physics.json' / '.domain.json'。
|
|
1343
|
+
* @returns {{ok: boolean, cred: object|null, reason: string, reason_code: string}}
|
|
1344
|
+
* reason_code ∈ 'ok' | 'missing' | 'unreadable' | 'unsigned' | 'invalid_signature'
|
|
1345
|
+
*/
|
|
1346
|
+
readCredentialDetailed(reportsDir, room, suffix) {
|
|
1347
|
+
// 房间名安全过滤(与 Python 侧 _safe_room_name 同口径):
|
|
1348
|
+
// 防路径穿越,同时保留中文/空格/连字符以匹配真实房间名。
|
|
1349
|
+
const safe = String(room).replace(/[^A-Za-z0-9_\u4e00-\u9fa5 -]/g, '_');
|
|
1350
|
+
const rel = 'reports/' + safe + suffix;
|
|
1351
|
+
const p = pathSync.join(reportsDir, safe + suffix);
|
|
1352
|
+
if (!fsSync.existsSync(p)) {
|
|
1353
|
+
return { ok: false, cred: null, reason_code: 'missing',
|
|
1354
|
+
reason: '凭据文件不存在(' + rel + ')' };
|
|
1355
|
+
}
|
|
1356
|
+
let cred = null;
|
|
1357
|
+
try {
|
|
1358
|
+
cred = JSON.parse(fsSync.readFileSync(p, 'utf8'));
|
|
1359
|
+
} catch (e) {
|
|
1360
|
+
return { ok: false, cred: null, reason_code: 'unreadable',
|
|
1361
|
+
reason: '凭据文件无法解析为 JSON(' + rel + ')' };
|
|
1362
|
+
}
|
|
1363
|
+
if (!cred || typeof cred !== 'object') {
|
|
1364
|
+
return { ok: false, cred: null, reason_code: 'unreadable',
|
|
1365
|
+
reason: '凭据内容不是对象(' + rel + ')' };
|
|
1366
|
+
}
|
|
1367
|
+
if (!cred._sig) {
|
|
1368
|
+
return { ok: false, cred: cred, reason_code: 'unsigned',
|
|
1369
|
+
reason: '凭据缺少宿主签名 —— 疑似手写伪造或宿主签发失败' };
|
|
1370
|
+
}
|
|
1371
|
+
const v = this.verify(cred, false);
|
|
1372
|
+
if (!v.ok) {
|
|
1373
|
+
return { ok: false, cred: cred, reason_code: 'invalid_signature',
|
|
1374
|
+
reason: '凭据签名校验未通过:' + String(v.reason || '未知') };
|
|
1375
|
+
}
|
|
1376
|
+
return { ok: true, cred: cred, reason_code: 'ok', reason: '' };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* 读取并验签一份凭据;验签失败或缺失返回 null。
|
|
1381
|
+
*
|
|
1382
|
+
* 兼容旧调用点:需要区分原因时请用 readCredentialDetailed()。
|
|
1383
|
+
* @param reportsDir - reports 目录。
|
|
1384
|
+
* @param room - 房间名。
|
|
1385
|
+
* @param suffix - '.physics.json' / '.domain.json'。
|
|
1386
|
+
* @returns 验签通过的凭据体,或 null。
|
|
1387
|
+
*/
|
|
1388
|
+
readCredential(reportsDir, room, suffix) {
|
|
1389
|
+
const r = this.readCredentialDetailed(reportsDir, room, suffix);
|
|
1390
|
+
return r.ok ? r.cred : null;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/**
|
|
1394
|
+
* 校验本房间登记零件的材料凭据(签名 + 哈希绑定)。
|
|
1395
|
+
* @param room - 房间名。
|
|
1396
|
+
* @returns {{part_count: number, blockers: string[], warnings: string[]}}
|
|
1397
|
+
*/
|
|
1398
|
+
checkRoomMaterials(room) {
|
|
1399
|
+
const out = { part_count: 0, blockers: [], warnings: [] };
|
|
1400
|
+
try {
|
|
1401
|
+
// ── 【A-22 修复·路径/身份分裂】台账必须按【候选集合】+【多键名】解析 ──
|
|
1402
|
+
// 原缺陷有三层,任何一层都会让"登记了零件"被看漏:
|
|
1403
|
+
// ① 只认 this.toolsDir/artifacts_registry.json 这一条【裸名】路径,
|
|
1404
|
+
// 而 Python 侧 artifact_registry_file() 现在写的是
|
|
1405
|
+
// artifacts_registry.<task_id>.json(按任务隔离),
|
|
1406
|
+
// 另有 workspaces/ 下的工作区副本 → 台账"明明写了却读不到";
|
|
1407
|
+
// ② 键名只认 reg[room],而写入侧同时存在房间名与 _safe_room_stem 词干
|
|
1408
|
+
// 两种写法(中文房间名会被词干化成不同字符串);
|
|
1409
|
+
// ③ 读不到就 return,part_count=0 —— 对下游是"空集合恒真"
|
|
1410
|
+
// (房间被当成"没有零件要校验",材料防线等于跳过)。
|
|
1411
|
+
// 现在:枚举候选路径与候选键;【一个都没找到】时显式记 warning 并标注
|
|
1412
|
+
// evaluated=false,让调用方知道"没校验过",而不是静默当成通过。
|
|
1413
|
+
const regCands = [];
|
|
1414
|
+
const _pushReg = (p) => {
|
|
1415
|
+
try {
|
|
1416
|
+
if (!p) return;
|
|
1417
|
+
const ap = pathSync.resolve(p);
|
|
1418
|
+
if (fsSync.existsSync(ap) && regCands.indexOf(ap) < 0) regCands.push(ap);
|
|
1419
|
+
} catch (e) {}
|
|
1420
|
+
};
|
|
1421
|
+
// ① 宿主自己那份 toolsDir(传统裸名台账)
|
|
1422
|
+
_pushReg(pathSync.join(this.toolsDir, 'artifacts_registry.json'));
|
|
1423
|
+
// ② 按任务隔离的台账(artifacts_registry.<task_id>.json),任务 id 取
|
|
1424
|
+
// workflow_state.json 的 task_id —— 与 defense_gate._current_task_id() 同源。
|
|
1425
|
+
try {
|
|
1426
|
+
const _wf = JSON.parse(fsSync.readFileSync(
|
|
1427
|
+
pathSync.join(this.toolsDir, 'workflow_state.json'), 'utf8'));
|
|
1428
|
+
const _tid = _wf && _wf.task_id;
|
|
1429
|
+
if (_tid) {
|
|
1430
|
+
_pushReg(pathSync.join(this.toolsDir, 'artifacts_registry.' + String(_tid) + '.json'));
|
|
1431
|
+
}
|
|
1432
|
+
} catch (e) {}
|
|
1433
|
+
// ③ 同一目录里任何 artifacts_registry*.json(按 mtime 新→旧,最后兜底)
|
|
1434
|
+
try {
|
|
1435
|
+
const _hits = [];
|
|
1436
|
+
for (const _fn of fsSync.readdirSync(this.toolsDir)) {
|
|
1437
|
+
if (!/^artifacts_registry.*\.json$/i.test(_fn)) continue;
|
|
1438
|
+
const _fp = pathSync.join(this.toolsDir, _fn);
|
|
1439
|
+
let _m = 0;
|
|
1440
|
+
try { _m = fsSync.statSync(_fp).mtimeMs; } catch (e) { _m = 0; }
|
|
1441
|
+
_hits.push({ p: _fp, m: _m });
|
|
1442
|
+
}
|
|
1443
|
+
_hits.sort((a, b) => b.m - a.m);
|
|
1444
|
+
for (const _h of _hits) _pushReg(_h.p);
|
|
1445
|
+
} catch (e) {}
|
|
1446
|
+
// ④ 工作区副本(.defense/workspace.txt 由 Python 侧声明)
|
|
1447
|
+
try {
|
|
1448
|
+
const _wsDecl = pathSync.join(this.dir, 'workspace.txt');
|
|
1449
|
+
if (fsSync.existsSync(_wsDecl)) {
|
|
1450
|
+
const _wsRoot = String(fsSync.readFileSync(_wsDecl, 'utf8') || '').trim();
|
|
1451
|
+
if (_wsRoot) {
|
|
1452
|
+
_pushReg(pathSync.join(_wsRoot, 'tools', 'artifacts_registry.json'));
|
|
1453
|
+
try {
|
|
1454
|
+
for (const _fn of fsSync.readdirSync(pathSync.join(_wsRoot, 'tools'))) {
|
|
1455
|
+
if (/^artifacts_registry.*\.json$/i.test(_fn)) {
|
|
1456
|
+
_pushReg(pathSync.join(_wsRoot, 'tools', _fn));
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
} catch (e) {}
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
} catch (e) {}
|
|
1463
|
+
if (!regCands.length) {
|
|
1464
|
+
// 没台账 = 【没校验过】,不是"通过"。显式标注,避免空集合恒真。
|
|
1465
|
+
out.evaluated = false;
|
|
1466
|
+
out.warnings.push('【防线①材料】未找到 artifacts_registry*.json,本房间材料防线【未评估】'
|
|
1467
|
+
+ '(parts=0 不等于通过)');
|
|
1468
|
+
return out;
|
|
1469
|
+
}
|
|
1470
|
+
let reg = null;
|
|
1471
|
+
let regUsed = null;
|
|
1472
|
+
for (const _rp of regCands) {
|
|
1473
|
+
try {
|
|
1474
|
+
const _r = JSON.parse(fsSync.readFileSync(_rp, 'utf8'));
|
|
1475
|
+
if (_r && typeof _r === 'object') {
|
|
1476
|
+
// 优先选"确实含本房间键"的那份
|
|
1477
|
+
if (Array.isArray(_r[room]) || (_r.rooms && Array.isArray(_r.rooms[room]))) {
|
|
1478
|
+
reg = _r; regUsed = _rp; break;
|
|
1479
|
+
}
|
|
1480
|
+
if (!reg) { reg = _r; regUsed = _rp; }
|
|
1481
|
+
}
|
|
1482
|
+
} catch (e) {}
|
|
1483
|
+
}
|
|
1484
|
+
out.registry_file = regUsed;
|
|
1485
|
+
if (!reg) return out;
|
|
1486
|
+
// ── 键名多写法:房间原名 / 词干化名 / rooms 子表 ────────────────
|
|
1487
|
+
const _stem = String(room || '').replace(/[^\p{L}\p{N} _-]/gu, '').trim();
|
|
1488
|
+
const list = Array.isArray(reg[room]) ? reg[room]
|
|
1489
|
+
: (Array.isArray(reg[_stem]) ? reg[_stem]
|
|
1490
|
+
: (reg.rooms && Array.isArray(reg.rooms[room]) ? reg.rooms[room] : null));
|
|
1491
|
+
if (!Array.isArray(list)) {
|
|
1492
|
+
out.evaluated = false;
|
|
1493
|
+
out.warnings.push('【防线①材料】台账 ' + pathSync.basename(String(regUsed || ''))
|
|
1494
|
+
+ ' 中【没有房间 ' + room + ' 的零件登记】—— 材料防线未评估(parts=0 不等于通过)');
|
|
1495
|
+
return out;
|
|
1496
|
+
}
|
|
1497
|
+
out.evaluated = true;
|
|
1498
|
+
for (const it of list) {
|
|
1499
|
+
if (!it || typeof it !== 'object') continue;
|
|
1500
|
+
const part = String(it.path || '');
|
|
1501
|
+
if (!part.toLowerCase().endsWith('.sldprt')) continue;
|
|
1502
|
+
if (!fsSync.existsSync(part)) continue;
|
|
1503
|
+
out.part_count += 1;
|
|
1504
|
+
const attPath = part + '.material.json';
|
|
1505
|
+
if (!fsSync.existsSync(attPath)) {
|
|
1506
|
+
out.blockers.push('【防线①材料】' + pathSync.basename(part) + ': 缺少材料凭据');
|
|
1507
|
+
continue;
|
|
1508
|
+
}
|
|
1509
|
+
let att = null;
|
|
1510
|
+
try {
|
|
1511
|
+
att = JSON.parse(fsSync.readFileSync(attPath, 'utf8'));
|
|
1512
|
+
} catch (e) {
|
|
1513
|
+
out.blockers.push('【防线①材料】' + pathSync.basename(part) + ': 凭据不可解析');
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
const v = this.verify(att, false);
|
|
1517
|
+
if (!v.ok) {
|
|
1518
|
+
out.blockers.push('【防线①材料】' + pathSync.basename(part) + ': 验签失败(' + v.reason + ')');
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
// 哈希绑定:凭据记录的 part_sha256 必须与当前文件一致
|
|
1522
|
+
const nowSha = fileSha256(part);
|
|
1523
|
+
if (att.part_sha256 && nowSha && att.part_sha256 !== nowSha) {
|
|
1524
|
+
out.blockers.push('【防线①材料】' + pathSync.basename(part) + ': 零件内容与凭据不符(疑似替换)');
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
const dens = Number(att.density_kg_m3);
|
|
1528
|
+
if (Number.isFinite(dens) && Math.abs(dens - 1000) < 1) {
|
|
1529
|
+
out.blockers.push('【防线①材料】' + pathSync.basename(part) + ': 密度 1000(疑似未赋材质)');
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
} catch (e) {
|
|
1533
|
+
out.blockers.push('材料防线校验异常: ' + String((e && e.message) || e));
|
|
1534
|
+
}
|
|
1535
|
+
return out;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/** 汇总状态(供 /defense/info 与故障排查)。 */
|
|
1539
|
+
status() {
|
|
1540
|
+
return {
|
|
1541
|
+
ok: true,
|
|
1542
|
+
kid: this.kid,
|
|
1543
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
1544
|
+
ttl_ms: SIG_TTL_MS,
|
|
1545
|
+
signed_count: this.ledger.filter((e) => e.kind === 'sign').length,
|
|
1546
|
+
anomaly_count: this.anomalies.length,
|
|
1547
|
+
quarantined_count: this.quarantined ? this.quarantined.size : 0,
|
|
1548
|
+
recent_anomalies: this.anomalies.slice(-10),
|
|
1549
|
+
runtime_file: this.runtimePath,
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
}
|