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,3374 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
defense_gate.py — 工程模式【三大防线】统一强制实现
|
|
5
|
+
====================================================
|
|
6
|
+
本模块把原先"只写在提示词里、靠模型自觉"的三道质量防线,
|
|
7
|
+
变成【代码级、到点自动执行、不通过就不放行】的硬门禁。
|
|
8
|
+
|
|
9
|
+
三大防线
|
|
10
|
+
--------
|
|
11
|
+
① 材料防线(Material Defense)
|
|
12
|
+
每一个落盘的 .sldprt 必须带一份【材料凭据】(<part>.material.json),
|
|
13
|
+
证明它的材料不是缺失值、不是密度 1000(水)、不是跨族静默替代。
|
|
14
|
+
凭据由 swapi.save() 在 SolidWorks 进程内写就(那里才有真实密度)。
|
|
15
|
+
|
|
16
|
+
② 物理防线(Physics / FEA Defense)
|
|
17
|
+
房间必须产出一份【物理校核凭据】(reports/<room>.physics.json),
|
|
18
|
+
综合 FEA gates 与 overall 判定;overall == FAIL 即视为未通过。
|
|
19
|
+
|
|
20
|
+
③ 领域防线(DSVA / GB 规则 Defense)
|
|
21
|
+
房间必须产出一份【领域校验凭据】(reports/<room>.domain.json),
|
|
22
|
+
由 domain_validator 按 rules/<domain>_rules.json 判定;
|
|
23
|
+
存在 CRITICAL 违规即视为未通过。
|
|
24
|
+
|
|
25
|
+
强制点("AI 到了这些步骤就必须过")
|
|
26
|
+
----------------------------------
|
|
27
|
+
· sw_bridge.py run 产物落盘即校验防线①(材料)
|
|
28
|
+
· mode_gate.py room-end 下线即校验①②③(该房间)
|
|
29
|
+
· workflow_gate confirm-assembly 开总装前校验全部建模房间①②③
|
|
30
|
+
· workflow_gate select(收尾) 宣告完成前校验全任务①②③
|
|
31
|
+
|
|
32
|
+
绕过
|
|
33
|
+
----
|
|
34
|
+
唯一合法绕行:DSH_DEFENSE_BYPASS=1 环境变量,或命令的 --force。
|
|
35
|
+
绕行【必定留痕】到 reports/defense_bypass.json,便于事后审计(绝不静默)。
|
|
36
|
+
|
|
37
|
+
CLI
|
|
38
|
+
---
|
|
39
|
+
python defense_gate.py check-room <房间名>
|
|
40
|
+
python defense_gate.py check-task
|
|
41
|
+
python defense_gate.py status
|
|
42
|
+
"""
|
|
43
|
+
import glob
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
import re
|
|
47
|
+
import sys
|
|
48
|
+
import time
|
|
49
|
+
import urllib.error
|
|
50
|
+
import urllib.request
|
|
51
|
+
|
|
52
|
+
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
53
|
+
|
|
54
|
+
# ── 【P0-3 写侧修复】凭据落点必须与【宿主读取处】一致 ────────────────────
|
|
55
|
+
# 原实现把 reports/ 与 artifacts_registry.json 写到本脚本所在目录。
|
|
56
|
+
# 但工程模式有两份副本:从工作区跑 physics/domain 校核时,凭据被写进
|
|
57
|
+
# 【工作区】的 reports/,而宿主(及其守卫、/defense/judge)读的是
|
|
58
|
+
# 【安装副本】的 reports/ —— 于是:
|
|
59
|
+
# · 工作区:有 结构件.physics.json / domain.json
|
|
60
|
+
# · 安装副本:一个都没有
|
|
61
|
+
# 宿主判定恒为"缺少凭据"→ 防线 fail-closed;用户还以为校核跑过了。
|
|
62
|
+
# 修复:统一由 _store.state_dir() 决定落点(与 mode_gate/workflow_gate 同一来源)。
|
|
63
|
+
try:
|
|
64
|
+
import _store as _store_mod
|
|
65
|
+
STATE_DIR = _store_mod.state_dir()
|
|
66
|
+
except Exception:
|
|
67
|
+
STATE_DIR = _BASE_DIR
|
|
68
|
+
def _current_task_id():
|
|
69
|
+
"""当前任务 id(用于凭据按局隔离)。读 workflow_state,读不到返回 None。"""
|
|
70
|
+
try:
|
|
71
|
+
import json as _json
|
|
72
|
+
_p = os.path.join(STATE_DIR, "workflow_state.json")
|
|
73
|
+
if os.path.exists(_p):
|
|
74
|
+
with open(_p, "r", encoding="utf-8") as _f:
|
|
75
|
+
return (_json.load(_f) or {}).get("task_id") or None
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def reports_dir(task_id=None):
|
|
82
|
+
"""凭据/报告目录 —— 按【任务】隔离,避免上一局产物污染新对话。
|
|
83
|
+
|
|
84
|
+
问题(用户反馈):删掉旧对话后,新对话仍能读到上一局的
|
|
85
|
+
reports/结构件.physics.json、*.domain.json、defense_bypass.json,
|
|
86
|
+
于是门禁认为"这些房间已经校核过了",任务一开始就带着旧结论。
|
|
87
|
+
根因:reports/ 是【全局共享】目录,文件名只按房间名索引,
|
|
88
|
+
跨任务必然撞车(房间名就那几种:结构件/传动机构/壳体机架…)。
|
|
89
|
+
|
|
90
|
+
现在:reports/<task_id>/<房间>.<kind>.json。task_id 取不到时退回旧路径
|
|
91
|
+
(保持向后兼容,不至于把已有现场直接作废)。
|
|
92
|
+
"""
|
|
93
|
+
tid = task_id or _current_task_id()
|
|
94
|
+
if tid:
|
|
95
|
+
_d = os.path.join(STATE_DIR, "reports", str(tid))
|
|
96
|
+
try:
|
|
97
|
+
os.makedirs(_d, exist_ok=True)
|
|
98
|
+
except Exception:
|
|
99
|
+
return os.path.join(STATE_DIR, "reports")
|
|
100
|
+
return _d
|
|
101
|
+
return os.path.join(STATE_DIR, "reports")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
105
|
+
# 【Bug⑤ 修复】reports 目录必须按【候选集合】解析,而不是只认第一个
|
|
106
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
107
|
+
# 现象(实测致命):凭据明明存在,check-room 仍报 "凭据文件不存在(missing)",
|
|
108
|
+
# room-end 被永久拒绝;而直接 import defense_gate 读同一个路径却完全正常。
|
|
109
|
+
# 根因有三层,任一层都会让"读到的目录"≠"写下的目录":
|
|
110
|
+
# ① 工程模式有【两份副本】:工作区 <repo>/engineering/tools 与安装副本
|
|
111
|
+
# %DSH_HOME%/.agent-presets/engineering/tools;
|
|
112
|
+
# ② reports 又按【任务】分子目录(reports/<task_id>/),而宿主判定
|
|
113
|
+
# (judgeDefense)只读 <它自己那份 tools>/reports/ 的【flat】路径;
|
|
114
|
+
# ③ 写入侧固定 STATE_DIR,万一 _store 不可用就落到 _BASE_DIR(工作区)。
|
|
115
|
+
# 修复原则(严格 fail-closed,不放宽任何判定):
|
|
116
|
+
# · 解析 reports 目录时给出【有序候选集合】:① STATE_DIR ② _BASE_DIR
|
|
117
|
+
# ③ 安装副本(DSH_HOME / DSH_ENGINEERING_ROOT 推导);
|
|
118
|
+
# · 【任一候选里存在该房间凭据即视为有效】(不是只看第一个);
|
|
119
|
+
# · 所有候选都找不到 → 仍然判 missing(真找不到就拒绝,绝不放水);
|
|
120
|
+
# · 宿主判定报 missing 但本地候选确有凭据 → 转本地验签(见
|
|
121
|
+
# check_room_defense 的 HOST_MISSING_LOCAL_CREDENTIAL 分支),
|
|
122
|
+
# 并把【宿主实际读的 reports_dir】回传,便于排障。
|
|
123
|
+
def _safe_room_stem(room):
|
|
124
|
+
"""房间名 → 文件名安全词干(与宿主 readCredentialDetailed 同口径)。"""
|
|
125
|
+
return "".join(c for c in str(room) if c.isalnum() or c in " _-").strip()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _dsh_home():
|
|
129
|
+
r"""DSH 主目录(宿主安装位置);缺失时返回空串。
|
|
130
|
+
|
|
131
|
+
-- 【隐藏 BUG 修复 · 2026-10-29】expanduser 未展开会产出【假路径】------
|
|
132
|
+
实测(本次任务盘查 check-room 输出):
|
|
133
|
+
reports_dir_local_candidates 里出现了
|
|
134
|
+
<cwd>\~\.dsh\.agent-presets\...\reports
|
|
135
|
+
即 os.path.expanduser("~") 原样返回了字符串 "~"(HOME/USERPROFILE 在某
|
|
136
|
+
些子进程环境里没有注入),随后 os.path.abspath 把它拼到【当前目录】下,
|
|
137
|
+
于是候选集合里混进一堆"看起来合法、实际不存在"的路径。
|
|
138
|
+
|
|
139
|
+
危害:这不是"多几个无效候选"那么简单 --
|
|
140
|
+
· 排障时 check-room 会把这些假路径当成"宿主实际读的目录"回显,
|
|
141
|
+
把排查方向彻底带偏(本次就是先被它误导);
|
|
142
|
+
· 若某台机器上恰好存在 <cwd>\~\ 这种目录,凭据解析会读到错误位置。
|
|
143
|
+
|
|
144
|
+
修复:① 先取 USERPROFILE / HOME 环境变量(Windows 上最可靠);
|
|
145
|
+
② 再试 expanduser,但【必须验证已展开】(结果不再含 "~" 才算数);
|
|
146
|
+
③ 都拿不到就返回空串 —— 宁可少一个候选,也不返回假路径。
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
v = (os.environ.get("DSH_HOME") or "").strip()
|
|
150
|
+
if v:
|
|
151
|
+
return os.path.abspath(v)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
home = ""
|
|
155
|
+
for _k in ("USERPROFILE", "HOME", "HOMEDRIVE"):
|
|
156
|
+
try:
|
|
157
|
+
_v = (os.environ.get(_k) or "").strip()
|
|
158
|
+
except Exception:
|
|
159
|
+
_v = ""
|
|
160
|
+
if not _v:
|
|
161
|
+
continue
|
|
162
|
+
# HOMEDRIVE 只是盘符("C:"),单独用没有意义
|
|
163
|
+
if _k == "HOMEDRIVE" and (not _v.endswith(":") or os.environ.get("HOMEPATH")):
|
|
164
|
+
continue
|
|
165
|
+
home = _v
|
|
166
|
+
break
|
|
167
|
+
if not home:
|
|
168
|
+
try:
|
|
169
|
+
home = os.path.expanduser("~")
|
|
170
|
+
except Exception:
|
|
171
|
+
home = ""
|
|
172
|
+
# ── 兜底:环境变量与 expanduser 都拿不到时,用 pwd/Path 模块再试一次 ──
|
|
173
|
+
# (某些宿主子进程只注入极少量环境变量,os.path.expanduser 会原样返回 "~";
|
|
174
|
+
# 而 Python 的 pathlib/Path.home() 在 Windows 上会走 SHGetFolderPath,
|
|
175
|
+
# 不依赖环境变量,能拿到真实主目录。)
|
|
176
|
+
if not home or home == "~" or home.startswith("~"):
|
|
177
|
+
try:
|
|
178
|
+
from pathlib import Path as _Path
|
|
179
|
+
_h = str(_Path.home())
|
|
180
|
+
if _h and _h != "~" and not _h.startswith("~"):
|
|
181
|
+
home = _h
|
|
182
|
+
except Exception:
|
|
183
|
+
pass
|
|
184
|
+
if not home or home == "~" or home.startswith("~"):
|
|
185
|
+
try:
|
|
186
|
+
import pwd as _pwd # 仅 POSIX 存在;Windows 会 ImportError
|
|
187
|
+
home = _pwd.getpwuid(os.getuid()).pw_dir or ""
|
|
188
|
+
except Exception:
|
|
189
|
+
home = ""
|
|
190
|
+
# 关键:未展开的 "~" 或空串都视为"取不到",绝不拼进候选路径
|
|
191
|
+
if not home or home == "~" or home.startswith("~"):
|
|
192
|
+
return ""
|
|
193
|
+
try:
|
|
194
|
+
return os.path.abspath(os.path.join(home, ".dsh"))
|
|
195
|
+
except Exception:
|
|
196
|
+
return ""
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _installed_tools_dir_candidates():
|
|
200
|
+
"""【安装副本】tools 目录候选(宿主加载插件的那份目录)。"""
|
|
201
|
+
out = []
|
|
202
|
+
dh = _dsh_home()
|
|
203
|
+
if dh:
|
|
204
|
+
out.append(os.path.join(dh, ".agent-presets", "engineering", "tools"))
|
|
205
|
+
out.append(os.path.join(dh, "engineering", "tools"))
|
|
206
|
+
try:
|
|
207
|
+
eng = (os.environ.get("DSH_ENGINEERING_ROOT") or "").strip()
|
|
208
|
+
if eng:
|
|
209
|
+
a = os.path.abspath(eng)
|
|
210
|
+
out.append(a if os.path.basename(a).lower() == "tools"
|
|
211
|
+
else os.path.join(a, "tools"))
|
|
212
|
+
except Exception:
|
|
213
|
+
pass
|
|
214
|
+
return out
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _tools_dir_roots():
|
|
218
|
+
"""reports 目录解析的【根目录候选】(按可信度递减,已归一化去重)。"""
|
|
219
|
+
out, seen = [], set()
|
|
220
|
+
for d in [STATE_DIR, _BASE_DIR] + _installed_tools_dir_candidates():
|
|
221
|
+
try:
|
|
222
|
+
a = os.path.abspath(d)
|
|
223
|
+
except Exception:
|
|
224
|
+
continue
|
|
225
|
+
if not a:
|
|
226
|
+
continue
|
|
227
|
+
k = os.path.normcase(a)
|
|
228
|
+
if k in seen:
|
|
229
|
+
continue
|
|
230
|
+
seen.add(k)
|
|
231
|
+
out.append(a)
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def reports_dir_candidates(task_id=None):
|
|
236
|
+
"""凭据目录【候选集合】(顺序 = 优先级)。
|
|
237
|
+
|
|
238
|
+
有 task_id 时逐根拼 reports/<task_id>(与写入侧同构);
|
|
239
|
+
取不到 task_id 时退回 flat reports/(向后兼容,与 reports_dir() 一致)。
|
|
240
|
+
"""
|
|
241
|
+
tid = task_id or _current_task_id()
|
|
242
|
+
out, seen = [], set()
|
|
243
|
+
for root in _tools_dir_roots():
|
|
244
|
+
p = (os.path.join(root, "reports", str(tid)) if tid
|
|
245
|
+
else os.path.join(root, "reports"))
|
|
246
|
+
try:
|
|
247
|
+
a = os.path.abspath(p)
|
|
248
|
+
except Exception:
|
|
249
|
+
continue
|
|
250
|
+
k = os.path.normcase(a)
|
|
251
|
+
if k in seen:
|
|
252
|
+
continue
|
|
253
|
+
seen.add(k)
|
|
254
|
+
out.append(a)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def reports_dir_effective(task_id=None):
|
|
259
|
+
"""候选里第一个【已存在】的目录(排障用);都不存在时返回首选写入位。"""
|
|
260
|
+
cands = reports_dir_candidates(task_id)
|
|
261
|
+
for c in cands:
|
|
262
|
+
try:
|
|
263
|
+
if os.path.isdir(c):
|
|
264
|
+
return c
|
|
265
|
+
except Exception:
|
|
266
|
+
continue
|
|
267
|
+
return cands[0] if cands else reports_dir(task_id)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def room_credential_candidates(room, suffix, task_id=None):
|
|
271
|
+
"""某房间凭据在【所有候选目录】下的路径(顺序 = 优先级)。"""
|
|
272
|
+
stem = _safe_room_stem(room) + str(suffix)
|
|
273
|
+
return [os.path.join(d, stem) for d in reports_dir_candidates(task_id)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def resolve_room_credential(room, suffix, task_id=None):
|
|
277
|
+
"""在候选目录中定位房间凭据。
|
|
278
|
+
|
|
279
|
+
Returns: (path|None, tried_paths)
|
|
280
|
+
· 命中【任一】候选即返回该路径(Bug⑤ 的核心修复:不再只看第一个);
|
|
281
|
+
· 全部未命中返回 (None, 所有尝试过的路径) —— 调用方必须 fail-closed。
|
|
282
|
+
"""
|
|
283
|
+
cands = room_credential_candidates(room, suffix, task_id)
|
|
284
|
+
for p in cands:
|
|
285
|
+
try:
|
|
286
|
+
if os.path.isfile(p):
|
|
287
|
+
return p, cands
|
|
288
|
+
except Exception:
|
|
289
|
+
continue
|
|
290
|
+
return None, cands
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
REPORTS_DIR = reports_dir()
|
|
294
|
+
def artifact_registry_file(task_id=None):
|
|
295
|
+
"""产物登记表同样按任务隔离(否则新任务会看到上一局的零件清单)。"""
|
|
296
|
+
tid = task_id or _current_task_id()
|
|
297
|
+
if tid:
|
|
298
|
+
return os.path.join(STATE_DIR, "artifacts_registry.%s.json" % tid)
|
|
299
|
+
return os.path.join(STATE_DIR, "artifacts_registry.json")
|
|
300
|
+
|
|
301
|
+
ARTIFACT_REGISTRY_FILE = artifact_registry_file()
|
|
302
|
+
# 绕行留痕同样按任务隔离 —— 旧任务的"已绕过"记录不能让新任务看起来豁免过。
|
|
303
|
+
def bypass_log_file(task_id=None):
|
|
304
|
+
return os.path.join(reports_dir(task_id), "defense_bypass.json")
|
|
305
|
+
|
|
306
|
+
BYPASS_LOG_FILE = bypass_log_file()
|
|
307
|
+
|
|
308
|
+
# ── 【Bug⑥ 修复】require_physics 白名单:凡【含零件】的房间类型一律强制校核 ──
|
|
309
|
+
# 实测缺陷(本任务三房间):
|
|
310
|
+
# · 底盘结构件 structural → require_physics=true(缺凭据被拦);
|
|
311
|
+
# · 传动与轮组 drivetrain → require_physics=false(**放行**,含 2 项 CRITICAL);
|
|
312
|
+
# · 传感器与电子件 sensor → require_physics=false(**放行**,从未校核)。
|
|
313
|
+
# drivetrain/sensor 只是历史遗留的类型名,它们【同样产出零件】,
|
|
314
|
+
# 却因为不在白名单里被判"非建模类"→ 物理/领域凭据均不强制 → 门禁形同虚设。
|
|
315
|
+
# 现在:白名单覆盖全部含零件类型(含 drivetrain / sensor / drawing 这些旧名),
|
|
316
|
+
# 只对【纯出图/协调类】豁免(见 NON_PART_ROOM_TYPES)。
|
|
317
|
+
MODELING_ROOM_TYPES = ("structural", "transmission", "housing",
|
|
318
|
+
"support", "spring", "thermal", "corrosion",
|
|
319
|
+
# ── 【Bug⑥】历史遗留 / 别名类型:同样含零件,必须校核 ──
|
|
320
|
+
# 注意:'drawing' 不在此列 —— 它由 ROOM_TYPE_ALIAS 归一化为
|
|
321
|
+
# 'drafting'(纯出图类),按 NON_PART_ROOM_TYPES 豁免。
|
|
322
|
+
"drivetrain", "sensor",
|
|
323
|
+
"mechanism", "bracket", "enclosure", "plate",
|
|
324
|
+
"frame", "shaft", "gear", "bearing")
|
|
325
|
+
|
|
326
|
+
# ── 【Bug⑥】纯出图 / 协调类房间:本就不产出零件级 FEA,豁免物理校核 ──────
|
|
327
|
+
# 仅这些类别可豁免(drafting / assembly / prime);其余一律按"含零件"处理,
|
|
328
|
+
# 避免再出现"新类型名一出现就被静默豁免"的漏洞(fail-closed 语义)。
|
|
329
|
+
NON_PART_ROOM_TYPES = ("drafting", "drawing_export", "assembly",
|
|
330
|
+
"assembly_verify", "prime", "coordination")
|
|
331
|
+
|
|
332
|
+
# ── 【Bug⑥ 修复】房间类型别名映射(与 workflow_gate.ROOM_TYPE_ALIAS 同步)──
|
|
333
|
+
# 为什么需要:门禁用规范名 transmission/housing/drafting,而 defense_gate 从
|
|
334
|
+
# mode_state / mode2_plan.json 快照里读到的可能是历史名 drivetrain/sensor/
|
|
335
|
+
# drawing。两边口径不一致 → 同一房间"门禁放行、防线豁免"(Bug③+⑥ 成因)。
|
|
336
|
+
# 先归一化再判定,保证两条链路结论一致。
|
|
337
|
+
ROOM_TYPE_ALIAS = {
|
|
338
|
+
"drivetrain": "transmission",
|
|
339
|
+
"drive_train": "transmission",
|
|
340
|
+
"transmission_system": "transmission",
|
|
341
|
+
"sensor": "housing",
|
|
342
|
+
"sensors": "housing",
|
|
343
|
+
"electronics": "housing",
|
|
344
|
+
"drawing": "drafting",
|
|
345
|
+
"drawings": "drafting",
|
|
346
|
+
"draft": "drafting",
|
|
347
|
+
"assembly_verify": "assembly",
|
|
348
|
+
"prime": "assembly",
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def normalize_room_type(rtype):
|
|
353
|
+
"""把房间类型别名归一化为规范名(未知类型小写后原样返回)。"""
|
|
354
|
+
_t = str(rtype or "").strip().lower()
|
|
355
|
+
if not _t:
|
|
356
|
+
return ""
|
|
357
|
+
return ROOM_TYPE_ALIAS.get(_t, _t)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def room_type_requires_physics(rtype):
|
|
361
|
+
"""房间类型是否需要物理/领域校核(Bug⑥:含零件即需要)。
|
|
362
|
+
|
|
363
|
+
规则:
|
|
364
|
+
· 空类型(legacy 快照无类型)→ 需要(fail-closed,保持旧行为);
|
|
365
|
+
· 纯出图/协调类别名 → 不需要(drafting / assembly / prime);
|
|
366
|
+
· 其余(含 drivetrain / sensor 等别名)→ 需要。
|
|
367
|
+
"""
|
|
368
|
+
_t = normalize_room_type(rtype)
|
|
369
|
+
if not _t:
|
|
370
|
+
return True
|
|
371
|
+
if _t in NON_PART_ROOM_TYPES:
|
|
372
|
+
return False
|
|
373
|
+
return True
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def delivery_requirements(state=None, room=None):
|
|
377
|
+
"""只从本任务原文决定交付要求,估算和历史工况不能升级用户要求。"""
|
|
378
|
+
st = state if isinstance(state, dict) else (
|
|
379
|
+
_read_json(os.path.join(STATE_DIR, "workflow_state.json")) or {})
|
|
380
|
+
task = str(st.get("task") or "").strip()
|
|
381
|
+
recorded_rooms = (st.get("subagent_config") or {}).get("rooms") or []
|
|
382
|
+
recorded_room = any((row.get("name") or row.get("room")) == room if isinstance(row, dict)
|
|
383
|
+
else bool(row and row[0] == room) for row in recorded_rooms)
|
|
384
|
+
if not task and room and recorded_room:
|
|
385
|
+
return {"material": True, "physics": True, "source": "legacy_unknown"}
|
|
386
|
+
room_text = str(room or "")
|
|
387
|
+
if room and st.get("task_id"):
|
|
388
|
+
plan_file = (st.get("mode2_flow") or {}).get("plan_file")
|
|
389
|
+
plan = _read_json(plan_file) if plan_file else {}
|
|
390
|
+
if isinstance(plan, dict) and plan.get("task_id") == st["task_id"]:
|
|
391
|
+
for row in (plan.get("subagent_plan") or {}).get("rooms") or []:
|
|
392
|
+
if row.get("name") == room and not row.get("auto_added"):
|
|
393
|
+
room_text += "\n" + str(row.get("why") or "")
|
|
394
|
+
text = "\n".join((task, str(st.get("context") or "") if task else "", room_text))
|
|
395
|
+
physical = r"(?:强度|疲劳|应力|刚度|安全系数|载荷|负载|承重|承载|寿命|物理|力学|有限元|\bFEA\b)"
|
|
396
|
+
check = r"(?:校核|验算|分析|验证|计算|评估|求解|优化|检验)"
|
|
397
|
+
physics_intent = (check + r".{0,12}" + physical + "|" + physical + r".{0,12}" + check
|
|
398
|
+
+ r"|有限元|\bFEA\b|载荷工况|(?:载荷|负载|承重|承载|寿命|安全系数)\s*[::=≥≤]?\s*\d")
|
|
399
|
+
negative_physics = (r"(?:无需|无须|不需要|不用|不做|不要|不考虑|不要求|别|暂不|\bno\b|\bwithout\b)"
|
|
400
|
+
r"(?:再|额外|设置|进行|做|计算|分析|校核|考虑|材料|材质|和|及|与|/|\s)*" + physical)
|
|
401
|
+
# 名称里出现“载荷传感器”不等于要验算;必须有分析动作或数值工况。
|
|
402
|
+
requested_physics = [c for c in re.split(r"[,,。;;\n]|但是|但|\bbut\b", text, flags=re.I)
|
|
403
|
+
if re.search(physics_intent, c, re.I) and not re.search(negative_physics, c, re.I)]
|
|
404
|
+
physics = bool(requested_physics)
|
|
405
|
+
optional_material = bool(re.search(
|
|
406
|
+
r"(?:材料|材质).{0,6}(?:可以不|可不|未指定|不指定|暂不|先不管|随意|不限|无要求|待定|\bTBD\b|[::]\s*无)|"
|
|
407
|
+
r"(?:不指定|无需|不需要|不用|没有|不要求)(?:任何|具体|设置|指定|提供|\s)*(?:材料|材质)", text, re.I))
|
|
408
|
+
material_text = re.sub(r"(?:不要|不用|别用|不采用|不选用)[^,,。;;\n]*?"
|
|
409
|
+
r"(?=改用|改成|改为|但|而|[,,。;;\n]|$)", "", text)
|
|
410
|
+
material_intent = bool(re.search(
|
|
411
|
+
r"(?:材料|材质)(?:\s+|\s*(?:[::=]|为|是|采用|选用|选|改成|改为|指定|必须|要求))|"
|
|
412
|
+
r"(?:指定|选用|采用|设置|赋予|要求|需要)\s*(?:具体|真实|某种)?(?:材料|材质)|赋材(?:为|[::=])", material_text))
|
|
413
|
+
from physics import material_db
|
|
414
|
+
names = {str(n) for key, info in [*material_db.ALL_MATERIALS.items(), *material_db.COMMON_MATERIALS.items()]
|
|
415
|
+
for n in [key, info.get("name") or "", *(info.get("aliases") or [])]
|
|
416
|
+
if n and not str(n).isdigit()}
|
|
417
|
+
material_named = any(re.search(r"(?<![A-Za-z0-9])" + re.escape(n) + r"(?![A-Za-z0-9])", material_text, re.I)
|
|
418
|
+
for n in names)
|
|
419
|
+
mass_required = bool(re.search(r"(?:计算|校核|测量|验证).{0,8}(?:重量|密度|质量属性)|"
|
|
420
|
+
r"(?:重量|密度|质量属性)\s*[::=≤≥]?\s*\d", text))
|
|
421
|
+
material = physics or mass_required or material_named or (not optional_material and material_intent)
|
|
422
|
+
return {"material": bool(material), "physics": physics,
|
|
423
|
+
"room_physics": any(c in room_text for c in requested_physics),
|
|
424
|
+
"source": "current_task"}
|
|
425
|
+
|
|
426
|
+
# 房间类型 → DSVA 领域 ID
|
|
427
|
+
TYPE_TO_DOMAIN = {
|
|
428
|
+
"structural": "structural",
|
|
429
|
+
"support": "structural",
|
|
430
|
+
"spring": "structural",
|
|
431
|
+
"thermal": "structural",
|
|
432
|
+
"corrosion": "structural",
|
|
433
|
+
"transmission": "transmission",
|
|
434
|
+
"housing": "housing",
|
|
435
|
+
"assembly": "structural",
|
|
436
|
+
"drafting": "structural",
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
# ── 材料家族判据(与 swapi._material_family 同源;此处本地实现,
|
|
440
|
+
# 避免门禁脚本依赖 pywin32/SolidWorks 才能运行)───────────────────
|
|
441
|
+
_FAMILY_BY_KEYWORD = (
|
|
442
|
+
("6061", "aluminum"), ("6063", "aluminum"), ("7075", "aluminum"),
|
|
443
|
+
("2024", "aluminum"), ("5052", "aluminum"), ("铝", "aluminum"),
|
|
444
|
+
("aluminum", "aluminum"), ("aluminium", "aluminum"), ("alsi", "aluminum"),
|
|
445
|
+
("ti-6al-4v", "titanium"), ("tc4", "titanium"), ("钛", "titanium"),
|
|
446
|
+
("titanium", "titanium"),
|
|
447
|
+
("q235", "steel"), ("45#", "steel"), ("40cr", "steel"), ("cr12mov", "steel"),
|
|
448
|
+
("1020", "steel"), ("1045", "steel"), ("4140", "steel"), ("4340", "steel"),
|
|
449
|
+
("abs", "plastic"), ("nylon", "plastic"), ("pom", "plastic"),
|
|
450
|
+
("polycarbonate", "plastic"), ("塑料", "plastic"),
|
|
451
|
+
("copper", "copper"), ("brass", "copper"), ("bronze", "copper"), ("铜", "copper"),
|
|
452
|
+
)
|
|
453
|
+
_FAMILY_BY_DENSITY = (
|
|
454
|
+
(2400, 2900, "aluminum"),
|
|
455
|
+
(4300, 4700, "titanium"),
|
|
456
|
+
(7400, 8100, "steel"),
|
|
457
|
+
(8300, 9000, "copper"),
|
|
458
|
+
# ── 【MA-03 修复】plastic 区间排除"水"窄带(990~1010)──────────────
|
|
459
|
+
# 水密度是 SW 未赋材的默认值,绝不能当工程塑料放行;
|
|
460
|
+
# material_family() 会先把该窄带判为 "water"。
|
|
461
|
+
(900, 989, "plastic"),
|
|
462
|
+
(1011, 2000, "plastic"),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def material_family(name_or_density):
|
|
467
|
+
"""判定材料家族;无法判定返回 None(调用方应视为"未知,不阻断")。
|
|
468
|
+
|
|
469
|
+
── 【MA-03 修复】密度 1000(水)不得判为 plastic ─────────────────────
|
|
470
|
+
实测缺陷:material_family(1000) → "plastic",于是 SolidWorks **未赋材**
|
|
471
|
+
的默认密度(1000 = 水)被当成"合法的工程塑料",
|
|
472
|
+
可能让未赋材零件通过家族校验。
|
|
473
|
+
修复:密度落在水的窄带(990~1010)时返回 "water",
|
|
474
|
+
并在 _FAMILY_BY_DENSITY 的 plastic 区间里排除该窄带 ——
|
|
475
|
+
"水"不是工程塑料,它是一个明确的错误信号。
|
|
476
|
+
"""
|
|
477
|
+
if name_or_density is None:
|
|
478
|
+
return None
|
|
479
|
+
if isinstance(name_or_density, bool):
|
|
480
|
+
return None
|
|
481
|
+
if isinstance(name_or_density, (int, float)):
|
|
482
|
+
d = float(name_or_density)
|
|
483
|
+
# ① 水密度窄带优先判定(SW 未赋材的默认值)
|
|
484
|
+
if 990.0 <= d <= 1010.0:
|
|
485
|
+
return "water"
|
|
486
|
+
# ② 其余按区间判定(plastic 区间已排除水窄带)
|
|
487
|
+
for lo, hi, fam in _FAMILY_BY_DENSITY:
|
|
488
|
+
if lo <= d <= hi:
|
|
489
|
+
return fam
|
|
490
|
+
return None
|
|
491
|
+
s = str(name_or_density).strip().lower()
|
|
492
|
+
if not s:
|
|
493
|
+
return None
|
|
494
|
+
for kw, fam in _FAMILY_BY_KEYWORD:
|
|
495
|
+
if kw in s:
|
|
496
|
+
return fam
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ── 【Bug-04】关键材料属性表(供跨材质替代判定)────────────────────────────
|
|
501
|
+
# 为什么在本模块自带一份:defense_gate 必须能独立运行(不能 import swapi ——
|
|
502
|
+
# 那会拉起 pywin32/SolidWorks 依赖),而跨材质替代判定需要屈服/密度属性。
|
|
503
|
+
#
|
|
504
|
+
# ── 【国标材料 · 外部数据源】本表【不再包含任何 GB/T 牌号数值】────────────
|
|
505
|
+
# GB/T 是国家统一标准,材料性能必须来自标准原文/权威手册,不得由代码作者
|
|
506
|
+
# 填写。因此国标牌号的属性一律经 _gb_props_from_json() 从外部
|
|
507
|
+
# gb_materials.json 读取;本表只保留【非国标】的通用条目
|
|
508
|
+
# (工程塑料 + 通用牌号),它们不涉及"国标"名义。
|
|
509
|
+
# 未导入国标数据时:国标牌号查不到 → 判定降级为"需人工确认",绝不猜数值。
|
|
510
|
+
_MATERIAL_PROPS = {
|
|
511
|
+
# ── 工程塑料(非国标强制牌号,属性取自公开供应商技术数据)──────────
|
|
512
|
+
"petg": {"yield_mpa": 40, "density": 1270, "uts_mpa": 50},
|
|
513
|
+
"pet": {"yield_mpa": 55, "density": 1420, "uts_mpa": 75},
|
|
514
|
+
"pla": {"yield_mpa": 55, "density": 1240, "uts_mpa": 60},
|
|
515
|
+
"abs": {"yield_mpa": 45, "density": 1050, "uts_mpa": 50},
|
|
516
|
+
"asa": {"yield_mpa": 45, "density": 1070, "uts_mpa": 50},
|
|
517
|
+
"nylon": {"yield_mpa": 80, "density": 1140, "uts_mpa": 85},
|
|
518
|
+
"pa12": {"yield_mpa": 48, "density": 1010, "uts_mpa": 50},
|
|
519
|
+
"tpu": {"yield_mpa": 30, "density": 1210, "uts_mpa": 40},
|
|
520
|
+
"pc": {"yield_mpa": 65, "density": 1200, "uts_mpa": 70},
|
|
521
|
+
"pom": {"yield_mpa": 70, "density": 1410, "uts_mpa": 75},
|
|
522
|
+
"peek": {"yield_mpa": 100, "density": 1320, "uts_mpa": 110},
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _gb_props_from_json(name):
|
|
527
|
+
"""【国标材料 · 外部数据源】从导入的 JSON 取牌号属性。
|
|
528
|
+
|
|
529
|
+
与 physics/material_db 共用同一份 gb_materials.json(单一数据源),
|
|
530
|
+
使"材料库"与"防线判定"永远看到同一组数值。
|
|
531
|
+
Returns: {yield_mpa, density, uts_mpa} 或 None(未收录 → 不猜数值)。
|
|
532
|
+
"""
|
|
533
|
+
_n = str(name or "").strip()
|
|
534
|
+
if not _n:
|
|
535
|
+
return None
|
|
536
|
+
try:
|
|
537
|
+
import sys as _sys
|
|
538
|
+
_pd = os.path.join(_BASE_DIR, "physics")
|
|
539
|
+
if _pd not in _sys.path:
|
|
540
|
+
_sys.path.insert(0, _pd)
|
|
541
|
+
import material_db as _mdb
|
|
542
|
+
_m = _mdb.get_material(_n)
|
|
543
|
+
if not _m:
|
|
544
|
+
# 再试 GB_ 前缀 id(JSON 里常用 GB_Q235 这类键)
|
|
545
|
+
_gid = "GB_" + _n.replace("-", "_").replace("#", "").upper()
|
|
546
|
+
_m = _mdb.get_material(_gid)
|
|
547
|
+
if _m and _m.get("yield_mpa"):
|
|
548
|
+
return {
|
|
549
|
+
"yield_mpa": _m.get("yield_mpa"),
|
|
550
|
+
"density": _m.get("density_kg_m3"),
|
|
551
|
+
"uts_mpa": _m.get("uts_mpa"),
|
|
552
|
+
"standard": _m.get("standard"),
|
|
553
|
+
"source": "gb_materials.json(外部导入)",
|
|
554
|
+
}
|
|
555
|
+
except Exception:
|
|
556
|
+
pass
|
|
557
|
+
return None
|
|
558
|
+
|
|
559
|
+
# 跨材质替代的判定阈值:屈服强度或密度差异超过此比例即判"不可接受替代"
|
|
560
|
+
#
|
|
561
|
+
# ── 【MA-06 修复】双层容差口径必须明确分工,不能各判各的 ────────────────
|
|
562
|
+
# 实测矛盾:swapi 层用 5%(MATCH_TOL)判"名称匹配是否可信",
|
|
563
|
+
# defense_gate 层用 10% 判"属性替代是否可接受";
|
|
564
|
+
# 于是 PETG→PET(密度差 11.8%)出现"sub_block=True 但 mismatch_rej=False"
|
|
565
|
+
# 的表面矛盾(两层各自正确,但没人说清谁负责什么)。
|
|
566
|
+
# 明确分工(两者不是同一个判据,本就不该相等):
|
|
567
|
+
# · swapi 侧 5% ——【写入期】判断"SW 是否真的写成了我要的材料",
|
|
568
|
+
# 偏严,用于尽早发现近似替换并告警;
|
|
569
|
+
# · gate 侧 10% ——【校验期】判断"已生效的材料替代能否放行",
|
|
570
|
+
# 偏宽,留给同族等价写法(Q235↔S235JR 等)余地。
|
|
571
|
+
# 两层都保留,但命中任一层即视为"属性级不可接受",不再出现口径分裂观感。
|
|
572
|
+
_SUBSTITUTION_TOL_PCT = 10.0
|
|
573
|
+
# ── 【Bug-C 运行时修复】密度实测差异容差 ────────────────────────────────
|
|
574
|
+
# 请求 PETG(1270) 而 SW 实得 PET(1420) → 差 11.8%,必须拦截。
|
|
575
|
+
# 同族材料(如 6061 vs 6061-T6)密度基本相同,不会误伤。
|
|
576
|
+
_DENSITY_MISMATCH_TOL = 0.05
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _material_props_lookup(name):
|
|
580
|
+
"""按材料名解析完整属性(physics material_db → 内置最小表)。
|
|
581
|
+
|
|
582
|
+
Returns: (ok, info, reason)
|
|
583
|
+
"""
|
|
584
|
+
_nm = str(name or "").strip()
|
|
585
|
+
if not _nm:
|
|
586
|
+
return False, None, "材料名为空"
|
|
587
|
+
try:
|
|
588
|
+
import sys as _sys
|
|
589
|
+
_pd = os.path.join(_BASE_DIR, "physics")
|
|
590
|
+
if _pd not in _sys.path:
|
|
591
|
+
_sys.path.insert(0, _pd)
|
|
592
|
+
import material_db as _mdb
|
|
593
|
+
_m = _mdb.get_material(_nm)
|
|
594
|
+
if _m:
|
|
595
|
+
return True, {
|
|
596
|
+
"name": _m.get("name") or _nm,
|
|
597
|
+
"yield_mpa": _m.get("yield_mpa"),
|
|
598
|
+
"density_kg_m3": _m.get("density_kg_m3"),
|
|
599
|
+
"uts_mpa": _m.get("uts_mpa"),
|
|
600
|
+
"standard": _m.get("standard"),
|
|
601
|
+
}, "material_db"
|
|
602
|
+
except Exception:
|
|
603
|
+
pass
|
|
604
|
+
return False, None, "材料库不可用或未收录"
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _material_props_any(name):
|
|
608
|
+
"""查关键属性:内置最小表 → custom_materials.json → 【material_db 兜底】。
|
|
609
|
+
|
|
610
|
+
── 【MA-02 修复】必须 fallback 到 material_db ────────────────────────
|
|
611
|
+
实测缺陷:内置表缺 Q355 / 20CrMnTi / GCr15 / 65Mn / QT500-7 / 2A12 /
|
|
612
|
+
ZL104 共 7 个国标牌号,而这些牌号在 physics/material_db 里【是有的】。
|
|
613
|
+
原实现只查内置表 + custom_materials,查不到就返回 None →
|
|
614
|
+
跨材质替代判定降级为"无法对比",只能 needs_review(过度保守),
|
|
615
|
+
国标牌号之间的替代差异(如 Q235→45# 屈服差 51%)反而判不出来。
|
|
616
|
+
现在末尾接【外部导入的国标材料 JSON】(与 material_db 同源),
|
|
617
|
+
把属性对比能力补齐;国标数值不在本文件写死。
|
|
618
|
+
"""
|
|
619
|
+
# ① 输入归一化(MA-05:剥 BOM、全角转半角)
|
|
620
|
+
_raw = str(name or "")
|
|
621
|
+
_k = _norm_material_key(_raw)
|
|
622
|
+
if not _k:
|
|
623
|
+
return None
|
|
624
|
+
# ② 内置最小表(键已归一化,这里逐项归一化比对,容忍 BOM/全角)
|
|
625
|
+
for _mk, _mv in _MATERIAL_PROPS.items():
|
|
626
|
+
if _norm_material_key(_mk) == _k:
|
|
627
|
+
return dict(_mv)
|
|
628
|
+
# ③ 用户自定义材料
|
|
629
|
+
try:
|
|
630
|
+
_cm = _read_json(os.path.join(_BASE_DIR, "custom_materials.json")) or {}
|
|
631
|
+
for _ck, _cv in _cm.items():
|
|
632
|
+
if _norm_material_key(_ck) == _k and isinstance(_cv, dict):
|
|
633
|
+
return {
|
|
634
|
+
"yield_mpa": _cv.get("yield_mpa"),
|
|
635
|
+
"density": _cv.get("density_kg_m3") or _cv.get("density"),
|
|
636
|
+
"uts_mpa": _cv.get("uts_mpa"),
|
|
637
|
+
}
|
|
638
|
+
except Exception:
|
|
639
|
+
pass
|
|
640
|
+
# ④ 【国标材料 · 外部数据源】从导入的 gb_materials.json 取属性
|
|
641
|
+
# 数值不再写死在本模块 —— GB/T 是国家标准,必须来自权威数据源。
|
|
642
|
+
# 未导入时返回 None → 判定降级为"需人工确认",绝不猜数值。
|
|
643
|
+
_gbp = _gb_props_from_json(_raw)
|
|
644
|
+
if _gbp:
|
|
645
|
+
return _gbp
|
|
646
|
+
# ⑤ 【MA-02】physics/material_db(通用牌号;其国标部分同样来自外部 JSON)
|
|
647
|
+
try:
|
|
648
|
+
_ok, _info, _ = _material_props_lookup(_raw)
|
|
649
|
+
if _ok and _info:
|
|
650
|
+
return {
|
|
651
|
+
"yield_mpa": _info.get("yield_mpa"),
|
|
652
|
+
"density": _info.get("density_kg_m3"),
|
|
653
|
+
"uts_mpa": _info.get("uts_mpa"),
|
|
654
|
+
}
|
|
655
|
+
except Exception:
|
|
656
|
+
pass
|
|
657
|
+
return None
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _norm_material_key(s):
|
|
661
|
+
"""【MA-05 修复】材料名归一化:剥 BOM/零宽字符、全角转半角、小写去分隔符。
|
|
662
|
+
|
|
663
|
+
实测:`\\ufeffQ235` 与 `Q235`(全角)会因字符形态差异被判"查不到",
|
|
664
|
+
属 fail-closed 的安全方向,但会误伤合法输入、也让排障困惑。
|
|
665
|
+
"""
|
|
666
|
+
import re as _re
|
|
667
|
+
_t = str(s or "")
|
|
668
|
+
_t = _t.replace("\ufeff", "").replace("\u200b", "").replace("\u200e", "")
|
|
669
|
+
_out = []
|
|
670
|
+
for _ch in _t:
|
|
671
|
+
_cp = ord(_ch)
|
|
672
|
+
if _cp == 0x3000:
|
|
673
|
+
_out.append(" ")
|
|
674
|
+
elif 0xFF01 <= _cp <= 0xFF5E:
|
|
675
|
+
_out.append(chr(_cp - 0xFEE0))
|
|
676
|
+
else:
|
|
677
|
+
_out.append(_ch)
|
|
678
|
+
_t = "".join(_out).strip().lower()
|
|
679
|
+
return _re.sub(r"[\s\-_()()·.]+", "", _t)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def material_substitution_check(requested, applied, attested_density=None):
|
|
683
|
+
"""【Bug-04 修复】判定"请求材料 → 实际生效材料"是否为不可接受的替代。
|
|
684
|
+
|
|
685
|
+
背景:PETG(ρ1270/σy40)被 SW 近似成 PET(ρ1420/σy55)时,
|
|
686
|
+
原实现仅凭 approximate_match=True 给 WARN 放行 —— 二者屈服差约 37%,
|
|
687
|
+
属跨材料静默替代,本应拦截。同族(都属 plastic)不足以作为放行理由,
|
|
688
|
+
必须做【属性级】对比。
|
|
689
|
+
|
|
690
|
+
Returns: {blocking: bool, reason: str, requested, applied,
|
|
691
|
+
req_props, app_props, deltas{pct}}
|
|
692
|
+
· blocking=True → 防线①应判不通过(硬阻断)
|
|
693
|
+
· blocking=False → 允许,但 reason 可能带说明(差异在容差内/无法判定)
|
|
694
|
+
"""
|
|
695
|
+
_rq = str(requested or "").strip()
|
|
696
|
+
_ap = str(applied or "").strip()
|
|
697
|
+
_out = {"blocking": False, "reason": "", "requested": _rq, "applied": _ap,
|
|
698
|
+
"req_props": None, "app_props": None, "deltas": {}}
|
|
699
|
+
if not _rq or not _ap or _rq.lower() == _ap.lower():
|
|
700
|
+
return _out
|
|
701
|
+
_rp = _material_props_any(_rq)
|
|
702
|
+
_apx = _material_props_any(_ap)
|
|
703
|
+
_out["req_props"] = _rp
|
|
704
|
+
_out["app_props"] = _apx
|
|
705
|
+
|
|
706
|
+
def _usable(_d):
|
|
707
|
+
"""属性是否【足以支撑比较】:必须有屈服强度。
|
|
708
|
+
|
|
709
|
+
踩坑(真实缺陷):GCr15 这类"只有标准身份、力学数值待补全"的牌号,
|
|
710
|
+
_material_props_any 会返回一个【部分填充】的 dict(例如只有密度)。
|
|
711
|
+
旧逻辑只判 `if not _rp or not _apx`,这种"非空但不完整"的返回值
|
|
712
|
+
会绕过守卫;随后两个差值都算不出来(None),于是落入 else 分支
|
|
713
|
+
输出"差异在容差内,已放行" —— 等于【拿不到数据却当成通过】,
|
|
714
|
+
是危险误放行。现在改为:缺屈服强度即视为不可比较。
|
|
715
|
+
"""
|
|
716
|
+
return bool(isinstance(_d, dict) and _d.get("yield_mpa"))
|
|
717
|
+
|
|
718
|
+
if not _usable(_rp) or not _usable(_apx):
|
|
719
|
+
# 属性不全 → 【不静默放行】:标记需人工确认(fail-closed 到"待复核")
|
|
720
|
+
_out["blocking"] = False
|
|
721
|
+
_out["needs_review"] = True
|
|
722
|
+
_out["degraded"] = True
|
|
723
|
+
_out["degraded_reason_code"] = "MATERIAL_PROPS_INCOMPLETE"
|
|
724
|
+
_miss = []
|
|
725
|
+
for _tag, _d in (("请求", _rp), ("实际", _apx)):
|
|
726
|
+
if not _usable(_d):
|
|
727
|
+
_miss.append("%s材料 %s" % (
|
|
728
|
+
_tag, "属性未收录(可能仅有标准身份,力学数值待补全)"
|
|
729
|
+
if _d else "查不到属性"))
|
|
730
|
+
_out["reason"] = (
|
|
731
|
+
"材料被替换(请求 %s → 实际 %s),但%s —— 无法做属性级对比;"
|
|
732
|
+
"请人工确认该替代是否可接受(跨材质替代不得静默放行)。"
|
|
733
|
+
% (_rq, _ap, "、".join(_miss)))
|
|
734
|
+
return _out
|
|
735
|
+
|
|
736
|
+
def _pct(_a, _b):
|
|
737
|
+
try:
|
|
738
|
+
_a, _b = float(_a), float(_b)
|
|
739
|
+
if not _b:
|
|
740
|
+
return None
|
|
741
|
+
return abs(_a - _b) / abs(_b) * 100.0
|
|
742
|
+
except Exception:
|
|
743
|
+
return None
|
|
744
|
+
|
|
745
|
+
_dy = _pct(_apx.get("yield_mpa"), _rp.get("yield_mpa"))
|
|
746
|
+
_dd = _pct(_apx.get("density"), _rp.get("density"))
|
|
747
|
+
_out["deltas"] = {"yield_pct": _dy, "density_pct": _dd}
|
|
748
|
+
_bad = []
|
|
749
|
+
if _dy is not None and _dy > _SUBSTITUTION_TOL_PCT:
|
|
750
|
+
_bad.append("屈服强度 %s→%s MPa(差 %.1f%%)"
|
|
751
|
+
% (_rp.get("yield_mpa"), _apx.get("yield_mpa"), _dy))
|
|
752
|
+
if _dd is not None and _dd > _SUBSTITUTION_TOL_PCT:
|
|
753
|
+
_bad.append("密度 %s→%s kg/m³(差 %.1f%%)"
|
|
754
|
+
% (_rp.get("density"), _apx.get("density"), _dd))
|
|
755
|
+
if _bad:
|
|
756
|
+
_out["blocking"] = True
|
|
757
|
+
_out["reason"] = (
|
|
758
|
+
"跨材质静默替代:请求材料 %s,实际生效 %s,关键属性差异超容差(%.0f%%)—— %s。"
|
|
759
|
+
"不得以 approximate_match 放行;请改用材料库中的正确牌号,"
|
|
760
|
+
"或用 set_custom_material() 显式登记后重做。"
|
|
761
|
+
% (_rq, _ap, _SUBSTITUTION_TOL_PCT, ";".join(_bad)))
|
|
762
|
+
else:
|
|
763
|
+
_out["reason"] = ("材料被替换(%s → %s),关键属性差异在容差(%.0f%%)内,已放行但留痕"
|
|
764
|
+
% (_rq, _ap, _SUBSTITUTION_TOL_PCT))
|
|
765
|
+
return _out
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _read_json(path):
|
|
769
|
+
"""稳健读取 JSON(utf-8-sig → utf-8 → gbk),失败返回 None。"""
|
|
770
|
+
if not path or not os.path.exists(path):
|
|
771
|
+
return None
|
|
772
|
+
for enc in ("utf-8-sig", "utf-8", "gbk", "latin-1"):
|
|
773
|
+
try:
|
|
774
|
+
with open(path, "r", encoding=enc) as f:
|
|
775
|
+
return json.load(f)
|
|
776
|
+
except Exception:
|
|
777
|
+
continue
|
|
778
|
+
return None
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def _write_json(path, data):
|
|
782
|
+
"""【Bug-06 修复】原子写入 JSON,杜绝守卫读到"半截文件"。
|
|
783
|
+
|
|
784
|
+
原实现直接 open(w) 写入:守卫每 5 秒巡检 reports/ 下的凭据,
|
|
785
|
+
若恰好在写入过程中读取,就会拿到【截断的 JSON】→ 解析失败 →
|
|
786
|
+
误判 credential-unreadable / signature-invalid → 把一份本来合法的
|
|
787
|
+
凭据隔离掉(实测:隔离区累计 19 个文件,部分即由此产生)。
|
|
788
|
+
修复:先写同目录临时文件并 fsync,再用 os.replace 原子替换
|
|
789
|
+
(同盘 replace 在 Windows/POSIX 均为原子操作),守卫永远只能看到
|
|
790
|
+
"完整旧版"或"完整新版"。
|
|
791
|
+
"""
|
|
792
|
+
try:
|
|
793
|
+
import tempfile as _tf
|
|
794
|
+
_dir = os.path.dirname(path) or "."
|
|
795
|
+
os.makedirs(_dir, exist_ok=True)
|
|
796
|
+
_fd, _tmp = _tf.mkstemp(prefix=".tmp-", suffix=".json", dir=_dir)
|
|
797
|
+
try:
|
|
798
|
+
with os.fdopen(_fd, "w", encoding="utf-8") as f:
|
|
799
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
800
|
+
f.flush()
|
|
801
|
+
try:
|
|
802
|
+
os.fsync(f.fileno())
|
|
803
|
+
except Exception:
|
|
804
|
+
pass
|
|
805
|
+
os.replace(_tmp, path)
|
|
806
|
+
except Exception:
|
|
807
|
+
try:
|
|
808
|
+
if os.path.exists(_tmp):
|
|
809
|
+
os.remove(_tmp)
|
|
810
|
+
except Exception:
|
|
811
|
+
pass
|
|
812
|
+
raise
|
|
813
|
+
return True
|
|
814
|
+
except Exception:
|
|
815
|
+
# 原子写入失败 → 退回直接写入(宁可写成功也不要丢凭据)
|
|
816
|
+
try:
|
|
817
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
818
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
819
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
820
|
+
return True
|
|
821
|
+
except Exception:
|
|
822
|
+
return False
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
826
|
+
# 防线 ① 材料
|
|
827
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
828
|
+
|
|
829
|
+
def _real_case_path(path):
|
|
830
|
+
"""【Bug-09 修复】返回磁盘上【真实大小写】的路径(找不到则原样返回)。
|
|
831
|
+
|
|
832
|
+
实测缺陷:`save()` 时传入的路径可能是小写 `.sldprt`,而 SolidWorks
|
|
833
|
+
保存后磁盘上的真实文件是 `.SLDPRT`。凭据名变成
|
|
834
|
+
`xxx.sldprt.material.json`,与零件 `xxx.SLDPRT` 的基名不一致 ——
|
|
835
|
+
下游按"零件路径 + .material.json"查找时会 MISS,表现为"凭据丢失"。
|
|
836
|
+
修复:逐级用目录列举做 casefold 比对,命中即返回真实大小写路径,
|
|
837
|
+
使凭据名与零件名严格同基名。
|
|
838
|
+
⚠️ 关键:Windows 下 os.path.exists() 【大小写不敏感】,因此不能靠
|
|
839
|
+
"exists 就返回原样" 短路 —— 必须无条件逐级还原真实大小写。
|
|
840
|
+
"""
|
|
841
|
+
try:
|
|
842
|
+
p = str(path)
|
|
843
|
+
_abs = os.path.abspath(p)
|
|
844
|
+
if not os.path.exists(_abs):
|
|
845
|
+
return p
|
|
846
|
+
_drive, _tail = os.path.splitdrive(_abs)
|
|
847
|
+
_segs = [s for s in _tail.split(os.sep) if s]
|
|
848
|
+
_cur = _drive + os.sep if _drive else os.sep
|
|
849
|
+
for _seg in _segs:
|
|
850
|
+
# 无条件在父目录里按 casefold 找真实名(不信任 exists 的短路)
|
|
851
|
+
_real_seg = None
|
|
852
|
+
try:
|
|
853
|
+
if os.path.isdir(_cur):
|
|
854
|
+
_want = _seg.casefold()
|
|
855
|
+
for _e in os.listdir(_cur):
|
|
856
|
+
if _e.casefold() == _want:
|
|
857
|
+
_real_seg = _e
|
|
858
|
+
break
|
|
859
|
+
except Exception:
|
|
860
|
+
_real_seg = None
|
|
861
|
+
_cur = os.path.join(_cur, _real_seg if _real_seg else _seg)
|
|
862
|
+
return _cur if os.path.exists(_cur) else p
|
|
863
|
+
except Exception:
|
|
864
|
+
return str(path)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def attestation_path(part_path):
|
|
868
|
+
"""材料凭据文件路径:<part>.SLDPRT → <part>.SLDPRT.material.json
|
|
869
|
+
|
|
870
|
+
── 【Bug-09 修复】用磁盘【真实大小写】的零件路径拼凭据名 ──────────────
|
|
871
|
+
原实现直接 str(part_path) 拼接:若 save() 传的是 `xxx.sldprt` 而磁盘是
|
|
872
|
+
`xxx.SLDPRT`,凭据会写成 `xxx.sldprt.material.json`,
|
|
873
|
+
与零件基名不一致 → 查找 MISS,被误判为"凭据未落盘"。
|
|
874
|
+
|
|
875
|
+
── 【P2 修复】必须校验路径边界 ────────────────────────────────────────
|
|
876
|
+
实测(子代理3):传 `../x`、`Z:`、`CON`、UNC 路径都被【原样返回】,
|
|
877
|
+
调用方随后会按这个路径读写 —— 等于给了一条越界写入的通道。
|
|
878
|
+
现在是纯函数式校验:命中可疑形态时【规范化到安全名】并记录原因,
|
|
879
|
+
绝不把危险路径原样交出去。
|
|
880
|
+
"""
|
|
881
|
+
_p = str(part_path or "")
|
|
882
|
+
_why = _unsafe_path_reason(_p)
|
|
883
|
+
if _why:
|
|
884
|
+
# 危险路径:退回"仅用文件名"(去掉任何目录成分与保留名)
|
|
885
|
+
_base = os.path.basename(_p.replace("\\", "/").rstrip("/")) or "part"
|
|
886
|
+
_safe = "".join(c for c in _base if c.isalnum() or c in "._- ").strip()
|
|
887
|
+
if not _safe:
|
|
888
|
+
_safe = "part.SLDPRT"
|
|
889
|
+
_fallback = os.path.join(os.path.abspath(os.getcwd()), _safe)
|
|
890
|
+
try:
|
|
891
|
+
# 记录一次越界尝试,便于审计(绝不静默)
|
|
892
|
+
note_unsigned_attempt(_fallback, "material",
|
|
893
|
+
"凭据路径越界被规范化: %r (%s)" % (_p, _why))
|
|
894
|
+
except Exception:
|
|
895
|
+
pass
|
|
896
|
+
return _fallback + ".material.json"
|
|
897
|
+
return _real_case_path(part_path) + ".material.json"
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _unsafe_path_reason(path):
|
|
901
|
+
"""【P2 修复】判定路径是否越界/危险;安全返回 None。
|
|
902
|
+
|
|
903
|
+
拦截的形态:
|
|
904
|
+
· 空路径;
|
|
905
|
+
· 目录穿越(.. 成分);
|
|
906
|
+
· 非本地盘符(UNC \\\\server\\share、设备路径 \\\\?\\);
|
|
907
|
+
· Windows 保留设备名(CON/PRN/AUX/NUL/COM1-9/LPT1-9);
|
|
908
|
+
· 含 NUL 等控制字符。
|
|
909
|
+
注意:绝对路径本身【不】判危险(零件就在绝对路径上),
|
|
910
|
+
只判"能逃出预期目录"或"操作系统特殊语义"的形态。
|
|
911
|
+
"""
|
|
912
|
+
if not path or not str(path).strip():
|
|
913
|
+
return "空路径"
|
|
914
|
+
_p = str(path)
|
|
915
|
+
if "\x00" in _p or any(ord(c) < 32 for c in _p):
|
|
916
|
+
return "含控制字符"
|
|
917
|
+
_n = _p.replace("/", "\\")
|
|
918
|
+
# UNC / 设备路径
|
|
919
|
+
if _n.startswith("\\\\"):
|
|
920
|
+
return "UNC 或设备路径"
|
|
921
|
+
# 目录穿越
|
|
922
|
+
_parts = [x for x in _n.split("\\") if x not in ("", ".")]
|
|
923
|
+
if ".." in _parts:
|
|
924
|
+
return "含目录穿越成分(..)"
|
|
925
|
+
# Windows 保留设备名(任意一段命中即危险)
|
|
926
|
+
_RESERVED = {"con", "prn", "aux", "nul"}
|
|
927
|
+
for _seg in _parts:
|
|
928
|
+
_stem = _seg.split(".")[0].strip().lower()
|
|
929
|
+
if _stem in _RESERVED:
|
|
930
|
+
return "Windows 保留设备名 %s" % _seg
|
|
931
|
+
if len(_stem) == 4 and _stem[:3] in ("com", "lpt") and _stem[3] in "123456789":
|
|
932
|
+
return "Windows 保留设备名 %s" % _seg
|
|
933
|
+
# 裸盘符("Z:" 这种无后续路径的)
|
|
934
|
+
if len(_n) == 2 and _n[1] == ":":
|
|
935
|
+
return "裸盘符无路径"
|
|
936
|
+
return None
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def write_material_attestation(part_path, info):
|
|
940
|
+
"""由 swapi.save() 在 SolidWorks 进程内调用:固化材料事实。
|
|
941
|
+
|
|
942
|
+
info 期望字段(缺省容忍):applied_name / material / density_kg_m3 /
|
|
943
|
+
density_before_kg_m3 / source / ok / approximate_match /
|
|
944
|
+
rejected_candidates。
|
|
945
|
+
|
|
946
|
+
── 【签后补字段 BUG 修复】info 可能是一份【宿主已签名的凭据】。 ─────────
|
|
947
|
+
原实现无条件 rec.update(info) 并自行追加 part_size_bytes / part_md5 /
|
|
948
|
+
attested_* 等字段 —— 对已签名凭据而言,这等于在签名之后改动凭据体,
|
|
949
|
+
HMAC 必然失配,防线①因此永远验签失败。
|
|
950
|
+
现在:已签名凭据【原样落盘】(这些字段已由宿主在签发时纳入待签体,
|
|
951
|
+
且 part_md5/part_size 由宿主自己读盘算出);只有未签名兜底凭据才由
|
|
952
|
+
本函数补齐本地可观测字段。
|
|
953
|
+
"""
|
|
954
|
+
_signed = bool((info or {}).get("_sig"))
|
|
955
|
+
if _signed:
|
|
956
|
+
rec = dict(info)
|
|
957
|
+
_write_json(attestation_path(part_path), rec)
|
|
958
|
+
return rec
|
|
959
|
+
|
|
960
|
+
rec = {
|
|
961
|
+
"schema": "dsh-material-attestation/1",
|
|
962
|
+
"part": os.path.abspath(str(part_path)),
|
|
963
|
+
"part_name": os.path.splitext(os.path.basename(str(part_path)))[0],
|
|
964
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
965
|
+
"ts": time.time(),
|
|
966
|
+
}
|
|
967
|
+
rec.update(info or {})
|
|
968
|
+
# ── [防伪造] 由本函数【自己】计算零件 size + md5,不依赖调用方传入 ──
|
|
969
|
+
# 否则调用方漏传就让"哈希绑定"静默失效(实测 swapi 传了、其它调用方没传,
|
|
970
|
+
# 导致合法零件反而被判缺 part_md5)。这里统一兜底,保证原子性。
|
|
971
|
+
try:
|
|
972
|
+
_pp = str(part_path)
|
|
973
|
+
if os.path.exists(_pp):
|
|
974
|
+
rec["part_size_bytes"] = os.path.getsize(_pp)
|
|
975
|
+
import hashlib as _hl
|
|
976
|
+
_h = _hl.md5()
|
|
977
|
+
with open(_pp, "rb") as _pf:
|
|
978
|
+
for _chunk in iter(lambda: _pf.read(1 << 20), b""):
|
|
979
|
+
_h.update(_chunk)
|
|
980
|
+
rec["part_md5"] = _h.hexdigest()
|
|
981
|
+
except Exception:
|
|
982
|
+
pass
|
|
983
|
+
# 归一化:材料名 + 密度 + 家族
|
|
984
|
+
_name = (rec.get("applied_name") or rec.get("material")
|
|
985
|
+
or rec.get("requested_material"))
|
|
986
|
+
_dens = rec.get("density_kg_m3")
|
|
987
|
+
try:
|
|
988
|
+
_dens = float(_dens) if _dens is not None else None
|
|
989
|
+
except Exception:
|
|
990
|
+
_dens = None
|
|
991
|
+
rec["attested_material"] = _name
|
|
992
|
+
rec["attested_density_kg_m3"] = _dens
|
|
993
|
+
rec["attested_family"] = material_family(_name) or material_family(_dens)
|
|
994
|
+
_write_json(attestation_path(part_path), rec)
|
|
995
|
+
return rec
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def check_material_attestation(part_path, required=True):
|
|
999
|
+
"""校验单个零件的材料凭据。
|
|
1000
|
+
|
|
1001
|
+
required=False 仅允许未指定材料;已有显式赋材事实仍按原规则验证。
|
|
1002
|
+
Returns: {ok, level, reasons[], attestation?}
|
|
1003
|
+
ok=False → 该零件材料不可信(硬阻断)
|
|
1004
|
+
"""
|
|
1005
|
+
out = {"ok": False, "level": "BLOCK", "reasons": [], "part": str(part_path),
|
|
1006
|
+
"requires_user_action": True, "retryable": False,
|
|
1007
|
+
"next_action": "停止重复赋材或重建;向用户说明请求材料、实际读回值与失败原因,等待材料名/数据/服务条件被确认后再继续。"}
|
|
1008
|
+
# ── 【Bug-09 修复】零件与凭据都要按【磁盘真实大小写】解析 ──────────────
|
|
1009
|
+
# 大小写不一致(.sldprt vs .SLDPRT)会让凭据查找 MISS,被误判为
|
|
1010
|
+
# "未落盘"。这里先归一化到真实路径,再据此找凭据。
|
|
1011
|
+
_real_part = _real_case_path(part_path)
|
|
1012
|
+
out["part"] = _real_part
|
|
1013
|
+
if not os.path.exists(str(_real_part)):
|
|
1014
|
+
out["reasons"].append("零件文件不存在: %s" % part_path)
|
|
1015
|
+
return out
|
|
1016
|
+
att = _read_json(attestation_path(_real_part))
|
|
1017
|
+
if not att:
|
|
1018
|
+
# 二次兜底:目录内按 casefold 找同名凭据(应对历史遗留的大小写混乱)
|
|
1019
|
+
try:
|
|
1020
|
+
_d, _n = os.path.split(_real_part)
|
|
1021
|
+
_want = (_n + ".material.json").casefold()
|
|
1022
|
+
for _e in os.listdir(_d):
|
|
1023
|
+
if _e.casefold() == _want:
|
|
1024
|
+
att = _read_json(os.path.join(_d, _e))
|
|
1025
|
+
if att:
|
|
1026
|
+
break
|
|
1027
|
+
except Exception:
|
|
1028
|
+
pass
|
|
1029
|
+
if not required and ((not att and not os.path.exists(attestation_path(_real_part)))
|
|
1030
|
+
or (isinstance(att, dict)
|
|
1031
|
+
and att.get("material_status") == "unspecified"
|
|
1032
|
+
and not att.get("requested_material")
|
|
1033
|
+
and not att.get("material_invalid"))):
|
|
1034
|
+
out.update(ok=True, level="NOT_REQUIRED", evaluated=False, skipped=True,
|
|
1035
|
+
requires_user_action=False, next_action=None,
|
|
1036
|
+
reason="本任务未要求材料,零件未指定材料;材料与质量属性未验证")
|
|
1037
|
+
return out
|
|
1038
|
+
if not att:
|
|
1039
|
+
out["reasons"].append(
|
|
1040
|
+
"缺少材料凭据(%s.material.json)—— 该零件未经过材料防线校验。"
|
|
1041
|
+
% os.path.basename(str(_real_part)))
|
|
1042
|
+
out["hint"] = ("用 swapi.new_part(material='Q235') 显式指定材料后重新保存;"
|
|
1043
|
+
"凭据由 swapi.save() 自动写出。")
|
|
1044
|
+
return out
|
|
1045
|
+
# ── 【Bug16 修复】非法材料必须先拦(在验签之前判定)───────────────
|
|
1046
|
+
# 非法材料不会获得宿主签名(swapi.save 已提前返回),因此若先验签,
|
|
1047
|
+
# 报错会是"缺少签名"这种误导性原因;这里先给出准确诊断。
|
|
1048
|
+
if att.get("material_invalid"):
|
|
1049
|
+
out["reasons"].append(
|
|
1050
|
+
"非法材料:%s" % (att.get("error") or "材料不在合法材料库中"))
|
|
1051
|
+
out["hint"] = att.get("hint") or (
|
|
1052
|
+
"请使用内置材料(Q235/45#/304/6061-T6/PETG/PLA/ABS/Nylon/PC/TPU…)"
|
|
1053
|
+
"或用 save_custom_material() 显式登记后重试。")
|
|
1054
|
+
out["material_invalid"] = True
|
|
1055
|
+
return out
|
|
1056
|
+
# ── 【主防线】先验签:不经宿主签发的凭据一律不可信 ────────────────
|
|
1057
|
+
_sig = credential_is_signed_and_valid(att)
|
|
1058
|
+
if not _sig["ok"]:
|
|
1059
|
+
out["reasons"].append("材料凭据未通过签名校验:%s" % _sig["reason"])
|
|
1060
|
+
out["hint"] = ("材料凭据必须由宿主签名(swapi.save 会自动请求 /defense/sign)。"
|
|
1061
|
+
"手工写入的 JSON 无法通过验签。")
|
|
1062
|
+
return out
|
|
1063
|
+
# ── 【防伪造】凭据必须与零件【同源】:schema 正确、part 路径一致、非手抄 ──
|
|
1064
|
+
_schema = str(att.get("schema") or "")
|
|
1065
|
+
if _schema != "dsh-material-attestation/1":
|
|
1066
|
+
out["reasons"].append(
|
|
1067
|
+
"材料凭据 schema 不匹配(%r)—— 非 swapi.save() 产出,疑似手工伪造" % _schema)
|
|
1068
|
+
_part_in_att = str(att.get("part") or "").strip()
|
|
1069
|
+
if _part_in_att:
|
|
1070
|
+
try:
|
|
1071
|
+
# ── 【Bug-09 修复】路径比对必须【大小写不敏感】─────────────────
|
|
1072
|
+
# Windows 上 xxx.SLDPRT 与 xxx.sldprt 是同一个文件;原实现用
|
|
1073
|
+
# os.path.abspath 直接比较,大小写不同就误判为"挪用他人凭据"。
|
|
1074
|
+
_a1 = os.path.normcase(os.path.abspath(_part_in_att))
|
|
1075
|
+
_a2 = os.path.normcase(os.path.abspath(str(_real_part)))
|
|
1076
|
+
if _a1 != _a2:
|
|
1077
|
+
out["reasons"].append(
|
|
1078
|
+
"材料凭据记录的零件路径与校验目标不一致(凭据=%s)—— 疑似挪用他人凭据"
|
|
1079
|
+
% os.path.basename(_part_in_att))
|
|
1080
|
+
except Exception:
|
|
1081
|
+
pass
|
|
1082
|
+
else:
|
|
1083
|
+
out["reasons"].append("材料凭据缺少 part 字段(无法确认凭据属于该零件)")
|
|
1084
|
+
# ── 【防伪造】零件本体必须"像个真零件":体积下限 + 哈希一致 ──────────
|
|
1085
|
+
_MIN_PART_BYTES = 4096 # 真 SLDPRT 通常几十 KB~几 MB
|
|
1086
|
+
try:
|
|
1087
|
+
_psz = os.path.getsize(str(part_path))
|
|
1088
|
+
except Exception:
|
|
1089
|
+
_psz = 0
|
|
1090
|
+
if _psz < _MIN_PART_BYTES:
|
|
1091
|
+
out["reasons"].append(
|
|
1092
|
+
"零件文件仅 %d 字节(< %d),不像真实的 SolidWorks 零件(疑似伪造)"
|
|
1093
|
+
% (_psz, _MIN_PART_BYTES))
|
|
1094
|
+
_att_md5 = str(att.get("part_md5") or "").strip()
|
|
1095
|
+
if _att_md5:
|
|
1096
|
+
try:
|
|
1097
|
+
import hashlib as _hl
|
|
1098
|
+
_h = _hl.md5()
|
|
1099
|
+
with open(str(part_path), "rb") as _pf:
|
|
1100
|
+
for _ch in iter(lambda: _pf.read(1 << 20), b""):
|
|
1101
|
+
_h.update(_ch)
|
|
1102
|
+
if _h.hexdigest() != _att_md5:
|
|
1103
|
+
out["reasons"].append(
|
|
1104
|
+
"零件内容与凭据记录的 md5 不一致 —— 凭据不是为当前文件生成的(疑似挪用/篡改)")
|
|
1105
|
+
except Exception:
|
|
1106
|
+
pass
|
|
1107
|
+
else:
|
|
1108
|
+
out["reasons"].append("材料凭据缺少 part_md5(无法确认凭据绑定当前零件内容)")
|
|
1109
|
+
out["attestation"] = {
|
|
1110
|
+
"material": att.get("attested_material"),
|
|
1111
|
+
"density_kg_m3": att.get("attested_density_kg_m3"),
|
|
1112
|
+
"family": att.get("attested_family"),
|
|
1113
|
+
"source": att.get("source"),
|
|
1114
|
+
"requested_material": att.get("requested_material"),
|
|
1115
|
+
"at": att.get("at"),
|
|
1116
|
+
}
|
|
1117
|
+
_name = att.get("attested_material")
|
|
1118
|
+
_dens = att.get("attested_density_kg_m3")
|
|
1119
|
+
_fam = att.get("attested_family")
|
|
1120
|
+
# (1) 材料身份缺失
|
|
1121
|
+
if not _name:
|
|
1122
|
+
out["reasons"].append("凭据里没有材料名(材料身份未知)")
|
|
1123
|
+
# (2) 密度缺失
|
|
1124
|
+
if _dens is None:
|
|
1125
|
+
out["reasons"].append("凭据里没有密度 —— 无法排除 SW 默认密度")
|
|
1126
|
+
else:
|
|
1127
|
+
# (3) 水密度:SW 未赋材质时的默认值
|
|
1128
|
+
if abs(float(_dens) - 1000.0) < 1.0:
|
|
1129
|
+
out["reasons"].append(
|
|
1130
|
+
"密度 1000 kg/m³(等同水)—— 典型的 SolidWorks 未赋材质默认值")
|
|
1131
|
+
# (4) 家族未知且密度不在任何工程材料区间
|
|
1132
|
+
elif _fam is None:
|
|
1133
|
+
out["reasons"].append(
|
|
1134
|
+
"密度 %.0f kg/m³ 不属于任何已知工程材料家族" % float(_dens))
|
|
1135
|
+
# ── 【MA-03 修复】水密度单列拦截 ────────────────────────────────
|
|
1136
|
+
# 密度 1000 曾被判为 plastic 而放行;现在 material_family 返回
|
|
1137
|
+
# "water",这里明确报出,避免"未赋材零件被当塑料"。
|
|
1138
|
+
elif _fam == "water":
|
|
1139
|
+
out["reasons"].append(
|
|
1140
|
+
"密度 %.0f kg/m³ 落在【水】的区间(990~1010)—— 这是 "
|
|
1141
|
+
"SolidWorks 未赋材质的默认密度,不是任何工程材料;"
|
|
1142
|
+
"请显式赋材后重新保存(如 swapi.new_part(material='Q235'))"
|
|
1143
|
+
% float(_dens))
|
|
1144
|
+
# (5) 显式记录过跨族拒绝 / 材料不匹配
|
|
1145
|
+
#
|
|
1146
|
+
# ── 【MA-01 修复】"曾被拒"标志不能单独定罪 ────────────────────────────
|
|
1147
|
+
# 实测缺陷:8 个【精确匹配】的国标牌号(Q355/GCr15/Cr12MoV/QT500-7/
|
|
1148
|
+
# 2A12/ZL104/20CrMnTi/65Mn)被材料防线误 BLOCK,而近似匹配的
|
|
1149
|
+
# HT200/Q235 反而 PASS —— 完全自相矛盾。
|
|
1150
|
+
# 根因:swapi 的 rejected_candidates 是【累积】列表(多候选逐个尝试的
|
|
1151
|
+
# 正常过程),而校验侧只看标志就定罪,于是"中途试错、最终成功"被当成
|
|
1152
|
+
# "材料写错了"。签发侧已修(成功即清标志),这里再加一道独立判据:
|
|
1153
|
+
# 若【请求名 == 实际生效名】(精确匹配,用户要什么就是什么),
|
|
1154
|
+
# 则该历史标志与本次结论无关,不得据此拦截。
|
|
1155
|
+
_mmr = bool(att.get("material_mismatch_rejected"))
|
|
1156
|
+
_req_for_mmr = str(att.get("requested_material") or "").strip()
|
|
1157
|
+
_app_for_mmr = str(att.get("applied_name")
|
|
1158
|
+
or att.get("attested_material_name") or "").strip()
|
|
1159
|
+
_exact_name_match = bool(_req_for_mmr and _app_for_mmr
|
|
1160
|
+
and _req_for_mmr.lower() == _app_for_mmr.lower())
|
|
1161
|
+
if _mmr and not _exact_name_match:
|
|
1162
|
+
out["reasons"].append("材料写入曾被判定为跨族/密度不符并拒绝(material_mismatch_rejected)")
|
|
1163
|
+
elif _mmr and _exact_name_match:
|
|
1164
|
+
out["material_mismatch_note"] = (
|
|
1165
|
+
"凭据带 material_mismatch_rejected 标志,但请求名与实际生效名一致"
|
|
1166
|
+
"(%s)—— 判为多候选试错的历史记录,不作为阻断依据(MA-01)"
|
|
1167
|
+
% _app_for_mmr)
|
|
1168
|
+
if att.get("ok") is False and _name is None:
|
|
1169
|
+
out["reasons"].append("凭据自报未成功写入材料")
|
|
1170
|
+
# ── 【Bug-04 修复】跨材质静默替代检测(属性级,不只看同族)─────────────
|
|
1171
|
+
# 实测缺陷:设计规格要求 PETG(ρ1270 / σy 40MPa),而 SW 材料库无此牌号,
|
|
1172
|
+
# 被近似成 PET(ρ1420 / σy≈55MPa,屈服差约 37%)。凭据只标
|
|
1173
|
+
# approximate_match=True 并配 WARN,防线【放行】—— 属"跨材料静默替代",
|
|
1174
|
+
# 本应拦截。原来判不出来是因为凭据里没有"用户请求的材料",
|
|
1175
|
+
# 门禁只能看 approximate_match 这个过宽的布尔量(同族即 true)。
|
|
1176
|
+
# 现在凭据已带 requested_material(宿主签名),这里做属性级对比:
|
|
1177
|
+
# · 请求名与实际生效名不同 → 对比二者在材料库中的关键属性;
|
|
1178
|
+
# · 屈服强度或密度差异超过阈值 → 判为跨材质替代,硬阻断;
|
|
1179
|
+
# · 属性查不到时【判为需要人工确认】(不静默放行)。
|
|
1180
|
+
_req_m = str(att.get("requested_material") or "").strip()
|
|
1181
|
+
_app_m = str(att.get("attested_material")
|
|
1182
|
+
or att.get("attested_material_name")
|
|
1183
|
+
or att.get("applied_name") or "").strip()
|
|
1184
|
+
# ── 【Bug-C 运行时修复】近似匹配必须按【属性差异】判定,不能只看名字 ──
|
|
1185
|
+
# 实测缺陷(子代理1/2):请求 PETG(ρ1270/σy40),SW 实得 PET(ρ1420/σy55),
|
|
1186
|
+
# 凭据里 requested_material 与 attested_material 都写 "PETG"
|
|
1187
|
+
# (attested 记的是【校验通过的名字】),于是"名字不同"这条判据
|
|
1188
|
+
# 根本不触发 —— PETG→PET 被静默放行,防线①可绕过。
|
|
1189
|
+
# 修复:加两条与"名字"无关的独立判据:
|
|
1190
|
+
# ① 【密度实测差异】凭据记录的实测密度 vs 请求材料的期望密度,
|
|
1191
|
+
# 相对差 > 阈值即判实得材料与请求材料不是同一材料;
|
|
1192
|
+
# ② 【近似匹配 + 期望属性不符】approximate_match=True 时,对比
|
|
1193
|
+
# applied_name(SW 真实生效名)与 requested 的属性差异。
|
|
1194
|
+
_applied_real = str(att.get("applied_name") or "").strip()
|
|
1195
|
+
# ① 密度实测差异(最强证据:密度是 SW 写进零件的客观事实)
|
|
1196
|
+
if _req_m and _dens:
|
|
1197
|
+
try:
|
|
1198
|
+
_ok_r, _info_r, _ = _material_props_lookup(_req_m)
|
|
1199
|
+
_exp_d = None
|
|
1200
|
+
if _ok_r and _info_r:
|
|
1201
|
+
_exp_d = _info_r.get("density_kg_m3") or _info_r.get("density")
|
|
1202
|
+
# 兜底:查本模块内置属性表
|
|
1203
|
+
if not _exp_d:
|
|
1204
|
+
_p = _material_props_any(_req_m)
|
|
1205
|
+
_exp_d = (_p or {}).get("density")
|
|
1206
|
+
if _exp_d:
|
|
1207
|
+
_rel = abs(float(_dens) - float(_exp_d)) / float(_exp_d)
|
|
1208
|
+
out["density_deviation_pct"] = round(_rel * 100, 2)
|
|
1209
|
+
if _rel > _DENSITY_MISMATCH_TOL:
|
|
1210
|
+
out["reasons"].append(
|
|
1211
|
+
"【Bug-C】实得材料密度与请求材料不符:请求 %s(期望 %.0f kg/m³),"
|
|
1212
|
+
"实测密度 %.0f kg/m³,相差 %.1f%%(容差 %.0f%%)—— "
|
|
1213
|
+
"说明 SW 实际生效的不是请求的材料(近似替代),"
|
|
1214
|
+
"请改用材料库中精确存在的牌号,不要以近似匹配放行。"
|
|
1215
|
+
% (_req_m, float(_exp_d), float(_dens), _rel * 100,
|
|
1216
|
+
_DENSITY_MISMATCH_TOL * 100))
|
|
1217
|
+
out["material_density_mismatch"] = True
|
|
1218
|
+
except Exception:
|
|
1219
|
+
pass
|
|
1220
|
+
# ② 名字不同的常规路径(保留原有属性级对比)
|
|
1221
|
+
_cmp_target = _applied_real or _app_m
|
|
1222
|
+
if required and not _req_m:
|
|
1223
|
+
out["reasons"].append("材料凭据缺少请求牌号,无法证明满足本任务的材料要求。")
|
|
1224
|
+
if _req_m and not _material_names_equivalent(_req_m, _cmp_target):
|
|
1225
|
+
out["reasons"].append("请求材料 %r 与实际材料 %r 没有同牌号或已登记同标准别名的证据;密度相同也不能放行。"
|
|
1226
|
+
% (_req_m, _cmp_target or "未读到"))
|
|
1227
|
+
if _req_m and _cmp_target and _req_m.lower() != _cmp_target.lower():
|
|
1228
|
+
_ms = material_substitution_check(_req_m, _cmp_target, _dens)
|
|
1229
|
+
out["material_substitution"] = _ms
|
|
1230
|
+
if _ms.get("blocking"):
|
|
1231
|
+
out["reasons"].append(_ms["reason"])
|
|
1232
|
+
elif _ms.get("reason"):
|
|
1233
|
+
out["material_substitution_note"] = _ms["reason"]
|
|
1234
|
+
# ③ 【Bug-C 运行时修复】屈服强度差异(凭据里的属性指纹)
|
|
1235
|
+
# 这是识别"名字与密度都看不出"的替代的关键判据:
|
|
1236
|
+
# 6061-T6(σy276) → 7075(σy503) 名字不同但密度仅差 4.1%,
|
|
1237
|
+
# 单靠密度容差会漏掉,而屈服差 82% 一眼可辨。
|
|
1238
|
+
try:
|
|
1239
|
+
_ey = att.get("expected_yield_mpa")
|
|
1240
|
+
_ay = att.get("applied_yield_mpa")
|
|
1241
|
+
_ypct = att.get("yield_deviation_pct")
|
|
1242
|
+
if _ypct is None and _ey and _ay:
|
|
1243
|
+
try:
|
|
1244
|
+
_ypct = abs(float(_ay) - float(_ey)) / float(_ey) * 100.0
|
|
1245
|
+
except Exception:
|
|
1246
|
+
_ypct = None
|
|
1247
|
+
if _ypct is not None:
|
|
1248
|
+
out["yield_deviation_pct"] = round(float(_ypct), 2)
|
|
1249
|
+
if float(_ypct) > _SUBSTITUTION_TOL_PCT:
|
|
1250
|
+
out["reasons"].append(
|
|
1251
|
+
"【Bug-C】实得材料屈服强度与请求材料不符:请求 %s(σy≈%s MPa),"
|
|
1252
|
+
"实际生效 %s(σy≈%s MPa),相差 %.1f%%(容差 %.0f%%)—— "
|
|
1253
|
+
"属不可接受的牌号替代,请改用材料库中精确存在的牌号。"
|
|
1254
|
+
% (_req_m or "?", _ey, _applied_real or _app_m or "?", _ay,
|
|
1255
|
+
float(_ypct), _SUBSTITUTION_TOL_PCT))
|
|
1256
|
+
out["material_yield_mismatch"] = True
|
|
1257
|
+
except Exception:
|
|
1258
|
+
pass
|
|
1259
|
+
# ── 【Bug-C 修复】跨标准体系替代必须拦截 ────────────────────────────
|
|
1260
|
+
# 实测缺陷:请求 Q235(GB/T 700),SW 材料库无此牌号时回退命中
|
|
1261
|
+
# AISI 1020(美标),而凭据只标 approximate_match=false ——
|
|
1262
|
+
# 报告看起来像"精确匹配",实际是跨标准替代(屈服差约 49%)。
|
|
1263
|
+
# 凭据现已带宿主签名的 cross_standard 标记,这里据此硬阻断。
|
|
1264
|
+
#
|
|
1265
|
+
# ── 【BUG-D 修复·死循环】原提示给的出路【不存在】──────────────────
|
|
1266
|
+
# 实测(子代理全量赋材矩阵):45#→AISI 1045、Q235→AISI 1020、
|
|
1267
|
+
# 40Cr→1.2083、316→AISI 316 全部被跨标准 BLOCK。
|
|
1268
|
+
# 而原提示写"请改用国标牌号重新赋材(如 Q235/45#/40Cr/HT200)"——
|
|
1269
|
+
# 这些牌号在 SolidWorks 材质库里【根本不存在】,照做必然再次被
|
|
1270
|
+
# 解析成同一个欧美标、再次 BLOCK。用户陷入无解循环,只能
|
|
1271
|
+
# DSH_DEFENSE_BYPASS=1 绕过防线(那等于防线失效)。
|
|
1272
|
+
# 现在:如实说明"SW 无此牌号"这一客观约束,并给出【真正可执行】的
|
|
1273
|
+
# 两条出路:① 用 set_custom_material() 在 SW 中登记国标牌号后重做;
|
|
1274
|
+
# ② 若该跨标准替代在工程上可接受(屈服差在容差内),
|
|
1275
|
+
# 走显式豁免流程(写明理由并留痕),而不是静默放行。
|
|
1276
|
+
if att.get("cross_standard"):
|
|
1277
|
+
_cs_note = str(att.get("cross_standard_note") or "").strip()
|
|
1278
|
+
# 判断是否为"SW 材质库无该牌号"导致的必然替代
|
|
1279
|
+
_sw_missing = bool(att.get("material_applied") is False) or bool(
|
|
1280
|
+
att.get("applied_name") and att.get("requested_material")
|
|
1281
|
+
and att.get("applied_name") != att.get("requested_material"))
|
|
1282
|
+
out["reasons"].append(
|
|
1283
|
+
"跨标准体系材料替代(疑似以欧美标顶替国标牌号):"
|
|
1284
|
+
+ (_cs_note or ("请求 %s,实际生效 %s" % (_req_m or "?", _app_m or "?")))
|
|
1285
|
+
+ "。"
|
|
1286
|
+
+ ("【注意】SolidWorks 材质库通常【不含】国标牌号,"
|
|
1287
|
+
"因此单纯改用国标牌号重新赋材无法解决问题(会再次命中同一替代)。"
|
|
1288
|
+
"可执行的出路:① 用 set_custom_material() 把该 GB 牌号"
|
|
1289
|
+
"(含 E/ν/σy/σb/ρ)登记进 SolidWorks 后再赋材重做;"
|
|
1290
|
+
"② 若该替代在工程上可接受,走显式豁免并在报告中写明理由与"
|
|
1291
|
+
"屈服差异,不要静默放行。"
|
|
1292
|
+
if _sw_missing else
|
|
1293
|
+
"请改用材料库中精确存在的牌号重新赋材。"))
|
|
1294
|
+
out["cross_standard"] = True
|
|
1295
|
+
out["sw_material_library_missing_gb"] = _sw_missing
|
|
1296
|
+
out["actionable_hint"] = (
|
|
1297
|
+
"set_custom_material(name, density, e_mpa, yield_mpa, uts_mpa, "
|
|
1298
|
+
"poissons_ratio) 登记国标牌号 → 重新赋材 → 重做校核"
|
|
1299
|
+
if _sw_missing else
|
|
1300
|
+
"改用材料库中精确存在的牌号")
|
|
1301
|
+
# ══ 【A-48 修复】防线①补齐【零件几何】判据 ═════════════════════════
|
|
1302
|
+
# 原 check_material_attestation 只回答"材料是否可信"(签名/md5/密度/
|
|
1303
|
+
# 家族/跨标准),对【零件几何】零约束 —— 实测(2026-11-01):
|
|
1304
|
+
# DSH_夹紧凸轮 外径 φ160(规范 φ200)、体积 352,447(规范 540,884,
|
|
1305
|
+
# 缺 35%)的早期错误草稿,凭"材料凭据 ok=true(20CrMnTi/7820)"
|
|
1306
|
+
# 直接通过防线①进入总装候选。也就是说这道门能证明"材料写对了",
|
|
1307
|
+
# 却完全无法回答"这是不是设计要的那件"。
|
|
1308
|
+
# 现在:凭据里带宿主签名的几何指纹(part_bbox_mm / part_dims_mm /
|
|
1309
|
+
# part_volume_mm3 / part_body_count,A-48 已纳入待签体),这里与
|
|
1310
|
+
# load_case 的设计域做对照。
|
|
1311
|
+
# 判据分级(全部 fail-closed 到"不可采信",不静默放行):
|
|
1312
|
+
# · 缺几何指纹(老凭据)→ 不阻断,但显式标注 not_evaluated;
|
|
1313
|
+
# · 三向尺寸超出 design_domain 外接盒(含 5% 容差)→ BLOCK;
|
|
1314
|
+
# · 有 expected_volume_mm3 时体积偏差 > 40% → BLOCK(错件/占位件);
|
|
1315
|
+
# · 实体数为 0 → BLOCK(空壳件)。
|
|
1316
|
+
try:
|
|
1317
|
+
_geo = _material_geometry_check(att, str(part_path))
|
|
1318
|
+
out["geometry"] = _geo
|
|
1319
|
+
if _geo.get("blocking"):
|
|
1320
|
+
out["reasons"].append(_geo["reason"])
|
|
1321
|
+
elif _geo.get("warning"):
|
|
1322
|
+
out.setdefault("warnings", []).append(_geo["warning"])
|
|
1323
|
+
except Exception as _e_geo:
|
|
1324
|
+
# 几何校验自身异常 → 记为未评估,但绝不因此判合格
|
|
1325
|
+
out["geometry"] = {"evaluated": False, "error": repr(_e_geo)}
|
|
1326
|
+
# ══ 【A-17 / A-74 修复 · 判定对象必须回读零件,而不是只看凭据自报】══════
|
|
1327
|
+
# 原缺陷:check_material_attestation 的全部判据都建立在【凭据自报值】上
|
|
1328
|
+
# (attested_material / attested_density_kg_m3 / applied_name…)。
|
|
1329
|
+
# 也就是说门禁在验证"凭据自己填的那张表",而不是验证"零件里到底是什么"。
|
|
1330
|
+
# 只要装材那一步写错、或凭据被某条容错路径"善意修正"过,
|
|
1331
|
+
# 零件属性栏显示 AISI 1020、凭据写 Q235,防线照样 PASS。
|
|
1332
|
+
#
|
|
1333
|
+
# 现在补一道【独立证据】判据:
|
|
1334
|
+
# ① swapi.save() 在 SW 进程内把"SW 内部实际生效名 + 实测密度"写成旁挂
|
|
1335
|
+
# 记录 <零件>.material.json.sw_internal.json(不参与验签,纯事实留痕);
|
|
1336
|
+
# ② 这里回读它,与凭据自报的 attested_material 做归一化比对;
|
|
1337
|
+
# ③ 两者矛盾 → 判 model_trustworthy=false 并 BLOCK(凭据不可采信);
|
|
1338
|
+
# ④ 要求材料却缺真实回读记录 → 无法验证,停止自动重试。
|
|
1339
|
+
#
|
|
1340
|
+
# 注意:旁挂记录【不参与签名】,因此它只作为"交叉核对的独立证人"——
|
|
1341
|
+
# 单靠它可以发现矛盾,但不能单靠它给出通过结论(通过仍须凭据验签成功)。
|
|
1342
|
+
try:
|
|
1343
|
+
_swi = _read_sw_internal_sidecar(_real_part)
|
|
1344
|
+
out["sw_internal_evaluated"] = bool(_swi)
|
|
1345
|
+
out["model_trustworthy"] = True
|
|
1346
|
+
if _swi:
|
|
1347
|
+
_swi_name = str(_swi.get("sw_internal_material")
|
|
1348
|
+
or _swi.get("material") or "").strip()
|
|
1349
|
+
if (required or _req_m) and not _swi_name:
|
|
1350
|
+
out["reasons"].append("SolidWorks 材料回读名为空,无法验证指定材料。")
|
|
1351
|
+
_swi_dens = _swi.get("density_kg_m3")
|
|
1352
|
+
out["sw_internal_material"] = _swi_name or None
|
|
1353
|
+
try:
|
|
1354
|
+
_swi_dens = float(_swi_dens) if _swi_dens is not None else None
|
|
1355
|
+
except Exception:
|
|
1356
|
+
_swi_dens = None
|
|
1357
|
+
out["sw_internal_density_kg_m3"] = _swi_dens
|
|
1358
|
+
# 名称必须有同牌号或已登记同标准别名的证据;密度相同不能证明身份。
|
|
1359
|
+
_name_mismatch = bool(_swi_name and _name
|
|
1360
|
+
and not _material_names_equivalent(_swi_name, _name))
|
|
1361
|
+
_dens_mismatch = False
|
|
1362
|
+
if _swi_dens and _dens:
|
|
1363
|
+
try:
|
|
1364
|
+
_rd = (abs(float(_swi_dens) - float(_dens))
|
|
1365
|
+
/ max(float(_swi_dens), 1.0))
|
|
1366
|
+
out["sw_internal_density_deviation_pct"] = round(_rd * 100, 2)
|
|
1367
|
+
_dens_mismatch = _rd > 0.03
|
|
1368
|
+
except Exception:
|
|
1369
|
+
_dens_mismatch = False
|
|
1370
|
+
if _name_mismatch:
|
|
1371
|
+
out["sw_internal_name_mismatch"] = True
|
|
1372
|
+
_swi_written = str(_swi.get("attested_material_name")
|
|
1373
|
+
or _swi.get("applied_name") or "").strip()
|
|
1374
|
+
_declared = _swi.get("sw_internal_matches_request")
|
|
1375
|
+
_sub_blocking = (_declared is False) or _name_mismatch
|
|
1376
|
+
out["sw_internal_substitution"] = {
|
|
1377
|
+
"written_to_sw": _swi_written or None,
|
|
1378
|
+
"read_from_sw": _swi_name or None,
|
|
1379
|
+
"swapi_declared_match": _declared,
|
|
1380
|
+
"names_equivalent": (None if not _name_mismatch
|
|
1381
|
+
else _material_names_equivalent(_swi_name, _name)),
|
|
1382
|
+
}
|
|
1383
|
+
if _dens_mismatch or _sub_blocking:
|
|
1384
|
+
out["model_trustworthy"] = False
|
|
1385
|
+
out["material_self_report_mismatch"] = True
|
|
1386
|
+
_bits = []
|
|
1387
|
+
if _name_mismatch:
|
|
1388
|
+
_bits.append("凭据自报材料 %r,而 SolidWorks 零件内部是 %r"
|
|
1389
|
+
% (str(_name), _swi_name))
|
|
1390
|
+
if _dens_mismatch:
|
|
1391
|
+
_bits.append("实测密度 %.0f kg/m³ 与凭据自报 %.0f kg/m³ 不符"
|
|
1392
|
+
% (float(_swi_dens), float(_dens)))
|
|
1393
|
+
out["reasons"].append(
|
|
1394
|
+
"【A-17】凭据自报值与零件实测事实矛盾(%s)。"
|
|
1395
|
+
"门禁不能只信凭据自报值,故该零件材料不可采信;"
|
|
1396
|
+
"停止重复赋材,先确认请求牌号与实际材料身份。"
|
|
1397
|
+
% ";".join(_bits))
|
|
1398
|
+
# (b) 密度判据已并入上方 _dens_mismatch(物理事实优先于名字)
|
|
1399
|
+
else:
|
|
1400
|
+
out["sw_internal_note"] = (
|
|
1401
|
+
"未找到 SW 内部材质旁挂记录(<零件>.material.json.sw_internal.json)—— "
|
|
1402
|
+
"本条【未评估】零件的真实材质,不能据此认为材质已核对。")
|
|
1403
|
+
if required or _req_m:
|
|
1404
|
+
out["reasons"].append(out["sw_internal_note"])
|
|
1405
|
+
except Exception as _e_swi:
|
|
1406
|
+
out["sw_internal_evaluated"] = False
|
|
1407
|
+
out["sw_internal_error"] = repr(_e_swi)
|
|
1408
|
+
if required or _req_m:
|
|
1409
|
+
out["reasons"].append("SolidWorks 材料回读校验失败,无法验证指定材料:%r" % (_e_swi,))
|
|
1410
|
+
# (6) 未赋材兜底警告:近似匹配按 WARNING 处理,不阻断
|
|
1411
|
+
if not out["reasons"]:
|
|
1412
|
+
out["ok"] = True
|
|
1413
|
+
out["requires_user_action"] = False
|
|
1414
|
+
out["next_action"] = None
|
|
1415
|
+
out["level"] = "WARN" if att.get("approximate_match") else "PASS"
|
|
1416
|
+
if att.get("approximate_match"):
|
|
1417
|
+
out["reasons"].append("材料为近似匹配(库中无精确同名),建议核对")
|
|
1418
|
+
return out
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _read_sw_internal_sidecar(part_path):
|
|
1422
|
+
"""读取 swapi 写下的【SW 内部真实材质】旁挂记录(A-17)。
|
|
1423
|
+
|
|
1424
|
+
路径约定与 swapi.sw_internal_record_path() 完全一致:
|
|
1425
|
+
<零件>.SLDPRT → <零件>.SLDPRT.material.json.sw_internal.json
|
|
1426
|
+
另兼容大小写不同的历史写法(同一目录按 casefold 找)。
|
|
1427
|
+
|
|
1428
|
+
纯读、无副作用;不存在/损坏一律返回 None(绝不抛异常、绝不写盘)。
|
|
1429
|
+
"""
|
|
1430
|
+
if not part_path:
|
|
1431
|
+
return None
|
|
1432
|
+
_s = str(part_path)
|
|
1433
|
+
_cands = [_s + ".material.json.sw_internal.json",
|
|
1434
|
+
_s + ".sw_internal.json"]
|
|
1435
|
+
for _p in _cands:
|
|
1436
|
+
d = _read_json(_p)
|
|
1437
|
+
if isinstance(d, dict) and d:
|
|
1438
|
+
return d
|
|
1439
|
+
# ── 大小写兜底:目录内按 casefold 匹配(Windows 上 .SLDPRT/.sldprt 混用) ──
|
|
1440
|
+
try:
|
|
1441
|
+
_d, _n = os.path.split(_s)
|
|
1442
|
+
_want = (_n + ".material.json.sw_internal.json").casefold()
|
|
1443
|
+
for _e in os.listdir(_d):
|
|
1444
|
+
if _e.casefold() == _want:
|
|
1445
|
+
d = _read_json(os.path.join(_d, _e))
|
|
1446
|
+
if isinstance(d, dict) and d:
|
|
1447
|
+
return d
|
|
1448
|
+
except Exception:
|
|
1449
|
+
pass
|
|
1450
|
+
return None
|
|
1451
|
+
|
|
1452
|
+
|
|
1453
|
+
# ── 【A-30 / A-17 共用】材质名归一化与等价判定 ───────────────────────────────
|
|
1454
|
+
# 必须与 swapi._material_names_match() 同口径,否则会出现"swapi 说一致、
|
|
1455
|
+
# defense_gate 说不一致"的分裂(这正是 A-22/A-30 那类"两套口径"问题)。
|
|
1456
|
+
def _material_names_equivalent(a, b):
|
|
1457
|
+
"""复用赋材所有者的精确名称/已登记同标准别名判定。"""
|
|
1458
|
+
from physics.material_db import material_names_match
|
|
1459
|
+
return material_names_match(a, b)
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def _material_geometry_check(att, part_path):
|
|
1463
|
+
"""【A-48】用凭据里的几何指纹回答"这是不是设计要的那件零件"。
|
|
1464
|
+
|
|
1465
|
+
背景:防线①原先只校验材料的可信性(签名 / part_md5 / 密度 / 家族 /
|
|
1466
|
+
跨标准),对零件几何零约束。实测(2026-11-01)出现过:
|
|
1467
|
+
· DSH_夹紧凸轮 旧草稿 φ160(规范 φ200)、体积缺 35%,
|
|
1468
|
+
凭"材料 ok=true"直通总装;
|
|
1469
|
+
· room-start --fresh 归档后零件数变 0,防线①反而"更绿"。
|
|
1470
|
+
|
|
1471
|
+
判据(分级,任何一条都不静默放行):
|
|
1472
|
+
· 凭据无几何字段(老凭据/取不到)→ evaluated=False,不阻断但标注;
|
|
1473
|
+
· 实体数 == 0 → BLOCK(空壳件);
|
|
1474
|
+
· 三向尺寸超出 design_domain 外接盒 → BLOCK(尺寸越界);
|
|
1475
|
+
· 有 expected_volume_mm3 且偏差>30% → BLOCK(错件/占位件)。
|
|
1476
|
+
|
|
1477
|
+
Returns: {evaluated, ok, blocking, reason|warning, facts}
|
|
1478
|
+
"""
|
|
1479
|
+
out = {"evaluated": False, "ok": True, "blocking": False, "facts": {}}
|
|
1480
|
+
_bb = att.get("part_bbox_mm")
|
|
1481
|
+
_dims = att.get("part_dims_mm")
|
|
1482
|
+
_vol = att.get("part_volume_mm3")
|
|
1483
|
+
_bodies = att.get("part_body_count")
|
|
1484
|
+
if not (isinstance(_dims, (list, tuple)) and len(_dims) == 3) \
|
|
1485
|
+
and not (isinstance(_bb, (list, tuple)) and len(_bb) == 6):
|
|
1486
|
+
out["reason_note"] = (
|
|
1487
|
+
"凭据未携带几何指纹(老凭据或建模时取不到 bbox/体积)——"
|
|
1488
|
+
"本次【未评估】零件几何,不能据此认为几何正确。"
|
|
1489
|
+
"新零件保存后凭据会自动带上 part_dims_mm / part_volume_mm3。")
|
|
1490
|
+
return out
|
|
1491
|
+
out["evaluated"] = True
|
|
1492
|
+
try:
|
|
1493
|
+
if isinstance(_dims, (list, tuple)) and len(_dims) == 3:
|
|
1494
|
+
_d = [abs(float(x)) for x in _dims]
|
|
1495
|
+
else:
|
|
1496
|
+
_d = [abs(float(_bb[3 + _i]) - float(_bb[_i])) for _i in range(3)]
|
|
1497
|
+
except Exception:
|
|
1498
|
+
out["evaluated"] = False
|
|
1499
|
+
out["reason_note"] = "凭据里的几何字段无法解析,本次未评估"
|
|
1500
|
+
return out
|
|
1501
|
+
out["facts"]["dims_mm"] = [round(x, 3) for x in _d]
|
|
1502
|
+
out["facts"]["volume_mm3"] = _vol
|
|
1503
|
+
out["facts"]["body_count"] = _bodies
|
|
1504
|
+
|
|
1505
|
+
if isinstance(_bodies, int) and _bodies <= 0:
|
|
1506
|
+
out["blocking"] = True
|
|
1507
|
+
out["ok"] = False
|
|
1508
|
+
out["reason"] = (
|
|
1509
|
+
"【A-48】凭据记录该零件实体数为 0 —— 这是空壳件"
|
|
1510
|
+
"(拉伸/切除全部静默失败时 SW2025 的典型产物),不能作为交付件。")
|
|
1511
|
+
return out
|
|
1512
|
+
|
|
1513
|
+
# ── 【A-97 修复·工况路径写死】必须按候选集合 + 任务隔离解析 ────────────
|
|
1514
|
+
# 原缺陷:写死 STATE_DIR/load_cases/gate_load_case.json 一条路径。而写入侧
|
|
1515
|
+
# 有两个变体:
|
|
1516
|
+
# · workflow_gate.py:1705 → out_dir = work_dir | BASE_DIR/load_cases
|
|
1517
|
+
# (按【任务工作目录】落盘,不再总是 tools/load_cases);
|
|
1518
|
+
# · swapi._material_from_load_case() → 脚本目录/load_cases。
|
|
1519
|
+
# 于是"工况写到了 A、防线去 B 找":拿不到 design_domain,几何判据静默
|
|
1520
|
+
# 降级为"未评估",A-48 的尺寸/体积拦截形同不存在。
|
|
1521
|
+
# 现在:按 ① state 目录 ② 脚本目录 ③ 工作目录 的顺序找同一份文件名,
|
|
1522
|
+
# 【取第一个真实存在的】;并把实际用到的路径回显到 facts 里便于排障。
|
|
1523
|
+
_lc = None
|
|
1524
|
+
_lc_used = None
|
|
1525
|
+
try:
|
|
1526
|
+
_lc_cands = []
|
|
1527
|
+
for _base in (STATE_DIR, _BASE_DIR,
|
|
1528
|
+
os.environ.get("DSH_WORK_DIR"),
|
|
1529
|
+
os.environ.get("DSH_ENGINEERING_ROOT"),
|
|
1530
|
+
os.path.dirname(_BASE_DIR)):
|
|
1531
|
+
if not _base:
|
|
1532
|
+
continue
|
|
1533
|
+
for _sub in (("load_cases",), ("tools", "load_cases")):
|
|
1534
|
+
_p = os.path.join(_base, *(list(_sub) + ["gate_load_case.json"]))
|
|
1535
|
+
if _p not in _lc_cands:
|
|
1536
|
+
_lc_cands.append(_p)
|
|
1537
|
+
# 同目录下按任务的工况(若 workflow_gate 用了别的名字)
|
|
1538
|
+
try:
|
|
1539
|
+
for _d in (os.path.join(STATE_DIR, "load_cases"),
|
|
1540
|
+
os.path.join(_BASE_DIR, "load_cases")):
|
|
1541
|
+
if os.path.isdir(_d):
|
|
1542
|
+
for _fn in os.listdir(_d):
|
|
1543
|
+
if _fn.lower().endswith(".json") and "load_case" in _fn.lower():
|
|
1544
|
+
_p = os.path.join(_d, _fn)
|
|
1545
|
+
if _p not in _lc_cands:
|
|
1546
|
+
_lc_cands.append(_p)
|
|
1547
|
+
except Exception:
|
|
1548
|
+
pass
|
|
1549
|
+
for _p in _lc_cands:
|
|
1550
|
+
if os.path.isfile(_p):
|
|
1551
|
+
_lc = _read_json(_p)
|
|
1552
|
+
if isinstance(_lc, dict):
|
|
1553
|
+
_lc_used = _p
|
|
1554
|
+
break
|
|
1555
|
+
except Exception:
|
|
1556
|
+
_lc = None
|
|
1557
|
+
|
|
1558
|
+
if _lc_used:
|
|
1559
|
+
out["facts"]["load_case_file"] = _lc_used
|
|
1560
|
+
_dom = _lc.get("design_domain") if isinstance(_lc, dict) else None
|
|
1561
|
+
if isinstance(_dom, dict):
|
|
1562
|
+
try:
|
|
1563
|
+
_span = []
|
|
1564
|
+
for _ax in ("x", "y", "z"):
|
|
1565
|
+
_mn = _dom.get(_ax + "_min", _dom.get("min_" + _ax))
|
|
1566
|
+
_mx = _dom.get(_ax + "_max", _dom.get("max_" + _ax))
|
|
1567
|
+
if _mn is None or _mx is None:
|
|
1568
|
+
_span = []
|
|
1569
|
+
break
|
|
1570
|
+
_span.append(abs(float(_mx) - float(_mn)))
|
|
1571
|
+
if len(_span) == 3:
|
|
1572
|
+
out["facts"]["design_span_mm"] = [round(x, 3) for x in _span]
|
|
1573
|
+
_ds = sorted(_d)
|
|
1574
|
+
_ss = sorted(_span)
|
|
1575
|
+
_over = []
|
|
1576
|
+
for _i in range(3):
|
|
1577
|
+
if _ss[_i] > 0 and _ds[_i] > _ss[_i] * 1.05:
|
|
1578
|
+
_over.append("%.1f > %.1f" % (_ds[_i], _ss[_i]))
|
|
1579
|
+
if _over:
|
|
1580
|
+
out["blocking"] = True
|
|
1581
|
+
out["ok"] = False
|
|
1582
|
+
out["reason"] = (
|
|
1583
|
+
"【A-48】零件三向尺寸超出 load_case 的设计域:%s。"
|
|
1584
|
+
"(零件 %s,设计域 %s)—— 该件不是按本任务设计域建的,"
|
|
1585
|
+
"疑似旧草稿或错件。" % (";".join(_over),
|
|
1586
|
+
[round(x, 1) for x in _ds],
|
|
1587
|
+
[round(x, 1) for x in _ss]))
|
|
1588
|
+
return out
|
|
1589
|
+
except Exception:
|
|
1590
|
+
pass
|
|
1591
|
+
|
|
1592
|
+
_exp_vol = None
|
|
1593
|
+
try:
|
|
1594
|
+
if isinstance(_lc, dict):
|
|
1595
|
+
_exp_vol = (_lc.get("expected_volume_mm3")
|
|
1596
|
+
or (_lc.get("acceptance") or {}).get("expected_volume_mm3"))
|
|
1597
|
+
except Exception:
|
|
1598
|
+
_exp_vol = None
|
|
1599
|
+
if _exp_vol and _vol:
|
|
1600
|
+
try:
|
|
1601
|
+
_dev = abs(float(_vol) - float(_exp_vol)) / float(_exp_vol)
|
|
1602
|
+
out["facts"]["volume_deviation_pct"] = round(_dev * 100, 2)
|
|
1603
|
+
# 阈值 30%:实测那次错件(夹紧凸轮旧草稿 φ160 vs 规范 φ200)
|
|
1604
|
+
# 体积缺 34.7%。若取 40% 会漏放,30% 才能拦住这一类。
|
|
1605
|
+
if _dev > 0.30:
|
|
1606
|
+
out["blocking"] = True
|
|
1607
|
+
out["ok"] = False
|
|
1608
|
+
out["reason"] = (
|
|
1609
|
+
"【A-48】零件体积 %.1f mm3 与设计预期 %.1f mm3 相差 %.1f%%"
|
|
1610
|
+
"(容差 30%%)—— 这不是设计要的那件(错件或占位件)。"
|
|
1611
|
+
% (float(_vol), float(_exp_vol), _dev * 100))
|
|
1612
|
+
return out
|
|
1613
|
+
elif _dev > 0.20:
|
|
1614
|
+
out["warning"] = (
|
|
1615
|
+
"【A-48】零件体积与设计预期相差 %.1f%%(容差内但偏大),"
|
|
1616
|
+
"建议复核是否为同一件。" % (_dev * 100))
|
|
1617
|
+
except Exception:
|
|
1618
|
+
pass
|
|
1619
|
+
return out
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _physics_part_binding_check(att, room):
|
|
1623
|
+
"""【A-68】确认物理凭据绑定的是【本房间这件零件】。
|
|
1624
|
+
|
|
1625
|
+
背景:凭据原先只有 room + run_id + report_path,没有零件维度。实测
|
|
1626
|
+
(2026-11-01)某房"通过"的凭据 run_id 来自另一个进程的另一次运行,
|
|
1627
|
+
part_path=null / material=null,而本房真正要校核的是另一件;同房
|
|
1628
|
+
2 小时内结论还能 2.39x → 2.82x → 1.45x,每次都"恰好更好"。
|
|
1629
|
+
|
|
1630
|
+
三级判定(fail-closed,绝不把"没绑定"当成"已绑定"):
|
|
1631
|
+
· 有 part_sha256 → 与【磁盘当前零件】比对,不一致即 BLOCK;
|
|
1632
|
+
对不上任何已登记零件也 BLOCK(无法证明这份结论属于本房)。
|
|
1633
|
+
· 有 part_path 无 sha256 → 该文件必须存在;与已登记零件集合不交集时
|
|
1634
|
+
只记 note(老凭据兼容),不阻断。
|
|
1635
|
+
· 两者都无 → note 提示重跑,不阻断(避免让历史任务全红)。
|
|
1636
|
+
|
|
1637
|
+
Returns: {evaluated, ok, blocking, bound_part, reason|note, evidence}
|
|
1638
|
+
"""
|
|
1639
|
+
out = {"evaluated": False, "ok": True, "blocking": False,
|
|
1640
|
+
"bound_part": None, "evidence": {}}
|
|
1641
|
+
_sha = str(att.get("part_sha256") or "").strip()
|
|
1642
|
+
_pp = str(att.get("part_path") or "").strip()
|
|
1643
|
+
# 本房间已登记的零件(mode_gate 的 artifacts_registry,按任务隔离)
|
|
1644
|
+
_parts = []
|
|
1645
|
+
try:
|
|
1646
|
+
_parts = _modeling_parts(room)
|
|
1647
|
+
except Exception:
|
|
1648
|
+
_parts = []
|
|
1649
|
+
_paths = [str(a.get("path") or "") for a in _parts if a.get("path")]
|
|
1650
|
+
out["evidence"]["room_registered_parts"] = [os.path.basename(p) for p in _paths]
|
|
1651
|
+
|
|
1652
|
+
if _sha:
|
|
1653
|
+
out["evaluated"] = True
|
|
1654
|
+
_matched = None
|
|
1655
|
+
try:
|
|
1656
|
+
import hashlib as _hl2
|
|
1657
|
+
for _p in _paths:
|
|
1658
|
+
if not os.path.isfile(_p):
|
|
1659
|
+
continue
|
|
1660
|
+
_h2 = _hl2.sha256()
|
|
1661
|
+
with open(_p, "rb") as _f2:
|
|
1662
|
+
for _c2 in iter(lambda: _f2.read(1 << 20), b""):
|
|
1663
|
+
_h2.update(_c2)
|
|
1664
|
+
if _h2.hexdigest() == _sha:
|
|
1665
|
+
_matched = _p
|
|
1666
|
+
break
|
|
1667
|
+
except Exception:
|
|
1668
|
+
_matched = None
|
|
1669
|
+
if _matched:
|
|
1670
|
+
out["bound_part"] = _matched
|
|
1671
|
+
out["ok"] = True
|
|
1672
|
+
return out
|
|
1673
|
+
# 没对上任何已登记零件 —— 分两种情况
|
|
1674
|
+
if not _paths:
|
|
1675
|
+
# 房间还没登记零件(登记链断裂时常见)→ 只告警,不阻断
|
|
1676
|
+
out["note"] = (
|
|
1677
|
+
"物理凭据绑定了零件(sha256=%s…),但本房间当前【没有任何已登记的"
|
|
1678
|
+
"零件】可比对 —— 无法确认这份结论属于本房。请先确认零件已落盘并"
|
|
1679
|
+
"用 mode_gate.py room-artifact 登记归属。" % _sha[:12])
|
|
1680
|
+
return out
|
|
1681
|
+
out["blocking"] = True
|
|
1682
|
+
out["ok"] = False
|
|
1683
|
+
out["reason"] = (
|
|
1684
|
+
"【A-68】物理凭据绑定的零件与本房间已登记的零件【都不匹配】"
|
|
1685
|
+
"(凭据 sha256=%s…,本房登记 %d 件:%s)—— 这份校核结论不是为"
|
|
1686
|
+
"本房当前这组零件算的,属于「别人的成绩单」,不能作为本房强度依据。"
|
|
1687
|
+
"请用 --part <本房零件.SLDPRT> 重跑 physics-optimize。"
|
|
1688
|
+
% (_sha[:12], len(_paths),
|
|
1689
|
+
", ".join(os.path.basename(p) for p in _paths[:5])))
|
|
1690
|
+
return out
|
|
1691
|
+
|
|
1692
|
+
if _pp:
|
|
1693
|
+
out["evaluated"] = True
|
|
1694
|
+
_exists = os.path.isfile(_pp)
|
|
1695
|
+
out["evidence"]["part_path_exists"] = bool(_exists)
|
|
1696
|
+
if not _exists:
|
|
1697
|
+
out["blocking"] = True
|
|
1698
|
+
out["ok"] = False
|
|
1699
|
+
out["reason"] = (
|
|
1700
|
+
"【A-68】物理凭据记录的零件 %r 已不存在 —— 无法证明这份结论"
|
|
1701
|
+
"对应的是哪一件零件(可能已被覆盖/改名/删除)。"
|
|
1702
|
+
"请用当前零件重跑 physics-optimize。" % os.path.basename(_pp))
|
|
1703
|
+
return out
|
|
1704
|
+
_norm_pp = os.path.normcase(os.path.abspath(_pp))
|
|
1705
|
+
if _paths and not any(os.path.normcase(os.path.abspath(p)) == _norm_pp
|
|
1706
|
+
for p in _paths):
|
|
1707
|
+
out["note"] = (
|
|
1708
|
+
"物理凭据绑定的零件 %r 不在本房已登记清单内(本房登记 %d 件)——"
|
|
1709
|
+
"老凭据无 sha256 无法强校验,建议用 --part 重跑以固化零件绑定。"
|
|
1710
|
+
% (os.path.basename(_pp), len(_paths)))
|
|
1711
|
+
out["bound_part"] = _pp
|
|
1712
|
+
return out
|
|
1713
|
+
|
|
1714
|
+
out["note"] = (
|
|
1715
|
+
"物理凭据【未携带零件绑定信息】(老凭据)—— 无法确认这份结论属于"
|
|
1716
|
+
"哪一件零件。本次不作阻断,但该结论的强度依据不可追溯;"
|
|
1717
|
+
"请用 --part <本房间零件.SLDPRT> 重跑 physics-optimize 以固化绑定。")
|
|
1718
|
+
return out
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1722
|
+
# 防线 ② 物理 / 防线 ③ 领域
|
|
1723
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1724
|
+
|
|
1725
|
+
def _room_file(room, suffix):
|
|
1726
|
+
"""凭据【写入】路径 —— 落在【当前任务】的 reports 子目录下(按局隔离)。
|
|
1727
|
+
|
|
1728
|
+
每次调用都现取目录(不缓存),这样中途换任务也不会写错地方。
|
|
1729
|
+
|
|
1730
|
+
【Bug⑤ 补充】写入位仍是唯一首选(STATE_DIR 派生),但【读取】一律走
|
|
1731
|
+
resolve_room_credential():只要任一副本里有该房间凭据就算有效,
|
|
1732
|
+
避免"写在工作区、宿主读安装副本 → 恒判 missing"。
|
|
1733
|
+
"""
|
|
1734
|
+
return os.path.join(reports_dir(), _safe_room_stem(room) + suffix)
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def physics_attest_path(room):
|
|
1738
|
+
"""物理凭据【写入】路径。"""
|
|
1739
|
+
return _room_file(room, ".physics.json")
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def domain_attest_path(room):
|
|
1743
|
+
"""领域凭据【写入】路径。"""
|
|
1744
|
+
return _room_file(room, ".domain.json")
|
|
1745
|
+
|
|
1746
|
+
|
|
1747
|
+
def physics_attest_read_path(room, task_id=None):
|
|
1748
|
+
"""物理凭据【读取】路径(候选集合里第一个真实存在的;都无则 None)。"""
|
|
1749
|
+
return resolve_room_credential(room, ".physics.json", task_id)[0]
|
|
1750
|
+
|
|
1751
|
+
|
|
1752
|
+
def domain_attest_read_path(room, task_id=None):
|
|
1753
|
+
"""领域凭据【读取】路径(候选集合里第一个真实存在的;都无则 None)。"""
|
|
1754
|
+
return resolve_room_credential(room, ".domain.json", task_id)[0]
|
|
1755
|
+
|
|
1756
|
+
|
|
1757
|
+
def _seal_attestation(payload, kind, room, extra_meta=None):
|
|
1758
|
+
"""把凭据体落盘,并在【只有未签名时】补齐展示用元数据。
|
|
1759
|
+
|
|
1760
|
+
── 【签后补字段 BUG 修复】这是三道防线此前 100% 失效的根因 ──────────────
|
|
1761
|
+
宿主签名 = HMAC(canonicalJson(待签体)),验签端先剥离 _sig/_kid/_ts/_nonce
|
|
1762
|
+
再逐字节重算。因此【任何在签名之后写入凭据体的字段都会让验签必然失配】。
|
|
1763
|
+
|
|
1764
|
+
原实现无条件 rec.update(payload),而 payload 是宿主已签好的凭据 →
|
|
1765
|
+
必然多出 schema/room/at/ts 等字段 → HMAC 对不上 → 防线①②③全部拦死,
|
|
1766
|
+
现场只能靠 `room-end --force` 绕过(审计留痕可证)。
|
|
1767
|
+
|
|
1768
|
+
正确语义:
|
|
1769
|
+
· 已签名凭据(含 _sig)→ 【原样落盘】,一个字段都不加、不改。
|
|
1770
|
+
需要的元数据已由宿主在签发时纳入待签体(见 defense-sign.js)。
|
|
1771
|
+
· 未签名兜底凭据(宿主不可用)→ 仍补齐元数据,便于人工排障;
|
|
1772
|
+
它本来就过不了验签(fail-closed),补字段不会造成额外损害。
|
|
1773
|
+
"""
|
|
1774
|
+
body = dict(payload or {})
|
|
1775
|
+
if not body.get("_sig"):
|
|
1776
|
+
rec = {
|
|
1777
|
+
"schema": "dsh-%s-attestation/1" % kind,
|
|
1778
|
+
"room": str(room),
|
|
1779
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
1780
|
+
"ts": time.time(),
|
|
1781
|
+
}
|
|
1782
|
+
rec.update(extra_meta or {})
|
|
1783
|
+
rec.update(body)
|
|
1784
|
+
body = rec
|
|
1785
|
+
return body
|
|
1786
|
+
|
|
1787
|
+
|
|
1788
|
+
def write_physics_attestation(room, payload):
|
|
1789
|
+
"""由 physics 链路写就(sw_bridge/physics_bridge 调用)。"""
|
|
1790
|
+
rec = _seal_attestation(payload, "physics", room)
|
|
1791
|
+
_write_json(physics_attest_path(room), rec)
|
|
1792
|
+
return rec
|
|
1793
|
+
|
|
1794
|
+
|
|
1795
|
+
def write_domain_attestation(room, payload):
|
|
1796
|
+
rec = _seal_attestation(payload, "domain", room)
|
|
1797
|
+
_write_json(domain_attest_path(room), rec)
|
|
1798
|
+
return rec
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1802
|
+
# 【三大防线 · 签名式信任根】宿主验签
|
|
1803
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
1804
|
+
# 密码学定位(用户给定):签名是主防线,守卫是补丁。
|
|
1805
|
+
# 本模块【不持有密钥】—— 密钥只在 DSH 宿主进程内存里,
|
|
1806
|
+
# 因此签名与验签都必须请求宿主。这样即使 AI 能读写工程模式目录下的
|
|
1807
|
+
# 任何文件,也拿不到签发能力,"手写一份格式完美的凭据"必然验签失败。
|
|
1808
|
+
#
|
|
1809
|
+
# 宿主端点由宿主在 <tools>/.defense/runtime.json 中公布(含端口)。
|
|
1810
|
+
# 宿主不可用时的行为 = 【fail-closed】:无法验签即视为未通过防线。
|
|
1811
|
+
# 唯一的合法绕行仍是 DSH_DEFENSE_BYPASS=1 / --force,且必定留痕。
|
|
1812
|
+
|
|
1813
|
+
# ── 【P0-3 修复】.defense 也必须落在【权威状态目录】────────────────────
|
|
1814
|
+
# 宿主把 .defense/ 写在它加载插件的那份 tools 目录(安装副本)。若 Python
|
|
1815
|
+
# 把 runtime.json / workspace.txt 写到【脚本所在目录】(工作区),就会:
|
|
1816
|
+
# · 在无关副本里凭空造出一份 .defense(含伪造/过期的 kid 与 port);
|
|
1817
|
+
# · 而 _runtime_candidates() 又优先读这份 → 连到【已死的端口】,
|
|
1818
|
+
# 三道防线全部 fail-closed,且现象与"签名服务未装配"难以区分。
|
|
1819
|
+
DEFENSE_DIR = os.path.join(STATE_DIR, ".defense")
|
|
1820
|
+
RUNTIME_FILE = os.path.join(DEFENSE_DIR, "runtime.json")
|
|
1821
|
+
|
|
1822
|
+
|
|
1823
|
+
def _runtime_candidates():
|
|
1824
|
+
"""runtime.json 的候选位置(按可信度排序)。
|
|
1825
|
+
|
|
1826
|
+
── 【Bug3 + P0-3 修复】──────────────────────────────────────────────
|
|
1827
|
+
宿主把 .defense/ 写在【它加载插件的那份 tools 目录】(安装副本);
|
|
1828
|
+
而脚本可能从工作区副本被调用。原实现把"脚本自身目录"排在**第一位**,
|
|
1829
|
+
于是任何在工作区残留的 .defense/runtime.json(例如非宿主进程写下的
|
|
1830
|
+
测试产物)都会**优先于真宿主**被采用 → 连到死端口 → 防线 fail-closed。
|
|
1831
|
+
|
|
1832
|
+
现按可信度排序:
|
|
1833
|
+
① 权威状态目录(_store 判定,通常就是安装副本 = 宿主所在处)
|
|
1834
|
+
② 安装副本(显式拼出,便于 DSH_HOME 与 ~ 不一致的场景)
|
|
1835
|
+
③ 脚本自身目录(最低,兼容未安装的纯仓库使用)
|
|
1836
|
+
"""
|
|
1837
|
+
cands = [RUNTIME_FILE]
|
|
1838
|
+
try:
|
|
1839
|
+
home = os.path.expanduser("~")
|
|
1840
|
+
dsh_home = os.environ.get("DSH_HOME") or os.path.join(home, ".dsh")
|
|
1841
|
+
# 宿主实际加载位置(安装目录)—— 最可能的真相来源
|
|
1842
|
+
cands.append(os.path.join(dsh_home, ".agent-presets", "engineering",
|
|
1843
|
+
"tools", ".defense", "runtime.json"))
|
|
1844
|
+
cands.append(os.path.join(dsh_home, "engineering", "tools",
|
|
1845
|
+
".defense", "runtime.json"))
|
|
1846
|
+
# 脚本自身目录(最后兜底;可能是工作区副本)
|
|
1847
|
+
cands.append(os.path.join(_BASE_DIR, ".defense", "runtime.json"))
|
|
1848
|
+
# 工程模式根目录旁的另一份副本
|
|
1849
|
+
cands.append(os.path.abspath(os.path.join(_BASE_DIR, "..", "..",
|
|
1850
|
+
"engineering", "tools", ".defense",
|
|
1851
|
+
"runtime.json")))
|
|
1852
|
+
except Exception:
|
|
1853
|
+
pass
|
|
1854
|
+
out, seen = [], set()
|
|
1855
|
+
for c in cands:
|
|
1856
|
+
try:
|
|
1857
|
+
a = os.path.abspath(c)
|
|
1858
|
+
except Exception:
|
|
1859
|
+
continue
|
|
1860
|
+
if a in seen:
|
|
1861
|
+
continue
|
|
1862
|
+
seen.add(a)
|
|
1863
|
+
out.append(a)
|
|
1864
|
+
return out
|
|
1865
|
+
|
|
1866
|
+
|
|
1867
|
+
def _workspace_root_candidates():
|
|
1868
|
+
"""本副本声明的工程模式根(仓库根 + engineering),供宿主扩大受管根。"""
|
|
1869
|
+
_root = os.path.abspath(os.path.join(_BASE_DIR, "..", ".."))
|
|
1870
|
+
# 兼容:engineering 目录也写一份(部分部署把 output 放在 engineering 下)
|
|
1871
|
+
return [_root, os.path.abspath(os.path.join(_BASE_DIR, ".."))]
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
def _defense_dirs():
|
|
1875
|
+
"""所有已知 .defense 目录(两份副本各一份)。"""
|
|
1876
|
+
dirs = []
|
|
1877
|
+
for _c in _runtime_candidates():
|
|
1878
|
+
try:
|
|
1879
|
+
_d = os.path.dirname(os.path.abspath(_c))
|
|
1880
|
+
except Exception:
|
|
1881
|
+
continue
|
|
1882
|
+
if _d and _d not in dirs:
|
|
1883
|
+
dirs.append(_d)
|
|
1884
|
+
for _d in (DEFENSE_DIR, os.path.join(_BASE_DIR, ".defense")):
|
|
1885
|
+
try:
|
|
1886
|
+
_a = os.path.abspath(_d)
|
|
1887
|
+
except Exception:
|
|
1888
|
+
continue
|
|
1889
|
+
if _a not in dirs:
|
|
1890
|
+
dirs.append(_a)
|
|
1891
|
+
return dirs
|
|
1892
|
+
|
|
1893
|
+
|
|
1894
|
+
def _publish_workspace_root():
|
|
1895
|
+
"""把本副本的工程模式根目录声明给宿主(供宿主扩大受管根)。
|
|
1896
|
+
|
|
1897
|
+
── 【Bug8 修复】工程模式有工作区/安装目录两份副本。宿主按自己的
|
|
1898
|
+
# toolsDir 算受管物理运行根,而报告可能写在另一份里,于是 /sign
|
|
1899
|
+
# 以 "report path outside managed physics_runs root" 拒签。
|
|
1900
|
+
# 这里由 Python 侧把【自己的根目录】写到宿主的 .defense/workspace.txt,
|
|
1901
|
+
# 使宿主能把该位置也纳入受管范围。
|
|
1902
|
+
|
|
1903
|
+
── 【Bug-02 修复】workspace.txt 原先【整体覆盖写】,谁最后跑谁生效 ───
|
|
1904
|
+
实测:工作区副本(Desktop\\DSH-SW-and-CAD-main)与安装副本
|
|
1905
|
+
(~/.dsh/.agent-presets/engineering)各写各的,后者把前者覆盖掉,
|
|
1906
|
+
于是宿主读到的受管根里【没有】工作区路径 → 报告落在工作区
|
|
1907
|
+
output/physics_runs 时一律被拒签("report path outside managed
|
|
1908
|
+
physics_runs root"),防线②凭据永远签不出来。
|
|
1909
|
+
现在:
|
|
1910
|
+
① 【合并式写入】读取已有内容并求并集,不覆盖别的副本的声明;
|
|
1911
|
+
② 【写入所有 .defense 目录】两份副本各写一份,宿主无论选中哪份
|
|
1912
|
+
都能读到【包括了工作区】的完整受管根集合;
|
|
1913
|
+
③ 路径归一化去重(大小写不敏感),避免同一目录重复。
|
|
1914
|
+
"""
|
|
1915
|
+
_roots = _workspace_root_candidates()
|
|
1916
|
+
_written = []
|
|
1917
|
+
for _d in _defense_dirs():
|
|
1918
|
+
try:
|
|
1919
|
+
if not os.path.isdir(_d):
|
|
1920
|
+
continue
|
|
1921
|
+
_dest = os.path.join(_d, "workspace.txt")
|
|
1922
|
+
_merged = []
|
|
1923
|
+
_seen = set()
|
|
1924
|
+
# ① 先并入磁盘上已有的声明(含另一份副本写的)
|
|
1925
|
+
try:
|
|
1926
|
+
if os.path.isfile(_dest):
|
|
1927
|
+
with open(_dest, "r", encoding="utf-8", errors="replace") as _f:
|
|
1928
|
+
for _ln in _f.read().splitlines():
|
|
1929
|
+
_s = _ln.strip()
|
|
1930
|
+
if _s:
|
|
1931
|
+
_merged.append(_s)
|
|
1932
|
+
except Exception:
|
|
1933
|
+
pass
|
|
1934
|
+
# ② 再并入本副本声明的根
|
|
1935
|
+
_merged.extend(_roots)
|
|
1936
|
+
# ③ 归一化去重(大小写不敏感,保留首个真实写法)
|
|
1937
|
+
_out = []
|
|
1938
|
+
for _m in _merged:
|
|
1939
|
+
try:
|
|
1940
|
+
_a = os.path.abspath(_m)
|
|
1941
|
+
except Exception:
|
|
1942
|
+
continue
|
|
1943
|
+
_k = os.path.normcase(_a)
|
|
1944
|
+
if _k in _seen:
|
|
1945
|
+
continue
|
|
1946
|
+
_seen.add(_k)
|
|
1947
|
+
_out.append(_a)
|
|
1948
|
+
if not _out:
|
|
1949
|
+
continue
|
|
1950
|
+
with open(_dest, "w", encoding="utf-8") as _f:
|
|
1951
|
+
_f.write("\n".join(_out))
|
|
1952
|
+
_written.append(_dest)
|
|
1953
|
+
except Exception:
|
|
1954
|
+
continue
|
|
1955
|
+
return _written or None
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
def _find_runtime_file():
|
|
1959
|
+
"""返回第一个存在的 runtime.json 路径(无则 None)。"""
|
|
1960
|
+
for c in _runtime_candidates():
|
|
1961
|
+
try:
|
|
1962
|
+
if os.path.exists(c):
|
|
1963
|
+
return c
|
|
1964
|
+
except Exception:
|
|
1965
|
+
continue
|
|
1966
|
+
return None
|
|
1967
|
+
|
|
1968
|
+
|
|
1969
|
+
def _read_runtime():
|
|
1970
|
+
"""读取宿主公布的运行时信息(端口 / kid)。"""
|
|
1971
|
+
# ── 【Bug3 修复】在多个候选位置查找(工作区 / 安装目录可能各一份)──
|
|
1972
|
+
p = _find_runtime_file()
|
|
1973
|
+
if not p:
|
|
1974
|
+
return {}
|
|
1975
|
+
return _read_json(p) or {}
|
|
1976
|
+
|
|
1977
|
+
|
|
1978
|
+
def _host_base(require_owner=True):
|
|
1979
|
+
"""宿主基地址;宿主未公布端口时返回 None。
|
|
1980
|
+
|
|
1981
|
+
── 【FL-05 修复】默认必须通过【端口归属校验】才返回基地址 ──────────────
|
|
1982
|
+
实测缺陷:`request_signed_credential` / `_host_base` 单点信任
|
|
1983
|
+
runtime.json 里的 port —— 同一用户改写该文件即可把【签发请求】
|
|
1984
|
+
劫持到任意端点(sign 层原本没有任何 owner_check;
|
|
1985
|
+
check_room_defense 那条路径有,属覆盖不全)。
|
|
1986
|
+
现在把校验收进 _host_base 本身,使 sign / verify / judge 三条链路
|
|
1987
|
+
统一受保护:
|
|
1988
|
+
· require_owner=True(默认)→ 端口监听者必须是 DSH 主进程;
|
|
1989
|
+
校验不通过返回 None(调用方按 fail-closed 处理);
|
|
1990
|
+
· 校验结果按 (port,pid) 缓存 30 秒 —— 端口归属检查要起
|
|
1991
|
+
PowerShell 查 TCP 表,每次调用都做会让门禁明显变慢。
|
|
1992
|
+
"""
|
|
1993
|
+
rt = _read_runtime()
|
|
1994
|
+
try:
|
|
1995
|
+
port = int(rt.get("port") or 0)
|
|
1996
|
+
except (TypeError, ValueError):
|
|
1997
|
+
return None
|
|
1998
|
+
if not 0 < port < 65536:
|
|
1999
|
+
return None
|
|
2000
|
+
if require_owner:
|
|
2001
|
+
global _HOST_OWNER_CACHE
|
|
2002
|
+
_key = (str(port), str(rt.get("pid") or ""))
|
|
2003
|
+
_now = time.time()
|
|
2004
|
+
_hit = _HOST_OWNER_CACHE.get("key")
|
|
2005
|
+
if _hit == _key and (_now - float(_HOST_OWNER_CACHE.get("at") or 0)) < 30.0:
|
|
2006
|
+
_ok = bool(_HOST_OWNER_CACHE.get("ok"))
|
|
2007
|
+
else:
|
|
2008
|
+
try:
|
|
2009
|
+
_own = _port_owner_check(rt.get("pid"), port)
|
|
2010
|
+
_ok = bool(_own.get("ok"))
|
|
2011
|
+
except Exception:
|
|
2012
|
+
_ok = False
|
|
2013
|
+
_HOST_OWNER_CACHE = {"key": _key, "at": _now, "ok": _ok}
|
|
2014
|
+
if not _ok:
|
|
2015
|
+
# 归属校验不通过 → 不交出基地址(fail-closed,不把请求发往可疑端点)
|
|
2016
|
+
return None
|
|
2017
|
+
# [Bug8] 把本副本根目录声明给宿主,使其受管根覆盖两个部署位置
|
|
2018
|
+
try:
|
|
2019
|
+
_publish_workspace_root()
|
|
2020
|
+
except Exception:
|
|
2021
|
+
pass
|
|
2022
|
+
return "http://127.0.0.1:%d" % port
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
# 端口归属校验缓存(避免每次调用都起 PowerShell 查 TCP 表)
|
|
2026
|
+
_HOST_OWNER_CACHE = {}
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def _post_json(url, payload, timeout=5.0, token=None):
|
|
2030
|
+
"""向宿主 POST JSON;返回解析后的 dict 或 None。"""
|
|
2031
|
+
try:
|
|
2032
|
+
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
2033
|
+
req = urllib.request.Request(
|
|
2034
|
+
url, data=data,
|
|
2035
|
+
headers={"content-type": "application/json; charset=utf-8"},
|
|
2036
|
+
method="POST")
|
|
2037
|
+
if token:
|
|
2038
|
+
req.add_unredirected_header("x-dsh-engineering-token", token)
|
|
2039
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
2040
|
+
with opener.open(req, timeout=timeout) as resp:
|
|
2041
|
+
body = resp.read().decode("utf-8", "replace")
|
|
2042
|
+
return json.loads(body)
|
|
2043
|
+
except Exception:
|
|
2044
|
+
return None
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def _port_owner_check(expected_pid, port=None):
|
|
2048
|
+
"""校验宿主端口确实由 DSH 主进程持有(堵"假宿主"冒充)。
|
|
2049
|
+
|
|
2050
|
+
对抗测试实证:Python 原本只信 runtime.json 里的 port —— AI 起一个
|
|
2051
|
+
"恒真"HTTP 服务并改写该文件即可整体绕过信任根。这里增加两项锚点:
|
|
2052
|
+
① 实际连接的服务端 PID 必须等于宿主写入的 pid;
|
|
2053
|
+
② 该 PID 的进程名必须匹配 DSH 主程序(DeepSeek Harness / electron / node)。
|
|
2054
|
+
|
|
2055
|
+
⚠️ 这是【提高成本】而非密码学保证:同用户仍可杀掉真 DSH 后占位,
|
|
2056
|
+
但那会立刻被用户察觉,且守卫会记录异常。真正闭环由宿主侧判定
|
|
2057
|
+
(judgeDefense) 承担 —— 见 check_room_defense 的宿主优先路径。
|
|
2058
|
+
|
|
2059
|
+
Returns: {ok, reason}
|
|
2060
|
+
"""
|
|
2061
|
+
if not expected_pid:
|
|
2062
|
+
return {"ok": False, "reason": "runtime.json 未记录宿主 pid"}
|
|
2063
|
+
try:
|
|
2064
|
+
if os.name != "nt":
|
|
2065
|
+
return {"ok": True, "reason": "非 Windows:跳过端口归属校验"}
|
|
2066
|
+
import socket
|
|
2067
|
+
import subprocess as _sp
|
|
2068
|
+
# Windows 有时不公布 Listen 状态,保持实际连接并核对服务端四元组。
|
|
2069
|
+
_p = int(port if port is not None else 0)
|
|
2070
|
+
if not _p:
|
|
2071
|
+
return {"ok": False, "reason": "未提供宿主端口"}
|
|
2072
|
+
with socket.create_connection(("127.0.0.1", _p), timeout=3) as probe:
|
|
2073
|
+
local_host, local_port = probe.getsockname()
|
|
2074
|
+
peer_host, peer_port = probe.getpeername()
|
|
2075
|
+
ps = ("Get-NetTCPConnection -LocalAddress '%s' -LocalPort %d "
|
|
2076
|
+
"-RemoteAddress '%s' -RemotePort %d -State Established "
|
|
2077
|
+
"-ErrorAction Stop | Select-Object -ExpandProperty OwningProcess"
|
|
2078
|
+
) % (peer_host, peer_port, local_host, local_port)
|
|
2079
|
+
r = _sp.run(["powershell", "-NoProfile", "-Command", ps],
|
|
2080
|
+
capture_output=True, text=True, timeout=10)
|
|
2081
|
+
port_pid = (r.stdout or "").strip()
|
|
2082
|
+
if not port_pid:
|
|
2083
|
+
return {"ok": False, "reason": "无法确认端口 %s 实际连接的服务端进程" % _p}
|
|
2084
|
+
if str(port_pid) != str(expected_pid):
|
|
2085
|
+
return {"ok": False,
|
|
2086
|
+
"reason": "服务端 PID=%s 与宿主声明的 pid=%s 不一致(疑似假宿主)"
|
|
2087
|
+
% (port_pid, expected_pid)}
|
|
2088
|
+
# 查进程名
|
|
2089
|
+
ps2 = ("(Get-Process -Id %s -ErrorAction SilentlyContinue).ProcessName" % str(expected_pid))
|
|
2090
|
+
r2 = _sp.run(["powershell", "-NoProfile", "-Command", ps2],
|
|
2091
|
+
capture_output=True, text=True, timeout=10)
|
|
2092
|
+
name = (r2.stdout or "").strip()
|
|
2093
|
+
if not name:
|
|
2094
|
+
return {"ok": False, "reason": "无法取得 PID %s 的进程名" % expected_pid}
|
|
2095
|
+
# ② 进程名必须像 DSH 宿主
|
|
2096
|
+
if not any(k in name.lower() for k in ("deepseek", "harness", "electron", "node")):
|
|
2097
|
+
return {"ok": False,
|
|
2098
|
+
"reason": "PID %s 进程名为 %r,不是 DSH 宿主(疑似假宿主)"
|
|
2099
|
+
% (expected_pid, name)}
|
|
2100
|
+
# ③ 更强锚点:可执行文件路径必须是真实 DSH 安装(进程名可被改名冒充,
|
|
2101
|
+
# 但可执行路径要伪造就必须在别处放一个同名的真程序,成本更高且更显眼)。
|
|
2102
|
+
ps3 = ("(Get-Process -Id %s -ErrorAction SilentlyContinue).Path" % str(expected_pid))
|
|
2103
|
+
r3 = _sp.run(["powershell", "-NoProfile", "-Command", ps3],
|
|
2104
|
+
capture_output=True, text=True, timeout=10)
|
|
2105
|
+
exe_path = (r3.stdout or "").strip()
|
|
2106
|
+
if not exe_path:
|
|
2107
|
+
return {"ok": False, "reason": "无法取得 PID %s 的可执行路径" % expected_pid}
|
|
2108
|
+
_lp = exe_path.replace("/", "\\").lower()
|
|
2109
|
+
_ok_path = any(k in _lp for k in (
|
|
2110
|
+
"\\dsh\\", "deepseek harness.exe", "deepseek-harness",
|
|
2111
|
+
"\\app.asar", "electron",
|
|
2112
|
+
))
|
|
2113
|
+
if not _ok_path:
|
|
2114
|
+
return {"ok": False,
|
|
2115
|
+
"reason": "PID %s 可执行路径 %r 不是 DSH 安装(疑似假宿主)"
|
|
2116
|
+
% (expected_pid, exe_path)}
|
|
2117
|
+
return {"ok": True,
|
|
2118
|
+
"reason": "端口归属校验通过 (pid=%s, name=%s, exe=%s)"
|
|
2119
|
+
% (port_pid, name, exe_path)}
|
|
2120
|
+
except Exception as e:
|
|
2121
|
+
return {"ok": False, "reason": "端口归属校验异常: %r" % (e,)}
|
|
2122
|
+
|
|
2123
|
+
|
|
2124
|
+
def _get_port():
|
|
2125
|
+
"""当前 runtime.json 声明的端口。"""
|
|
2126
|
+
return (_read_runtime() or {}).get("port")
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
def _host_tools_dir():
|
|
2130
|
+
"""【宿主实际加载的 tools 目录】—— 由宿主自己写下的 runtime.json 反推。
|
|
2131
|
+
|
|
2132
|
+
宿主启动时把 runtime.json 写进它自己的 <toolsDir>/.defense/,
|
|
2133
|
+
(见 defense-sign.js 的 runtimePath / status().runtime_file 实测值:
|
|
2134
|
+
C:\\Users\\<user>\\.dsh\\.agent-presets\\engineering\\tools\\.defense\\runtime.json)
|
|
2135
|
+
因此 dirname(dirname(runtime_file)) 就是宿主的 toolsDir —— 它 judgeDefense
|
|
2136
|
+
读凭据用的正是 <toolsDir>/reports/。返回值可能为空串(未装配/无 runtime)。
|
|
2137
|
+
"""
|
|
2138
|
+
p = _find_runtime_file()
|
|
2139
|
+
if not p:
|
|
2140
|
+
return ""
|
|
2141
|
+
try:
|
|
2142
|
+
return os.path.dirname(os.path.dirname(os.path.abspath(p)))
|
|
2143
|
+
except Exception:
|
|
2144
|
+
return ""
|
|
2145
|
+
|
|
2146
|
+
|
|
2147
|
+
def host_reports_dir():
|
|
2148
|
+
"""【宿主实际读取的】reports 目录(Bug⑤ 排障关键字段)。"""
|
|
2149
|
+
_td = _host_tools_dir()
|
|
2150
|
+
return os.path.join(_td, "reports") if _td else ""
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def host_judge_room(room, require_physics=True):
|
|
2154
|
+
"""请求【宿主】执行完整防线判定(C 方案)。
|
|
2155
|
+
|
|
2156
|
+
Returns: {ok, blockers, warnings, details, judged_by} 或 None(宿主不可用)。
|
|
2157
|
+
|
|
2158
|
+
── 【Bug⑤ 修复】响应必须回传【宿主实际读的 reports_dir】──────────────
|
|
2159
|
+
原实现只把宿主的 blockers 原样返回,"missing" 时完全看不出宿主读的是
|
|
2160
|
+
哪一份 reports(工作区 vs 安装副本 × 是否按 task_id 分目录),排障只能靠猜。
|
|
2161
|
+
现在无论宿主是否回传该字段,都补上:
|
|
2162
|
+
· reports_dir_host —— 宿主实际读的目录(由它自己的 runtime.json 反推,
|
|
2163
|
+
与 judgeDefense 的 pathSync.join(this.toolsDir,'reports') 同源);
|
|
2164
|
+
· credential_host_path —— 宿主本次会去读的凭据绝对路径;
|
|
2165
|
+
· reports_dir_local_candidates —— 本地候选集合(写入侧用的那几个)。
|
|
2166
|
+
"""
|
|
2167
|
+
base = _host_base()
|
|
2168
|
+
if not base:
|
|
2169
|
+
return None
|
|
2170
|
+
# ── 【Bug⑤ 根治·宿主侧】必须把 task_id 一起发给宿主 ──────────────────
|
|
2171
|
+
# 凭据实际落在 reports/<task_id>/ 子目录;宿主 judgeDefense 原先只读
|
|
2172
|
+
# flat 的 reports/ 根 → 凭据存在却判 missing(room-end 永久被拒)。
|
|
2173
|
+
# 宿主侧已改为按 task_id 解析子目录(_resolveReportsDir),
|
|
2174
|
+
# 这里负责把 id 送过去;取不到就传空串,宿主会自行兜底。
|
|
2175
|
+
_tid = _current_task_id() or ""
|
|
2176
|
+
res = _post_json(base + "/dsh-engineering-ui/defense/judge",
|
|
2177
|
+
{"room": str(room), "kind": "room",
|
|
2178
|
+
"task_id": str(_tid),
|
|
2179
|
+
"require_physics": bool(require_physics)}, timeout=8.0)
|
|
2180
|
+
if not isinstance(res, dict) or "blockers" not in res:
|
|
2181
|
+
return None
|
|
2182
|
+
# ── 【Bug⑤】回传宿主实际读的目录(宿主自身未回传时由本地推导补齐)──
|
|
2183
|
+
_hrd = str(res.get("reports_dir") or "").strip() or host_reports_dir()
|
|
2184
|
+
res["host_reports_dir"] = _hrd
|
|
2185
|
+
if _hrd:
|
|
2186
|
+
res.setdefault("credential_host_path", {
|
|
2187
|
+
"physics": os.path.join(_hrd, _safe_room_stem(room) + ".physics.json"),
|
|
2188
|
+
"domain": os.path.join(_hrd, _safe_room_stem(room) + ".domain.json"),
|
|
2189
|
+
})
|
|
2190
|
+
res.setdefault("host_reports_dir_exists", os.path.isdir(_hrd))
|
|
2191
|
+
res.setdefault("reports_dir_local_candidates", reports_dir_candidates())
|
|
2192
|
+
return res
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
def host_verify_credential(cred):
|
|
2196
|
+
"""请求宿主验签(门禁内部通道,不消费 nonce)。
|
|
2197
|
+
|
|
2198
|
+
Returns: {ok, reason, host_available}
|
|
2199
|
+
host_available=False 表示宿主不在(此时调用方按 fail-closed 处理)。
|
|
2200
|
+
|
|
2201
|
+
── 【P2 修复】门禁必须走 /defense/verify-gate,不能用公开端点 ──────────
|
|
2202
|
+
公开端点 /defense/verify 现在【单次消费 nonce】(防外部重放)。
|
|
2203
|
+
但门禁自身要对同一份凭据做多阶段校验:
|
|
2204
|
+
room-end → confirm-assembly → 收尾 select
|
|
2205
|
+
若走公开端点,第二次校验就会被判"重放"而失败 —— 那是把防重放
|
|
2206
|
+
误伤到了正常流程。因此门禁固定走【内部端点 verify-gate】(不消费)。
|
|
2207
|
+
两个端点共用同一套 HMAC 校验,安全强度一致,只是消费语义不同。
|
|
2208
|
+
"""
|
|
2209
|
+
base = _host_base()
|
|
2210
|
+
if not base:
|
|
2211
|
+
return {"ok": False, "reason": "宿主未公布防线端点(runtime.json 缺失或无私端口)",
|
|
2212
|
+
"host_available": False}
|
|
2213
|
+
# ① 优先走内部端点(不消费 nonce,支持门禁多阶段校验)
|
|
2214
|
+
res = _post_json(base + "/dsh-engineering-ui/defense/verify-gate", cred)
|
|
2215
|
+
if not isinstance(res, dict):
|
|
2216
|
+
# ② 兼容旧宿主(尚无 verify-gate 端点)→ 回落公开端点
|
|
2217
|
+
res = _post_json(base + "/dsh-engineering-ui/defense/verify", cred)
|
|
2218
|
+
if not isinstance(res, dict):
|
|
2219
|
+
return {"ok": False, "reason": "宿主验签端点无响应",
|
|
2220
|
+
"host_available": False}
|
|
2221
|
+
return {"ok": bool(res.get("verified")), "reason": res.get("reason"),
|
|
2222
|
+
"host_available": True}
|
|
2223
|
+
|
|
2224
|
+
|
|
2225
|
+
def request_signed_credential(spec):
|
|
2226
|
+
"""向宿主请求签发一份凭据。
|
|
2227
|
+
|
|
2228
|
+
spec 只描述"要签什么";待签体由【宿主自己读磁盘】构造,
|
|
2229
|
+
调用方无法指定结论(例如无法让宿主为一份不存在的 PASS 背书)。
|
|
2230
|
+
"""
|
|
2231
|
+
base = _host_base()
|
|
2232
|
+
if not base:
|
|
2233
|
+
return {"ok": False, "error": "宿主未公布防线端点(runtime.json 缺失或无私端口)"}
|
|
2234
|
+
token = os.environ.get("DSH_ENGINEERING_TOKEN")
|
|
2235
|
+
host_tools = _host_tools_dir()
|
|
2236
|
+
if not token and host_tools:
|
|
2237
|
+
try:
|
|
2238
|
+
with open(os.path.join(host_tools, ".defense", "local.token"), encoding="utf-8-sig") as f:
|
|
2239
|
+
token = f.read().strip()
|
|
2240
|
+
except FileNotFoundError:
|
|
2241
|
+
pass
|
|
2242
|
+
except (OSError, UnicodeError):
|
|
2243
|
+
return {"ok": False, "error": "本机令牌配置不可读或为空,已拒绝签发"}
|
|
2244
|
+
else:
|
|
2245
|
+
if not token:
|
|
2246
|
+
return {"ok": False, "error": "本机令牌配置不可读或为空,已拒绝签发"}
|
|
2247
|
+
res = _post_json(base + "/dsh-engineering-ui/defense/sign", spec, timeout=8.0, token=token)
|
|
2248
|
+
if not isinstance(res, dict):
|
|
2249
|
+
return {"ok": False, "error": "宿主签发端点无响应"}
|
|
2250
|
+
return res
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
def note_unsigned_attempt(room, kind, reason):
|
|
2254
|
+
"""记录一次"宿主不可用导致产出无签名凭据"的事实(审计用)。"""
|
|
2255
|
+
try:
|
|
2256
|
+
items = _read_json(bypass_log_file()) or {"items": []}
|
|
2257
|
+
if not isinstance(items, dict) or not isinstance(items.get("items"), list):
|
|
2258
|
+
items = {"items": []}
|
|
2259
|
+
items["items"].append({
|
|
2260
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
2261
|
+
"where": "defense_gate.unsigned:" + str(room),
|
|
2262
|
+
"kind": str(kind),
|
|
2263
|
+
"reason": str(reason or "宿主签名服务不可用"),
|
|
2264
|
+
"by": "fail-closed",
|
|
2265
|
+
})
|
|
2266
|
+
items["items"] = items["items"][-200:]
|
|
2267
|
+
_write_json(bypass_log_file(), items)
|
|
2268
|
+
except Exception:
|
|
2269
|
+
pass
|
|
2270
|
+
|
|
2271
|
+
|
|
2272
|
+
def credential_is_signed_and_valid(cred):
|
|
2273
|
+
"""凭据必须带签名且经宿主验签通过。
|
|
2274
|
+
|
|
2275
|
+
这是防线①②③共同的强制前置:任何凭据在进入原有判定逻辑之前,
|
|
2276
|
+
都必须先过这一关。不带签名 = 直接判不可信(不进入后续逻辑)。
|
|
2277
|
+
"""
|
|
2278
|
+
if not isinstance(cred, dict):
|
|
2279
|
+
return {"ok": False, "reason": "凭据不是对象"}
|
|
2280
|
+
if not cred.get("_sig"):
|
|
2281
|
+
return {"ok": False, "reason": "凭据缺少签名 —— 非宿主签发,疑似伪造"}
|
|
2282
|
+
v = host_verify_credential(cred)
|
|
2283
|
+
if not v["ok"]:
|
|
2284
|
+
return {"ok": False, "reason": "签名校验未通过:%s" % (v.get("reason") or "未知")}
|
|
2285
|
+
return {"ok": True}
|
|
2286
|
+
|
|
2287
|
+
|
|
2288
|
+
def _physics_roots_candidates():
|
|
2289
|
+
"""物理运行根目录的候选(受管根 + 报告实际落点)。
|
|
2290
|
+
|
|
2291
|
+
── 【P0-3 / Bug8 修复】必须同时覆盖【两份副本】与【仓库根】──────────
|
|
2292
|
+
工程模式的物理报告由 physics_bridge 写在 <repo>/output/physics_runs,
|
|
2293
|
+
而脚本可能从【工作区】或【安装副本】加载;同时受管根还要与宿主
|
|
2294
|
+
(defense-sign.js managedRoots())保持一致,否则宿主会以
|
|
2295
|
+
"report path outside managed physics_runs root" 拒签(Bug8)。
|
|
2296
|
+
因此这里把三处都纳入:状态目录、脚本目录,各自向上两级。
|
|
2297
|
+
"""
|
|
2298
|
+
bases = []
|
|
2299
|
+
for d in (STATE_DIR, _BASE_DIR):
|
|
2300
|
+
try:
|
|
2301
|
+
a = os.path.abspath(d)
|
|
2302
|
+
if a and a not in bases:
|
|
2303
|
+
bases.append(a)
|
|
2304
|
+
except Exception:
|
|
2305
|
+
continue
|
|
2306
|
+
out = []
|
|
2307
|
+
for b in bases:
|
|
2308
|
+
for rel in ((os.pardir, os.pardir, "output", "physics_runs"),
|
|
2309
|
+
(os.pardir, "output", "physics_runs"),
|
|
2310
|
+
("output", "physics_runs")):
|
|
2311
|
+
try:
|
|
2312
|
+
p = os.path.abspath(os.path.join(b, *rel))
|
|
2313
|
+
if p not in out:
|
|
2314
|
+
out.append(p)
|
|
2315
|
+
except Exception:
|
|
2316
|
+
continue
|
|
2317
|
+
return out
|
|
2318
|
+
|
|
2319
|
+
|
|
2320
|
+
def _physics_run_roots():
|
|
2321
|
+
"""受管的物理运行根目录(只有这些目录下的报告才算"真实运行")。"""
|
|
2322
|
+
return _physics_roots_candidates()
|
|
2323
|
+
|
|
2324
|
+
|
|
2325
|
+
def _real_physics_runs(case_path=None):
|
|
2326
|
+
"""扫描 output/physics_runs,返回【真实存在】的仿真报告列表。
|
|
2327
|
+
|
|
2328
|
+
用途:凭据只写 JSON 是【可手写伪造】的 —— 必须回查磁盘上是否真有
|
|
2329
|
+
对应的 FEA 运行报告,否则"三大防线"就只是一张可自制的通行证。
|
|
2330
|
+
"""
|
|
2331
|
+
out = []
|
|
2332
|
+
for base in _physics_roots_candidates():
|
|
2333
|
+
base = os.path.abspath(base)
|
|
2334
|
+
if not os.path.isdir(base):
|
|
2335
|
+
continue
|
|
2336
|
+
for path in glob.glob(os.path.join(base, "*", "**", "*_report.json"),
|
|
2337
|
+
recursive=True):
|
|
2338
|
+
rep = _read_json(path)
|
|
2339
|
+
if not isinstance(rep, dict):
|
|
2340
|
+
continue
|
|
2341
|
+
out.append({
|
|
2342
|
+
"path": path,
|
|
2343
|
+
"run_id": rep.get("run_id"),
|
|
2344
|
+
"overall": rep.get("overall_status"),
|
|
2345
|
+
"case": ((rep.get("load_case_summary") or {}).get("problem_id")
|
|
2346
|
+
or (rep.get("load_case_summary") or {}).get("id")),
|
|
2347
|
+
"mtime": os.path.getmtime(path),
|
|
2348
|
+
})
|
|
2349
|
+
if out:
|
|
2350
|
+
break
|
|
2351
|
+
return out
|
|
2352
|
+
|
|
2353
|
+
|
|
2354
|
+
def _report_room_of(path):
|
|
2355
|
+
"""读一份仿真报告的房间归属(无则空串)。"""
|
|
2356
|
+
rep = _read_json(path)
|
|
2357
|
+
if not isinstance(rep, dict):
|
|
2358
|
+
return ""
|
|
2359
|
+
return str(rep.get("room") or "").strip()
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def run_attribution_for_room(run_id, room, report_path=None):
|
|
2363
|
+
"""【Bug④ 修复】校验 run_id 是否真的属于 room。
|
|
2364
|
+
|
|
2365
|
+
为什么必须校验:实测 房间 domain/physics 凭据的 run_id 引用的是
|
|
2366
|
+
【主代理历史仿真】的 run(opt_iter_3,10 月 11 日遗留),而 run_id 在
|
|
2367
|
+
磁盘上【不唯一】(opt_iter_1 有 7 份,opt_iter_3 有 31 份,分属
|
|
2368
|
+
结构件/壳体机架/传动机构…),所以"run_id 存在"完全无法证明
|
|
2369
|
+
"它是这个房间的这次运行"。凭据于是借到别人的 FEA 结论 →
|
|
2370
|
+
报出与本房零件无关的虚假 CRITICAL(TR-001 轴径 1.5 / TR-005 变形 45mm)。
|
|
2371
|
+
现在按【房间归属】校验,且 fail-closed:
|
|
2372
|
+
① 有 report_path 且文件存在 → 报告必须带 room 且 room == 本房,
|
|
2373
|
+
报告 run_id(若有)必须与凭据一致;
|
|
2374
|
+
② 否则在受管 output/physics_runs 下按 run_id 找报告,必须存在
|
|
2375
|
+
【属于本房】的至少一份;
|
|
2376
|
+
③ 都找不到 / 只找到别的房间 → 判不可信并给出明确原因。
|
|
2377
|
+
|
|
2378
|
+
Returns: (ok: bool, why: str, evidence: dict)
|
|
2379
|
+
"""
|
|
2380
|
+
rid = str(run_id or "").strip()
|
|
2381
|
+
rm = str(room or "").strip()
|
|
2382
|
+
rp = str(report_path or "").strip()
|
|
2383
|
+
if not rid:
|
|
2384
|
+
return False, ("凭据未绑定 run_id —— 无法证明物理数据属于本次、本房间"
|
|
2385
|
+
"(fail-closed;请用本房本次运行重新签发)"), {}
|
|
2386
|
+
if not rm:
|
|
2387
|
+
return False, "未提供房间名,无法校验 run_id 归属", {}
|
|
2388
|
+
# ① 凭据自带 report_path:最强证据
|
|
2389
|
+
if rp and os.path.isfile(rp):
|
|
2390
|
+
rep = _read_json(rp)
|
|
2391
|
+
if isinstance(rep, dict):
|
|
2392
|
+
_rroom = str(rep.get("room") or "").strip()
|
|
2393
|
+
_rrun = str(rep.get("run_id") or "").strip()
|
|
2394
|
+
if _rrun and _rrun != rid:
|
|
2395
|
+
return False, ("report_path 报告的 run_id=%r 与凭据 run_id=%r 不一致"
|
|
2396
|
+
"(凭据被改写或指向了别的运行)" % (_rrun, rid)), {}
|
|
2397
|
+
if not _rroom:
|
|
2398
|
+
return False, ("report_path 报告未记录房间归属,无法证明属于本房 %r"
|
|
2399
|
+
"(Bug④ fail-closed)" % rm), {}
|
|
2400
|
+
if _rroom != rm:
|
|
2401
|
+
return False, ("【跨房间污染】凭据 run_id=%r 引用的报告属于房间 %r,"
|
|
2402
|
+
"与本房 %r 不一致(Bug④)" % (rid, _rroom, rm)), {}
|
|
2403
|
+
return True, "report_path 报告房间/run_id 与本房一致", {"report_path": rp}
|
|
2404
|
+
# ② 回退:在受管根下按 run_id 找报告,核对 room
|
|
2405
|
+
hits = [r for r in _real_physics_runs()
|
|
2406
|
+
if str(r.get("run_id") or "") == rid]
|
|
2407
|
+
if not hits:
|
|
2408
|
+
return False, ("凭据声明的 run_id=%r 在受管 output/physics_runs 下找不到"
|
|
2409
|
+
"任何运行记录(run_id 不真实)" % rid), {}
|
|
2410
|
+
rooms, paths = set(), []
|
|
2411
|
+
for h in hits:
|
|
2412
|
+
_p = str(h.get("path") or "")
|
|
2413
|
+
_r = _report_room_of(_p)
|
|
2414
|
+
if _r:
|
|
2415
|
+
rooms.add(_r)
|
|
2416
|
+
paths.append(_p)
|
|
2417
|
+
if rm in rooms:
|
|
2418
|
+
return True, "受管根下存在属于本房的同 run_id 报告", {"report_path": paths[0]}
|
|
2419
|
+
if not rooms:
|
|
2420
|
+
return False, ("run_id=%r 的所有报告都未记录房间归属,无法证明属于本房 %r"
|
|
2421
|
+
"(Bug④ fail-closed)" % (rid, rm)), {}
|
|
2422
|
+
return False, ("【跨房间污染】run_id=%r 在磁盘上只属于房间 %s,"
|
|
2423
|
+
"与凭据房间 %r 不一致(Bug④)"
|
|
2424
|
+
% (rid, "/".join(sorted(rooms)[:4]), rm)), {"rooms": sorted(rooms)}
|
|
2425
|
+
|
|
2426
|
+
|
|
2427
|
+
def _attestation_is_backed_by_real_run(att):
|
|
2428
|
+
"""凭据是否【有真实运行记录背书】(防手写伪造)。
|
|
2429
|
+
|
|
2430
|
+
[!] 不能只按 run_id 匹配:实测 run_id 在磁盘上【不唯一】——
|
|
2431
|
+
opt_iter_1 同时存在 7 份报告,overall 分别为 FAIL/PASS/UNKNOWN。
|
|
2432
|
+
若只看 run_id,伪造者写一个常见的 run_id 即可借到别人的真实运行。
|
|
2433
|
+
因此判据按【强度递减】且要求【结论一致】:
|
|
2434
|
+
|
|
2435
|
+
① report_path 真实存在 且 其 overall 与凭据一致;
|
|
2436
|
+
② run_dir 是真实目录 且 其下有报告 且 报告 overall 与凭据一致;
|
|
2437
|
+
③ run_id 命中的真实报告里,存在与凭据 overall 一致的那一份;
|
|
2438
|
+
④ 以上都不成立 -> 无背书(判为疑似伪造)。
|
|
2439
|
+
"""
|
|
2440
|
+
_run = str(att.get("run_id") or "").strip()
|
|
2441
|
+
_dir = str(att.get("run_dir") or "").strip()
|
|
2442
|
+
_rp = str(att.get("report_path") or "").strip()
|
|
2443
|
+
_want = str(att.get("overall") or "").strip().upper()
|
|
2444
|
+
|
|
2445
|
+
# ── 【防伪造】运行记录必须位于【受管目录 output/physics_runs】之下 ────
|
|
2446
|
+
# 对抗测试 C1d:自己新建任意目录 + 一份 _report.json 即可"制造真实运行"。
|
|
2447
|
+
# 这里限定只接受受管根目录内的路径 —— 手工在别处造目录不再算数。
|
|
2448
|
+
def _under_managed_root(p):
|
|
2449
|
+
if not p:
|
|
2450
|
+
return False
|
|
2451
|
+
try:
|
|
2452
|
+
ap = os.path.abspath(p)
|
|
2453
|
+
except Exception:
|
|
2454
|
+
return False
|
|
2455
|
+
for root in _physics_run_roots():
|
|
2456
|
+
try:
|
|
2457
|
+
if os.path.commonpath([ap, root]) == root:
|
|
2458
|
+
return True
|
|
2459
|
+
except Exception:
|
|
2460
|
+
continue
|
|
2461
|
+
return False
|
|
2462
|
+
|
|
2463
|
+
_att_room = str(att.get("room") or "").strip()
|
|
2464
|
+
|
|
2465
|
+
def _report_matches(path):
|
|
2466
|
+
"""读一份报告:① overall 与凭据一致;② (若报告带房间)房间归属一致。
|
|
2467
|
+
|
|
2468
|
+
房间归属是防"借用/复制他人报告"的关键:报告由 physics_bridge 在运行
|
|
2469
|
+
时就写入 room,无法靠事后手写凭据伪造(对抗测试 C1c/C1d 的根因)。
|
|
2470
|
+
"""
|
|
2471
|
+
rep = _read_json(path)
|
|
2472
|
+
if not isinstance(rep, dict):
|
|
2473
|
+
return False
|
|
2474
|
+
_rep_room = str(rep.get("room") or "").strip()
|
|
2475
|
+
if _att_room:
|
|
2476
|
+
# 强制要求报告带房间归属:不带 room 的报告(含历史遗留报告)
|
|
2477
|
+
# 一律【不作为背书】—— 它们无法证明"属于这次、这个房间的运行",
|
|
2478
|
+
# 正是对抗测试 C1c/C1d 借用的对象。
|
|
2479
|
+
if not _rep_room:
|
|
2480
|
+
return False
|
|
2481
|
+
if _rep_room != _att_room:
|
|
2482
|
+
return False # 报告属于别的房间 → 不可借用
|
|
2483
|
+
# ── 报告必须"结构完整",不能是一份手写的两行 JSON ──────────────
|
|
2484
|
+
# 真实报告由 simulation_report.build_report() 产出,带固定字段:
|
|
2485
|
+
# schema_version / run_id / timestamp / load_case_summary /
|
|
2486
|
+
# geometry_gate / fea_result / acceptance_criteria /
|
|
2487
|
+
# overall_status / passed_gates / failed_gates / release_readiness
|
|
2488
|
+
# 只写 {"overall_status":"PASS","room":X} 的一行假报告会被这里挡下。
|
|
2489
|
+
_required = ("schema_version", "run_id", "timestamp", "fea_result",
|
|
2490
|
+
"geometry_gate", "acceptance_criteria", "overall_status")
|
|
2491
|
+
_missing = [k for k in _required if k not in rep]
|
|
2492
|
+
if _missing:
|
|
2493
|
+
return False
|
|
2494
|
+
_fea = rep.get("fea_result")
|
|
2495
|
+
if not isinstance(_fea, dict):
|
|
2496
|
+
return False
|
|
2497
|
+
# 真实 fea_result 至少带 gates(可能为空 dict)或 ok/error 之一
|
|
2498
|
+
if not (("gates" in _fea) or ("ok" in _fea) or ("error" in _fea)):
|
|
2499
|
+
return False
|
|
2500
|
+
# ── 【P3 修复】run_id 必须与报告相互印证 ──────────────────────────
|
|
2501
|
+
# 实测缺陷(子代理1):只带 report_path 时把 run_id 改成假值仍判
|
|
2502
|
+
# backed=true —— 背书检查没看 run_id。虽然签名能兜住(假 run_id
|
|
2503
|
+
# 会让 HMAC 失配),但背书本身不应只看单一字段。
|
|
2504
|
+
# 现在:凭据带了 run_id 时,要求报告里的 run_id 与之【一致】。
|
|
2505
|
+
if _run:
|
|
2506
|
+
_rep_run = str(rep.get("run_id") or "").strip()
|
|
2507
|
+
if _rep_run and _rep_run != _run:
|
|
2508
|
+
return False # 报告属于另一次运行 → 凭据 run_id 不真实
|
|
2509
|
+
got = str(rep.get("overall_status") or rep.get("overall") or "").strip().upper()
|
|
2510
|
+
if not _want:
|
|
2511
|
+
return True
|
|
2512
|
+
return got == _want
|
|
2513
|
+
|
|
2514
|
+
# (1) 最直接:凭据自带 report_path —— 且必须位于受管根目录内
|
|
2515
|
+
if _rp and not _under_managed_root(_rp):
|
|
2516
|
+
return False, ("report_path 不在受管的 output/physics_runs 目录内"
|
|
2517
|
+
"(疑似自建目录/伪造运行记录)")
|
|
2518
|
+
if _rp and os.path.exists(_rp):
|
|
2519
|
+
if _report_matches(_rp):
|
|
2520
|
+
return True, "report_path 真实存在且结论一致"
|
|
2521
|
+
return False, ("report_path 存在但结论与凭据不一致(凭据=%s)" % (_want or "?"))
|
|
2522
|
+
# (2) run_dir:唯一路径,最强背书
|
|
2523
|
+
if _dir and not _under_managed_root(_dir):
|
|
2524
|
+
return False, "run_dir 不在受管的 output/physics_runs 目录内(疑似自建/伪造)"
|
|
2525
|
+
if _dir and os.path.isdir(_dir):
|
|
2526
|
+
hits = glob.glob(os.path.join(_dir, "**", "*_report.json"), recursive=True)
|
|
2527
|
+
if any(_report_matches(h) for h in hits):
|
|
2528
|
+
return True, "run_dir 下存在结论一致的真实报告"
|
|
2529
|
+
if hits:
|
|
2530
|
+
return False, "run_dir 存在报告但结论与凭据不一致(疑似篡改 overall)"
|
|
2531
|
+
return False, "run_dir 下没有任何真实报告"
|
|
2532
|
+
# (3) run_id 单用【不作为背书】—— 它跨任务复用(opt_iter_1 有 7 份且状态各异),
|
|
2533
|
+
# 只凭 run_id 就能"借"到别人的真实运行,等于没防。仅作为诊断信息返回。
|
|
2534
|
+
#
|
|
2535
|
+
# ── 【P3 修复】run_id 必须与 run_dir 或 report_path【相互印证】──────────
|
|
2536
|
+
# 实测缺陷(子代理1):凭据只带 report_path 时,把 run_id 改成任意假值
|
|
2537
|
+
# 仍判 backed=true —— 因为该分支根本没看 run_id。
|
|
2538
|
+
# 虽然签名能兜住最终结论(假 run_id 会让 HMAC 失配),但背书检查
|
|
2539
|
+
# 本身不应"只看一个字段",否则一旦某条路径的签名校验被绕过,
|
|
2540
|
+
# 背书就成了空壳。现在:若凭据同时带 run_id 与 report_path/run_dir,
|
|
2541
|
+
# 则要求【报告里的 run_id 与凭据 run_id 一致】才算背书。
|
|
2542
|
+
runs = _real_physics_runs()
|
|
2543
|
+
if _run:
|
|
2544
|
+
_same = [r for r in runs if str(r.get("run_id") or "") == _run]
|
|
2545
|
+
if not _same:
|
|
2546
|
+
# run_id 在磁盘上找不到任何对应运行 → 明确判无背书
|
|
2547
|
+
return False, ("凭据声明的 run_id=%r 在受管 output/physics_runs 下"
|
|
2548
|
+
"找不到任何对应运行记录(run_id 不真实)" % _run)
|
|
2549
|
+
if _same and not (_rp or _dir):
|
|
2550
|
+
_dirs = ", ".join(sorted({os.path.dirname(str(r.get("path"))) for r in _same})[:2])
|
|
2551
|
+
return False, ("凭据只有 run_id=%r,而该 run_id 在磁盘上对应 %d 份不同运行"
|
|
2552
|
+
"(如 %s)—— 无法确认是哪一次,必须携带 run_dir 或 report_path"
|
|
2553
|
+
% (_run, len(_same), _dirs))
|
|
2554
|
+
return False, ("凭据未携带 run_dir / report_path(唯一运行路径),"
|
|
2555
|
+
"仅有 run_id 无法追到具体这次运行")
|
|
2556
|
+
|
|
2557
|
+
def check_physics_attestation(room):
|
|
2558
|
+
out = {"ok": False, "level": "BLOCK", "reasons": [], "room": str(room)}
|
|
2559
|
+
# ── 【Bug⑤ 修复】按【候选集合】找凭据(任一副本存在即视为存在)──────
|
|
2560
|
+
_cred_path, _tried = resolve_room_credential(room, ".physics.json")
|
|
2561
|
+
out["credential_path"] = _cred_path
|
|
2562
|
+
out["credential_searched"] = _tried
|
|
2563
|
+
att = _read_json(_cred_path) if _cred_path else None
|
|
2564
|
+
if not att:
|
|
2565
|
+
out["reasons"].append(
|
|
2566
|
+
"缺少物理校核凭据(reports/%s.physics.json)—— 该房间未做过 FEA/疲劳校核。"
|
|
2567
|
+
% str(room))
|
|
2568
|
+
# 真找不到时如实回报搜索过的目录(多为两份副本各一份,便于排障)
|
|
2569
|
+
out["searched_dirs"] = sorted({os.path.dirname(_p) for _p in _tried})
|
|
2570
|
+
out["hint"] = ("在房间内执行: python sw_bridge.py physics-optimize "
|
|
2571
|
+
"<load_case.json> --room %s" % str(room))
|
|
2572
|
+
return out
|
|
2573
|
+
# ── 【主防线】先验签 ──────────────────────────────────────────────
|
|
2574
|
+
_sig = credential_is_signed_and_valid(att)
|
|
2575
|
+
if not _sig["ok"]:
|
|
2576
|
+
out["reasons"].append("物理凭据未通过签名校验:%s" % _sig["reason"])
|
|
2577
|
+
out["hint"] = ("物理凭据必须由宿主签发(physics-optimize 会自动请求)。"
|
|
2578
|
+
"手工写入的 JSON 无法通过验签。")
|
|
2579
|
+
return out
|
|
2580
|
+
# ── 【防伪造】凭据的 room 必须就是被校验的房间(防跨房间借用)──
|
|
2581
|
+
_att_room = str(att.get("room") or "").strip()
|
|
2582
|
+
if _att_room and _att_room != str(room):
|
|
2583
|
+
out["reasons"].append(
|
|
2584
|
+
"物理凭据归属房间为 %r,与被校验房间 %r 不一致 —— 疑似挪用其他房间的凭据"
|
|
2585
|
+
% (_att_room, str(room)))
|
|
2586
|
+
# ── 【Bug④ 修复】room ↔ run_id 一致性(凭据 run_id 必须属于本房本次运行)──
|
|
2587
|
+
# 实测缺陷:房间凭据的 run_id 引用的是【主代理历史仿真】的 run(opt_iter_3),
|
|
2588
|
+
# 而 run_id 跨房间/跨任务复用,单看"存在"无法证明归属 → 借到别人的 FEA
|
|
2589
|
+
# 结论 → 报与本房零件无关的虚假 CRITICAL。
|
|
2590
|
+
_run_ok, _run_why, _run_ev = run_attribution_for_room(
|
|
2591
|
+
att.get("run_id"), str(room), att.get("report_path"))
|
|
2592
|
+
out["run_id_attributed_to_room"] = bool(_run_ok)
|
|
2593
|
+
out["run_id_attribution_note"] = _run_why
|
|
2594
|
+
if _run_ev:
|
|
2595
|
+
out["run_id_evidence"] = _run_ev
|
|
2596
|
+
if not _run_ok:
|
|
2597
|
+
out["reasons"].append(
|
|
2598
|
+
"物理凭据的 run_id 无法归属到本房间本次运行(%s)—— 凭据不可信(Bug④)"
|
|
2599
|
+
% _run_why)
|
|
2600
|
+
out["attestation"] = {
|
|
2601
|
+
"overall": att.get("overall"),
|
|
2602
|
+
"safety_factor": att.get("safety_factor"),
|
|
2603
|
+
"fatigue": att.get("fatigue_verdict"),
|
|
2604
|
+
"run_id": att.get("run_id"),
|
|
2605
|
+
"at": att.get("at"),
|
|
2606
|
+
}
|
|
2607
|
+
# ── 【防伪造】凭据必须有真实运行记录背书 ──────────────────────────
|
|
2608
|
+
_backed, _why = _attestation_is_backed_by_real_run(att)
|
|
2609
|
+
out["backed_by_real_run"] = _backed
|
|
2610
|
+
if not _backed:
|
|
2611
|
+
out["reasons"].append(
|
|
2612
|
+
"物理凭据缺少真实运行记录背书(%s)—— 凭据不可信,疑似手工伪造。" % _why)
|
|
2613
|
+
out["hint"] = ("请真实运行: python sw_bridge.py physics-optimize "
|
|
2614
|
+
"<load_case.json> --room %s" % str(room))
|
|
2615
|
+
# ══ 【A-68 修复】物理凭据必须绑定【当前这件零件】 ═══════════════════
|
|
2616
|
+
# 原凭据只有 room + run_id + report_path,【没有零件维度】,于是出现:
|
|
2617
|
+
# · 某房"通过"的凭据 run_id 来自另一个进程的另一次运行,
|
|
2618
|
+
# part_path=null / material=null,本房真正要校核的是另一件;
|
|
2619
|
+
# · 同一房间 2 小时内结论 2.39x → 2.82x → 1.45x,每次都"恰好更好"。
|
|
2620
|
+
# 也就是说防线②能证明"某个零件某次算过且过了",但无法回答
|
|
2621
|
+
# "这份结论属于本房这件零件吗"。
|
|
2622
|
+
# 现在按三级判定(fail-closed):
|
|
2623
|
+
# · 凭据带 part_sha256 → 与磁盘当前零件比对,不一致即 BLOCK;
|
|
2624
|
+
# · 凭据带 part_path 但不带 sha256 → 路径必须存在且与登记件一致;
|
|
2625
|
+
# · 两者都没有(老凭据)→ 显式标 not_bound,不阻断但留痕,
|
|
2626
|
+
# 并在 hints 里要求重跑(绝不静默当成"已绑定")。
|
|
2627
|
+
try:
|
|
2628
|
+
_pb = _physics_part_binding_check(att, str(room))
|
|
2629
|
+
out["part_binding"] = _pb
|
|
2630
|
+
if _pb.get("blocking"):
|
|
2631
|
+
out["reasons"].append(_pb["reason"])
|
|
2632
|
+
elif _pb.get("note"):
|
|
2633
|
+
out.setdefault("binding_notes", []).append(_pb["note"])
|
|
2634
|
+
except Exception as _e_pb:
|
|
2635
|
+
out["part_binding"] = {"evaluated": False, "error": repr(_e_pb)}
|
|
2636
|
+
_overall = str(att.get("overall") or "").upper()
|
|
2637
|
+
if _overall == "FAIL":
|
|
2638
|
+
_failed = att.get("failed_gates") or []
|
|
2639
|
+
out["reasons"].append(
|
|
2640
|
+
"物理校核判定为 FAIL(未通过闸口: %s)"
|
|
2641
|
+
% (", ".join(map(str, _failed)) or "见报告"))
|
|
2642
|
+
elif _overall not in ("PASS", "REVIEW"):
|
|
2643
|
+
out["reasons"].append("物理校核结论缺失或无法识别(overall=%r)" % att.get("overall"))
|
|
2644
|
+
# 疲劳闸口:明确 FAIL 也阻断
|
|
2645
|
+
if str(att.get("fatigue_verdict") or "").upper() == "FAIL":
|
|
2646
|
+
out["reasons"].append("疲劳/寿命校核未通过")
|
|
2647
|
+
if not out["reasons"]:
|
|
2648
|
+
out["ok"] = True
|
|
2649
|
+
out["level"] = "WARN" if _overall == "REVIEW" else "PASS"
|
|
2650
|
+
if _overall == "REVIEW":
|
|
2651
|
+
out["reasons"].append("物理校核为 REVIEW(有需复核项),已放行但留痕")
|
|
2652
|
+
return out
|
|
2653
|
+
|
|
2654
|
+
|
|
2655
|
+
def check_domain_attestation(room):
|
|
2656
|
+
out = {"ok": False, "level": "BLOCK", "reasons": [], "room": str(room)}
|
|
2657
|
+
# ── 【Bug⑤ 修复】按【候选集合】找凭据(任一副本存在即视为存在)──────
|
|
2658
|
+
_cred_path, _tried = resolve_room_credential(room, ".domain.json")
|
|
2659
|
+
out["credential_path"] = _cred_path
|
|
2660
|
+
out["credential_searched"] = _tried
|
|
2661
|
+
att = _read_json(_cred_path) if _cred_path else None
|
|
2662
|
+
if not att:
|
|
2663
|
+
out["reasons"].append(
|
|
2664
|
+
"缺少领域校验凭据(reports/%s.domain.json)—— 未跑 DSVA/GB 规则校验。"
|
|
2665
|
+
% str(room))
|
|
2666
|
+
out["searched_dirs"] = sorted({os.path.dirname(_p) for _p in _tried})
|
|
2667
|
+
out["hint"] = ("在房间内执行: python sw_bridge.py physics-validate-domain "
|
|
2668
|
+
"<domain> --room %s" % str(room))
|
|
2669
|
+
return out
|
|
2670
|
+
# ── 【主防线】先验签 ──────────────────────────────────────────────
|
|
2671
|
+
_sig = credential_is_signed_and_valid(att)
|
|
2672
|
+
if not _sig["ok"]:
|
|
2673
|
+
out["reasons"].append("领域凭据未通过签名校验:%s" % _sig["reason"])
|
|
2674
|
+
out["hint"] = ("领域凭据必须由宿主签发(physics-validate-domain 会自动请求)。"
|
|
2675
|
+
"手工写入的 JSON 无法通过验签。")
|
|
2676
|
+
return out
|
|
2677
|
+
# ── 【FL-01 修复】领域凭据必须校验 room 归属(防跨房间挪用)────────────
|
|
2678
|
+
# 实测缺陷:对 room="CF房间A" 签发的 domain 凭据,复制到
|
|
2679
|
+
# reports/CF房间B.domain.json 后,校验【PASS】未被识别 ——
|
|
2680
|
+
# 因为本函数只校验 domain/rules_file/violations/score/passed/run_id,
|
|
2681
|
+
# 唯独漏了 att.room 与目标房间的一致性(physics 凭据有此校验,
|
|
2682
|
+
# domain 凭据缺,属单防线置信漏洞;虽有 physics 线兜底,仍应补齐)。
|
|
2683
|
+
_att_room = str(att.get("room") or "").strip()
|
|
2684
|
+
if _att_room and _att_room != str(room):
|
|
2685
|
+
out["reasons"].append(
|
|
2686
|
+
"领域凭据归属房间为 %r,与被校验房间 %r 不一致 —— "
|
|
2687
|
+
"疑似挪用其他房间的凭据(FL-01)" % (_att_room, str(room)))
|
|
2688
|
+
out["room_mismatch"] = True
|
|
2689
|
+
# ── 【防伪造】领域凭据必须指明 domain,且该 domain 的规则文件真实存在 ──
|
|
2690
|
+
_dom = str(att.get("domain") or "").strip()
|
|
2691
|
+
if not _dom:
|
|
2692
|
+
out["reasons"].append("领域凭据未声明 domain,无法确认校验基于哪套规则(疑似伪造)")
|
|
2693
|
+
else:
|
|
2694
|
+
_rules = os.path.join(_BASE_DIR, "physics", "rules", _dom + "_rules.json")
|
|
2695
|
+
if not os.path.exists(_rules):
|
|
2696
|
+
out["reasons"].append(
|
|
2697
|
+
"领域凭据声明 domain=%r,但规则文件 %s 不存在(疑似伪造)"
|
|
2698
|
+
% (_dom, os.path.basename(_rules)))
|
|
2699
|
+
else:
|
|
2700
|
+
out["rules_file"] = _rules
|
|
2701
|
+
_v = att.get("violations")
|
|
2702
|
+
if not isinstance(_v, list):
|
|
2703
|
+
out["reasons"].append("violations 字段不是数组(凭据结构异常,疑似伪造/损坏)")
|
|
2704
|
+
_v = []
|
|
2705
|
+
# [防伪造] 只写 violations=[] 是无成本的 —— 必须能证明"真的跑过校验":
|
|
2706
|
+
# 要求凭据携带 score 或 passed 项数,且二者至少有一项存在。
|
|
2707
|
+
_score = att.get("score")
|
|
2708
|
+
_passed = att.get("passed")
|
|
2709
|
+
if _score is None and not isinstance(_passed, list):
|
|
2710
|
+
out["reasons"].append(
|
|
2711
|
+
"领域凭据既无 score 也无 passed 清单 —— 无法证明真的执行过 DSVA 校验"
|
|
2712
|
+
"(空 violations 不足以作为通过依据,疑似伪造)")
|
|
2713
|
+
# run_id/时间戳:领域校验同样要有可追溯标识
|
|
2714
|
+
if not str(att.get("run_id") or "").strip() and not str(att.get("checked_at") or "").strip():
|
|
2715
|
+
out["reasons"].append("领域凭据缺少 run_id/checked_at 之类的可追溯标识(疑似伪造)")
|
|
2716
|
+
# ── 【Bug④ 修复】领域凭据的 run_id 必须属于本房本次运行 ──────────────
|
|
2717
|
+
# 实测缺陷:传动与轮组房的 domain 凭据 run_id 引用主代理历史 run(opt_iter_3),
|
|
2718
|
+
# 规则于是在【别人的仿真数据】上跑出 TR-001/TR-005 两条虚假 CRITICAL,
|
|
2719
|
+
# 与本房零件(轴孔 Ø5)无关。domain 校验器原实现只验签/验 room/验
|
|
2720
|
+
# rules_file,从不校验 run_id 归属 —— 这里补上,且 fail-closed:
|
|
2721
|
+
# · 有 run_id → 必须能在受管根下找到【属于本房】的同 run_id 运行;
|
|
2722
|
+
# · 只有 checked_at 没有 run_id → 明确告警(没有仿真数据来源可校验),
|
|
2723
|
+
# 但不在此处硬拦(领域校验可基于人工参数,见 domain_validator)。
|
|
2724
|
+
_drun = str(att.get("run_id") or "").strip()
|
|
2725
|
+
if _drun:
|
|
2726
|
+
_d_ok, _d_why, _d_ev = run_attribution_for_room(
|
|
2727
|
+
_drun, str(room),
|
|
2728
|
+
((att.get("data_source") or {}) or {}).get("report_path")
|
|
2729
|
+
if isinstance(att.get("data_source"), dict) else None)
|
|
2730
|
+
out["run_id_attributed_to_room"] = bool(_d_ok)
|
|
2731
|
+
out["run_id_attribution_note"] = _d_why
|
|
2732
|
+
if _d_ev:
|
|
2733
|
+
out["run_id_evidence"] = _d_ev
|
|
2734
|
+
if not _d_ok:
|
|
2735
|
+
out["reasons"].append(
|
|
2736
|
+
"领域凭据的 run_id 无法归属到本房间本次运行(%s)—— "
|
|
2737
|
+
"该结论可能基于别的房间/历史运行的仿真数据(Bug④)" % _d_why)
|
|
2738
|
+
out["run_id_mismatch"] = True
|
|
2739
|
+
else:
|
|
2740
|
+
out["run_id_attributed_to_room"] = None
|
|
2741
|
+
out["warnings_for_operator"] = list(out.get("warnings_for_operator") or []) + [
|
|
2742
|
+
"领域凭据只有 checked_at 没有 run_id:无法核对仿真数据是否属于本房本次运行"]
|
|
2743
|
+
out["attestation"] = {
|
|
2744
|
+
"domain": att.get("domain"),
|
|
2745
|
+
"score": att.get("score"),
|
|
2746
|
+
"violations": len(_v),
|
|
2747
|
+
"at": att.get("at"),
|
|
2748
|
+
}
|
|
2749
|
+
# ══ 【A-94 修复】违规不能"只数条数",必须读 message/severity ═══════════
|
|
2750
|
+
# 原缺陷:只要 violations 数组非空就一律当成 CRITICAL,并且只回报 rule_id。
|
|
2751
|
+
# 两个方向的错都在:
|
|
2752
|
+
# · 误拦:WARNING 级(甚至 INFO 级)的提示被当成 CRITICAL,房间永远下不了线,
|
|
2753
|
+
# 最后只能 --force,防线②③ 形同虚设;
|
|
2754
|
+
# · 漏拦:凭据里 violations 条目的 severity 写错/缺失时,
|
|
2755
|
+
# 判定完全不看内容,只要"数组空"就 PASS —— 而内容恰恰是唯一证据。
|
|
2756
|
+
# 现在:逐条读 severity/level 与 message;按严重度分级计数:
|
|
2757
|
+
# · 任一条为 CRITICAL(或无法判定严重度)→ 阻断(对未知严重度 fail-closed);
|
|
2758
|
+
# · 只有 WARNING/INFO → 不阻断,但把"具体是哪条、说了什么"回传,
|
|
2759
|
+
# 并要求人工在报告里回应,而不是静默丢弃。
|
|
2760
|
+
_crit, _warn, _info, _unknown = [], [], [], []
|
|
2761
|
+
for _x in _v:
|
|
2762
|
+
if not isinstance(_x, dict):
|
|
2763
|
+
_unknown.append({"rule_id": None, "message": repr(_x)})
|
|
2764
|
+
continue
|
|
2765
|
+
_sev = str(_x.get("severity") or _x.get("level") or "").strip().upper()
|
|
2766
|
+
_item = {"rule_id": _x.get("rule_id"),
|
|
2767
|
+
"severity": _sev or None,
|
|
2768
|
+
"message": str(_x.get("message") or _x.get("msg") or "").strip()[:300],
|
|
2769
|
+
"value": _x.get("value"), "limit": _x.get("limit")}
|
|
2770
|
+
if _sev in ("CRITICAL", "ERROR", "FATAL", "BLOCK"):
|
|
2771
|
+
_crit.append(_item)
|
|
2772
|
+
elif _sev in ("WARNING", "WARN", "MAJOR"):
|
|
2773
|
+
_warn.append(_item)
|
|
2774
|
+
elif _sev in ("INFO", "NOTE", "MINOR", "HINT"):
|
|
2775
|
+
_info.append(_item)
|
|
2776
|
+
else:
|
|
2777
|
+
# 严重度缺失/无法识别 → 不能当"小事"放过(fail-closed)
|
|
2778
|
+
_unknown.append(_item)
|
|
2779
|
+
out["violation_breakdown"] = {
|
|
2780
|
+
"critical": len(_crit), "warning": len(_warn),
|
|
2781
|
+
"info": len(_info), "unknown_severity": len(_unknown)}
|
|
2782
|
+
if _warn or _info:
|
|
2783
|
+
# 非阻断项照样如实回传内容(供报告与人工复核),绝不静默丢弃
|
|
2784
|
+
out["non_blocking_violations"] = (_warn + _info)[:20]
|
|
2785
|
+
if _unknown:
|
|
2786
|
+
out["reasons"].append(
|
|
2787
|
+
"存在 %d 条【无法判定严重度】的领域违规(缺少 severity/level 字段)—— "
|
|
2788
|
+
"按 fail-closed 视为阻断项,请让 domain_validator 重新产出凭据。"
|
|
2789
|
+
% len(_unknown))
|
|
2790
|
+
if _crit:
|
|
2791
|
+
_desc = "; ".join(
|
|
2792
|
+
"%s%s" % (x.get("rule_id") or "?",
|
|
2793
|
+
(": " + x["message"]) if x.get("message") else "")
|
|
2794
|
+
for x in _crit[:6])
|
|
2795
|
+
out["reasons"].append(
|
|
2796
|
+
"存在 %d 条 CRITICAL 领域违规: %s" % (len(_crit), _desc))
|
|
2797
|
+
out["attestation"]["violations_by_severity"] = out["violation_breakdown"]
|
|
2798
|
+
if not out["reasons"]:
|
|
2799
|
+
out["ok"] = True
|
|
2800
|
+
out["level"] = "PASS"
|
|
2801
|
+
return out
|
|
2802
|
+
|
|
2803
|
+
|
|
2804
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
2805
|
+
# 汇总:房间 / 任务
|
|
2806
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
2807
|
+
|
|
2808
|
+
def _registered_artifacts(room):
|
|
2809
|
+
"""只读当前任务的归属台账;历史任务不能追加本房间的零件。"""
|
|
2810
|
+
reg = _read_json(artifact_registry_file()) or {}
|
|
2811
|
+
rec = reg.get(str(room))
|
|
2812
|
+
if not isinstance(rec, list):
|
|
2813
|
+
# 键名兜底:词干化(中文房间名在部分写入侧被 _safe_room_stem 处理过)
|
|
2814
|
+
_stem = _safe_room_stem(room)
|
|
2815
|
+
if isinstance(reg.get(_stem), list):
|
|
2816
|
+
rec = reg.get(_stem)
|
|
2817
|
+
out = []
|
|
2818
|
+
if not isinstance(rec, list):
|
|
2819
|
+
return out
|
|
2820
|
+
_seen = set()
|
|
2821
|
+
for it in rec:
|
|
2822
|
+
if not isinstance(it, dict):
|
|
2823
|
+
continue
|
|
2824
|
+
p = it.get("path")
|
|
2825
|
+
if not p:
|
|
2826
|
+
continue
|
|
2827
|
+
_key = os.path.normcase(os.path.abspath(str(p)))
|
|
2828
|
+
if _key in _seen:
|
|
2829
|
+
continue
|
|
2830
|
+
_seen.add(_key)
|
|
2831
|
+
out.append({"path": p, "name": it.get("name"),
|
|
2832
|
+
"exists": os.path.exists(p)})
|
|
2833
|
+
return out
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
def _modeling_parts(room):
|
|
2837
|
+
"""本房间登记的【零件】产物(.sldprt),排除装配体/图纸。"""
|
|
2838
|
+
out = []
|
|
2839
|
+
for a in _registered_artifacts(room):
|
|
2840
|
+
_p = str(a.get("path") or "")
|
|
2841
|
+
if _p.lower().endswith(".sldprt") and a.get("exists"):
|
|
2842
|
+
out.append(a)
|
|
2843
|
+
return out
|
|
2844
|
+
|
|
2845
|
+
|
|
2846
|
+
def _room_types():
|
|
2847
|
+
"""从 workflow_state.subagent_config.rooms 取 房间名 → 类型 映射。"""
|
|
2848
|
+
st = _read_json(os.path.join(STATE_DIR, "workflow_state.json")) or {}
|
|
2849
|
+
cfg = st.get("subagent_config") or {}
|
|
2850
|
+
out = {}
|
|
2851
|
+
for row in (cfg.get("rooms") or []):
|
|
2852
|
+
if isinstance(row, (list, tuple)) and len(row) >= 2:
|
|
2853
|
+
out[str(row[0])] = str(row[1])
|
|
2854
|
+
elif isinstance(row, dict):
|
|
2855
|
+
_n = row.get("name") or row.get("room")
|
|
2856
|
+
if _n:
|
|
2857
|
+
out[str(_n)] = str(row.get("type") or "")
|
|
2858
|
+
return out
|
|
2859
|
+
|
|
2860
|
+
|
|
2861
|
+
def _host_judge_missing_credentials(hj):
|
|
2862
|
+
"""宿主判定里,哪些防线被判为【凭据缺失(missing)】。
|
|
2863
|
+
|
|
2864
|
+
只认 reason_code/失败标记为 missing 的那些;其余一律不认
|
|
2865
|
+
(验签失败、结论 FAIL、材料缺失等都必须保留宿主结论,不得转本地)。
|
|
2866
|
+
Returns: 缺失的防线 kind 列表,如 ["physics", "domain"]
|
|
2867
|
+
"""
|
|
2868
|
+
out = []
|
|
2869
|
+
_det = (hj or {}).get("details") or {}
|
|
2870
|
+
for _kind in ("physics", "domain"):
|
|
2871
|
+
_d = _det.get(_kind)
|
|
2872
|
+
if not isinstance(_d, dict):
|
|
2873
|
+
continue
|
|
2874
|
+
if str(_d.get("failure") or "").strip() == "missing":
|
|
2875
|
+
out.append(_kind)
|
|
2876
|
+
elif _d.get("verified") is False and "不存在" in str(_d.get("reason") or ""):
|
|
2877
|
+
out.append(_kind)
|
|
2878
|
+
return out
|
|
2879
|
+
|
|
2880
|
+
|
|
2881
|
+
def _host_missing_but_local_has_credentials(room, hj):
|
|
2882
|
+
"""【Bug⑤ 修复】宿主判 missing、但本地候选确实找到凭据 → 转本地验签。
|
|
2883
|
+
|
|
2884
|
+
判据(任一不满足即【不转】,保持宿主结论 = fail-closed):
|
|
2885
|
+
① 宿主给出的 blockers 全部来自"凭据缺失"(没有别的实质阻断,
|
|
2886
|
+
例如结论 FAIL / 验签失败 / 材料缺失 —— 那些必须保留宿主判定);
|
|
2887
|
+
② 对每一个判 missing 的防线,本地【候选集合】里确实存在该房间的
|
|
2888
|
+
凭据文件(不是"猜"存在,而是 os.path.isfile 命中)。
|
|
2889
|
+
|
|
2890
|
+
Returns: {convert: bool, note: str, kinds: [...], local_paths: {...}}
|
|
2891
|
+
"""
|
|
2892
|
+
_out = {"convert": False, "note": "", "kinds": [], "local_paths": {}}
|
|
2893
|
+
_kinds = _host_judge_missing_credentials(hj)
|
|
2894
|
+
if not _kinds:
|
|
2895
|
+
_out["note"] = "宿主未判'凭据缺失',保留宿主判定"
|
|
2896
|
+
return _out
|
|
2897
|
+
# ① 宿主 blockers 必须【只】由"凭据缺失"构成
|
|
2898
|
+
_blk = [str(b) for b in ((hj or {}).get("blockers") or [])]
|
|
2899
|
+
_miss_prefix = {"physics": "【防线②物理】未做", "domain": "【防线③领域】未做"}
|
|
2900
|
+
_others = [b for b in _blk
|
|
2901
|
+
if not any(b.startswith(_miss_prefix[k]) for k in _kinds)]
|
|
2902
|
+
if _others:
|
|
2903
|
+
_out["note"] = ("宿主判定含非'凭据缺失'的阻断(%s),不得转本地判定"
|
|
2904
|
+
% ("; ".join(_others)[:200]))
|
|
2905
|
+
return _out
|
|
2906
|
+
# ② 每一个 missing 的防线,本地候选都必须真的找到凭据文件
|
|
2907
|
+
_suffix = {"physics": ".physics.json", "domain": ".domain.json"}
|
|
2908
|
+
_found = {}
|
|
2909
|
+
for k in _kinds:
|
|
2910
|
+
fp, _tried = resolve_room_credential(room, _suffix[k])
|
|
2911
|
+
if not fp:
|
|
2912
|
+
_out["note"] = ("宿主判 %s 凭据缺失,且本地候选集合也未找到(已搜索 %d 处)"
|
|
2913
|
+
"—— 维持 fail-closed 拒绝" % (k, len(_tried)))
|
|
2914
|
+
return _out
|
|
2915
|
+
_found[k] = fp
|
|
2916
|
+
# ③ 宿主 blockers 必须不含【不止 missing】的东西 —— 已由 ① 保证
|
|
2917
|
+
_out["convert"] = True
|
|
2918
|
+
_out["kinds"] = list(_kinds)
|
|
2919
|
+
_out["local_paths"] = _found
|
|
2920
|
+
_hdir = str((hj or {}).get("host_reports_dir") or "").strip() or host_reports_dir()
|
|
2921
|
+
_out["note"] = (
|
|
2922
|
+
"【Bug⑤】宿主判定用的是 %s(未找到凭据),但本地候选集合在 %s 找到同名凭据 "
|
|
2923
|
+
"—— 已转本地验签(签名校验力度不变)。若仍被拒,请核对两份副本/任务子目录是否分裂。"
|
|
2924
|
+
% (_hdir or "未知目录",
|
|
2925
|
+
"; ".join(sorted({os.path.dirname(v) for v in _found.values()}))))
|
|
2926
|
+
return _out
|
|
2927
|
+
|
|
2928
|
+
|
|
2929
|
+
def check_room_defense(room, require_physics=None):
|
|
2930
|
+
"""校验一个房间的三大防线。
|
|
2931
|
+
|
|
2932
|
+
require_physics: None = 按房间类型自动判定(建模类房间强制);
|
|
2933
|
+
True/False = 显式覆盖。
|
|
2934
|
+
|
|
2935
|
+
Returns: {ok, room, blockers[], warnings[], details{}}
|
|
2936
|
+
"""
|
|
2937
|
+
room = str(room)
|
|
2938
|
+
rtypes = _room_types()
|
|
2939
|
+
rtype = rtypes.get(room, "")
|
|
2940
|
+
requirements = delivery_requirements(room=room)
|
|
2941
|
+
if require_physics is None:
|
|
2942
|
+
# ── 【Bug⑥ 修复】按【归一化后的类型】判定是否强制物理校核 ──────────
|
|
2943
|
+
# 原实现只看 MODELING_ROOM_TYPES 白名单:drivetrain / sensor 这类
|
|
2944
|
+
# 历史别名不在表里 → require_physics=False → 含零件房间被静默豁免
|
|
2945
|
+
# (实测传动房 2 项 CRITICAL、传感器房从未校核,全部放行)。
|
|
2946
|
+
# 现在统一走 room_type_requires_physics():先归一化别名,再只对
|
|
2947
|
+
# 纯出图/协调类(drafting/assembly/prime)豁免,其余含零件类型一律强制。
|
|
2948
|
+
require_physics = (requirements["physics"]
|
|
2949
|
+
and (room_type_requires_physics(rtype)
|
|
2950
|
+
or requirements.get("room_physics")))
|
|
2951
|
+
require_material = requirements["material"] or bool(require_physics)
|
|
2952
|
+
|
|
2953
|
+
details = {}
|
|
2954
|
+
blockers = []
|
|
2955
|
+
warnings = []
|
|
2956
|
+
|
|
2957
|
+
# ── 【C 方案】宿主优先判定 ────────────────────────────────────────────
|
|
2958
|
+
# 为什么:Python 侧信任的是"某个 HTTP 端点",该端点可被同用户进程冒充
|
|
2959
|
+
# (对抗测试实证:伪造 runtime.json 指向恒真服务即可整体绕过)。
|
|
2960
|
+
# 把判定权收回宿主后,AI 在 Python 侧不再有可冒充的判定点。
|
|
2961
|
+
# 仅在"宿主可用且端口归属校验通过"时走此路径;否则回落到本地判定
|
|
2962
|
+
# 并明确标注降级原因(fail-closed 语义不变:本地判定对签名同样严格)。
|
|
2963
|
+
_host_note = None
|
|
2964
|
+
# ── 【MA-04 / FL-03 修复】降级必须带【结构化原因】───────────────────
|
|
2965
|
+
# 实测缺陷:runtime.json 被指向 9999 死端口时,校验确实 fail-closed 回落
|
|
2966
|
+
# 本地判定(judged_by="local"),但 degraded_note 为空字符串 ——
|
|
2967
|
+
# 排障时看不出"为什么没走宿主判定",只能靠猜。
|
|
2968
|
+
# 现在同时给出:
|
|
2969
|
+
# degraded_reason_code —— 机器可判定的枚举
|
|
2970
|
+
# degraded_note —— 人类可读原因(报告明确点名这个字段)
|
|
2971
|
+
_degrade_code = None
|
|
2972
|
+
try:
|
|
2973
|
+
_rt = _read_runtime()
|
|
2974
|
+
if not (require_material or require_physics):
|
|
2975
|
+
# 几何交付不请求强制三防线总判定;显式材料仍在下方逐件验签。
|
|
2976
|
+
pass
|
|
2977
|
+
elif _rt.get("port"):
|
|
2978
|
+
_own = _port_owner_check(_rt.get("pid"), _rt.get("port"))
|
|
2979
|
+
if _own.get("ok"):
|
|
2980
|
+
_hj = host_judge_room(room, require_physics=bool(require_physics))
|
|
2981
|
+
if isinstance(_hj, dict) and "blockers" in _hj:
|
|
2982
|
+
_hj.setdefault("room", room)
|
|
2983
|
+
_hj.setdefault("room_type", rtype if "rtype" in dir() else None)
|
|
2984
|
+
_hj["judged_by"] = "host"
|
|
2985
|
+
# ── 【Bug⑤ 修复】宿主报 missing、本地候选却有凭据 → 转本地验签 ──
|
|
2986
|
+
# 原实现无条件 return 宿主结论,于是"凭据写在工作区/任务子目录、
|
|
2987
|
+
# 宿主只读安装副本 flat reports/" 的场景下,凭据【存在即失效】,
|
|
2988
|
+
# room-end 被永久拒绝(Bug⑤ 致命项)。
|
|
2989
|
+
# 现在:仅在【宿主全部阻断都是'凭据缺失'】且【本地候选集合里
|
|
2990
|
+
# 确实 os.path.isfile 命中】时才转本地验签;其余情况一律保留
|
|
2991
|
+
# 宿主结论 —— 也就是 fail-closed 语义没有放松,验签仍由宿主
|
|
2992
|
+
# verify-gate 完成(本地判定不改任何签名强度)。
|
|
2993
|
+
_conv = _host_missing_but_local_has_credentials(room, _hj)
|
|
2994
|
+
if not _conv.get("convert"):
|
|
2995
|
+
# ══ 【A-21 修复】宿主判定【不覆盖】材料防线 ═══════════
|
|
2996
|
+
# 原实现拿到宿主结论后【直接 return】,下面整段本地校验
|
|
2997
|
+
# (含 ① 材料 check_material_attestation)永不执行。
|
|
2998
|
+
# 而宿主 checkRoomMaterials 依赖 artifacts_registry.json
|
|
2999
|
+
# 的【裸名】文件;A-22 修复前写端只写带 task_id 后缀的
|
|
3000
|
+
# 那份 → 宿主恒得 part_count=0 → 材料维度【完全不存在】
|
|
3001
|
+
# → 即便 ok=true 也不代表材料过了(A-63 铁证:
|
|
3002
|
+
# ok=true 时磁盘 3/6 件 md5 已不符)。
|
|
3003
|
+
# 现在:宿主结论【只覆盖它真正判定过的维度】——
|
|
3004
|
+
# physics / domain 以宿主为准;材料维度由本地补齐
|
|
3005
|
+
# (本地 check_material_attestation 同样严格验签,
|
|
3006
|
+
# 不降低强度,只是把"没人判"变成"有人判")。
|
|
3007
|
+
_res = {
|
|
3008
|
+
"ok": bool(_hj.get("ok")),
|
|
3009
|
+
"room": room,
|
|
3010
|
+
"room_type": (rtypes.get(room, "") or None),
|
|
3011
|
+
"require_physics": bool(require_physics),
|
|
3012
|
+
"blockers": list(_hj.get("blockers") or []),
|
|
3013
|
+
"warnings": list(_hj.get("warnings") or []),
|
|
3014
|
+
"details": dict(_hj.get("details") or {}),
|
|
3015
|
+
"judged_by": "host",
|
|
3016
|
+
}
|
|
3017
|
+
# 排障字段(Bug⑤ 要求):宿主实际读的 reports 目录
|
|
3018
|
+
_res["host_reports_dir"] = _hj.get("host_reports_dir") or host_reports_dir()
|
|
3019
|
+
_res["reports_dir_local_candidates"] = (
|
|
3020
|
+
_hj.get("reports_dir_local_candidates")
|
|
3021
|
+
or reports_dir_candidates())
|
|
3022
|
+
if _conv.get("note"):
|
|
3023
|
+
_res["host_judge_note"] = _conv["note"]
|
|
3024
|
+
# ── 【A-21】宿主结论补齐【本地材料防线】──
|
|
3025
|
+
try:
|
|
3026
|
+
_hk = (_res.get("details") or {}).get("material")
|
|
3027
|
+
_host_judged_material = bool(
|
|
3028
|
+
isinstance(_hk, dict)
|
|
3029
|
+
and _hk.get("judged_by") == "host"
|
|
3030
|
+
and _hk.get("results"))
|
|
3031
|
+
if not _host_judged_material:
|
|
3032
|
+
_lp = _modeling_parts(room)
|
|
3033
|
+
_lm = []
|
|
3034
|
+
for _a in _lp:
|
|
3035
|
+
_lr = check_material_attestation(_a["path"], required=require_material)
|
|
3036
|
+
_lr["part_name"] = (_a.get("name")
|
|
3037
|
+
or os.path.basename(_a["path"]))
|
|
3038
|
+
_lm.append(_lr)
|
|
3039
|
+
if not _lr["ok"]:
|
|
3040
|
+
_res["blockers"].append(
|
|
3041
|
+
"【防线①材料】%s: %s"
|
|
3042
|
+
% (_lr["part_name"],
|
|
3043
|
+
";".join(_lr["reasons"])))
|
|
3044
|
+
_res["ok"] = False
|
|
3045
|
+
elif _lr.get("level") == "WARN":
|
|
3046
|
+
_res["warnings"].append(
|
|
3047
|
+
"【防线①材料】%s: %s"
|
|
3048
|
+
% (_lr["part_name"],
|
|
3049
|
+
";".join(_lr["reasons"])))
|
|
3050
|
+
_needs_parts2 = True
|
|
3051
|
+
try:
|
|
3052
|
+
_needs_parts2 = room_type_requires_physics(rtype)
|
|
3053
|
+
except Exception:
|
|
3054
|
+
_needs_parts2 = True
|
|
3055
|
+
if not _lp and _needs_parts2:
|
|
3056
|
+
_res["blockers"].append(
|
|
3057
|
+
"【防线①材料】零件登记数为 0 —— 该房间没有"
|
|
3058
|
+
"任何已登记零件,无法执行材料校验"
|
|
3059
|
+
"(这不是'通过',是'未验证')。"
|
|
3060
|
+
"房间类型=%s。请先 room-artifact 登记,"
|
|
3061
|
+
"或把非建模房间的类型改为 drafting/assembly/prime。"
|
|
3062
|
+
% (rtype or "未知(按建模类处理)"))
|
|
3063
|
+
_res["ok"] = False
|
|
3064
|
+
_res.setdefault("details", {})["material"] = {
|
|
3065
|
+
"part_count": len(_lp),
|
|
3066
|
+
"results": _lm,
|
|
3067
|
+
"judged_by": "local(host 未判定该维度,已由本地补齐)",
|
|
3068
|
+
}
|
|
3069
|
+
except Exception as _e_lm:
|
|
3070
|
+
# 本地补判自身异常 → fail-closed,不能当成通过
|
|
3071
|
+
_res["blockers"].append(
|
|
3072
|
+
"【防线①材料】本地材料校验异常,按未通过处理: %r"
|
|
3073
|
+
% (_e_lm,))
|
|
3074
|
+
_res["ok"] = False
|
|
3075
|
+
_mat = (_res.get("details", {}).get("material") or {}).get("results") or []
|
|
3076
|
+
_waiting = next((r for r in _mat if r.get("requires_user_action")), {})
|
|
3077
|
+
_res.update(requires_user_action=bool(_waiting), retryable=False,
|
|
3078
|
+
next_action=_waiting.get("next_action"))
|
|
3079
|
+
return _res
|
|
3080
|
+
# 转到本地验签:把宿主 missing 的成因与本地命中的路径都留痕
|
|
3081
|
+
_degrade_code = "HOST_MISSING_LOCAL_CREDENTIAL"
|
|
3082
|
+
_host_note = _conv.get("note")
|
|
3083
|
+
details["host_judge"] = {
|
|
3084
|
+
"judged_by": "host",
|
|
3085
|
+
"blockers": _hj.get("blockers") or [],
|
|
3086
|
+
"host_reports_dir": _hj.get("host_reports_dir") or host_reports_dir(),
|
|
3087
|
+
"converted_to_local": True,
|
|
3088
|
+
"converted_kinds": _conv.get("kinds") or [],
|
|
3089
|
+
"local_credential_paths": _conv.get("local_paths") or {},
|
|
3090
|
+
"reason": _conv.get("note"),
|
|
3091
|
+
# 宿主的 warnings 不丢:转本地判定后仍须留痕
|
|
3092
|
+
"host_warnings": list(_hj.get("warnings") or []),
|
|
3093
|
+
}
|
|
3094
|
+
else:
|
|
3095
|
+
_degrade_code = "HOST_JUDGE_NO_RESPONSE"
|
|
3096
|
+
_host_note = ("宿主判定端点无响应(可能端口不通/宿主重启中),"
|
|
3097
|
+
"已回落本地判定;本地判定对签名同样严格(fail-closed)")
|
|
3098
|
+
else:
|
|
3099
|
+
_degrade_code = "PORT_OWNER_MISMATCH"
|
|
3100
|
+
_host_note = ("宿主端口归属校验未通过(%s),已回落本地判定 —— "
|
|
3101
|
+
"常见原因:runtime.json 指向的不是 DSH 主进程"
|
|
3102
|
+
"(被改写/残留旧文件)" % _own.get("reason"))
|
|
3103
|
+
else:
|
|
3104
|
+
_degrade_code = "SIGNER_NOT_ASSEMBLED"
|
|
3105
|
+
_host_note = ("签名服务未装配(runtime.json 缺失或无私端口)—— "
|
|
3106
|
+
"请重启 DSH 使签名端点生效;"
|
|
3107
|
+
"期间防线以 fail-closed 方式回落本地判定")
|
|
3108
|
+
except Exception as _e_host:
|
|
3109
|
+
_host_note = "宿主判定异常(%r),已回落本地判定" % (_e_host,)
|
|
3110
|
+
|
|
3111
|
+
# ── ① 材料:本房间所有零件 ──
|
|
3112
|
+
parts = _modeling_parts(room)
|
|
3113
|
+
mat_results = []
|
|
3114
|
+
if parts:
|
|
3115
|
+
for a in parts:
|
|
3116
|
+
r = check_material_attestation(a["path"], required=require_material)
|
|
3117
|
+
r["part_name"] = a.get("name") or os.path.basename(a["path"])
|
|
3118
|
+
mat_results.append(r)
|
|
3119
|
+
if not r["ok"]:
|
|
3120
|
+
blockers.append("【防线①材料】%s: %s"
|
|
3121
|
+
% (r["part_name"], ";".join(r["reasons"])))
|
|
3122
|
+
elif r["level"] == "WARN":
|
|
3123
|
+
warnings.append("【防线①材料】%s: %s"
|
|
3124
|
+
% (r["part_name"], ";".join(r["reasons"])))
|
|
3125
|
+
else:
|
|
3126
|
+
# ══ 【A-06 修复】parts 为空【不得】静默放行 ═══════════════════════
|
|
3127
|
+
# 原实现只有 if parts: ... else: mat_results = [] —— 没有 else 分支,
|
|
3128
|
+
# 于是 part_count=0 时【不产生任何 blocker】,check-room 返回 ok=true。
|
|
3129
|
+
# 实测(2026-11-01 任务 20261101_002006_024C510A):
|
|
3130
|
+
# · 加工头室 check-room → ok=true / blockers=[] / material.part_count=0,
|
|
3131
|
+
# 而同房手工复核 4/15 件实际 BLOCK —— 防线①【全程空转】;
|
|
3132
|
+
# · 三房间"34/34 材质全绿"全是模型手工复核,门禁一次都没真正跑过;
|
|
3133
|
+
# · room-start 的 fresh 归档把别的房间零件搬走后,part_count 变 0,
|
|
3134
|
+
# 看起来"更绿了"—— 丢件反而让门禁通过。
|
|
3135
|
+
# 正确语义(fail-closed):建模类房间【必须】有登记零件;
|
|
3136
|
+
# 登记表为空 = "无法验证" ≠ "验证通过"。
|
|
3137
|
+
# 豁免仅限本来就【不产出零件】的纯出图/协调类房间。
|
|
3138
|
+
_needs_parts = True
|
|
3139
|
+
try:
|
|
3140
|
+
_needs_parts = room_type_requires_physics(rtype)
|
|
3141
|
+
except Exception:
|
|
3142
|
+
_needs_parts = True
|
|
3143
|
+
if _needs_parts:
|
|
3144
|
+
blockers.append(
|
|
3145
|
+
"【防线①材料】零件登记数为 0 —— 该房间没有任何已登记零件,"
|
|
3146
|
+
"无法执行材料校验(这不是'通过',是'未验证')。"
|
|
3147
|
+
"房间类型=%s。请确认:① 建模脚本已 save 且落盘到 parts/;"
|
|
3148
|
+
"② 已调 mode_gate.py room-artifact <房间> <文件> 登记归属;"
|
|
3149
|
+
"③ 若确为非建模房间,请在编制里改为 drafting/assembly/prime 类型。"
|
|
3150
|
+
% (rtype or "未知(按建模类处理)"))
|
|
3151
|
+
details["material"] = {"part_count": len(parts), "results": mat_results}
|
|
3152
|
+
|
|
3153
|
+
# ── ② 物理 ──
|
|
3154
|
+
if require_physics:
|
|
3155
|
+
ph = check_physics_attestation(room)
|
|
3156
|
+
details["physics"] = {k: v for k, v in ph.items() if k != "hint"}
|
|
3157
|
+
if not ph["ok"]:
|
|
3158
|
+
blockers.append("【防线②物理】" + ";".join(ph["reasons"]))
|
|
3159
|
+
details["physics_hint"] = ph.get("hint")
|
|
3160
|
+
elif ph["level"] == "WARN":
|
|
3161
|
+
warnings.append("【防线②物理】" + ";".join(ph["reasons"]))
|
|
3162
|
+
# ── ③ 领域 ──
|
|
3163
|
+
dm = check_domain_attestation(room)
|
|
3164
|
+
details["domain"] = {k: v for k, v in dm.items() if k != "hint"}
|
|
3165
|
+
if not dm["ok"]:
|
|
3166
|
+
blockers.append("【防线③领域】" + ";".join(dm["reasons"]))
|
|
3167
|
+
details["domain_hint"] = dm.get("hint")
|
|
3168
|
+
else:
|
|
3169
|
+
details["physics"] = {"skipped": True, "evaluated": False, "reason":
|
|
3170
|
+
"本任务未要求物理校核,或房间类型 %r 不承担零件校核;未验证物理性能" % rtype}
|
|
3171
|
+
details["domain"] = {"skipped": True}
|
|
3172
|
+
|
|
3173
|
+
if _host_note:
|
|
3174
|
+
details["host_note"] = _host_note
|
|
3175
|
+
# ── 【MA-04 / FL-03 修复】降级原因必须显式可读 ──────────────────────
|
|
3176
|
+
# 报告点名 degraded_note 为空;这里补齐三段:
|
|
3177
|
+
# judged_by / degraded / degraded_reason_code / degraded_note
|
|
3178
|
+
# 让调用方一眼看出"本次是谁判的、为什么没用宿主判定"。
|
|
3179
|
+
_out_local = {
|
|
3180
|
+
"ok": not blockers, "room": room, "room_type": rtype or None,
|
|
3181
|
+
"require_physics": bool(require_physics),
|
|
3182
|
+
"requirements": requirements,
|
|
3183
|
+
"blockers": blockers, "warnings": warnings, "details": details,
|
|
3184
|
+
"judged_by": "local",
|
|
3185
|
+
"degraded": bool(_degrade_code),
|
|
3186
|
+
"requires_user_action": any(r.get("requires_user_action") for r in mat_results),
|
|
3187
|
+
"retryable": False,
|
|
3188
|
+
"next_action": next((r.get("next_action") for r in mat_results
|
|
3189
|
+
if r.get("requires_user_action")), None),
|
|
3190
|
+
}
|
|
3191
|
+
if _degrade_code:
|
|
3192
|
+
_out_local["degraded_reason_code"] = _degrade_code
|
|
3193
|
+
_out_local["degraded_note"] = _host_note or (
|
|
3194
|
+
"宿主判定不可用,已回落本地判定(原因代码 %s)" % _degrade_code)
|
|
3195
|
+
else:
|
|
3196
|
+
_out_local["degraded_note"] = ""
|
|
3197
|
+
return _out_local
|
|
3198
|
+
|
|
3199
|
+
|
|
3200
|
+
def _all_rooms():
|
|
3201
|
+
"""全部房间名(mode_state.rooms ∪ workflow_state.subagent_config.rooms)。"""
|
|
3202
|
+
names = set()
|
|
3203
|
+
ms = _read_json(os.path.join(STATE_DIR, "mode_state.json")) or {}
|
|
3204
|
+
for n in (ms.get("rooms") or {}):
|
|
3205
|
+
names.add(str(n))
|
|
3206
|
+
for n in _room_types():
|
|
3207
|
+
names.add(str(n))
|
|
3208
|
+
return sorted(names)
|
|
3209
|
+
|
|
3210
|
+
|
|
3211
|
+
def check_task_defense():
|
|
3212
|
+
"""全任务三防线汇总(交付前总门禁)。"""
|
|
3213
|
+
rooms = {}
|
|
3214
|
+
blockers = []
|
|
3215
|
+
warnings = []
|
|
3216
|
+
for room in _all_rooms():
|
|
3217
|
+
r = check_room_defense(room)
|
|
3218
|
+
rooms[room] = r
|
|
3219
|
+
blockers.extend(r["blockers"])
|
|
3220
|
+
warnings.extend(r["warnings"])
|
|
3221
|
+
# 任务级额外要求:至少存在一份【通过】的物理凭据
|
|
3222
|
+
# [防伪造] 必须"PASS 且有真实运行背书",只看字面 overall 会被 C1c/C1d 骗过。
|
|
3223
|
+
phys_pass = []
|
|
3224
|
+
for r in rooms.values():
|
|
3225
|
+
_ph = (r.get("details", {}) or {}).get("physics") or {}
|
|
3226
|
+
if str(_ph.get("overall") or "").upper() != "PASS":
|
|
3227
|
+
continue
|
|
3228
|
+
if not _ph.get("backed_by_real_run"):
|
|
3229
|
+
continue
|
|
3230
|
+
phys_pass.append(r)
|
|
3231
|
+
if (delivery_requirements()["physics"] or any(r.get("require_physics") for r in rooms.values())) and not phys_pass:
|
|
3232
|
+
blockers.append(
|
|
3233
|
+
"【防线②物理】全任务没有任何一份【有真实运行背书】的 PASS 物理凭据 —— "
|
|
3234
|
+
"未做过(可追溯的)强度/寿命校核的任务不得交付")
|
|
3235
|
+
return {"ok": not blockers, "blockers": blockers, "warnings": warnings,
|
|
3236
|
+
"rooms": rooms, "room_count": len(rooms),
|
|
3237
|
+
"requires_user_action": any(r.get("requires_user_action") for r in rooms.values()),
|
|
3238
|
+
"retryable": False,
|
|
3239
|
+
"next_action": next((r.get("next_action") for r in rooms.values()
|
|
3240
|
+
if r.get("requires_user_action")), None)}
|
|
3241
|
+
|
|
3242
|
+
|
|
3243
|
+
def log_bypass(where, reason="", by="unknown"):
|
|
3244
|
+
"""记录一次绕行(绝不静默绕过)。"""
|
|
3245
|
+
try:
|
|
3246
|
+
items = _read_json(BYPASS_LOG_FILE) or {"items": []}
|
|
3247
|
+
if not isinstance(items, dict) or not isinstance(items.get("items"), list):
|
|
3248
|
+
items = {"items": []}
|
|
3249
|
+
items["items"].append({
|
|
3250
|
+
"at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
3251
|
+
"where": str(where), "reason": str(reason), "by": str(by),
|
|
3252
|
+
})
|
|
3253
|
+
items["items"] = items["items"][-200:]
|
|
3254
|
+
_write_json(BYPASS_LOG_FILE, items)
|
|
3255
|
+
except Exception:
|
|
3256
|
+
pass
|
|
3257
|
+
|
|
3258
|
+
|
|
3259
|
+
def bypass_requested():
|
|
3260
|
+
return str(os.environ.get("DSH_DEFENSE_BYPASS") or "").strip() in ("1", "true", "yes")
|
|
3261
|
+
|
|
3262
|
+
|
|
3263
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
3264
|
+
# CLI
|
|
3265
|
+
# ══════════════════════════════════════════════════════════════════════
|
|
3266
|
+
|
|
3267
|
+
def main():
|
|
3268
|
+
try:
|
|
3269
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
3270
|
+
except Exception:
|
|
3271
|
+
pass
|
|
3272
|
+
args = sys.argv[1:]
|
|
3273
|
+
cmd = args[0] if args else "status"
|
|
3274
|
+
# ── 【A-09 修复·CLI 一致性】--help / -h / help 必须给用法,不能判"未知命令" ──
|
|
3275
|
+
# 实测缺陷:mode_gate / workflow_gate 都已把 --help 加进白名单,唯独
|
|
3276
|
+
# defense_gate 走通用分支打印 {"error": "unknown command: --help"} 并以退出码 2
|
|
3277
|
+
# 结束。AI 按提示探路时会把它读成"defense_gate 坏了/命令不存在",进而
|
|
3278
|
+
# 绕开门禁去改源码(A-20)。三处 CLI 必须同一套行为。
|
|
3279
|
+
if str(cmd) in ("--help", "-h", "-?", "help", "--usage"):
|
|
3280
|
+
_usage = (
|
|
3281
|
+
"defense_gate.py — 工程模式三大防线统一强制实现\n"
|
|
3282
|
+
"\n"
|
|
3283
|
+
"用法:\n"
|
|
3284
|
+
" python defense_gate.py check-room <房间名> [--force] # 只读判定某房间①②③\n"
|
|
3285
|
+
" python defense_gate.py check-task # 判定全任务\n"
|
|
3286
|
+
" python defense_gate.py status # 宿主签名/房间/类型/旁路状态\n"
|
|
3287
|
+
" python defense_gate.py --help # 本帮助\n"
|
|
3288
|
+
"\n"
|
|
3289
|
+
"输出: JSON(ok=true 退出码 0;ok=false 退出码 1;用法错误退出码 2)\n"
|
|
3290
|
+
"绕行: 仅 --force 或 DSH_DEFENSE_BYPASS=1,且必定留痕到 "
|
|
3291
|
+
"reports/<task>/defense_bypass.json(绝不静默)。\n")
|
|
3292
|
+
print(json.dumps({"ok": True, "command": "help", "usage": _usage},
|
|
3293
|
+
ensure_ascii=False, indent=2))
|
|
3294
|
+
sys.exit(0)
|
|
3295
|
+
if cmd == "check-room":
|
|
3296
|
+
# == [A-24 修复] --force / DSH_DEFENSE_BYPASS 必须被识别并留痕 ===
|
|
3297
|
+
# 原实现只取 args[1] 当房间名,【不解析任何 flag】:
|
|
3298
|
+
# · `check-room X --force` 里的 --force 被静默忽略,结果一字不差;
|
|
3299
|
+
# · `$env:DSH_DEFENSE_BYPASS=1` 也不影响 check-room;
|
|
3300
|
+
# · 两者都【不留痕】,与模块自己"唯一合法绕行必定留痕"的承诺相反。
|
|
3301
|
+
# 实测(2026-11-01):设了 BYPASS + --force 重跑,返回与之前完全一致,
|
|
3302
|
+
# 模型据此误判"BYPASS 无效",转而去改门禁源码(A-20)。
|
|
3303
|
+
# 现在:识别 --force / --json / --bypass,命中即显式留痕并回显。
|
|
3304
|
+
_flags = ("--force", "-f", "--bypass")
|
|
3305
|
+
_pos = [a for a in args[1:] if not str(a).startswith("-")]
|
|
3306
|
+
room = _pos[0] if _pos else ""
|
|
3307
|
+
_force = any(a in _flags for a in args[1:])
|
|
3308
|
+
if not room:
|
|
3309
|
+
print(json.dumps({"ok": False, "error": "需要房间名",
|
|
3310
|
+
"usage": "defense_gate.py check-room <room> [--force]"},
|
|
3311
|
+
ensure_ascii=False))
|
|
3312
|
+
sys.exit(2)
|
|
3313
|
+
r = check_room_defense(room)
|
|
3314
|
+
_bypass_env = bypass_requested()
|
|
3315
|
+
if (_force or _bypass_env) and isinstance(r, dict):
|
|
3316
|
+
# 留痕:与 room-end --force 走同一本账(reports/defense_bypass.json)
|
|
3317
|
+
try:
|
|
3318
|
+
log_bypass(
|
|
3319
|
+
where="check-room:%s" % room,
|
|
3320
|
+
reason=("显式请求绕行(%s);check-room 为只读判定,"
|
|
3321
|
+
"仍返回真实结论,不伪造 ok=true"
|
|
3322
|
+
% ("--force" if _force else "DSH_DEFENSE_BYPASS=1")),
|
|
3323
|
+
by="defense_gate.py check-room")
|
|
3324
|
+
except Exception:
|
|
3325
|
+
pass
|
|
3326
|
+
r["bypass_requested"] = True
|
|
3327
|
+
r["bypass_source"] = ("--force" if _force else "DSH_DEFENSE_BYPASS=1")
|
|
3328
|
+
r["bypass_note"] = (
|
|
3329
|
+
"本次带绕行标志调用;check-room 是【只读判定】,绕行标志不会让它"
|
|
3330
|
+
"返回 ok=true(那样等于伪造结论)。真正放行请在 room-end / "
|
|
3331
|
+
"confirm-assembly 上用 --force,那两处会留痕并允许继续。")
|
|
3332
|
+
elif isinstance(r, dict):
|
|
3333
|
+
r["bypass_requested"] = False
|
|
3334
|
+
print(json.dumps(r, ensure_ascii=False, indent=2))
|
|
3335
|
+
sys.exit(0 if r["ok"] else 1)
|
|
3336
|
+
if cmd == "check-task":
|
|
3337
|
+
r = check_task_defense()
|
|
3338
|
+
print(json.dumps(r, ensure_ascii=False, indent=2))
|
|
3339
|
+
sys.exit(0 if r["ok"] else 1)
|
|
3340
|
+
if cmd == "status":
|
|
3341
|
+
_rt = _read_runtime()
|
|
3342
|
+
# ── 【P0-3 修复】回报【真正生效】的 runtime.json 路径 ──────────────
|
|
3343
|
+
# 原先固定回报 RUNTIME_FILE(脚本自身目录下那份),而实际生效的是
|
|
3344
|
+
# _find_runtime_file() 按多候选回退命中的那份。当两份副本分裂时,
|
|
3345
|
+
# 该字段会指向一个【并不存在】的路径,把排障引向错误方向。
|
|
3346
|
+
_rt_path = _find_runtime_file()
|
|
3347
|
+
_host = {"assembled": bool(_rt.get("port")),
|
|
3348
|
+
"runtime_file": _rt_path or RUNTIME_FILE,
|
|
3349
|
+
"runtime_file_exists": bool(_rt_path),
|
|
3350
|
+
"expected_local_path": RUNTIME_FILE,
|
|
3351
|
+
"kid": _rt.get("kid"), "port": _rt.get("port"), "pid": _rt.get("pid")}
|
|
3352
|
+
if not _host["assembled"]:
|
|
3353
|
+
_host["action_required"] = "签名服务未装配:请【重启 DSH】使宿主加载签名端点(否则凭据无法签发,防线必然不通过)"
|
|
3354
|
+
else:
|
|
3355
|
+
_host["owner_check"] = _port_owner_check(_rt.get("pid"), _rt.get("port"))
|
|
3356
|
+
print(json.dumps({
|
|
3357
|
+
"ok": True,
|
|
3358
|
+
"host_signature": _host,
|
|
3359
|
+
"rooms": _all_rooms(),
|
|
3360
|
+
"room_types": _room_types(),
|
|
3361
|
+
"reports_dir": REPORTS_DIR,
|
|
3362
|
+
"bypass_env": bypass_requested(),
|
|
3363
|
+
}, ensure_ascii=False, indent=2))
|
|
3364
|
+
return
|
|
3365
|
+
print(json.dumps({"ok": False, "error": "unknown command: %s" % cmd,
|
|
3366
|
+
"usage": "defense_gate.py check-room <room> [--force] | "
|
|
3367
|
+
"check-task | status | --help",
|
|
3368
|
+
"hint": "运行 python defense_gate.py --help 查看完整说明"},
|
|
3369
|
+
ensure_ascii=False))
|
|
3370
|
+
sys.exit(2)
|
|
3371
|
+
|
|
3372
|
+
|
|
3373
|
+
if __name__ == "__main__":
|
|
3374
|
+
main()
|