@hupan56/wlkj 2.2.3 → 2.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +532 -532
- package/package.json +28 -28
- package/templates/qoder/hooks/inject-workflow-state.py +117 -117
- package/templates/qoder/hooks/session-start.py +204 -204
- package/templates/qoder/scripts/common/developer.py +231 -161
- package/templates/qoder/scripts/common/paths.py +310 -310
- package/templates/qoder/scripts/common/task_utils.py +392 -387
- package/templates/qoder/scripts/init_developer.py +75 -75
- package/templates/qoder/scripts/install_qoderwork.py +367 -367
- package/templates/qoder/scripts/role.py +39 -39
- package/templates/qoder/scripts/syncgate.py +333 -333
- package/templates/qoder/scripts/team_sync.py +439 -439
- package/templates/qoder/skills/design-review/SKILL.md +25 -25
- package/templates/qoder/skills/prd-generator/SKILL.md +180 -180
- package/templates/qoder/skills/prd-review/SKILL.md +36 -36
- package/templates/qoder/skills/prototype-generator/SKILL.md +141 -141
- package/templates/qoder/skills/spec-coder/SKILL.md +68 -68
- package/templates/qoder/skills/spec-generator/SKILL.md +66 -66
- package/templates/qoder/skills/test-generator/SKILL.md +71 -71
- package/templates/root/AGENTS.md +182 -182
|
@@ -1,333 +1,333 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""
|
|
3
|
-
syncgate.py - team_sync 推送前的零信任门禁
|
|
4
|
-
|
|
5
|
-
四道关卡, 任一失败则拒绝 push (exit 非 0, 不 commit 不 push):
|
|
6
|
-
1. 身份强制: 当前开发者必须已注册 (member.json + 本地密钥)
|
|
7
|
-
2. 作者强制: git config user.name/email 必须与 member.json[git_author] 一致
|
|
8
|
-
3. 秘密扫描: staged 文件不能含 AWS key / GitHub PAT / 私钥 / 高熵串
|
|
9
|
-
4. EVA 门禁: staged 的 PRD 必须过 eval_prd (>=80%)
|
|
10
|
-
|
|
11
|
-
设计为独立模块, 方便单元测试和复用。team_sync.do_push 在 commit 前调用
|
|
12
|
-
run_gates(staged_files) -> (passed, reasons)。
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
import os
|
|
16
|
-
import re
|
|
17
|
-
import subprocess
|
|
18
|
-
import sys
|
|
19
|
-
from typing import List, Tuple, Optional
|
|
20
|
-
|
|
21
|
-
# 相对导入
|
|
22
|
-
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
-
if _THIS_DIR not in sys.path:
|
|
24
|
-
sys.path.insert(0, _THIS_DIR)
|
|
25
|
-
_COMMON = os.path.join(_THIS_DIR, "common")
|
|
26
|
-
if _COMMON not in sys.path:
|
|
27
|
-
sys.path.insert(0, _COMMON)
|
|
28
|
-
|
|
29
|
-
from common.identity import (
|
|
30
|
-
get_member, verify_member, EXIT_AUTHZ, IdentityError,
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
__all__ = [
|
|
34
|
-
"run_gates",
|
|
35
|
-
"check_identity",
|
|
36
|
-
"check_git_author",
|
|
37
|
-
"scan_secrets",
|
|
38
|
-
"check_eval_gate",
|
|
39
|
-
"SAFE_EXTENSIONS",
|
|
40
|
-
"SecretFound",
|
|
41
|
-
"GateFailure",
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
# 退出码统一
|
|
45
|
-
EXIT_QUALITY = 5 # 质量门禁失败 (EVA 不过)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
class GateFailure(Exception):
|
|
49
|
-
"""门禁失败。"""
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
class SecretFound(GateFailure):
|
|
53
|
-
"""发现疑似秘密。"""
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
# ============================================================
|
|
57
|
-
# Allowlist: 只允许这些后缀被 stage (替代危险的 git add -A)
|
|
58
|
-
# ============================================================
|
|
59
|
-
|
|
60
|
-
SAFE_EXTENSIONS = {
|
|
61
|
-
".md", ".html", ".json", ".jsonl", ".yaml", ".yml",
|
|
62
|
-
".toml", ".txt", ".csv", ".svg",
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
# 已知的无扩展名文件 (task 系统的元数据)
|
|
66
|
-
SAFE_NOEXT_FILES = {"task", "implement", "check"} # task.jsonl 等其实有扩展
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
# ============================================================
|
|
70
|
-
# 1. 身份强制
|
|
71
|
-
# ============================================================
|
|
72
|
-
|
|
73
|
-
def check_identity(dev: Optional[str], repo_root: str) -> None:
|
|
74
|
-
"""开发者必须已注册且有本地密钥。"""
|
|
75
|
-
if not dev:
|
|
76
|
-
raise GateFailure(
|
|
77
|
-
"[authz] 拒绝: 未设置开发者身份。先跑 /wl-init <名字> <角色> 注册。"
|
|
78
|
-
)
|
|
79
|
-
m = get_member(dev, repo_root)
|
|
80
|
-
if not m:
|
|
81
|
-
raise GateFailure(
|
|
82
|
-
"[authz] 拒绝: 成员 %s 未在注册表 (workspace/members/%s/member.json)。"
|
|
83
|
-
"先跑 /wl-init %s 注册。" % (dev, dev, dev)
|
|
84
|
-
)
|
|
85
|
-
if not verify_member(dev, repo_root):
|
|
86
|
-
raise GateFailure(
|
|
87
|
-
"[authz] 拒绝: 成员 %s 在本机无签名密钥。"
|
|
88
|
-
"可能换了机器或未完成 /wl-init。跑 /wl-init %s 修复。" % (dev, dev)
|
|
89
|
-
)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
# ============================================================
|
|
93
|
-
# 2. 作者强制: git config 必须与 member.json 一致
|
|
94
|
-
# ============================================================
|
|
95
|
-
|
|
96
|
-
def _git_config(key: str) -> str:
|
|
97
|
-
"""读 git config。git 未安装时返回空串, 不崩溃。"""
|
|
98
|
-
try:
|
|
99
|
-
r = subprocess.run(
|
|
100
|
-
["git", "config", key],
|
|
101
|
-
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
102
|
-
)
|
|
103
|
-
return r.stdout.strip() if r.returncode == 0 else ""
|
|
104
|
-
except FileNotFoundError:
|
|
105
|
-
return "" # git 未装
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def check_git_author(dev: str, repo_root: str) -> None:
|
|
109
|
-
"""git user.name/email 必须与 member.json 的 git_author 一致。
|
|
110
|
-
|
|
111
|
-
member.json git_author 格式: "Name <email>"
|
|
112
|
-
git 未安装时跳过此检查 (本地无提交可冒名)。
|
|
113
|
-
"""
|
|
114
|
-
m = get_member(dev, repo_root)
|
|
115
|
-
if not m:
|
|
116
|
-
check_identity(dev, repo_root) # 会抛
|
|
117
|
-
return
|
|
118
|
-
expected_author = m.get("git_author", "")
|
|
119
|
-
if not expected_author:
|
|
120
|
-
return # 老数据没设, 不阻塞 (向后兼容)
|
|
121
|
-
|
|
122
|
-
# git 未安装 — 无法校验作者, 跳过 (本地工作不阻塞)
|
|
123
|
-
try:
|
|
124
|
-
subprocess.run(["git", "--version"], capture_output=True, timeout=5)
|
|
125
|
-
except (FileNotFoundError, OSError):
|
|
126
|
-
return # 无 git, 跳过
|
|
127
|
-
|
|
128
|
-
# 解析 "Name <email>"
|
|
129
|
-
match = re.match(r"^(.+?) <(.+?)>$", expected_author)
|
|
130
|
-
if not match:
|
|
131
|
-
return
|
|
132
|
-
exp_name, exp_email = match.group(1).strip(), match.group(2).strip()
|
|
133
|
-
|
|
134
|
-
actual_name = _git_config("user.name")
|
|
135
|
-
actual_email = _git_config("user.email")
|
|
136
|
-
|
|
137
|
-
# 名字或邮箱任一不符则拒绝 (防止冒名提交)
|
|
138
|
-
# 注意: 允许 git user.name 带/不带数字后缀等小差异? 不, 严格匹配防冒名
|
|
139
|
-
if actual_name != exp_name or actual_email != exp_email:
|
|
140
|
-
raise GateFailure(
|
|
141
|
-
"[authz] 拒绝: git 作者与注册身份不符。\n"
|
|
142
|
-
" 注册: %s <%s>\n"
|
|
143
|
-
" 当前 git config: user.name=%r user.email=%r\n"
|
|
144
|
-
" 修复: git config user.name %s && git config user.email %s\n"
|
|
145
|
-
" (用 --local 只改本仓库, 不影响全局)" % (
|
|
146
|
-
exp_name, exp_email, actual_name, actual_email,
|
|
147
|
-
exp_name, exp_email,
|
|
148
|
-
)
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
# ============================================================
|
|
153
|
-
# 3. 秘密扫描
|
|
154
|
-
# ============================================================
|
|
155
|
-
|
|
156
|
-
# 常见秘密模式 (保守, 低误报)
|
|
157
|
-
SECRET_PATTERNS = [
|
|
158
|
-
# AWS Access Key
|
|
159
|
-
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS Access Key"),
|
|
160
|
-
# AWS Secret (40 字符 base64, 上下文提示)
|
|
161
|
-
(re.compile(r"(?i)aws[_-]?secret[_-]?access[_-]?key['\"\s:=]+([A-Za-z0-9/+=]{40})"), "AWS Secret Key"),
|
|
162
|
-
# GitHub PAT (ghp_/gho_/ghu_/ghs_/ghr_)
|
|
163
|
-
(re.compile(r"gh[pousr]_[A-Za-z0-9]{36}"), "GitHub Token"),
|
|
164
|
-
# Slack
|
|
165
|
-
(re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "Slack Token"),
|
|
166
|
-
# Google API key
|
|
167
|
-
(re.compile(r"AIza[0-9A-Za-z\-_]{35}"), "Google API Key"),
|
|
168
|
-
# Generic private key header
|
|
169
|
-
(re.compile(r"-----BEGIN (RSA |EC |DSA |OPENSSH |)PRIVATE KEY-----"), "Private Key"),
|
|
170
|
-
# Generic password assignment (保守: 只匹配明显的赋值)
|
|
171
|
-
(re.compile(r"(?i)(password|passwd|pwd)\s*[:=]\s*['\"]([^'\"\s]{8,})['\"]"), "Password literal"),
|
|
172
|
-
# 高熵连接字符串里的凭据
|
|
173
|
-
(re.compile(r"(?i)(postgres|mysql|mongodb|redis)://[^:\s]+:([^@\s]{8,})@"), "DB connection password"),
|
|
174
|
-
]
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
def scan_secrets(file_paths: List[str], repo_root: str) -> None:
|
|
178
|
-
"""扫描文件内容, 发现秘密则抛 SecretFound。
|
|
179
|
-
|
|
180
|
-
只扫文本文件 (按 SAFE_EXTENSIONS), 二进制跳过。
|
|
181
|
-
限制每个文件读前 256KB (防巨大文件)。
|
|
182
|
-
"""
|
|
183
|
-
hits = []
|
|
184
|
-
for fp in file_paths:
|
|
185
|
-
full = os.path.join(repo_root, fp) if not os.path.isabs(fp) else fp
|
|
186
|
-
ext = os.path.splitext(fp)[1].lower()
|
|
187
|
-
if ext not in SAFE_EXTENSIONS and ext != "":
|
|
188
|
-
continue
|
|
189
|
-
if not os.path.isfile(full):
|
|
190
|
-
continue
|
|
191
|
-
try:
|
|
192
|
-
with open(full, "r", encoding="utf-8", errors="ignore") as f:
|
|
193
|
-
content = f.read(256 * 1024)
|
|
194
|
-
except OSError:
|
|
195
|
-
continue
|
|
196
|
-
for pattern, label in SECRET_PATTERNS:
|
|
197
|
-
for m in pattern.finditer(content):
|
|
198
|
-
# 屏蔽匹配到的实际值, 只报告位置
|
|
199
|
-
line_no = content[:m.start()].count("\n") + 1
|
|
200
|
-
hits.append((fp, line_no, label))
|
|
201
|
-
if hits:
|
|
202
|
-
lines = ["[gate] 拒绝: 检测到疑似秘密, 已阻止提交:"]
|
|
203
|
-
for fp, ln, label in hits[:20]:
|
|
204
|
-
lines.append(" %s:%d %s" % (fp, ln, label))
|
|
205
|
-
if len(hits) > 20:
|
|
206
|
-
lines.append(" ... 还有 %d 处" % (len(hits) - 20))
|
|
207
|
-
lines.append("若确认误报, 在文件里标注 '# noscan' 或用 --skip-secret-scan (仅 admin)")
|
|
208
|
-
raise SecretFound("\n".join(lines))
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
# ============================================================
|
|
212
|
-
# 4. EVA 门禁 (PRD 质量校验)
|
|
213
|
-
# ============================================================
|
|
214
|
-
|
|
215
|
-
def check_eval_gate(
|
|
216
|
-
prd_paths: List[str],
|
|
217
|
-
repo_root: str,
|
|
218
|
-
skip_eval: bool = False,
|
|
219
|
-
) -> None:
|
|
220
|
-
"""对每个 PRD 跑 eval_prd, 任一 FAIL 则拒绝。
|
|
221
|
-
|
|
222
|
-
skip_eval=True 时跳过 (仅 admin 用, 会记录到审计日志)。
|
|
223
|
-
"""
|
|
224
|
-
if skip_eval or not prd_paths:
|
|
225
|
-
return
|
|
226
|
-
try:
|
|
227
|
-
from common.eval_api import evaluate
|
|
228
|
-
except ImportError:
|
|
229
|
-
# eval_api 还没建 (阶段 1.2 建), 跳过不阻塞
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
failures = []
|
|
233
|
-
for prd in prd_paths:
|
|
234
|
-
full = os.path.join(repo_root, prd) if not os.path.isabs(prd) else prd
|
|
235
|
-
if not os.path.isfile(full):
|
|
236
|
-
continue
|
|
237
|
-
# 找同名原型 (可选)
|
|
238
|
-
dirname = os.path.dirname(full)
|
|
239
|
-
base = os.path.splitext(os.path.basename(full))[0]
|
|
240
|
-
feature = base.replace("REQ-", "").split("-", 1)[-1] if "-" in base else base
|
|
241
|
-
html_candidates = [
|
|
242
|
-
os.path.join(dirname, "prototype-%s.html" % feature),
|
|
243
|
-
os.path.join(dirname, "prototype-%s-web.html" % feature),
|
|
244
|
-
]
|
|
245
|
-
html = next((h for h in html_candidates if os.path.isfile(h)), None)
|
|
246
|
-
try:
|
|
247
|
-
result = evaluate(full, html)
|
|
248
|
-
except Exception as e:
|
|
249
|
-
failures.append((prd, "评估异常: %s" % e))
|
|
250
|
-
continue
|
|
251
|
-
if not result.get("passed"):
|
|
252
|
-
pct = result.get("pct", 0)
|
|
253
|
-
misses = result.get("misses", [])[:5]
|
|
254
|
-
failures.append((prd, "%.0f%% (<80%%). 问题: %s" % (pct, "; ".join(misses) if misses else "见 EVA 报告")))
|
|
255
|
-
|
|
256
|
-
if failures:
|
|
257
|
-
lines = ["[gate] 拒绝: %d 个 PRD 未过 EVA 门禁 (<80%%):" % len(failures)]
|
|
258
|
-
issues_all = []
|
|
259
|
-
for prd, reason in failures:
|
|
260
|
-
lines.append(" %s %s" % (prd, reason))
|
|
261
|
-
issues_all.append("%s: %s" % (prd, reason))
|
|
262
|
-
lines.append("修复 PRD 后重试, 或 admin 用 --skip-eval 跳过 (会记录审计)")
|
|
263
|
-
# D2: 飞书通知 EVA 拒绝
|
|
264
|
-
try:
|
|
265
|
-
from common.feishu import notify_eval_rejected
|
|
266
|
-
notify_eval_rejected(
|
|
267
|
-
req_id=",".join(os.path.basename(p) for p, _ in failures[:3]),
|
|
268
|
-
title="批量 EVA 拒绝",
|
|
269
|
-
issues=issues_all,
|
|
270
|
-
)
|
|
271
|
-
except Exception:
|
|
272
|
-
pass
|
|
273
|
-
raise GateFailure("\n".join(lines))
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
# ============================================================
|
|
277
|
-
# 总入口
|
|
278
|
-
# ============================================================
|
|
279
|
-
|
|
280
|
-
def run_gates(
|
|
281
|
-
staged_files: List[str],
|
|
282
|
-
dev: Optional[str],
|
|
283
|
-
repo_root: str,
|
|
284
|
-
skip_eval: bool = False,
|
|
285
|
-
skip_secret: bool = False,
|
|
286
|
-
) -> Tuple[bool, str]:
|
|
287
|
-
"""跑全部门禁。返回 (passed, reason)。
|
|
288
|
-
|
|
289
|
-
passed=True 表示全过。passed=False 时 reason 是给人看的错误信息。
|
|
290
|
-
"""
|
|
291
|
-
try:
|
|
292
|
-
# 1. 身份
|
|
293
|
-
check_identity(dev, repo_root)
|
|
294
|
-
# 2. 作者
|
|
295
|
-
check_git_author(dev, repo_root)
|
|
296
|
-
# 3. 秘密
|
|
297
|
-
if not skip_secret:
|
|
298
|
-
scan_secrets(staged_files, repo_root)
|
|
299
|
-
# 4. REQ-ID 校验 (防撞号/跳号)
|
|
300
|
-
prd_files = [
|
|
301
|
-
f for f in staged_files
|
|
302
|
-
if ("/prd/" in f.replace("\\", "/").lower() or
|
|
303
|
-
os.path.basename(f).lower().startswith("req-"))
|
|
304
|
-
and f.lower().endswith(".md")
|
|
305
|
-
]
|
|
306
|
-
if prd_files:
|
|
307
|
-
try:
|
|
308
|
-
from common.reqid import validate_req_ids, migrate_from_existing, ReqIdError
|
|
309
|
-
# 计数器缺失则先迁移 (幂等)
|
|
310
|
-
try:
|
|
311
|
-
validate_req_ids(prd_files, repo_root)
|
|
312
|
-
except ReqIdError as e:
|
|
313
|
-
if '超过计数器最大值 0' in str(e) or 'req-counter' in str(e).lower():
|
|
314
|
-
# 计数器未初始化, 迁移后重试
|
|
315
|
-
migrate_from_existing(repo_root)
|
|
316
|
-
validate_req_ids(prd_files, repo_root)
|
|
317
|
-
else:
|
|
318
|
-
raise
|
|
319
|
-
except ImportError:
|
|
320
|
-
pass # reqid 模块不可用, 跳过 (向后兼容)
|
|
321
|
-
# 5. EVA 门禁 (只对 PRD 文件)
|
|
322
|
-
check_eval_gate(prd_files, repo_root, skip_eval=skip_eval)
|
|
323
|
-
return True, ""
|
|
324
|
-
except GateFailure as e:
|
|
325
|
-
return False, str(e)
|
|
326
|
-
except Exception as e:
|
|
327
|
-
# ReqIdError / AtomicIOError 等也包装成 GateFailure 风格, 不让原始
|
|
328
|
-
# traceback 冒泡到产品经理眼前
|
|
329
|
-
msg = str(e)
|
|
330
|
-
if 'REQ-ID' in msg or '撞号' in msg or 'REQ' in msg:
|
|
331
|
-
return False, msg
|
|
332
|
-
# 其他意外异常: 返回友好错误而非崩溃栈
|
|
333
|
-
return False, '[gate] 门禁检查遇到意外错误 (非门禁失败, 请重试或检查环境): ' + msg[:200]
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
syncgate.py - team_sync 推送前的零信任门禁
|
|
4
|
+
|
|
5
|
+
四道关卡, 任一失败则拒绝 push (exit 非 0, 不 commit 不 push):
|
|
6
|
+
1. 身份强制: 当前开发者必须已注册 (member.json + 本地密钥)
|
|
7
|
+
2. 作者强制: git config user.name/email 必须与 member.json[git_author] 一致
|
|
8
|
+
3. 秘密扫描: staged 文件不能含 AWS key / GitHub PAT / 私钥 / 高熵串
|
|
9
|
+
4. EVA 门禁: staged 的 PRD 必须过 eval_prd (>=80%)
|
|
10
|
+
|
|
11
|
+
设计为独立模块, 方便单元测试和复用。team_sync.do_push 在 commit 前调用
|
|
12
|
+
run_gates(staged_files) -> (passed, reasons)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from typing import List, Tuple, Optional
|
|
20
|
+
|
|
21
|
+
# 相对导入
|
|
22
|
+
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
23
|
+
if _THIS_DIR not in sys.path:
|
|
24
|
+
sys.path.insert(0, _THIS_DIR)
|
|
25
|
+
_COMMON = os.path.join(_THIS_DIR, "common")
|
|
26
|
+
if _COMMON not in sys.path:
|
|
27
|
+
sys.path.insert(0, _COMMON)
|
|
28
|
+
|
|
29
|
+
from common.identity import (
|
|
30
|
+
get_member, verify_member, EXIT_AUTHZ, IdentityError,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"run_gates",
|
|
35
|
+
"check_identity",
|
|
36
|
+
"check_git_author",
|
|
37
|
+
"scan_secrets",
|
|
38
|
+
"check_eval_gate",
|
|
39
|
+
"SAFE_EXTENSIONS",
|
|
40
|
+
"SecretFound",
|
|
41
|
+
"GateFailure",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# 退出码统一
|
|
45
|
+
EXIT_QUALITY = 5 # 质量门禁失败 (EVA 不过)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class GateFailure(Exception):
|
|
49
|
+
"""门禁失败。"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SecretFound(GateFailure):
|
|
53
|
+
"""发现疑似秘密。"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ============================================================
|
|
57
|
+
# Allowlist: 只允许这些后缀被 stage (替代危险的 git add -A)
|
|
58
|
+
# ============================================================
|
|
59
|
+
|
|
60
|
+
SAFE_EXTENSIONS = {
|
|
61
|
+
".md", ".html", ".json", ".jsonl", ".yaml", ".yml",
|
|
62
|
+
".toml", ".txt", ".csv", ".svg",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# 已知的无扩展名文件 (task 系统的元数据)
|
|
66
|
+
SAFE_NOEXT_FILES = {"task", "implement", "check"} # task.jsonl 等其实有扩展
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ============================================================
|
|
70
|
+
# 1. 身份强制
|
|
71
|
+
# ============================================================
|
|
72
|
+
|
|
73
|
+
def check_identity(dev: Optional[str], repo_root: str) -> None:
|
|
74
|
+
"""开发者必须已注册且有本地密钥。"""
|
|
75
|
+
if not dev:
|
|
76
|
+
raise GateFailure(
|
|
77
|
+
"[authz] 拒绝: 未设置开发者身份。先跑 /wl-init <名字> <角色> 注册。"
|
|
78
|
+
)
|
|
79
|
+
m = get_member(dev, repo_root)
|
|
80
|
+
if not m:
|
|
81
|
+
raise GateFailure(
|
|
82
|
+
"[authz] 拒绝: 成员 %s 未在注册表 (workspace/members/%s/member.json)。"
|
|
83
|
+
"先跑 /wl-init %s 注册。" % (dev, dev, dev)
|
|
84
|
+
)
|
|
85
|
+
if not verify_member(dev, repo_root):
|
|
86
|
+
raise GateFailure(
|
|
87
|
+
"[authz] 拒绝: 成员 %s 在本机无签名密钥。"
|
|
88
|
+
"可能换了机器或未完成 /wl-init。跑 /wl-init %s 修复。" % (dev, dev)
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ============================================================
|
|
93
|
+
# 2. 作者强制: git config 必须与 member.json 一致
|
|
94
|
+
# ============================================================
|
|
95
|
+
|
|
96
|
+
def _git_config(key: str) -> str:
|
|
97
|
+
"""读 git config。git 未安装时返回空串, 不崩溃。"""
|
|
98
|
+
try:
|
|
99
|
+
r = subprocess.run(
|
|
100
|
+
["git", "config", key],
|
|
101
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
102
|
+
)
|
|
103
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
return "" # git 未装
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_git_author(dev: str, repo_root: str) -> None:
|
|
109
|
+
"""git user.name/email 必须与 member.json 的 git_author 一致。
|
|
110
|
+
|
|
111
|
+
member.json git_author 格式: "Name <email>"
|
|
112
|
+
git 未安装时跳过此检查 (本地无提交可冒名)。
|
|
113
|
+
"""
|
|
114
|
+
m = get_member(dev, repo_root)
|
|
115
|
+
if not m:
|
|
116
|
+
check_identity(dev, repo_root) # 会抛
|
|
117
|
+
return
|
|
118
|
+
expected_author = m.get("git_author", "")
|
|
119
|
+
if not expected_author:
|
|
120
|
+
return # 老数据没设, 不阻塞 (向后兼容)
|
|
121
|
+
|
|
122
|
+
# git 未安装 — 无法校验作者, 跳过 (本地工作不阻塞)
|
|
123
|
+
try:
|
|
124
|
+
subprocess.run(["git", "--version"], capture_output=True, timeout=5)
|
|
125
|
+
except (FileNotFoundError, OSError):
|
|
126
|
+
return # 无 git, 跳过
|
|
127
|
+
|
|
128
|
+
# 解析 "Name <email>"
|
|
129
|
+
match = re.match(r"^(.+?) <(.+?)>$", expected_author)
|
|
130
|
+
if not match:
|
|
131
|
+
return
|
|
132
|
+
exp_name, exp_email = match.group(1).strip(), match.group(2).strip()
|
|
133
|
+
|
|
134
|
+
actual_name = _git_config("user.name")
|
|
135
|
+
actual_email = _git_config("user.email")
|
|
136
|
+
|
|
137
|
+
# 名字或邮箱任一不符则拒绝 (防止冒名提交)
|
|
138
|
+
# 注意: 允许 git user.name 带/不带数字后缀等小差异? 不, 严格匹配防冒名
|
|
139
|
+
if actual_name != exp_name or actual_email != exp_email:
|
|
140
|
+
raise GateFailure(
|
|
141
|
+
"[authz] 拒绝: git 作者与注册身份不符。\n"
|
|
142
|
+
" 注册: %s <%s>\n"
|
|
143
|
+
" 当前 git config: user.name=%r user.email=%r\n"
|
|
144
|
+
" 修复: git config user.name %s && git config user.email %s\n"
|
|
145
|
+
" (用 --local 只改本仓库, 不影响全局)" % (
|
|
146
|
+
exp_name, exp_email, actual_name, actual_email,
|
|
147
|
+
exp_name, exp_email,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ============================================================
|
|
153
|
+
# 3. 秘密扫描
|
|
154
|
+
# ============================================================
|
|
155
|
+
|
|
156
|
+
# 常见秘密模式 (保守, 低误报)
|
|
157
|
+
SECRET_PATTERNS = [
|
|
158
|
+
# AWS Access Key
|
|
159
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS Access Key"),
|
|
160
|
+
# AWS Secret (40 字符 base64, 上下文提示)
|
|
161
|
+
(re.compile(r"(?i)aws[_-]?secret[_-]?access[_-]?key['\"\s:=]+([A-Za-z0-9/+=]{40})"), "AWS Secret Key"),
|
|
162
|
+
# GitHub PAT (ghp_/gho_/ghu_/ghs_/ghr_)
|
|
163
|
+
(re.compile(r"gh[pousr]_[A-Za-z0-9]{36}"), "GitHub Token"),
|
|
164
|
+
# Slack
|
|
165
|
+
(re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "Slack Token"),
|
|
166
|
+
# Google API key
|
|
167
|
+
(re.compile(r"AIza[0-9A-Za-z\-_]{35}"), "Google API Key"),
|
|
168
|
+
# Generic private key header
|
|
169
|
+
(re.compile(r"-----BEGIN (RSA |EC |DSA |OPENSSH |)PRIVATE KEY-----"), "Private Key"),
|
|
170
|
+
# Generic password assignment (保守: 只匹配明显的赋值)
|
|
171
|
+
(re.compile(r"(?i)(password|passwd|pwd)\s*[:=]\s*['\"]([^'\"\s]{8,})['\"]"), "Password literal"),
|
|
172
|
+
# 高熵连接字符串里的凭据
|
|
173
|
+
(re.compile(r"(?i)(postgres|mysql|mongodb|redis)://[^:\s]+:([^@\s]{8,})@"), "DB connection password"),
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def scan_secrets(file_paths: List[str], repo_root: str) -> None:
|
|
178
|
+
"""扫描文件内容, 发现秘密则抛 SecretFound。
|
|
179
|
+
|
|
180
|
+
只扫文本文件 (按 SAFE_EXTENSIONS), 二进制跳过。
|
|
181
|
+
限制每个文件读前 256KB (防巨大文件)。
|
|
182
|
+
"""
|
|
183
|
+
hits = []
|
|
184
|
+
for fp in file_paths:
|
|
185
|
+
full = os.path.join(repo_root, fp) if not os.path.isabs(fp) else fp
|
|
186
|
+
ext = os.path.splitext(fp)[1].lower()
|
|
187
|
+
if ext not in SAFE_EXTENSIONS and ext != "":
|
|
188
|
+
continue
|
|
189
|
+
if not os.path.isfile(full):
|
|
190
|
+
continue
|
|
191
|
+
try:
|
|
192
|
+
with open(full, "r", encoding="utf-8", errors="ignore") as f:
|
|
193
|
+
content = f.read(256 * 1024)
|
|
194
|
+
except OSError:
|
|
195
|
+
continue
|
|
196
|
+
for pattern, label in SECRET_PATTERNS:
|
|
197
|
+
for m in pattern.finditer(content):
|
|
198
|
+
# 屏蔽匹配到的实际值, 只报告位置
|
|
199
|
+
line_no = content[:m.start()].count("\n") + 1
|
|
200
|
+
hits.append((fp, line_no, label))
|
|
201
|
+
if hits:
|
|
202
|
+
lines = ["[gate] 拒绝: 检测到疑似秘密, 已阻止提交:"]
|
|
203
|
+
for fp, ln, label in hits[:20]:
|
|
204
|
+
lines.append(" %s:%d %s" % (fp, ln, label))
|
|
205
|
+
if len(hits) > 20:
|
|
206
|
+
lines.append(" ... 还有 %d 处" % (len(hits) - 20))
|
|
207
|
+
lines.append("若确认误报, 在文件里标注 '# noscan' 或用 --skip-secret-scan (仅 admin)")
|
|
208
|
+
raise SecretFound("\n".join(lines))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ============================================================
|
|
212
|
+
# 4. EVA 门禁 (PRD 质量校验)
|
|
213
|
+
# ============================================================
|
|
214
|
+
|
|
215
|
+
def check_eval_gate(
|
|
216
|
+
prd_paths: List[str],
|
|
217
|
+
repo_root: str,
|
|
218
|
+
skip_eval: bool = False,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""对每个 PRD 跑 eval_prd, 任一 FAIL 则拒绝。
|
|
221
|
+
|
|
222
|
+
skip_eval=True 时跳过 (仅 admin 用, 会记录到审计日志)。
|
|
223
|
+
"""
|
|
224
|
+
if skip_eval or not prd_paths:
|
|
225
|
+
return
|
|
226
|
+
try:
|
|
227
|
+
from common.eval_api import evaluate
|
|
228
|
+
except ImportError:
|
|
229
|
+
# eval_api 还没建 (阶段 1.2 建), 跳过不阻塞
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
failures = []
|
|
233
|
+
for prd in prd_paths:
|
|
234
|
+
full = os.path.join(repo_root, prd) if not os.path.isabs(prd) else prd
|
|
235
|
+
if not os.path.isfile(full):
|
|
236
|
+
continue
|
|
237
|
+
# 找同名原型 (可选)
|
|
238
|
+
dirname = os.path.dirname(full)
|
|
239
|
+
base = os.path.splitext(os.path.basename(full))[0]
|
|
240
|
+
feature = base.replace("REQ-", "").split("-", 1)[-1] if "-" in base else base
|
|
241
|
+
html_candidates = [
|
|
242
|
+
os.path.join(dirname, "prototype-%s.html" % feature),
|
|
243
|
+
os.path.join(dirname, "prototype-%s-web.html" % feature),
|
|
244
|
+
]
|
|
245
|
+
html = next((h for h in html_candidates if os.path.isfile(h)), None)
|
|
246
|
+
try:
|
|
247
|
+
result = evaluate(full, html)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
failures.append((prd, "评估异常: %s" % e))
|
|
250
|
+
continue
|
|
251
|
+
if not result.get("passed"):
|
|
252
|
+
pct = result.get("pct", 0)
|
|
253
|
+
misses = result.get("misses", [])[:5]
|
|
254
|
+
failures.append((prd, "%.0f%% (<80%%). 问题: %s" % (pct, "; ".join(misses) if misses else "见 EVA 报告")))
|
|
255
|
+
|
|
256
|
+
if failures:
|
|
257
|
+
lines = ["[gate] 拒绝: %d 个 PRD 未过 EVA 门禁 (<80%%):" % len(failures)]
|
|
258
|
+
issues_all = []
|
|
259
|
+
for prd, reason in failures:
|
|
260
|
+
lines.append(" %s %s" % (prd, reason))
|
|
261
|
+
issues_all.append("%s: %s" % (prd, reason))
|
|
262
|
+
lines.append("修复 PRD 后重试, 或 admin 用 --skip-eval 跳过 (会记录审计)")
|
|
263
|
+
# D2: 飞书通知 EVA 拒绝
|
|
264
|
+
try:
|
|
265
|
+
from common.feishu import notify_eval_rejected
|
|
266
|
+
notify_eval_rejected(
|
|
267
|
+
req_id=",".join(os.path.basename(p) for p, _ in failures[:3]),
|
|
268
|
+
title="批量 EVA 拒绝",
|
|
269
|
+
issues=issues_all,
|
|
270
|
+
)
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
raise GateFailure("\n".join(lines))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ============================================================
|
|
277
|
+
# 总入口
|
|
278
|
+
# ============================================================
|
|
279
|
+
|
|
280
|
+
def run_gates(
|
|
281
|
+
staged_files: List[str],
|
|
282
|
+
dev: Optional[str],
|
|
283
|
+
repo_root: str,
|
|
284
|
+
skip_eval: bool = False,
|
|
285
|
+
skip_secret: bool = False,
|
|
286
|
+
) -> Tuple[bool, str]:
|
|
287
|
+
"""跑全部门禁。返回 (passed, reason)。
|
|
288
|
+
|
|
289
|
+
passed=True 表示全过。passed=False 时 reason 是给人看的错误信息。
|
|
290
|
+
"""
|
|
291
|
+
try:
|
|
292
|
+
# 1. 身份
|
|
293
|
+
check_identity(dev, repo_root)
|
|
294
|
+
# 2. 作者
|
|
295
|
+
check_git_author(dev, repo_root)
|
|
296
|
+
# 3. 秘密
|
|
297
|
+
if not skip_secret:
|
|
298
|
+
scan_secrets(staged_files, repo_root)
|
|
299
|
+
# 4. REQ-ID 校验 (防撞号/跳号)
|
|
300
|
+
prd_files = [
|
|
301
|
+
f for f in staged_files
|
|
302
|
+
if ("/prd/" in f.replace("\\", "/").lower() or
|
|
303
|
+
os.path.basename(f).lower().startswith("req-"))
|
|
304
|
+
and f.lower().endswith(".md")
|
|
305
|
+
]
|
|
306
|
+
if prd_files:
|
|
307
|
+
try:
|
|
308
|
+
from common.reqid import validate_req_ids, migrate_from_existing, ReqIdError
|
|
309
|
+
# 计数器缺失则先迁移 (幂等)
|
|
310
|
+
try:
|
|
311
|
+
validate_req_ids(prd_files, repo_root)
|
|
312
|
+
except ReqIdError as e:
|
|
313
|
+
if '超过计数器最大值 0' in str(e) or 'req-counter' in str(e).lower():
|
|
314
|
+
# 计数器未初始化, 迁移后重试
|
|
315
|
+
migrate_from_existing(repo_root)
|
|
316
|
+
validate_req_ids(prd_files, repo_root)
|
|
317
|
+
else:
|
|
318
|
+
raise
|
|
319
|
+
except ImportError:
|
|
320
|
+
pass # reqid 模块不可用, 跳过 (向后兼容)
|
|
321
|
+
# 5. EVA 门禁 (只对 PRD 文件)
|
|
322
|
+
check_eval_gate(prd_files, repo_root, skip_eval=skip_eval)
|
|
323
|
+
return True, ""
|
|
324
|
+
except GateFailure as e:
|
|
325
|
+
return False, str(e)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
# ReqIdError / AtomicIOError 等也包装成 GateFailure 风格, 不让原始
|
|
328
|
+
# traceback 冒泡到产品经理眼前
|
|
329
|
+
msg = str(e)
|
|
330
|
+
if 'REQ-ID' in msg or '撞号' in msg or 'REQ' in msg:
|
|
331
|
+
return False, msg
|
|
332
|
+
# 其他意外异常: 返回友好错误而非崩溃栈
|
|
333
|
+
return False, '[gate] 门禁检查遇到意外错误 (非门禁失败, 请重试或检查环境): ' + msg[:200]
|