weaver-work-cli 0.1.6 → 0.1.7
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/README.md +14 -9
- package/dist/cmd/skills/index.js +60 -0
- package/dist/internal/e10/auth/commands.js +44 -31
- package/dist/internal/e10/auth/session.js +44 -3
- package/dist/internal/e10/auth/xiaoe.js +363 -11
- package/dist/internal/e10/context.js +4 -2
- package/dist/internal/skills/detector.js +42 -0
- package/dist/internal/skills/install.js +50 -3
- package/dist/shortcuts/archive/render/search-format.js +12 -3
- package/dist/shortcuts/ebuilder-form/continuation.js +70 -0
- package/dist/shortcuts/ebuilder-form/errors.js +54 -0
- package/dist/shortcuts/ebuilder-form/host.js +1238 -0
- package/dist/shortcuts/ebuilder-form/index.js +108 -0
- package/dist/shortcuts/ebuilder-form/manifest.js +347 -0
- package/dist/shortcuts/ebuilder-form/operations/read.js +157 -0
- package/dist/shortcuts/ebuilder-form/operations/registry.js +56 -0
- package/dist/shortcuts/ebuilder-form/operations/shared.js +132 -0
- package/dist/shortcuts/ebuilder-form/operations/types.js +19 -0
- package/dist/shortcuts/ebuilder-form/operations/write.js +277 -0
- package/dist/shortcuts/ebuilder-form/operations.js +8 -0
- package/dist/shortcuts/ehr/operations/salary.js +2 -140
- package/dist/shortcuts/im/continuation.js +70 -0
- package/dist/shortcuts/{yimiaoban → im}/errors.js +14 -14
- package/dist/shortcuts/{yimiaoban → im}/host.js +26 -26
- package/dist/shortcuts/{yimiaoban → im}/index.js +42 -42
- package/dist/shortcuts/{yimiaoban → im}/manifest.js +7 -6
- package/dist/shortcuts/{yimiaoban → im}/operations/file-user.js +54 -41
- package/dist/shortcuts/{yimiaoban → im}/operations/group-read.js +46 -44
- package/dist/shortcuts/{yimiaoban → im}/operations/group-write.js +113 -66
- package/dist/shortcuts/{yimiaoban → im}/operations/msg-sync.js +164 -141
- package/dist/shortcuts/{yimiaoban → im}/operations/msg-write.js +47 -47
- package/dist/shortcuts/{yimiaoban → im}/operations/person.js +10 -10
- package/dist/shortcuts/{yimiaoban → im}/operations/registry.js +10 -8
- package/dist/shortcuts/{yimiaoban → im}/operations/session.js +82 -83
- package/dist/shortcuts/im/operations/shared.js +442 -0
- package/dist/shortcuts/{yimiaoban → im}/operations/types.js +1 -1
- package/dist/shortcuts/im/operations/user-remind.js +201 -0
- package/dist/shortcuts/{yimiaoban → im}/operations/write-util.js +7 -7
- package/dist/shortcuts/{yimiaoban → im}/operations.js +3 -3
- package/dist/shortcuts/index.js +10 -2
- package/dist/shortcuts/invoice/manifest.js +113 -10
- package/dist/shortcuts/invoice/operations/import.js +39 -14
- package/dist/shortcuts/invoice/operations/reim.js +38 -18
- package/dist/shortcuts/invoice/operations/shared.js +35 -6
- package/dist/shortcuts/jiuchuanhui/manifest.js +3 -3
- package/dist/shortcuts/jiuchuanhui/operations/registry.js +2 -2
- package/dist/shortcuts/{yimiaoban → okr}/continuation.js +10 -10
- package/dist/shortcuts/okr/errors.js +70 -0
- package/dist/shortcuts/okr/host.js +169 -0
- package/dist/shortcuts/okr/index.js +155 -0
- package/dist/shortcuts/okr/manifest.js +70 -0
- package/dist/shortcuts/okr/operations/link.js +305 -0
- package/dist/shortcuts/okr/operations/read.js +481 -0
- package/dist/shortcuts/okr/operations/registry.js +31 -0
- package/dist/shortcuts/okr/operations/shared.js +381 -0
- package/dist/shortcuts/okr/operations/types.js +88 -0
- package/dist/shortcuts/okr/operations/write.js +838 -0
- package/dist/shortcuts/okr/operations.js +8 -0
- package/dist/shortcuts/project/continuation.js +70 -0
- package/dist/shortcuts/project/errors.js +53 -0
- package/dist/shortcuts/project/host.js +160 -0
- package/dist/shortcuts/project/index.js +106 -0
- package/dist/shortcuts/project/manifest.js +36 -0
- package/dist/shortcuts/project/operations/read.js +551 -0
- package/dist/shortcuts/project/operations/registry.js +31 -0
- package/dist/shortcuts/project/operations/shared.js +68 -0
- package/dist/shortcuts/project/operations/types.js +39 -0
- package/dist/shortcuts/project/operations/write.js +266 -0
- package/dist/shortcuts/project/operations.js +8 -0
- package/dist/shortcuts/workflow/continuation.js +508 -0
- package/dist/shortcuts/workflow/endpoints.js +149 -0
- package/dist/shortcuts/workflow/errors.js +112 -0
- package/dist/shortcuts/workflow/host.js +224 -0
- package/dist/shortcuts/workflow/index.js +116 -0
- package/dist/shortcuts/workflow/manifest.js +30 -0
- package/dist/shortcuts/workflow/operations/form.js +4159 -0
- package/dist/shortcuts/workflow/operations/read.js +3778 -0
- package/dist/shortcuts/workflow/operations/registry.js +33 -0
- package/dist/shortcuts/workflow/operations/shared.js +167 -0
- package/dist/shortcuts/workflow/operations/types.js +44 -0
- package/dist/shortcuts/workflow/operations/write.js +1991 -0
- package/dist/shortcuts/workflow/operations.js +119 -0
- package/dist/shortcuts/workflow/state.js +182 -0
- package/docs/SKILL.md +5 -3
- package/docs/_catalog.md +10 -1
- package/docs/agent-invoice.md +2 -2
- package/docs/agent-skill-install.md +2 -2
- package/docs/e10-auth.md +3 -1
- package/docs/ebuilder-form.md +38 -0
- package/docs/im.md +104 -0
- package/docs/invoice.md +5 -5
- package/docs/okr.md +122 -0
- package/docs/operation-manual.md +10 -7
- package/docs/project.md +69 -0
- package/docs/skill-review-2026-09-19.md +68 -0
- package/package.json +3 -2
- package/scripts/package-skill.mjs +38 -11
- package/scripts/sync-connector-skills.mjs +172 -0
- package/skill-template/business-info.json +357 -16
- package/skill-template/domains/ebuilder-form.md +5 -0
- package/skill-template/domains/okr.md +18 -0
- package/skill-template/domains/shijingran.md +3 -0
- package/skill-template/domains/skill-maker.md +4 -4
- package/skill-template/domains/workflow.md +22 -0
- package/skill-template/domains/yepiaotong.md +4 -2
- package/skill-template/domains/yimiaoban.md +13 -10
- package/skill-template/product.json +5 -0
- package/skill-template/skill-template.md +1 -0
- package/skills/weaver-e10-calendar/SKILL.md +5 -1
- package/skills/weaver-e10-calendar/references/calendar-query.md +7 -0
- package/skills/weaver-e10-calendar/references/source-manifest.json +314 -32
- package/skills/weaver-e10-ebuilder-form/SKILL.md +123 -0
- package/skills/weaver-e10-ebuilder-form/references/context-routing.md +90 -0
- package/skills/weaver-e10-ebuilder-form/references/custom-api.md +86 -0
- package/skills/weaver-e10-ebuilder-form/references/disabled-capabilities.md +50 -0
- package/skills/weaver-e10-ebuilder-form/references/fields-browser.md +100 -0
- package/skills/weaver-e10-ebuilder-form/references/list-nlist.md +91 -0
- package/skills/weaver-e10-ebuilder-form/references/openapi-read.md +74 -0
- package/skills/weaver-e10-ebuilder-form/references/openapi-write.md +86 -0
- package/skills/weaver-e10-ebuilder-form/references/source-manifest.json +107 -0
- package/skills/weaver-e10-esb/SKILL.md +1 -1
- package/skills/weaver-e10-esb/references/source-manifest.json +15 -14
- package/skills/weaver-e10-hrm/SKILL.md +1 -1
- package/skills/weaver-e10-hrm/references/source-manifest.json +357 -38
- package/skills/weaver-e10-jiuchuanhui/SKILL.md +22 -4
- package/skills/weaver-e10-jiuchuanhui/references/clue.md +4 -2
- package/skills/weaver-e10-jiuchuanhui/references/contact.md +1 -1
- package/skills/weaver-e10-jiuchuanhui/references/customer.md +3 -3
- package/skills/weaver-e10-jiuchuanhui/references/disabled-capabilities.md +1 -1
- package/skills/weaver-e10-jiuchuanhui/references/discovery-config.md +10 -10
- package/skills/weaver-e10-jiuchuanhui/references/general-helpers.md +3 -3
- package/skills/weaver-e10-jiuchuanhui/references/sale.md +7 -7
- package/skills/weaver-e10-jiuchuanhui/references/source-manifest.json +3 -3
- package/skills/weaver-e10-jucailin/SKILL.md +2 -2
- package/skills/weaver-e10-mail/SKILL.md +22 -6
- package/skills/weaver-e10-meeting/SKILL.md +22 -1
- package/skills/weaver-e10-okr/SKILL.md +139 -0
- package/skills/weaver-e10-okr/references/okr-align.md +59 -0
- package/skills/weaver-e10-okr/references/okr-keyresult.md +68 -0
- package/skills/weaver-e10-okr/references/okr-link-routing.md +67 -0
- package/skills/weaver-e10-okr/references/okr-query.md +105 -0
- package/skills/weaver-e10-okr/references/okr-write.md +94 -0
- package/skills/weaver-e10-okr/references/source-manifest.json +672 -0
- package/skills/weaver-e10-plan/SKILL.md +3 -3
- package/skills/weaver-e10-plan/references/plan-report-write.md +1 -1
- package/skills/weaver-e10-plan/references/source-manifest.json +20 -20
- package/skills/weaver-e10-qiyecheng/SKILL.md +21 -1
- package/skills/weaver-e10-qiyecheng/references/source-manifest.json +17 -17
- package/skills/weaver-e10-shared/SKILL.md +79 -11
- package/skills/weaver-e10-shared/references/e10-auth-and-session.md +41 -8
- package/skills/weaver-e10-shared/references/non-interactive-environment.md +78 -0
- package/skills/weaver-e10-shared/references/weaver-e10-installation.md +39 -0
- package/skills/weaver-e10-shijingran/SKILL.md +83 -0
- package/skills/weaver-e10-shijingran/references/field-resolution.md +105 -0
- package/skills/weaver-e10-shijingran/references/operations.md +154 -0
- package/skills/weaver-e10-shijingran/references/source-manifest.json +1380 -0
- package/skills/weaver-e10-shijingran/references/write-operations.md +90 -0
- package/skills/weaver-e10-skill-maker/SKILL.md +26 -9
- package/skills/weaver-e10-skill-maker/references/business-skill-generation.md +39 -8
- package/skills/weaver-e10-skill-maker/references/detector-validation.md +25 -8
- package/skills/weaver-e10-skill-maker/references/module-cli-generation.md +66 -5
- package/skills/weaver-e10-skill-maker/references/module-gap-audit-and-repair.md +86 -0
- package/skills/weaver-e10-skill-maker/references/source-update-detection.md +59 -5
- package/skills/weaver-e10-skill-maker/references/weaver-skill-style.md +16 -8
- package/skills/weaver-e10-wenshuding/SKILL.md +28 -4
- package/skills/weaver-e10-wenshuding/references/archive-search.md +14 -0
- package/skills/weaver-e10-wenshuding/references/source-manifest.json +27 -26
- package/skills/weaver-e10-workflow/SKILL.md +250 -0
- package/skills/weaver-e10-workflow/references/source-manifest.json +1062 -0
- package/skills/weaver-e10-workflow/references/workflow-agent-entry.md +96 -0
- package/skills/weaver-e10-workflow/references/workflow-batch.md +275 -0
- package/skills/weaver-e10-workflow/references/workflow-create.md +182 -0
- package/skills/weaver-e10-workflow/references/workflow-edit.md +106 -0
- package/skills/weaver-e10-workflow/references/workflow-memory.md +165 -0
- package/skills/weaver-e10-workflow/references/workflow-operate.md +153 -0
- package/skills/weaver-e10-workflow/references/workflow-query.md +821 -0
- package/skills/weaver-e10-workflow/references/workflow-share.md +117 -0
- package/skills/weaver-e10-workflow/references/workflow-view.md +189 -0
- package/skills/weaver-e10-yepiaotong/SKILL.md +28 -28
- package/skills/weaver-e10-yepiaotong/references/invoice-disabled-capabilities.md +11 -5
- package/skills/weaver-e10-yepiaotong/references/invoice-import.md +14 -8
- package/skills/weaver-e10-yepiaotong/references/invoice-issuing-examples.md +9 -43
- package/skills/weaver-e10-yepiaotong/references/invoice-issuing.md +8 -0
- package/skills/weaver-e10-yepiaotong/references/invoice-reim.md +13 -17
- package/skills/weaver-e10-yepiaotong/references/reim-api-reference.md +6 -7
- package/skills/weaver-e10-yepiaotong/references/reim-examples.md +2 -2
- package/skills/weaver-e10-yepiaotong/references/source-manifest.json +1155 -553
- package/skills/weaver-e10-yimiaoban/SKILL.md +56 -38
- package/skills/weaver-e10-yimiaoban/references/ding-write.md +8 -8
- package/skills/weaver-e10-yimiaoban/references/error-codes.md +16 -0
- package/skills/weaver-e10-yimiaoban/references/field-resolution.md +10 -10
- package/skills/weaver-e10-yimiaoban/references/file-user-i18n.md +12 -12
- package/skills/weaver-e10-yimiaoban/references/group-read.md +16 -16
- package/skills/weaver-e10-yimiaoban/references/group-write.md +17 -15
- package/skills/weaver-e10-yimiaoban/references/msg-read.md +21 -17
- package/skills/weaver-e10-yimiaoban/references/msg-write.md +10 -10
- package/skills/weaver-e10-yimiaoban/references/person.md +6 -6
- package/skills/weaver-e10-yimiaoban/references/safety-boundaries.md +15 -11
- package/skills/weaver-e10-yimiaoban/references/session-sysmsg.md +11 -10
- package/skills/weaver-e10-yimiaoban/references/source-manifest.json +2164 -154
- package/skills/weaver-e10-yimiaoban/references/user-remind.md +75 -0
- package/skills/weaver-e10-ziguanjia/SKILL.md +4 -2
- package/skills/weaver-e10-ziguanjia/references/source-manifest.json +99 -29
- package/tools/weaver-skill-detector-1.0.13/SKILL.md +253 -0
- package/tools/weaver-skill-detector-1.0.13/_meta.json +6 -0
- package/tools/weaver-skill-detector-1.0.13/references/checklist.md +298 -0
- package/tools/weaver-skill-detector-1.0.13/scripts/validate_skill.py +4190 -0
- package/dist/shortcuts/yimiaoban/operations/shared.js +0 -236
- package/docs/yimiaoban.md +0 -86
- package/skills/weaver-e10-calendar/product.json +0 -8
- package/skills/weaver-e10-esb/product.json +0 -8
- package/skills/weaver-e10-hrm/product.json +0 -8
- package/skills/weaver-e10-jiuchuanhui/product.json +0 -8
- package/skills/weaver-e10-jucailin/product.json +0 -8
- package/skills/weaver-e10-mail/product.json +0 -8
- package/skills/weaver-e10-meeting/product.json +0 -8
- package/skills/weaver-e10-plan/product.json +0 -8
- package/skills/weaver-e10-qiyecheng/product.json +0 -8
- package/skills/weaver-e10-skill-maker/product.json +0 -8
- package/skills/weaver-e10-wenshuding/product.json +0 -8
- package/skills/weaver-e10-yepiaotong/product.json +0 -8
- package/skills/weaver-e10-yepiaotong/references/invoice-red.md +0 -261
- package/skills/weaver-e10-yimiaoban/product.json +0 -8
- package/skills/weaver-e10-ziguanjia/product.json +0 -8
- /package/dist/shortcuts/{yimiaoban → im}/operations/msg-parse.js +0 -0
|
@@ -0,0 +1,4190 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
泛微技能检测工具 · 静态检测脚本
|
|
5
|
+
|
|
6
|
+
检测上传的 skill 压缩包(.zip) 或 Markdown 文件(.md) 是否为「符合 WorkBuddy 规范、
|
|
7
|
+
且通过泛微专属安全合规质检」的 skill。
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
python validate_skill.py <path_to_skill.zip|.md|directory> [--json] [--out report.md] [--html report.html]
|
|
11
|
+
[--market-catalog catalog.json] [--market-live] [--no-html]
|
|
12
|
+
[--platform {weaver,qwenwork}]
|
|
13
|
+
# 默认每次检测都会自动输出 HTML 报告到「输入同目录/<输入名>-report.html」(如 a.zip → a-report.html);
|
|
14
|
+
# 显式传 --html <path> 可自定义输出路径;--no-html 可完全关闭 HTML 输出(仅控制台/JSON)。
|
|
15
|
+
# --platform 显式指定目标发布平台(默认 weaver):
|
|
16
|
+
# weaver - 泛微生态(WorkBuddy 平台 + 泛微专属品牌/契约约束,原默认行为)
|
|
17
|
+
# qwenwork - 千问办公生态(按千问开发指南校验打包硬性、frontmatter 字段、连接器契约等)
|
|
18
|
+
|
|
19
|
+
检测维度:
|
|
20
|
+
A. 基础合规 (WorkBuddy 通用): SKILL.md / frontmatter / name(必须weaver前缀) / display_name(必须泛微前缀)
|
|
21
|
+
/ display_name_en / description / description_zh / description_en / version / author(泛微强制) / 命名规范
|
|
22
|
+
/ 打包结构: A1a Windows反斜杠路径(BLOCK) + A1b 目录层级不超过两级(BLOCK: 仅支持 根/二级目录/文件)
|
|
23
|
+
/ A1c 文件总数≤300(BLOCK) + A1d 仅纯文本(禁止任意位置二进制/压缩/媒体, BLOCK)
|
|
24
|
+
/ A1e SKILL.md 文件名大小写精确(BLOCK: 必须精确大写 SKILL.md, 禁止 skill.md/Skill.md 等变体)
|
|
25
|
+
/ A2 结构化文件可解析性(BLOCK: JSON/YAML/TOML) / A3 依赖引导完整性(WARN)
|
|
26
|
+
B. 专项质检 (泛微合规与安全): 默认登录地址、硬编码 IP、硬编码凭证、凭证来源、敏感词
|
|
27
|
+
/ API 请求凭证完整性(BLOCK: 发起 API 请求必须携带 Cookie 完整原始串+eteamsid+User-Agent(AgentType=,IsAgent=true))
|
|
28
|
+
G. 扩展安全 (对齐官方 expert-reviewer): G02危险命令 / G03内网域名 / G04个人路径+IDE残留+CDN@latest
|
|
29
|
+
/ C08平台虚假承诺 / BOM+CRLF编码规范
|
|
30
|
+
Q. 质量评估 (启发式): Q1路由触发清晰度 / Q2上下文效率 / Q3容错降级 / Q4字段字数控制
|
|
31
|
+
C. 威胁模型 (8 类) 与 D. 行为图谱: 暂时停用(用户要求移除,函数保留可随时恢复)
|
|
32
|
+
E. 技能能力清单: HTTP/API/文件读写/安装依赖/浏览器控制/环境变量/命令执行等
|
|
33
|
+
|
|
34
|
+
退出码:
|
|
35
|
+
0 通过 (PASS) 或 仅警告通过 (WARN)
|
|
36
|
+
1 不通过 (FAIL,存在阻断项)
|
|
37
|
+
2 输入非法 (文件不存在 / 无法识别)
|
|
38
|
+
|
|
39
|
+
检测维度:
|
|
40
|
+
A. 基础合规 (WorkBuddy 通用): SKILL.md / frontmatter / name(必须weaver前缀) / display_name(必须泛微前缀)
|
|
41
|
+
/ display_name_en / description / description_zh / description_en / version / author(泛微强制) / 命名规范
|
|
42
|
+
/ 打包结构: A1a Windows反斜杠路径(BLOCK) + A1b 目录层级不超过两级(BLOCK: 仅支持 根/二级目录/文件)
|
|
43
|
+
/ A1c 文件总数≤300(BLOCK) + A1d 仅纯文本(禁止任意位置二进制/压缩/媒体, BLOCK)
|
|
44
|
+
/ A1e SKILL.md 文件名大小写精确(BLOCK: 必须精确大写 SKILL.md, 禁止 skill.md/Skill.md 等变体)
|
|
45
|
+
/ A2 结构化文件可解析性(BLOCK: JSON/YAML/TOML) / A3 依赖引导完整性(WARN)
|
|
46
|
+
B. 专项质检 (泛微合规与安全): 默认登录地址、硬编码 IP、硬编码凭证、凭证来源、敏感词
|
|
47
|
+
/ API 请求凭证完整性(BLOCK: 发起 API 请求必须携带 Cookie 完整原始串+eteamsid+User-Agent(AgentType=,IsAgent=true))
|
|
48
|
+
G. 扩展安全 (对齐官方 expert-reviewer): G02危险命令 / G03内网域名 / G04个人路径+IDE残留+CDN@latest
|
|
49
|
+
/ C08平台虚假承诺 / BOM+CRLF编码规范
|
|
50
|
+
Q. 质量评估 (启发式): Q1路由触发清晰度 / Q2上下文效率 / Q3容错降级 / Q4字段字数控制
|
|
51
|
+
C. 威胁模型 (8 类) 与 D. 行为图谱: 暂时停用(用户要求移除,函数保留可随时恢复)
|
|
52
|
+
E. 技能能力清单: HTTP/API/文件读写/安装依赖/浏览器控制/环境变量/命令执行等
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import sys
|
|
56
|
+
import os
|
|
57
|
+
import re
|
|
58
|
+
import json
|
|
59
|
+
import zipfile
|
|
60
|
+
import tempfile
|
|
61
|
+
import shutil
|
|
62
|
+
import hashlib
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
import urllib.request
|
|
66
|
+
import urllib.parse
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# 检测规则常量
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# skillhub.cn 公开检索接口(市场可达性动态核查用,零第三方依赖,仅用标准库)
|
|
73
|
+
SKILLHUB_API = "https://api.skillhub.cn/api/skills"
|
|
74
|
+
|
|
75
|
+
# 泛微自用系统地址(严禁作为默认地址写死在 skill 中)
|
|
76
|
+
WEAVER_OWNED_DOMAINS = [
|
|
77
|
+
"e-cology.com.cn",
|
|
78
|
+
"e-weaver.com",
|
|
79
|
+
"weaver.com.cn",
|
|
80
|
+
"eteams.cn",
|
|
81
|
+
"weaver.com",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# 可接受的占位符 / 示例地址(不应判定为真实 IP)
|
|
85
|
+
SAFE_IP_PATTERNS = re.compile(
|
|
86
|
+
r"^(127\.0\.0\.1|0\.0\.0\.0|255\.255\.255\.255|10\.0\.0\.0|192\.168\.0\.0|172\.16\.0\.0)$"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 文本文件扩展名(仅扫描这些,避免二进制误读)
|
|
90
|
+
TEXT_EXTS = {
|
|
91
|
+
".md", ".markdown", ".txt", ".py", ".sh", ".bash", ".js", ".mjs", ".cjs", ".ts",
|
|
92
|
+
".jsx", ".tsx", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini", ".env",
|
|
93
|
+
".html", ".htm", ".css", ".scss", ".java", ".sql", ".xml", ".csv", ".rst",
|
|
94
|
+
".go", ".rb", ".php", ".ps1", ".bat", ".pl",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
# 纯文本允许清单(ZIP 包内只允许包含以下纯文本格式文件,其余一律视为违规)
|
|
98
|
+
TEXT_EXT_ALLOWLIST = {
|
|
99
|
+
# 文档 / 标记
|
|
100
|
+
"md", "markdown", "txt", "text", "rst", "adoc", "asc", "org",
|
|
101
|
+
# 源码 / 脚本
|
|
102
|
+
"py", "pyw", "js", "mjs", "cjs", "ts", "tsx", "jsx", "json", "jsonl",
|
|
103
|
+
"yaml", "yml", "toml", "ini", "cfg", "conf", "config", "sh", "bash", "zsh",
|
|
104
|
+
"ps1", "bat", "cmd", "html", "htm", "xhtml", "css", "scss", "less", "svg",
|
|
105
|
+
"xml", "csv", "tsv", "tab", "vue", "svelte", "ipynb",
|
|
106
|
+
# 编译型 / 其它文本源码
|
|
107
|
+
"c", "h", "cpp", "cc", "hpp", "java", "go", "rs", "rb", "php", "sql",
|
|
108
|
+
"r", "lua", "pl", "scala", "kt", "swift", "dart",
|
|
109
|
+
# 数据 / 配置 / 杂项文本
|
|
110
|
+
"proto", "graphql", "gql", "drawio", "lock", "log", "env", "env.example",
|
|
111
|
+
"gitignore", "gitattributes", "dockerignore", "editorconfig", "npmignore",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
# 已知二进制扩展名黑名单(命中即视为二进制文件,禁止打包)
|
|
115
|
+
BINARY_EXT_BLACKLIST = {
|
|
116
|
+
# 可执行 / 库
|
|
117
|
+
"exe", "dll", "so", "dylib", "sys", "ocx", "com", "msi", "class", "jar",
|
|
118
|
+
"pyc", "pyd", "pyo", "wasm", "node", "nupkg",
|
|
119
|
+
# 图片 / 媒体
|
|
120
|
+
"png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp", "ico", "heic",
|
|
121
|
+
"mp4", "mov", "avi", "mkv", "webm", "flv", "wmv", "mp3", "wav", "ogg",
|
|
122
|
+
"flac", "m4a", "aac", "wma", "bin",
|
|
123
|
+
# 压缩 / 归档
|
|
124
|
+
"zip", "tar", "gz", "tgz", "bz2", "xz", "7z", "rar", "zst", "iso", "img",
|
|
125
|
+
# 办公 / 文档二进制
|
|
126
|
+
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp",
|
|
127
|
+
# 数据库 / 数据
|
|
128
|
+
"db", "sqlite", "sqlite3", "mdb", "dat",
|
|
129
|
+
# 字体
|
|
130
|
+
"ttf", "otf", "woff", "woff2", "eot",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# 无扩展名但允许的常见纯文本文件名(避免误杀 LICENSE / Makefile 等)
|
|
134
|
+
KNOWN_TEXT_NOEXT = {
|
|
135
|
+
"license", "licence", "makefile", "dockerfile", "copying", "authors",
|
|
136
|
+
"changelog", "notice", "readme", "news", "todo",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# 二进制文件魔数(内容嗅探,防止「文本扩展名但实为二进制」漏网)
|
|
140
|
+
BINARY_MAGIC_PREFIXES = (
|
|
141
|
+
b"\x89PNG", b"\xff\xd8\xff", b"GIF87a", b"GIF89a", b"%PDF",
|
|
142
|
+
b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08", b"\x1f\x8b", b"Rar!",
|
|
143
|
+
b"\x37\x7a\xbc\xaf", b"RIFF", b"OggS", b"\xca\xfe\xba\xbe", b"\xfe\xed\xfa\xce",
|
|
144
|
+
b"\x4d\x5a", # MZ (Windows 可执行)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _sniff_binary(path: Path) -> bool:
|
|
149
|
+
"""读取前 512 字节做二进制嗅探:含空字节或命中魔数即判为二进制。"""
|
|
150
|
+
try:
|
|
151
|
+
with open(path, "rb") as f:
|
|
152
|
+
head = f.read(512)
|
|
153
|
+
except OSError:
|
|
154
|
+
return False
|
|
155
|
+
if not head:
|
|
156
|
+
return False
|
|
157
|
+
if b"\x00" in head:
|
|
158
|
+
return True
|
|
159
|
+
for m in BINARY_MAGIC_PREFIXES:
|
|
160
|
+
if head.startswith(m):
|
|
161
|
+
return True
|
|
162
|
+
return False
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# 硬编码凭证可疑模式(赋值型字面量)
|
|
166
|
+
# 负向前瞻 (?![\w_]+\s*\():排除 RHS 为函数调用(如 token = _parent_token(key))的误判;
|
|
167
|
+
# 真实硬编码字面量不会以「标识符(」开头,故该排除安全。
|
|
168
|
+
CREDENTIAL_PATTERNS = [
|
|
169
|
+
re.compile(r"(?i)(password|passwd|pwd)\s*[:=]\s*(?![\w_]+\s*\()['\"]?[^\s'\"]{4,}"),
|
|
170
|
+
re.compile(r"(?i)(api[_-]?key|apikey|secret|token|access[_-]?key)\s*[:=]\s*(?![\w_]+\s*\()['\"]?[A-Za-z0-9_\-]{8,}"),
|
|
171
|
+
re.compile(r"(?i)authorization\s*[:=]\s*['\"]?bearer\s+[A-Za-z0-9._\-]{8,}"),
|
|
172
|
+
re.compile(r"(?i)cookie\s*[:=]\s*['\"]?[A-Za-z0-9_=;.\-]{12,}"),
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# 凭证应通过 E10 插件获取的迹象(用于正向确认)
|
|
176
|
+
PLUGIN_CREDENTIAL_HINTS = [
|
|
177
|
+
"命名凭据", "e10 插件", "E10 插件", "ETEAMSID", "获取.*凭证", "凭据",
|
|
178
|
+
"plugin", "credential",
|
|
179
|
+
]
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# 运行时行为风险(B6/B7/B8):子进程、命令行传凭证、硬编码密钥解密本地凭证
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# 仅对脚本类扩展名做 AST 级检测,避免对文档正文误报
|
|
185
|
+
SCRIPT_EXTS = (".py", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx",
|
|
186
|
+
".sh", ".bash", ".bat", ".ps1", ".rb", ".pl")
|
|
187
|
+
|
|
188
|
+
# 敏感子进程调用(实际调用点;import child_process 不算调用,由 COMMAND_EXEC_RE 在威胁模型中识别)
|
|
189
|
+
RISKY_SUBPROCESS_RE = re.compile(
|
|
190
|
+
r"\b(spawnSync|spawn|execSync|execFileSync|execFile|exec)\s*\("
|
|
191
|
+
r"|\bsubprocess\.(Popen|run|call|check_output|check_call)\b",
|
|
192
|
+
re.IGNORECASE,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# 调用了包外脚本文件(.py/.sh 等拼在子进程参数里)
|
|
196
|
+
EXTERNAL_SCRIPT_RE = re.compile(
|
|
197
|
+
r"['\"][^'\"]*\.(py|sh|bash|bat|ps1|pl|rb|js|mjs|ts)['\"]", re.IGNORECASE)
|
|
198
|
+
|
|
199
|
+
# 子进程参数块内出现的凭证类字段(命令行明文传递)
|
|
200
|
+
CRED_IN_SUBPROC_RE = re.compile(
|
|
201
|
+
r"\b(ETEAMSID|eteamsid|token|session|cookie|password|passwd|"
|
|
202
|
+
r"secret|credential|auth)\b", re.IGNORECASE)
|
|
203
|
+
|
|
204
|
+
# AES / 解密调用
|
|
205
|
+
AES_DECRYPT_RE = re.compile(
|
|
206
|
+
r"aes-?256|createDecipheriv|createDecipher|decipher|cipher\.update|"
|
|
207
|
+
r"cipher\.final|AES\.(new|decrypt|createDecipheriv)|CryptoJS\.AES|"
|
|
208
|
+
r"cryptography.*decrypt|fromEncryptedString|\.decrypt\(", re.IGNORECASE)
|
|
209
|
+
|
|
210
|
+
# 疑似硬编码密钥常量(较长随机字符串字面量,如 'cow_eteams_auth_v1_8a3f...')
|
|
211
|
+
HARDCODED_SECRET_RE = re.compile(r"['\"][A-Za-z0-9_\-]{16,}['\"]")
|
|
212
|
+
|
|
213
|
+
# 本地敏感凭证路径(会话/凭证文件)
|
|
214
|
+
# 注意:第四分支要求路径上下文(引号/反引号/~/斜杠开头),避免把 x.key 这类
|
|
215
|
+
# 对象属性访问误判为本地凭证文件;统一用 [^\s'"] 排除空白/引号(含换行,不跨行)。
|
|
216
|
+
LOCAL_CRED_PATH_RE = re.compile(
|
|
217
|
+
r"(~?/?\.?(?:e10-cli|e10|eteams|weaver)[^\s'\"]*profiles[^\s'\"]*auth(?:[\"'\s\`]|$))"
|
|
218
|
+
r"|([^\s'\"]*profiles[^\s'\"]*/(?:auth|session|cookie|credential)(?:[\"'\s\`]|$))"
|
|
219
|
+
r"|([^\s'\"]*/(?:session|cookie|credential)[^\s'\"]*file(?:[\"'\s\`]|$))"
|
|
220
|
+
r"|([\"'`~][^\s'\"\`]*\.(?:auth|session|cookie|pem|key)(?:[\"'\s\`]|$))",
|
|
221
|
+
re.IGNORECASE)
|
|
222
|
+
|
|
223
|
+
TODO_PATTERN = re.compile(r"\[?TODO\]?|待补充|待填写|placeholder\s*=|示例填写", re.IGNORECASE)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# 威胁模型 / 行为图谱 / 技能能力清单(新增)
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
# 说明:以下规则用于从 skill 源码/文档中识别运行时行为、威胁分类与能力清单,
|
|
230
|
+
# 辅助提交前做「威胁捕获模型视图」与「威胁行为图谱」式自检。
|
|
231
|
+
# 注意:C(威胁捕获模型视图)/ D(威胁行为图谱)已重新启用,详见 main() 内调用与 render_report。
|
|
232
|
+
|
|
233
|
+
# 安装依赖 / 供应链风险
|
|
234
|
+
DEPENDENCY_INSTALL_RE = re.compile(
|
|
235
|
+
r"\b(pip\s+install|pip3\s+install|npm\s+install|npm\s+i\b|yarn\s+add|"
|
|
236
|
+
r"pnpm\s+add|pnpm\s+install|composer\s+require|gem\s+install|"
|
|
237
|
+
r"apt-get\s+install|yum\s+install|brew\s+install)\b",
|
|
238
|
+
re.IGNORECASE)
|
|
239
|
+
|
|
240
|
+
# 命令执行风险(含 eval / system / os.system 等)
|
|
241
|
+
COMMAND_EXEC_RE = re.compile(
|
|
242
|
+
r"\b(eval|exec|system|os\.system|subprocess\.call|subprocess\.run|"
|
|
243
|
+
r"subprocess\.Popen|subprocess\.check_output|subprocess\.check_call|"
|
|
244
|
+
r"spawnSync|spawn|execSync|execFileSync|execFile|exec\s*\(|"
|
|
245
|
+
r"child_process|powershell|cmd\.exe|bash\s+-c|sh\s+-c)\b",
|
|
246
|
+
re.IGNORECASE)
|
|
247
|
+
|
|
248
|
+
# 网络请求与数据外传(避免把 dict.get() 等误判为 HTTP GET)
|
|
249
|
+
NETWORK_RE = re.compile(
|
|
250
|
+
r"\b(requests\.(get|post|put|patch|delete)|urllib\.request|http\.client|"
|
|
251
|
+
r"httpx\b|aiohttp\b|axios\.(get|post|put|patch|delete)|fetch\(|"
|
|
252
|
+
r"XMLHttpRequest|curl\b|wget\b|Invoke-WebRequest|Invoke-RestMethod|"
|
|
253
|
+
r"\.(post|put|patch|delete)\s*\()")
|
|
254
|
+
EXTERNAL_API_RE = re.compile(
|
|
255
|
+
r"\b(api\.|openapi|external\s+api|third.?party|第三方接口|调用.*接口|"
|
|
256
|
+
r"https?://[^\s'\"]+/(api|v\d+|rest|graphql|webhook))",
|
|
257
|
+
re.IGNORECASE)
|
|
258
|
+
|
|
259
|
+
# 文件操作(读 / 写 / 删)
|
|
260
|
+
FILE_IO_RE = re.compile(
|
|
261
|
+
r"\b(open\s*\(|read_text\(|write_text\(|read_file\(|write_file\(|"
|
|
262
|
+
r"readFileSync|writeFileSync|readFile\(|writeFile\(|fs\.read|fs\.write|"
|
|
263
|
+
r"os\.path\.join|Path\(|with\s+open|\.open\s*\()\b",
|
|
264
|
+
re.IGNORECASE)
|
|
265
|
+
|
|
266
|
+
# 敏感路径(凭证、系统、隐私数据)
|
|
267
|
+
# 注意:这里的 `[^\s'\"]*` 一律写成有界量词 `{0,N}`。
|
|
268
|
+
# 无界量词 + 末尾定界在超长单行(压缩 JS / 无换行数据)上会退化成逐起点回溯的 O(n²),
|
|
269
|
+
# 11 MB 单行曾让扫描卡死数百秒;有界后单次尝试被限制在 N 个字符内,整体降为线性。
|
|
270
|
+
# 写法约定(性能关键):每条分支都**先锚定字面量 token**,再向后做「有界尾部 + 定界」校验。
|
|
271
|
+
# 反例(切勿回退):`[^\s'\"]{0,120}(?:\.ssh|\.aws|...)` 这种「可空前缀 + 有界量词」写法,
|
|
272
|
+
# 引擎会在每个起点试 120 种前缀长度 → 单次扫描退化为准 O(n·120),1.6 MB 单行需 90+ 秒。
|
|
273
|
+
SENSITIVE_PATH_RE = re.compile(
|
|
274
|
+
# ① 泛微 / eteams 客户端凭证目录(如 ~/.e10-cli/profiles/<x>/auth)
|
|
275
|
+
r"((?:e10-cli|e10|eteams|weaver)[^\s'\"]{0,120}profiles[^\s'\"]{0,120}auth(?:[\"'\s`]|$))"
|
|
276
|
+
# ② .ssh / .aws / .docker 等敏感目录本身,或其下的配置文件(如 .aws/credentials)
|
|
277
|
+
r"|((?:\.ssh|\.aws|\.kube|\.docker|\.gitconfig|\.npmrc|"
|
|
278
|
+
r"\.pypirc|id_rsa|id_dsa|known_hosts|passwd|shadow|sam)"
|
|
279
|
+
r"(?:/[^\s'\"]{0,60})?(?:[\"'\s`]|$))"
|
|
280
|
+
# ③ profiles/<x>/{auth,session,cookie,credential} 形态
|
|
281
|
+
r"|(profiles[^\s'\"]{0,60}/(?:auth|session|cookie|credential)(?:[\"'\s`]|$))",
|
|
282
|
+
re.IGNORECASE)
|
|
283
|
+
|
|
284
|
+
# 浏览器控制
|
|
285
|
+
BROWSER_CONTROL_RE = re.compile(
|
|
286
|
+
r"\b(playwright|puppeteer|selenium|webdriver|chromium|chrome|edge|"
|
|
287
|
+
r"browserType|launch\(|new_context\(|new_page\()\b",
|
|
288
|
+
re.IGNORECASE)
|
|
289
|
+
|
|
290
|
+
# 沙箱绕过参数
|
|
291
|
+
SANDBOX_BYPASS_RE = re.compile(
|
|
292
|
+
r"--no-sandbox|--disable-gpu|--disable-setuid-sandbox|--disable-dev-shm-usage|"
|
|
293
|
+
r"--no-first-run|--single-process|--disable-web-security|--disable-features", re.IGNORECASE)
|
|
294
|
+
|
|
295
|
+
# dangerouslyDisableSandbox + run_in_background(跨行匹配用)
|
|
296
|
+
DISABLE_SANDBOX_FLAG_RE = re.compile(r"dangerouslyDisableSandbox", re.IGNORECASE)
|
|
297
|
+
RUN_BACKGROUND_FLAG_RE = re.compile(r"run_in_background", re.IGNORECASE)
|
|
298
|
+
|
|
299
|
+
# 环境变量读取
|
|
300
|
+
ENV_READ_RE = re.compile(
|
|
301
|
+
r"\b(os\.environ|process\.env|env\.|getenv|ENV\[|os\.getenv|"
|
|
302
|
+
r"process\.env\[|dotenv|load_dotenv)\b",
|
|
303
|
+
re.IGNORECASE)
|
|
304
|
+
|
|
305
|
+
# Prompt 注入 / 诱导用户贴凭证(限定强诱导动词,避免“提供登录地址”等正常说明误报)
|
|
306
|
+
PROMPT_CRED_RE = re.compile(
|
|
307
|
+
r"(ETEAMSID|token|session|cookie|password|passwd|secret|credential)\b"
|
|
308
|
+
r"[^。,\n]{0,35}(粘贴|贴到|贴入|贴进|贴出来|发给我|发过来|发送给|"
|
|
309
|
+
r"告诉我|输入.*对话|贴到对话|贴进对话)", re.IGNORECASE)
|
|
310
|
+
# 否定/禁止性安全声明(如「不要要求用户直接粘贴 ETEAMSID」),属合规正向约束,非诱导贴凭证
|
|
311
|
+
NEGATION_SAFE_RE = re.compile(
|
|
312
|
+
r"(不要|禁止|严禁|不得|切勿|请勿|不可|避免|不允许|拒绝|无须|无需|不必)", re.IGNORECASE)
|
|
313
|
+
PROMPT_INJECTION_RE = re.compile(
|
|
314
|
+
r"(忽略.*指令|ignore\s+previous\s+instructions|忽略.*提示|"
|
|
315
|
+
r"\bDAN\b|\bjailbreak\b|越狱|绕过.*限制|你是.*现在.*需要)", re.IGNORECASE)
|
|
316
|
+
|
|
317
|
+
# 远程脚本下载执行
|
|
318
|
+
REMOTE_SCRIPT_EXEC_RE = re.compile(
|
|
319
|
+
r"(curl\s+[^|]*\|\s*(bash|sh|python|python3|powershell)|"
|
|
320
|
+
r"wget\s+.*\|\s*(bash|sh)|"
|
|
321
|
+
r"powershell\s+-enc|iex\s+\(Invoke-WebRequest|"
|
|
322
|
+
r"Invoke-Expression|"
|
|
323
|
+
r"https?://[^\s'\"]+\.(py|sh|ps1|bat|js|mjs)\b)",
|
|
324
|
+
re.IGNORECASE)
|
|
325
|
+
|
|
326
|
+
# 可疑编码 / 混淆(不再裸匹配长串 base64,避免 URL 路径误报)
|
|
327
|
+
OBFUSCATION_RE = re.compile(
|
|
328
|
+
r"(eval\s*\(\s*(atob|btoa|Base64|decodeURIComponent|unescape)|"
|
|
329
|
+
r"atob\s*\(|btoa\s*\(|base64\s+--decode|Base64\.decode|Base64\.encode|"
|
|
330
|
+
r"\\\\x[0-9a-fA-F]{2}|\\\\u[0-9a-fA-F]{4}|"
|
|
331
|
+
r"(\\x[0-9a-fA-F]{2}){8,})", re.IGNORECASE)
|
|
332
|
+
|
|
333
|
+
# 其他高风险:屏幕截图、键盘记录、剪贴板读取、摄像头/麦克风
|
|
334
|
+
OTHER_PRIVACY_RE = re.compile(
|
|
335
|
+
r"\b(screenshot|keylogger|clipboard|camera|microphone|录音|录屏|截屏|"
|
|
336
|
+
r"getUserMedia|navigator\.mediaDevices)\b",
|
|
337
|
+
re.IGNORECASE)
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# 官方 expert-reviewer 对齐补充检测规则(G 组安全 + C 组展示 + A 组结构 + Q 组质量)
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
# G02: 危险 shell 命令(rm -rf /、curl|sh、sudo、chmod 777、dd、fork bomb 等)
|
|
344
|
+
DANGEROUS_CMD_RE = re.compile(
|
|
345
|
+
r"\brm\s+-rf\s+/(?!\w)|" # rm -rf /
|
|
346
|
+
r"\brm\s+-rf\s+~(?!\w)|" # rm -rf ~
|
|
347
|
+
r"\brm\s+-rf\s+\*(?!\w)|" # rm -rf *
|
|
348
|
+
r":\(\)\s*\{.*;\}\s*:|&?\s*\};:|" # fork bomb :(){ :|:& };:
|
|
349
|
+
r"\bcurl\s+[^|]*\|\s*(?:bash|sh|python|python3)\b|"
|
|
350
|
+
r"\bwget\s+[^|]*\|\s*(?:bash|sh)\b|"
|
|
351
|
+
r"\bsudo\s+(?:rm|chmod|chown|dd|mkfs|kill|reboot|shutdown)\b|"
|
|
352
|
+
r"\bchmod\s+777\s+/(?!\w)|" # chmod 777 /
|
|
353
|
+
r"\bdd\s+if=.*of=/dev/|"
|
|
354
|
+
r"\bmkfs\.\w+\s+/dev/|"
|
|
355
|
+
r">\s*/dev/sda\b|"
|
|
356
|
+
r"\bshutdown\s+-[rh]|"
|
|
357
|
+
r"\breboot\b|"
|
|
358
|
+
r"\bkill\s+-9\s+-?\d+\s*$",
|
|
359
|
+
re.IGNORECASE)
|
|
360
|
+
|
|
361
|
+
# G04a: 开发者个人路径残留(/Users/xxx/、/home/xxx/、C:\Users\xxx\)
|
|
362
|
+
PERSONAL_PATH_RE = re.compile(
|
|
363
|
+
r"/Users/[a-zA-Z0-9._-]+/" # macOS: /Users/tangbin/
|
|
364
|
+
r"|/home/[a-zA-Z0-9._-]+/" # Linux: /home/user/
|
|
365
|
+
r"|C:\\Users\\[a-zA-Z0-9._-]+\\\\" # Windows: C:\Users\user\
|
|
366
|
+
r"|C:/Users/[a-zA-Z0-9._-]+/", # Windows forward slash
|
|
367
|
+
re.IGNORECASE)
|
|
368
|
+
|
|
369
|
+
# G04b: 其他 IDE / 平台配置目录残留
|
|
370
|
+
IDE_DIR_RE = re.compile(
|
|
371
|
+
r"\.cursor/|\.vscode/|\.idea/|\.codebuddy/|\.fleet/|"
|
|
372
|
+
r"\.claude/|\.aider/|\.github/|\.gitlab/|"
|
|
373
|
+
r"\.DS_Store|Thumbs\.db",
|
|
374
|
+
re.IGNORECASE)
|
|
375
|
+
|
|
376
|
+
# G04c: CDN @latest 未锁定版本(jsdelivr/unpkg/cdnjs 等)
|
|
377
|
+
CDN_LATEST_RE = re.compile(
|
|
378
|
+
r"https?://(?:cdn\.jsdelivr\.net|unpkg\.com|cdnjs\.cloudflare\.com|"
|
|
379
|
+
r"cdn\.tailwindcss\.com|esm\.sh)/[^\s'\"]+@latest\b",
|
|
380
|
+
re.IGNORECASE)
|
|
381
|
+
|
|
382
|
+
# G03: 扩展内网域名 / IP 段(除泛微自用域名外,补充通用内网域名)
|
|
383
|
+
# 注意:10.x / 172.16-31.x / 192.168.x 由 B2 IP 检查覆盖,此处仅补充域名
|
|
384
|
+
INTERNAL_DOMAIN_RE = re.compile(
|
|
385
|
+
r"\b[\w.-]+\.("
|
|
386
|
+
r"woa\.com|oa\.com|tencent\.com|"
|
|
387
|
+
r"internal|local|localhost|intranet|"
|
|
388
|
+
r"corp|privatesubnet|test\.local"
|
|
389
|
+
r")\b",
|
|
390
|
+
re.IGNORECASE)
|
|
391
|
+
|
|
392
|
+
# C08: 平台虚假技术承诺(本地AI/离线运行/不上云/数据不出本机 等)
|
|
393
|
+
FALSE_PROMISE_RE = re.compile(
|
|
394
|
+
r"本地AI|本地运行|离线运行|不上云|完全离线|"
|
|
395
|
+
r"无需联网|不联网|纯本地|数据不出本机|"
|
|
396
|
+
r"100%.*私密|绝对.*安全|永不泄露|"
|
|
397
|
+
r"完全私有化部署.*无需|零云端|no.*cloud|"
|
|
398
|
+
r"fully\s+offline|local\s+only|runs?\s+locally\s+only",
|
|
399
|
+
re.IGNORECASE)
|
|
400
|
+
|
|
401
|
+
# BOM 检测:UTF-8 BOM (EF BB BF)
|
|
402
|
+
BOM_RE = re.compile(b"\xef\xbb\xbf")
|
|
403
|
+
# CRLF 行尾
|
|
404
|
+
CRLF_RE = re.compile(b"\r\n")
|
|
405
|
+
|
|
406
|
+
# 结构化配置文件扩展名(可解析性检查)
|
|
407
|
+
STRUCTURED_EXTS = (".json", ".yaml", ".yml", ".toml")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# 工具函数
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def log(msg):
|
|
415
|
+
sys.stderr.write(msg + "\n")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def extract_frontmatter(content):
|
|
419
|
+
"""返回 (frontmatter_text, body_text, ok)。"""
|
|
420
|
+
if not content.startswith("---"):
|
|
421
|
+
return None, content, False
|
|
422
|
+
m = re.match(r"^---\s*\n(.*?)\n---\s*\n?(.*)$", content, re.DOTALL)
|
|
423
|
+
if not m:
|
|
424
|
+
return None, content, False
|
|
425
|
+
return m.group(1), m.group(2), True
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def parse_frontmatter_field(fm, key):
|
|
429
|
+
"""宽松解析 frontmatter **顶层**单行字段(容忍引号与不整齐缩进)。
|
|
430
|
+
|
|
431
|
+
仅认可顶层写法(行首顶格 `key: ...`),不认可嵌套写法——例如
|
|
432
|
+
`metadata:\\n author: ...` 中的 author 不会被读取(视为缺失)。
|
|
433
|
+
合作方规范:所有字段(name/version/author/description 等)均为顶层,
|
|
434
|
+
不存在 metadata 块;嵌套写法由 check_frontmatter_flat_structure 单独阻断。
|
|
435
|
+
"""
|
|
436
|
+
m = re.search(r"^" + re.escape(key) + r"\s*:\s*(.*)$", fm, re.MULTILINE)
|
|
437
|
+
if not m:
|
|
438
|
+
return None
|
|
439
|
+
val = m.group(1).strip()
|
|
440
|
+
# 去掉首尾引号
|
|
441
|
+
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
|
|
442
|
+
val = val[1:-1]
|
|
443
|
+
return val
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def get_field(fm, *keys):
|
|
447
|
+
"""按顺序读取多个候选字段名,返回首个非空值。
|
|
448
|
+
|
|
449
|
+
用于兼容不同平台/规范的字段命名差异,例如:
|
|
450
|
+
get_field(fm, "display_name", "displayName")
|
|
451
|
+
# 优先取 display_name,缺失时回退取 displayName
|
|
452
|
+
全部候选都缺失则返回 None。
|
|
453
|
+
"""
|
|
454
|
+
for k in keys:
|
|
455
|
+
if not k:
|
|
456
|
+
continue
|
|
457
|
+
val = parse_frontmatter_field(fm, k)
|
|
458
|
+
if val:
|
|
459
|
+
return val, k # 同时返回命中字段名用于提示
|
|
460
|
+
return None, (keys[0] if keys else "")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def check_frontmatter_flat_structure(fm, checks):
|
|
464
|
+
"""A2m frontmatter 顶层结构(禁止 metadata 嵌套)。
|
|
465
|
+
|
|
466
|
+
合作方规范:所有字段均为顶层,frontmatter 中不应出现 `metadata:` 块。
|
|
467
|
+
若出现嵌套且其中包含关键字段(author/version/name/description 等),
|
|
468
|
+
检测器按顶层读取会判缺失,因此嵌套写法必须 BLOCK 纠正;metadata 块内
|
|
469
|
+
无关键字段则 WARN 提示提升到顶层。
|
|
470
|
+
"""
|
|
471
|
+
meta_idx = None
|
|
472
|
+
for i, ln in enumerate(fm.splitlines()):
|
|
473
|
+
if re.match(r"^metadata\s*:", ln):
|
|
474
|
+
meta_idx = i
|
|
475
|
+
break
|
|
476
|
+
if meta_idx is None:
|
|
477
|
+
return
|
|
478
|
+
lines = fm.splitlines()
|
|
479
|
+
inner = []
|
|
480
|
+
for ln in lines[meta_idx + 1:]:
|
|
481
|
+
if ln.strip() and not (ln.startswith(" ") or ln.startswith("\t")):
|
|
482
|
+
break
|
|
483
|
+
if ln.strip():
|
|
484
|
+
inner.append(ln.strip())
|
|
485
|
+
keys_in_meta = re.findall(r"^([\w-]+)\s*:", "\n".join(inner), re.MULTILINE)
|
|
486
|
+
CRITICAL_KEYS = ("author", "version", "name", "description", "description_zh",
|
|
487
|
+
"description_en", "display_name", "display_name_en")
|
|
488
|
+
blocked = [k for k in CRITICAL_KEYS if k in keys_in_meta]
|
|
489
|
+
if blocked:
|
|
490
|
+
checks.append(Check(
|
|
491
|
+
"基础合规", "frontmatter 顶层结构(禁止metadata嵌套)", "FAIL", "BLOCK",
|
|
492
|
+
f"frontmatter 不允许 `metadata:` 嵌套块,字段 {blocked} 必须写在顶层。"
|
|
493
|
+
"嵌套写法不会被识别,将视为字段缺失。",
|
|
494
|
+
suggestion="删除 `metadata:` 层级,改为顶层字段,例如:\n"
|
|
495
|
+
"author: 泛微网络科技股份有限公司\nversion: 1.0.0"))
|
|
496
|
+
else:
|
|
497
|
+
checks.append(Check(
|
|
498
|
+
"基础合规", "frontmatter 顶层结构(禁止metadata嵌套)", "WARN", "MEDIUM",
|
|
499
|
+
"frontmatter 中不应包含 `metadata:` 嵌套块;所有字段(name/version/author 等)均为顶层字段。",
|
|
500
|
+
suggestion="删除 `metadata:` 层级,把其中的字段提升到顶层。"))
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def check_frontmatter_yaml_syntax(fm, checks):
|
|
504
|
+
"""A2n frontmatter YAML 语法真解析(BLOCK)。
|
|
505
|
+
|
|
506
|
+
平台上传时用真 YAML 解析器(Go gopkg.in/yaml)解析 SKILL.md frontmatter,
|
|
507
|
+
典型报错「yaml: line N: mapping values are not allowed in this context」,
|
|
508
|
+
常见触发:① 一行挤多个键值对(`name: xxx display_name: yyy`,粘贴丢换行);
|
|
509
|
+
② 值中含未加引号的 `: `(如 `description: 使用 xxx: yyy`);③ Tab 缩进。
|
|
510
|
+
本检查双层实现:PyYAML 可用时真解析并精确报行号;否则降级为启发式扫描,
|
|
511
|
+
覆盖上述三类高频错误(零第三方依赖,保证可移植)。
|
|
512
|
+
"""
|
|
513
|
+
lines = fm.splitlines()
|
|
514
|
+
errors = []
|
|
515
|
+
|
|
516
|
+
try:
|
|
517
|
+
import yaml # noqa: F401
|
|
518
|
+
has_yaml = True
|
|
519
|
+
except ImportError:
|
|
520
|
+
has_yaml = False
|
|
521
|
+
|
|
522
|
+
if has_yaml:
|
|
523
|
+
try:
|
|
524
|
+
data = yaml.safe_load(fm)
|
|
525
|
+
if data is None:
|
|
526
|
+
errors.append("frontmatter 解析结果为空(仅含注释或空白)")
|
|
527
|
+
elif not isinstance(data, dict):
|
|
528
|
+
errors.append(f"frontmatter 解析结果不是键值映射(得到 {type(data).__name__}),"
|
|
529
|
+
"顶层必须是一组 `key: value`")
|
|
530
|
+
except yaml.YAMLError as e:
|
|
531
|
+
mark = getattr(e, "problem_mark", None)
|
|
532
|
+
if mark is not None and mark.line < len(lines):
|
|
533
|
+
ctx = lines[mark.line].strip()[:60]
|
|
534
|
+
errors.append(f"YAML 解析失败(第 {mark.line + 1} 行,near: `{ctx}`):"
|
|
535
|
+
f"{getattr(e, 'problem', '') or str(e)}")
|
|
536
|
+
else:
|
|
537
|
+
errors.append(f"YAML 解析失败:{e}")
|
|
538
|
+
else:
|
|
539
|
+
# 降级启发式:覆盖 Go yaml 报 mapping values are not allowed 的高频场景
|
|
540
|
+
for i, line in enumerate(lines, start=1):
|
|
541
|
+
stripped = line.strip()
|
|
542
|
+
if not stripped or stripped.startswith("#"):
|
|
543
|
+
continue
|
|
544
|
+
indent = line[:len(line) - len(line.lstrip())]
|
|
545
|
+
if "\t" in indent:
|
|
546
|
+
errors.append(f"第 {i} 行: 行首使用 Tab 缩进(YAML 仅允许空格缩进): `{stripped[:60]}`")
|
|
547
|
+
continue
|
|
548
|
+
# 仅检查顶层键行(行首顶格 key: value)
|
|
549
|
+
m = re.match(r"^([A-Za-z_][\w-]*)\s*:\s*(.*)$", line)
|
|
550
|
+
if not m:
|
|
551
|
+
continue
|
|
552
|
+
key, val = m.group(1), m.group(2).strip()
|
|
553
|
+
if not val:
|
|
554
|
+
continue
|
|
555
|
+
quoted = (val.startswith('"') and val.endswith('"')) or \
|
|
556
|
+
(val.startswith("'") and val.endswith("'"))
|
|
557
|
+
if quoted:
|
|
558
|
+
# 整体引号包裹的值内部 `: ` 合法
|
|
559
|
+
continue
|
|
560
|
+
# ① 一行多键:值中再出现 ` word: `(键名后冒号+空格)
|
|
561
|
+
mm = re.search(r"(?:^|\s)([A-Za-z_][\w-]*)\s*:\s", val)
|
|
562
|
+
if mm:
|
|
563
|
+
errors.append(
|
|
564
|
+
f"第 {i} 行: 一行内出现多个键值对(`{key}: … {mm.group(1)}: …`)。"
|
|
565
|
+
"YAML 不允许在值中继续写键——这正是平台报 "
|
|
566
|
+
"`mapping values are not allowed in this context` 的最常见原因"
|
|
567
|
+
"(多为粘贴时丢失换行)。"
|
|
568
|
+
)
|
|
569
|
+
continue
|
|
570
|
+
# ② 值中含裸冒号+空格(如 `description: 支持 xx: yy 模式`)
|
|
571
|
+
if re.search(r":\s", val) and not re.match(r"https?://", val):
|
|
572
|
+
errors.append(
|
|
573
|
+
f"第 {i} 行: `{key}` 的值中含未加引号的冒号+空格(`{stripped[:60]}`),"
|
|
574
|
+
"会被 YAML 解析器误判为新键。请用引号包裹整个值,或改用中文全角冒号「:」。"
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
if errors:
|
|
578
|
+
checks.append(Check(
|
|
579
|
+
"基础合规", "frontmatter YAML 语法", "FAIL", "BLOCK",
|
|
580
|
+
"SKILL.md frontmatter 存在 YAML 语法错误,平台上传时会直接报 "
|
|
581
|
+
"「frontmatter 无法解析 / mapping values are not allowed in this context」。",
|
|
582
|
+
locations=errors[:10],
|
|
583
|
+
suggestion=(
|
|
584
|
+
"常见修复:① 每个字段独占一行(`key: value`),不要把多个字段挤在一行;"
|
|
585
|
+
"② 值中含英文冒号+空格时用引号包裹整个值(`description: '支持 xx: yy'`);"
|
|
586
|
+
"③ 缩进只用空格,不用 Tab;④ 冒号后必须有一个空格。"
|
|
587
|
+
)
|
|
588
|
+
))
|
|
589
|
+
else:
|
|
590
|
+
checks.append(Check(
|
|
591
|
+
"基础合规", "frontmatter YAML 语法", "PASS", "INFO",
|
|
592
|
+
"frontmatter YAML 语法校验通过"
|
|
593
|
+
+ ("(PyYAML 真解析)" if has_yaml else "(启发式扫描,未安装 PyYAML)") + "。"
|
|
594
|
+
))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# 单文件参与正则扫描的最大字节数(超出部分截断)。
|
|
598
|
+
# 背景:超大文本(如 11 MB 单行数据文件)会让扫描耗时从毫秒级膨胀到分钟级。
|
|
599
|
+
MAX_SCAN_FILE_BYTES = 4 * 1024 * 1024
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def iter_text_files(root: Path):
|
|
603
|
+
"""遍历 root 下所有文本文件,yield (rel_path, text)。
|
|
604
|
+
|
|
605
|
+
超大文件按 MAX_SCAN_FILE_BYTES 截断后再参与扫描:既避免病态耗时,
|
|
606
|
+
又不影响检测结论(威胁特征几乎总在前若干 KB 内出现;扫描顺序覆盖全部文件)。
|
|
607
|
+
"""
|
|
608
|
+
for p in root.rglob("*"):
|
|
609
|
+
if not p.is_file():
|
|
610
|
+
continue
|
|
611
|
+
if p.suffix.lower() not in TEXT_EXTS:
|
|
612
|
+
continue
|
|
613
|
+
try:
|
|
614
|
+
text = p.read_text(encoding="utf-8")
|
|
615
|
+
except (UnicodeDecodeError, OSError):
|
|
616
|
+
# 二进制或无法解码,跳过
|
|
617
|
+
continue
|
|
618
|
+
except MemoryError:
|
|
619
|
+
# 超大/异常文件:直接跳过,避免拖垮整体扫描
|
|
620
|
+
continue
|
|
621
|
+
if len(text.encode("utf-8", errors="ignore")) > MAX_SCAN_FILE_BYTES:
|
|
622
|
+
text = _truncate_chars(text, MAX_SCAN_FILE_BYTES)
|
|
623
|
+
yield p.relative_to(root), text
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _truncate_chars(text: str, max_bytes: int) -> str:
|
|
627
|
+
"""按字节预算安全截断文本(不切断多字节字符的语义,直接按字符切片后解码回退)。"""
|
|
628
|
+
encoded = text.encode("utf-8", errors="ignore")
|
|
629
|
+
if len(encoded) <= max_bytes:
|
|
630
|
+
return text
|
|
631
|
+
return encoded[:max_bytes].decode("utf-8", errors="ignore")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# 单行参与正则扫描的最大字符数(超出部分截断,行号保持不变)。
|
|
635
|
+
# 背景:部分检测正则含 `[^\\s'\"]*` 这类可回溯量词,在超长单行(压缩 JS、
|
|
636
|
+
# 无换行数据文件)上会退化成逐起点回溯的 O(n²),11 MB 单行曾导致扫描卡死数百秒。
|
|
637
|
+
# 截断后行号一一对应,正常代码行远小于阈值,检测结论不受影响。
|
|
638
|
+
SCAN_LINE_MAX_CHARS = 4000
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def scan_lines(text: str, max_chars: int = SCAN_LINE_MAX_CHARS):
|
|
642
|
+
"""返回与 splitlines() 行号一一对应的截断行列表,供所有逐行正则扫描使用。"""
|
|
643
|
+
return [ln if len(ln) <= max_chars else ln[:max_chars]
|
|
644
|
+
for ln in text.splitlines()]
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def is_safe_ip(ip):
|
|
648
|
+
"""判断是否为明显占位/回环地址,避免误报。"""
|
|
649
|
+
if SAFE_IP_PATTERNS.match(ip):
|
|
650
|
+
return True
|
|
651
|
+
# 数字段中带 x / * / 0 占据多段 视为占位
|
|
652
|
+
if re.search(r"\bx\b|\*|example", ip):
|
|
653
|
+
return True
|
|
654
|
+
return False
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
# ---------------------------------------------------------------------------
|
|
658
|
+
# 检测主流程
|
|
659
|
+
# ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
class Check:
|
|
662
|
+
def __init__(self, category, name, status, severity, detail, locations=None, suggestion=None, extra=None):
|
|
663
|
+
self.category = category
|
|
664
|
+
self.name = name
|
|
665
|
+
self.status = status # PASS / FAIL / WARN / INFO
|
|
666
|
+
self.severity = severity # BLOCK / HIGH / MEDIUM / LOW / INFO
|
|
667
|
+
self.detail = detail
|
|
668
|
+
self.locations = locations or []
|
|
669
|
+
self.suggestion = suggestion
|
|
670
|
+
self.extra = extra # 可选:结构化附加数据(如依赖市场可达性)
|
|
671
|
+
|
|
672
|
+
def to_dict(self):
|
|
673
|
+
return {
|
|
674
|
+
"category": self.category,
|
|
675
|
+
"name": self.name,
|
|
676
|
+
"status": self.status,
|
|
677
|
+
"severity": self.severity,
|
|
678
|
+
"detail": self.detail,
|
|
679
|
+
"locations": self.locations,
|
|
680
|
+
"suggestion": self.suggestion,
|
|
681
|
+
"extra": self.extra,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def prepare_skill_source(target: Path):
|
|
686
|
+
"""
|
|
687
|
+
根据输入类型准备 skill 源码目录。
|
|
688
|
+
返回 (skill_root: Path, source_type: str, cleanup: bool, error: str, zip_raw_names: list)
|
|
689
|
+
source_type ∈ {'directory','zip','markdown'}
|
|
690
|
+
zip_raw_names: 对 zip 输入返回原始 namelist(保留 zip 条目中的反斜杠等字面字符),
|
|
691
|
+
用于 A1a 跨平台准确判定;其他类型为 None。
|
|
692
|
+
"""
|
|
693
|
+
if not target.exists():
|
|
694
|
+
return None, None, False, f"文件/目录不存在: {target}", None
|
|
695
|
+
|
|
696
|
+
if target.is_dir():
|
|
697
|
+
return target, "directory", False, None, None
|
|
698
|
+
|
|
699
|
+
suffix = target.suffix.lower()
|
|
700
|
+
if suffix == ".zip":
|
|
701
|
+
tmp = tempfile.mkdtemp(prefix="weaver_skill_")
|
|
702
|
+
zip_raw_names = []
|
|
703
|
+
try:
|
|
704
|
+
# 文件名编码鲁棒处理:优先 gbk(兼容 Windows 打包的中文名),
|
|
705
|
+
# 失败则依次回退 utf-8 / 自动(Python 依据 UTF-8 标志位选 utf-8,否则 cp437),
|
|
706
|
+
# 避免 gbk 无法解码某些字节(如 UTF-8 文件名)时直接崩溃。
|
|
707
|
+
zf = None
|
|
708
|
+
last_err = None
|
|
709
|
+
for enc in ("gbk", "utf-8", None):
|
|
710
|
+
try:
|
|
711
|
+
zf = zipfile.ZipFile(target, "r", metadata_encoding=enc)
|
|
712
|
+
break
|
|
713
|
+
except UnicodeDecodeError as e:
|
|
714
|
+
last_err = e
|
|
715
|
+
continue
|
|
716
|
+
if zf is None:
|
|
717
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
718
|
+
return None, None, False, f"压缩包文件名解码失败: {last_err}", None
|
|
719
|
+
with zf:
|
|
720
|
+
# 防止 zip 路径穿越
|
|
721
|
+
zip_raw_names = list(zf.namelist())
|
|
722
|
+
for name in zip_raw_names:
|
|
723
|
+
if name.startswith("/") or ".." in name.split("/"):
|
|
724
|
+
return None, None, False, f"压缩包含不安全路径: {name}", None
|
|
725
|
+
zf.extractall(tmp)
|
|
726
|
+
except zipfile.BadZipFile:
|
|
727
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
728
|
+
return None, None, False, "压缩包损坏或不是合法的 zip 文件", None
|
|
729
|
+
return Path(tmp), "zip", True, None, zip_raw_names
|
|
730
|
+
|
|
731
|
+
if suffix in (".md", ".markdown"):
|
|
732
|
+
# 单 Markdown 文件:视作 SKILL.md 候选,临时目录承载
|
|
733
|
+
tmp = tempfile.mkdtemp(prefix="weaver_skill_")
|
|
734
|
+
shutil.copy(target, Path(tmp) / "SKILL.md")
|
|
735
|
+
return Path(tmp), "markdown", True, None, None
|
|
736
|
+
|
|
737
|
+
return None, None, False, f"不支持的输入类型: {suffix}(仅支持 .zip / .md / 目录)", None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def find_skill_md(root: Path):
|
|
741
|
+
"""在 root 下查找 SKILL.md,返回其所在目录与路径。
|
|
742
|
+
兼容 Windows 反斜杠路径:反斜杠在 Unix 上是文件名里的字面字符,
|
|
743
|
+
需对路径字符串归一化(\\ -> /)后判断末段是否为 SKILL.md。"""
|
|
744
|
+
def _is_skill_md(p: Path) -> bool:
|
|
745
|
+
return str(p).replace("\\", "/").split("/")[-1] == "SKILL.md"
|
|
746
|
+
candidates = [p for p in root.rglob("*") if p.is_file() and _is_skill_md(p)]
|
|
747
|
+
if not candidates:
|
|
748
|
+
return None, None
|
|
749
|
+
if len(candidates) == 1:
|
|
750
|
+
p = candidates[0]
|
|
751
|
+
return p, p.parent
|
|
752
|
+
# 多个:优先选取层级最浅的(最接近 root 的)
|
|
753
|
+
candidates.sort(key=lambda p: len(p.parts))
|
|
754
|
+
p = candidates[0]
|
|
755
|
+
return p, p.parent
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _duplicate_skill_md_at_same_level(root: Path):
|
|
759
|
+
"""返回同一目录下出现多个 SKILL.md 的重复路径列表(平台 450019 的真正拒收情形)。"""
|
|
760
|
+
def _is_skill_md(p: Path) -> bool:
|
|
761
|
+
return str(p).replace("\\", "/").split("/")[-1] == "SKILL.md"
|
|
762
|
+
by_parent = {}
|
|
763
|
+
for p in root.rglob("*"):
|
|
764
|
+
if p.is_file() and _is_skill_md(p):
|
|
765
|
+
by_parent.setdefault(p.parent, []).append(p)
|
|
766
|
+
dups = []
|
|
767
|
+
for paths in by_parent.values():
|
|
768
|
+
if len(paths) > 1:
|
|
769
|
+
dups.extend(str(x) for x in paths)
|
|
770
|
+
return dups
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
# ---------------------------------------------------------------------------
|
|
774
|
+
# 各维度检测
|
|
775
|
+
# ---------------------------------------------------------------------------
|
|
776
|
+
|
|
777
|
+
def check_package_constraints(root: Path, platform: str = "weaver", target: Path = None):
|
|
778
|
+
"""A. 打包约束红线:① 文件总数(按 platform 取阈值,weaver ≤ 300 / qwenwork < 1000);
|
|
779
|
+
③ 禁止任意位置含二进制文件;
|
|
780
|
+
④ ZIP 包内只允许纯文本格式文件;
|
|
781
|
+
⑤ ZIP 包整体大小(仅 qwenwork,硬性 ≤ 10 MB)。
|
|
782
|
+
|
|
783
|
+
target 为原始输入路径:zip/md 输入时直接 stat 真实文件体积(权威值);
|
|
784
|
+
目录输入时无 archive 可测,降级为「解压后文件合计」并在结论中标注为近似值。
|
|
785
|
+
|
|
786
|
+
实现稳健性:
|
|
787
|
+
- 允许清单法 + 二进制魔数嗅探双保险:扩展名不在纯文本清单内的文件,
|
|
788
|
+
再做内容嗅探(空字节 / 文件头魔数),确保「文本扩展名却实为二进制」也漏不掉。
|
|
789
|
+
- 无扩展名的纯文本文件(LICENSE / Makefile 等)通过已知名单或嗅探放行,
|
|
790
|
+
避免因命名习惯误杀。
|
|
791
|
+
- macOS 打包垃圾(.DS_Store / ._xxx / __MACOSX/)一律判为违规二进制。
|
|
792
|
+
"""
|
|
793
|
+
checks = []
|
|
794
|
+
spec = PLATFORMS.get(platform, PLATFORMS["weaver"])
|
|
795
|
+
files = [p for p in root.rglob("*") if p.is_file()]
|
|
796
|
+
total = len(files)
|
|
797
|
+
max_entries = spec["max_entries"]
|
|
798
|
+
entry_rule_name = (f"文件总数 < {max_entries + 1}" if max_entries == 999
|
|
799
|
+
else f"文件总数 ≤ {max_entries}")
|
|
800
|
+
|
|
801
|
+
# --- ① 文件总数(按 platform 阈值) ---
|
|
802
|
+
if total > max_entries:
|
|
803
|
+
checks.append(Check(
|
|
804
|
+
"基础合规", entry_rule_name, "FAIL", "BLOCK",
|
|
805
|
+
f"包内文件总数为 {total},超过 {spec['label']} 平台上限 {max_entries} 个,"
|
|
806
|
+
f"上传将被拒收。",
|
|
807
|
+
suggestion="清理冗余文件(如 __pycache__、.DS_Store、临时产物),"
|
|
808
|
+
"或将大文件外链到 SKILL.md 而非塞进包里。",
|
|
809
|
+
))
|
|
810
|
+
else:
|
|
811
|
+
checks.append(Check(
|
|
812
|
+
"基础合规", entry_rule_name, "PASS", "INFO",
|
|
813
|
+
f"包内文件总数 {total}(≤ {max_entries})。",
|
|
814
|
+
))
|
|
815
|
+
|
|
816
|
+
# --- ⑤ ZIP 包大小(仅 qwenwork,硬性 ≤ 10 MB) ---
|
|
817
|
+
if spec.get("max_zip_mb"):
|
|
818
|
+
max_bytes = spec["max_zip_mb"] * 1024 * 1024
|
|
819
|
+
# 优先使用真实归档文件体积(zip/md 输入);目录输入降级为「文件合计」近似值
|
|
820
|
+
zip_note = ""
|
|
821
|
+
if target is not None and target.is_file():
|
|
822
|
+
try:
|
|
823
|
+
total_bytes = target.stat().st_size
|
|
824
|
+
zip_note = f"(实测归档文件 {target.name})"
|
|
825
|
+
except OSError:
|
|
826
|
+
total_bytes = None
|
|
827
|
+
else:
|
|
828
|
+
total_bytes = None
|
|
829
|
+
if total_bytes is None:
|
|
830
|
+
total_bytes = 0
|
|
831
|
+
for p in files:
|
|
832
|
+
try:
|
|
833
|
+
total_bytes += p.stat().st_size
|
|
834
|
+
except OSError:
|
|
835
|
+
pass
|
|
836
|
+
zip_note = "(目录输入,无归档文件,取包内文件合计近似)"
|
|
837
|
+
if total_bytes > max_bytes:
|
|
838
|
+
checks.append(Check(
|
|
839
|
+
"基础合规", f"zip 包大小 ≤ {spec['max_zip_mb']} MB", "FAIL", "BLOCK",
|
|
840
|
+
f"包体积 {total_bytes / 1024 / 1024:.2f} MB {zip_note},"
|
|
841
|
+
f"超过 {spec['label']} 平台硬性上限 {spec['max_zip_mb']} MB,上传将被拒收。",
|
|
842
|
+
suggestion=f"瘦身包体积:图片/示例文档外链到 SKILL.md,"
|
|
843
|
+
f"剔除冗余文件,控制在 {spec['max_zip_mb']} MB 以内。",
|
|
844
|
+
))
|
|
845
|
+
else:
|
|
846
|
+
checks.append(Check(
|
|
847
|
+
"基础合规", f"zip 包大小 ≤ {spec['max_zip_mb']} MB", "PASS", "INFO",
|
|
848
|
+
f"包体积 {total_bytes / 1024 / 1024:.2f} MB {zip_note}"
|
|
849
|
+
f"(≤ {spec['max_zip_mb']} MB)。",
|
|
850
|
+
))
|
|
851
|
+
|
|
852
|
+
# --- ②/③ 禁止二进制 / 仅纯文本 ---
|
|
853
|
+
violations = []
|
|
854
|
+
for p in files:
|
|
855
|
+
rel = str(p.relative_to(root))
|
|
856
|
+
parts = Path(rel).parts
|
|
857
|
+
name = p.name
|
|
858
|
+
# macOS 打包垃圾(二进制/无意义)
|
|
859
|
+
if name == ".DS_Store" or name.startswith("._") or "__MACOSX" in parts:
|
|
860
|
+
violations.append((rel, "macOS 垃圾/二进制文件(禁止打包)"))
|
|
861
|
+
continue
|
|
862
|
+
ext = p.suffix.lower().lstrip(".")
|
|
863
|
+
if ext in TEXT_EXT_ALLOWLIST:
|
|
864
|
+
if _sniff_binary(p):
|
|
865
|
+
violations.append((rel, "扩展名虽为纯文本,但内容为二进制(疑似伪装)"))
|
|
866
|
+
continue
|
|
867
|
+
if ext == "":
|
|
868
|
+
if name.lower() in KNOWN_TEXT_NOEXT:
|
|
869
|
+
continue
|
|
870
|
+
if _sniff_binary(p):
|
|
871
|
+
violations.append((rel, "无扩展名且内容为二进制"))
|
|
872
|
+
# 无扩展名但内容为文本 → 放行(避免误杀 LICENSE 等)
|
|
873
|
+
continue
|
|
874
|
+
# 扩展名不在纯文本允许清单
|
|
875
|
+
if ext in BINARY_EXT_BLACKLIST or _sniff_binary(p):
|
|
876
|
+
violations.append((rel, "二进制文件(禁止)"))
|
|
877
|
+
else:
|
|
878
|
+
violations.append((rel, "非纯文本文件(不在允许清单)"))
|
|
879
|
+
|
|
880
|
+
if violations:
|
|
881
|
+
locs = [f"{rel} —— {reason}" for rel, reason in violations[:30]]
|
|
882
|
+
tail = f"(共 {len(violations)} 个,仅展示前 30)" if len(violations) > 30 else ""
|
|
883
|
+
checks.append(Check(
|
|
884
|
+
"基础合规", "仅含纯文本文件(禁止二进制)", "FAIL", "BLOCK",
|
|
885
|
+
f"包内存在 {len(violations)} 个非纯文本/二进制文件,违反"
|
|
886
|
+
f"「ZIP 包内只允许纯文本文件」规定{tail}:",
|
|
887
|
+
locations=locs,
|
|
888
|
+
suggestion="移除所有二进制/压缩/媒体文件,仅保留 .md/.py/.js/.txt/.json/.yaml 等纯文本。",
|
|
889
|
+
))
|
|
890
|
+
else:
|
|
891
|
+
checks.append(Check(
|
|
892
|
+
"基础合规", "仅含纯文本文件(禁止二进制)", "PASS", "INFO",
|
|
893
|
+
"包内所有文件均为允许清单内的纯文本格式。",
|
|
894
|
+
))
|
|
895
|
+
|
|
896
|
+
return checks
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def check_skill_md_naming(root: Path):
|
|
900
|
+
"""A. SKILL.md 文件名大小写精确性:必须是精确大写 SKILL.md,
|
|
901
|
+
不允许 skill.md / Skill.md / Skill.MD 等任何大小写变体。
|
|
902
|
+
|
|
903
|
+
平台/工具以区分大小写方式匹配入口文件名,变体不会被识别为 skill 入口,
|
|
904
|
+
等于「无 SKILL.md」。本检查给出明确、可操作的判定与重命名建议,
|
|
905
|
+
优于笼统的「未找到 SKILL.md」。
|
|
906
|
+
"""
|
|
907
|
+
checks = []
|
|
908
|
+
md_variants = [p for p in root.rglob("*") if p.is_file() and p.name.lower() == "skill.md"]
|
|
909
|
+
exact = [p for p in md_variants if p.name == "SKILL.md"]
|
|
910
|
+
wrong_case = [str(p.relative_to(root)) for p in md_variants if p.name != "SKILL.md"]
|
|
911
|
+
|
|
912
|
+
if exact and not wrong_case:
|
|
913
|
+
checks.append(Check(
|
|
914
|
+
"基础合规", "SKILL.md 文件名大小写精确", "PASS", "INFO",
|
|
915
|
+
"已找到精确大写的 SKILL.md,且无大小写变体。",
|
|
916
|
+
))
|
|
917
|
+
elif not exact and wrong_case:
|
|
918
|
+
checks.append(Check(
|
|
919
|
+
"基础合规", "SKILL.md 文件名大小写精确", "FAIL", "BLOCK",
|
|
920
|
+
f"未找到精确大写的 SKILL.md,但发现大小写变体:{', '.join(wrong_case)}。"
|
|
921
|
+
"平台要求入口文件名必须精确为 SKILL.md(区分大小写),变体将不被识别为 skill 入口。",
|
|
922
|
+
locations=wrong_case,
|
|
923
|
+
suggestion="将变体文件重命名为 SKILL.md(首字母大写、后缀全大写 MD)。",
|
|
924
|
+
))
|
|
925
|
+
elif exact and wrong_case:
|
|
926
|
+
checks.append(Check(
|
|
927
|
+
"基础合规", "SKILL.md 文件名大小写精确", "FAIL", "BLOCK",
|
|
928
|
+
f"同时存在精确 SKILL.md 与大小写变体 {', '.join(wrong_case)},"
|
|
929
|
+
"可能导致平台/工具加载歧义或重复打包。",
|
|
930
|
+
locations=wrong_case,
|
|
931
|
+
suggestion="删除多余的大小写变体,仅保留 SKILL.md。",
|
|
932
|
+
))
|
|
933
|
+
# 两者皆无:交给 check_structure 的「SKILL.md 存在」检查处理,不重复添加。
|
|
934
|
+
return checks
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def check_structure(root: Path, skill_md: Path, skill_dir: Path, source_type: str, zip_raw_names=None, platform: str = "weaver"):
|
|
938
|
+
"""A. 基础合规检测。
|
|
939
|
+
默认 platform="weaver"(泛微生态,保持向后兼容);platform="qwenwork" 时启用千问专属规则。
|
|
940
|
+
"""
|
|
941
|
+
checks = []
|
|
942
|
+
spec = PLATFORMS.get(platform, PLATFORMS["weaver"])
|
|
943
|
+
|
|
944
|
+
# A1 文件/SKILL.md 存在
|
|
945
|
+
if skill_md is None:
|
|
946
|
+
# 若已存在大小写变体(skill.md/Skill.md 等),精确大小写检查已给出 BLOCK,
|
|
947
|
+
# 此处不再重复「未找到 SKILL.md」,避免提示歧义。
|
|
948
|
+
has_case_variant = any(
|
|
949
|
+
p.is_file() and p.name.lower() == "skill.md" and p.name != "SKILL.md"
|
|
950
|
+
for p in root.rglob("*")
|
|
951
|
+
)
|
|
952
|
+
if not has_case_variant:
|
|
953
|
+
checks.append(Check(
|
|
954
|
+
"基础合规", "SKILL.md 存在", "FAIL", "BLOCK",
|
|
955
|
+
"未找到 SKILL.md,这不是一个标准 WorkBuddy skill 包。"
|
|
956
|
+
+ ("(单 Markdown 文件未包含 frontmatter,或压缩包内无 SKILL.md)" if source_type in ("markdown", "zip") else ""),
|
|
957
|
+
suggestion="在 skill 根目录放置 SKILL.md(含 YAML frontmatter 与正文说明)。"
|
|
958
|
+
))
|
|
959
|
+
return checks, None
|
|
960
|
+
|
|
961
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
962
|
+
checks.append(Check("基础合规", "SKILL.md 存在", "PASS", "INFO", f"已找到: {skill_md.name}"))
|
|
963
|
+
|
|
964
|
+
# A1a Windows 反斜杠路径(打包安全,阻断性)
|
|
965
|
+
# skillhub 上传校验运行在 Unix 环境,反斜杠在 zip 条目路径里会被视为文件名中的
|
|
966
|
+
# 非法/不安全字符,整包被判「文件路径不安全」而拒收。常见于 Windows 资源管理器
|
|
967
|
+
# 直接拖拽打包(中文 Windows 易写出反斜杠路径)。请用 `zip -r xxx.zip xxx/`
|
|
968
|
+
# 或 7-Zip「标准 zip」模式重新打包。
|
|
969
|
+
#
|
|
970
|
+
# 实现稳健性:必须基于 zip 原始条目名判断,不能检查解压后的 OS 路径——在 Windows
|
|
971
|
+
# 上 OS 路径天然使用 \,会 100% 误报;在 Unix 上目录输入的文件名若含 \ 也会误报。
|
|
972
|
+
# prepare_skill_source 已把 zip.namelist() 原始字符串传入 zip_raw_names。
|
|
973
|
+
if source_type == "zip" and zip_raw_names is not None:
|
|
974
|
+
_bs = [n for n in zip_raw_names if "\\" in n]
|
|
975
|
+
if _bs:
|
|
976
|
+
_sample = _bs[:5]
|
|
977
|
+
checks.append(Check(
|
|
978
|
+
"基础合规", "Windows 反斜杠路径分隔符", "FAIL", "BLOCK",
|
|
979
|
+
f"检测到 {len(_bs)} 个 zip 条目路径使用 Windows 反斜杠 `\\` 作为分隔符"
|
|
980
|
+
f"(示例:{'; '.join(_sample)}" + (" …" if len(_bs) > 5 else "") + ")。"
|
|
981
|
+
"skillhub 的上传校验运行在 Unix 环境,会把反斜杠判定为文件名中的非法/不安全字符,"
|
|
982
|
+
"整包将被标记为「文件路径不安全」而无法上传。",
|
|
983
|
+
suggestion=(
|
|
984
|
+
"在 Windows 上重新打包:用 `zip -r weaver-e10-doc.zip weaver-e10-doc/` "
|
|
985
|
+
"或 7-Zip 的「标准 zip」模式;不要使用资源管理器直接拖拽"
|
|
986
|
+
"(中文 Windows 易写出反斜杠路径)。"
|
|
987
|
+
)
|
|
988
|
+
))
|
|
989
|
+
else:
|
|
990
|
+
checks.append(Check("基础合规", "Windows 反斜杠路径分隔符", "PASS", "INFO",
|
|
991
|
+
"未检测到 Windows 反斜杠路径分隔符,所有 zip 条目均为 Unix 正斜杠路径。"))
|
|
992
|
+
elif source_type == "directory":
|
|
993
|
+
# 目录输入不是 zip,无法从 OS 路径推断打包后的 zip 路径分隔符,跳过本检查。
|
|
994
|
+
checks.append(Check(
|
|
995
|
+
"基础合规", "Windows 反斜杠路径分隔符", "PASS", "INFO",
|
|
996
|
+
"目录输入:跳过 zip 路径分隔符检查(打包时请使用 Unix 正斜杠 `/`)。"
|
|
997
|
+
))
|
|
998
|
+
|
|
999
|
+
# A1b 目录层级限制(打包结构合规,阻断性)
|
|
1000
|
+
# Skill 包仅支持两级目录结构:根目录/二级目录/文件。以 SKILL.md 所在目录为 skill 根,
|
|
1001
|
+
# 任何条目相对深度超过 2(即出现三级及以上子目录)即判 FAIL——平台解析会异常。
|
|
1002
|
+
# 构建产物(__pycache__/.pyc)与隐藏项(.git、.DS_Store 等)不计入层级判定。
|
|
1003
|
+
if source_type in ("zip", "directory"):
|
|
1004
|
+
_deep = []
|
|
1005
|
+
for _dp, _dirs, _files in os.walk(skill_dir):
|
|
1006
|
+
_dirs[:] = [d for d in _dirs if d != "__pycache__" and not d.startswith(".")]
|
|
1007
|
+
for _f in sorted(_files):
|
|
1008
|
+
if _f.endswith(".pyc") or _f.startswith("."):
|
|
1009
|
+
continue
|
|
1010
|
+
_rel = Path(_dp).relative_to(skill_dir) / _f
|
|
1011
|
+
if len(_rel.parts) > 2:
|
|
1012
|
+
_deep.append(str(_rel))
|
|
1013
|
+
if _deep:
|
|
1014
|
+
_sample = _deep[:5]
|
|
1015
|
+
checks.append(Check(
|
|
1016
|
+
"基础合规", "目录层级不超过两级", "FAIL", "BLOCK",
|
|
1017
|
+
f"检测到 {len(_deep)} 个条目超过两级目录结构(根目录/二级目录/文件)"
|
|
1018
|
+
f"(示例:{'; '.join(_sample)}" + (" …" if len(_deep) > 5 else "") + ")。"
|
|
1019
|
+
"Skill 包仅支持两级目录结构,三级及以上子目录会导致平台解析异常。",
|
|
1020
|
+
suggestion=(
|
|
1021
|
+
"将深层文件上移到二级目录,仅保留「根目录/二级目录/文件」结构,"
|
|
1022
|
+
"例如 scripts/ 或 references/ 下直接放文件,不要再嵌套子目录。"
|
|
1023
|
+
)
|
|
1024
|
+
))
|
|
1025
|
+
else:
|
|
1026
|
+
checks.append(Check("基础合规", "目录层级不超过两级", "PASS", "INFO",
|
|
1027
|
+
"所有条目均符合两级目录结构(根目录/二级目录/文件)。"))
|
|
1028
|
+
|
|
1029
|
+
# A1b SKILL.md 唯一性(平台 450019 的真正拒收情形:同一层级出现多个 SKILL.md)
|
|
1030
|
+
# 说明:外层包裹目录(如 weaver-e10-xxx/ 单一包裹层)是允许的,平台取最浅层 SKILL.md 作为根。
|
|
1031
|
+
# 仅在「同一目录下存在多个 SKILL.md」时才无法确定根目录,会被拒(450019)。
|
|
1032
|
+
if source_type in ("zip", "directory"):
|
|
1033
|
+
dup = _duplicate_skill_md_at_same_level(root)
|
|
1034
|
+
if dup:
|
|
1035
|
+
checks.append(Check(
|
|
1036
|
+
"基础合规", "SKILL.md 唯一性(450019)", "FAIL", "BLOCK",
|
|
1037
|
+
f"在同一目录下发现多个 SKILL.md,平台无法确定根目录,会拒绝(450019)。"
|
|
1038
|
+
f"重复路径:{'; '.join(dup[:6])}" + (" …" if len(dup) > 6 else ""),
|
|
1039
|
+
suggestion="每个 skill 包只保留一个 SKILL.md(移除多余的副本或合并)。"
|
|
1040
|
+
))
|
|
1041
|
+
else:
|
|
1042
|
+
checks.append(Check("基础合规", "SKILL.md 唯一性(450019)", "PASS", "INFO",
|
|
1043
|
+
"SKILL.md 唯一,无同级重复。"))
|
|
1044
|
+
|
|
1045
|
+
# A2 frontmatter
|
|
1046
|
+
fm, body, fm_ok = extract_frontmatter(content)
|
|
1047
|
+
if not fm_ok:
|
|
1048
|
+
checks.append(Check(
|
|
1049
|
+
"基础合规", "YAML frontmatter 完整", "FAIL", "BLOCK",
|
|
1050
|
+
"SKILL.md 缺少以 `---` 包裹的 YAML frontmatter。",
|
|
1051
|
+
suggestion="在文件开头使用:\n---\nname: xxx\ndescription: xxx\n---"
|
|
1052
|
+
))
|
|
1053
|
+
return checks, (None, body)
|
|
1054
|
+
checks.append(Check("基础合规", "YAML frontmatter 完整", "PASS", "INFO", "frontmatter 解析成功"))
|
|
1055
|
+
|
|
1056
|
+
# A2n frontmatter YAML 语法真解析(平台用真 YAML 解析器,语法错误直接拒收)
|
|
1057
|
+
check_frontmatter_yaml_syntax(fm, checks)
|
|
1058
|
+
|
|
1059
|
+
# A2m frontmatter 顶层结构(禁止 metadata 嵌套;author/version 等必须为顶层字段)
|
|
1060
|
+
check_frontmatter_flat_structure(fm, checks)
|
|
1061
|
+
|
|
1062
|
+
# A3 name
|
|
1063
|
+
name = parse_frontmatter_field(fm, "name")
|
|
1064
|
+
if not name:
|
|
1065
|
+
checks.append(Check("基础合规", "name 字段", "FAIL", "BLOCK",
|
|
1066
|
+
"frontmatter 缺少 `name` 字段。",
|
|
1067
|
+
suggestion="补充 name: your-skill-name(hyphen-case)。"))
|
|
1068
|
+
else:
|
|
1069
|
+
if not re.match(r"^[a-z0-9-]+$", name):
|
|
1070
|
+
checks.append(Check("基础合规", "name 命名规范", "FAIL", "BLOCK",
|
|
1071
|
+
f"name='{name}' 必须为 hyphen-case(仅小写字母、数字、连字符)。",
|
|
1072
|
+
suggestion="改为小写连字符命名,如 weaver-skill-detector。"))
|
|
1073
|
+
elif name.startswith("-") or name.endswith("-") or "--" in name:
|
|
1074
|
+
checks.append(Check("基础合规", "name 命名规范", "FAIL", "BLOCK",
|
|
1075
|
+
f"name='{name}' 不能以连字符开头/结尾,也不能含连续连字符。",
|
|
1076
|
+
suggestion="修正命名。"))
|
|
1077
|
+
elif len(name) < 3 or len(name) > 64:
|
|
1078
|
+
checks.append(Check("基础合规", "name 命名规范", "FAIL", "BLOCK",
|
|
1079
|
+
f"name='{name}' 长度 {len(name)},必须在 3-64 个字符之间。",
|
|
1080
|
+
suggestion="调整 name 长度至 3-64 个字符。"))
|
|
1081
|
+
else:
|
|
1082
|
+
checks.append(Check("基础合规", "name 命名规范", "PASS", "INFO",
|
|
1083
|
+
f"name='{name}' 合法(字符集/首尾/连字符/长度 3-64 均通过)"))
|
|
1084
|
+
|
|
1085
|
+
# A3b name 前缀(按 platform 区分:weaver 必须 weaver- 开头;qwenwork 无品牌前缀要求)
|
|
1086
|
+
if name:
|
|
1087
|
+
if platform == "weaver":
|
|
1088
|
+
if not name.startswith("weaver"):
|
|
1089
|
+
checks.append(Check(
|
|
1090
|
+
"基础合规", "name 前缀(泛微强制)", "FAIL", "BLOCK",
|
|
1091
|
+
f"name='{name}' 必须以 `weaver` 开头。泛微相关 skill 的唯一标识必须以 weaver 作为前缀。",
|
|
1092
|
+
suggestion="将 name 改为以 weaver 开头,如 weaver-e10-xxx。"))
|
|
1093
|
+
else:
|
|
1094
|
+
checks.append(Check("基础合规", "name 前缀(泛微强制)", "PASS", "INFO",
|
|
1095
|
+
f"name='{name}' 以 weaver 开头"))
|
|
1096
|
+
elif platform == "qwenwork":
|
|
1097
|
+
# 千问硬性:仅小写字母、数字、连字符,首尾不能是连字符,≤ 64 字符(沿用 A3 命名规范)
|
|
1098
|
+
# 千问另要求 name 与目录名完全一致(精确匹配在 A4b 单独触发)
|
|
1099
|
+
if len(name) > 64:
|
|
1100
|
+
checks.append(Check(
|
|
1101
|
+
"基础合规", "name 长度(千问硬性 ≤ 64)", "FAIL", "BLOCK",
|
|
1102
|
+
f"name='{name}' 长度 {len(name)},超过千问硬性上限 64 字符。",
|
|
1103
|
+
suggestion="精简 name 至 64 字符以内。"))
|
|
1104
|
+
|
|
1105
|
+
# A4 千问规范:name 与目录名完全一致(精确路径匹配 <name>/SKILL.md,硬性)
|
|
1106
|
+
# 三种输入形态都能判定:
|
|
1107
|
+
# zip → 用原始 namelist 精确比对 "<name>/SKILL.md"
|
|
1108
|
+
# directory → 用 SKILL.md 所在目录名比对 name(与解压后必须保留同名目录等价)
|
|
1109
|
+
# markdown → 单文件输入无目录结构,不做该项判定
|
|
1110
|
+
if name and platform == "qwenwork":
|
|
1111
|
+
if source_type == "zip" and zip_raw_names:
|
|
1112
|
+
# 千问硬性:必须存在精确路径 <name>/SKILL.md(区分大小写、区分路径分隔符)
|
|
1113
|
+
target_path = f"{name}/SKILL.md"
|
|
1114
|
+
has_exact = any(
|
|
1115
|
+
n == target_path or n == f"./{target_path}"
|
|
1116
|
+
for n in zip_raw_names
|
|
1117
|
+
)
|
|
1118
|
+
if not has_exact:
|
|
1119
|
+
checks.append(Check(
|
|
1120
|
+
"基础合规", "千问硬性 <name>/SKILL.md 精确路径", "FAIL", "BLOCK",
|
|
1121
|
+
f"千问硬性要求压缩包内必须存在精确路径 `<name>/SKILL.md`(即 `{target_path}`),"
|
|
1122
|
+
f"且目录名与填写的名称完全一致。这是千问平台最高频的失败原因——"
|
|
1123
|
+
f"macOS Finder 右键压缩文件会得到 `SKILL.md` 而不是 `<name>/SKILL.md`,会被直接拒收。",
|
|
1124
|
+
suggestion=(
|
|
1125
|
+
f"在命令行执行:`zip -r my-skill.zip {name}/`(带上外层目录一起压缩),\\"
|
|
1126
|
+
f"不要使用 Finder 的「压缩 N 项」只压缩内部文件。"
|
|
1127
|
+
)
|
|
1128
|
+
))
|
|
1129
|
+
else:
|
|
1130
|
+
checks.append(Check(
|
|
1131
|
+
"基础合规", "千问硬性 <name>/SKILL.md 精确路径", "PASS", "INFO",
|
|
1132
|
+
f"精确路径 `{target_path}` 命中。"
|
|
1133
|
+
))
|
|
1134
|
+
elif source_type == "directory" and skill_dir is not None:
|
|
1135
|
+
# 目录输入:无 zip namelist 可比对,改为校验「SKILL.md 所在目录名 == name」,
|
|
1136
|
+
# 与 zip 场景的 <name>/SKILL.md 等价(解压后同样必须保留外层同名目录)。
|
|
1137
|
+
dir_name = skill_dir.name
|
|
1138
|
+
if dir_name != name:
|
|
1139
|
+
checks.append(Check(
|
|
1140
|
+
"基础合规", "千问硬性 <name>/SKILL.md 精确路径", "FAIL", "BLOCK",
|
|
1141
|
+
f"SKILL.md 所在目录名为 `{dir_name}`,与 frontmatter 的 name=`{name}` 不一致。"
|
|
1142
|
+
f"千问硬性要求入口路径为 `<name>/SKILL.md`,解压后同样必须保留这层同名目录,"
|
|
1143
|
+
f"否则会被直接拒收。",
|
|
1144
|
+
suggestion=f"把 SKILL.md 放进以 `{name}` 命名的目录后再打包:"
|
|
1145
|
+
f"`mkdir -p {name} && mv SKILL.md {name}/ && zip -r {name}.zip {name}/`"
|
|
1146
|
+
))
|
|
1147
|
+
else:
|
|
1148
|
+
checks.append(Check(
|
|
1149
|
+
"基础合规", "千问硬性 <name>/SKILL.md 精确路径", "PASS", "INFO",
|
|
1150
|
+
f"SKILL.md 位于 `<name>/` 目录(`{name}/SKILL.md`)命中。"
|
|
1151
|
+
))
|
|
1152
|
+
|
|
1153
|
+
# A4 description
|
|
1154
|
+
desc = parse_frontmatter_field(fm, "description")
|
|
1155
|
+
if not desc:
|
|
1156
|
+
checks.append(Check("基础合规", "description 字段", "FAIL", "BLOCK",
|
|
1157
|
+
"frontmatter 缺少 `description` 字段。",
|
|
1158
|
+
suggestion="补充 description,写明技能做什么、何时触发。"))
|
|
1159
|
+
else:
|
|
1160
|
+
if len(desc) > 1024:
|
|
1161
|
+
checks.append(Check("基础合规", "description 长度(1-1024)", "FAIL", "BLOCK",
|
|
1162
|
+
f"description 长度 {len(desc)} 字符,超过 1024 字符上限。",
|
|
1163
|
+
suggestion="精简 description 至 1024 字符以内;详细内容放入 description_zh/description_en 或正文。"))
|
|
1164
|
+
elif "<" in desc or ">" in desc:
|
|
1165
|
+
checks.append(Check("基础合规", "description 无尖括号", "WARN", "LOW",
|
|
1166
|
+
"description 包含尖括号 `<` 或 `>`。平台规范未禁止,但建议改为全角〈〉或直接移除占位符,"
|
|
1167
|
+
"避免个别 YAML 解析器对未加引号的尖括号标量产生误判。",
|
|
1168
|
+
suggestion="将 <> 占位符改为中文全角〈〉,或移除;确保 YAML 中 description 用单引号包裹。"))
|
|
1169
|
+
elif len(desc) < 20:
|
|
1170
|
+
checks.append(Check("基础合规", "description 充分性", "WARN", "MEDIUM",
|
|
1171
|
+
f"description 仅 {len(desc)} 字,过于简略,不利于触发匹配。",
|
|
1172
|
+
suggestion="补全为 1-3 句:技能能力 + 触发场景。"))
|
|
1173
|
+
else:
|
|
1174
|
+
checks.append(Check("基础合规", "description 充分性", "PASS", "INFO",
|
|
1175
|
+
f"description 长度 {len(desc)} 字,内容充分。"))
|
|
1176
|
+
|
|
1177
|
+
# A5 agent_created 标志
|
|
1178
|
+
if parse_frontmatter_field(fm, "agent_created") != "true":
|
|
1179
|
+
checks.append(Check("基础合规", "agent_created 标志", "WARN", "LOW",
|
|
1180
|
+
"缺少 `agent_created: true`(由 WorkBuddy 生成的技能建议带此标志,便于后续管理)。",
|
|
1181
|
+
suggestion="在 frontmatter 增加 agent_created: true"))
|
|
1182
|
+
else:
|
|
1183
|
+
checks.append(Check("基础合规", "agent_created 标志", "PASS", "INFO", "已包含 agent_created: true"))
|
|
1184
|
+
|
|
1185
|
+
# A5b 中文展示名(按 platform 区分字段名 + 前缀要求)
|
|
1186
|
+
cn_name_field = spec["name_cn_field"]
|
|
1187
|
+
cn_aliases = tuple(filter(None, [cn_name_field, "display_name", "displayName"]))
|
|
1188
|
+
cn_name, cn_hit_field = get_field(fm, *cn_aliases)
|
|
1189
|
+
cn_prefix_required = spec.get("name_cn_prefix_required")
|
|
1190
|
+
if not cn_name:
|
|
1191
|
+
checks.append(Check(
|
|
1192
|
+
"基础合规", f"{cn_name_field} 字段", "FAIL", "BLOCK",
|
|
1193
|
+
f"frontmatter 缺少 `{cn_name_field}` 字段({spec['label']}规范必填)。",
|
|
1194
|
+
suggestion=f"补充 {cn_name_field}: 中文展示名称"))
|
|
1195
|
+
elif cn_prefix_required and not cn_name.startswith(cn_prefix_required):
|
|
1196
|
+
checks.append(Check(
|
|
1197
|
+
"基础合规", f"{cn_name_field} 前缀({cn_prefix_required}强制)", "FAIL", "BLOCK",
|
|
1198
|
+
f"{cn_name_field}='{cn_name}' 必须以「{cn_prefix_required}」开头。",
|
|
1199
|
+
suggestion=f"将 {cn_name_field} 改为以「{cn_prefix_required}」开头,"
|
|
1200
|
+
f"如「{cn_prefix_required} E10 日程管理」。"))
|
|
1201
|
+
else:
|
|
1202
|
+
checks.append(Check("基础合规", f"{cn_name_field} 字段", "PASS", "INFO",
|
|
1203
|
+
f"{cn_name_field}='{cn_name}' 符合要求"))
|
|
1204
|
+
|
|
1205
|
+
# A5c 英文展示名(按 platform 区分字段名)
|
|
1206
|
+
en_name_field = spec["name_en_field"]
|
|
1207
|
+
en_aliases = tuple(filter(None, [en_name_field, "display_name_en", "name_en"]))
|
|
1208
|
+
en_name, _ = get_field(fm, *en_aliases)
|
|
1209
|
+
if not en_name:
|
|
1210
|
+
checks.append(Check(
|
|
1211
|
+
"基础合规", f"{en_name_field} 字段", "FAIL", "BLOCK",
|
|
1212
|
+
f"frontmatter 缺少 `{en_name_field}` 字段({spec['label']}规范必填)。",
|
|
1213
|
+
suggestion=f"补充 {en_name_field}: English Display Name"))
|
|
1214
|
+
else:
|
|
1215
|
+
checks.append(Check("基础合规", f"{en_name_field} 字段", "PASS", "INFO",
|
|
1216
|
+
f"{en_name_field}='{en_name}'"))
|
|
1217
|
+
|
|
1218
|
+
# A5d 中文描述(按 platform 区分字段名)
|
|
1219
|
+
# 千问:description 直接是中文(已在 A4 校验);description_zh 不强制,但仍可识别存在
|
|
1220
|
+
desc_cn_field = spec["desc_cn_field"]
|
|
1221
|
+
desc_cn_aliases = tuple(filter(None, [desc_cn_field, "description_zh", "description"]))
|
|
1222
|
+
desc_cn, desc_cn_hit = get_field(fm, *desc_cn_aliases)
|
|
1223
|
+
if not desc_cn:
|
|
1224
|
+
# 千问模式下,description 在前面已经校验过必填,所以这里基本不会触发
|
|
1225
|
+
checks.append(Check(
|
|
1226
|
+
"基础合规", f"{desc_cn_field} 字段", "FAIL", "BLOCK",
|
|
1227
|
+
f"frontmatter 缺少 `{desc_cn_field}` 字段({spec['label']}规范必填)。",
|
|
1228
|
+
suggestion=f"补充 {desc_cn_field}: 简短中文介绍"))
|
|
1229
|
+
else:
|
|
1230
|
+
checks.append(Check("基础合规", f"{desc_cn_field} 字段", "PASS", "INFO",
|
|
1231
|
+
f"{desc_cn_hit or desc_cn_field} 长度 {len(desc_cn)} 字"))
|
|
1232
|
+
|
|
1233
|
+
# A5e 英文描述(按 platform 区分字段名)
|
|
1234
|
+
desc_en_field = spec["desc_en_field"]
|
|
1235
|
+
desc_en_aliases = tuple(filter(None, [desc_en_field, "description_en"]))
|
|
1236
|
+
desc_en, _ = get_field(fm, *desc_en_aliases)
|
|
1237
|
+
if not desc_en:
|
|
1238
|
+
checks.append(Check(
|
|
1239
|
+
"基础合规", f"{desc_en_field} 字段", "FAIL", "BLOCK",
|
|
1240
|
+
f"frontmatter 缺少 `{desc_en_field}` 字段({spec['label']}规范必填)。",
|
|
1241
|
+
suggestion=f"补充 {desc_en_field}: A Brief English Introduction"))
|
|
1242
|
+
else:
|
|
1243
|
+
checks.append(Check("基础合规", f"{desc_en_field} 字段", "PASS", "INFO",
|
|
1244
|
+
f"{desc_en_field} 长度 {len(desc_en)} 字符"))
|
|
1245
|
+
|
|
1246
|
+
# A5f version 字段(泛微强制)
|
|
1247
|
+
version = parse_frontmatter_field(fm, "version")
|
|
1248
|
+
if not version:
|
|
1249
|
+
checks.append(Check(
|
|
1250
|
+
"基础合规", "version 字段", "FAIL", "BLOCK",
|
|
1251
|
+
"frontmatter 缺少顶层 `version` 字段(不允许 metadata 嵌套,嵌套写法判为缺失)。",
|
|
1252
|
+
suggestion="补充顶层 version: 1.0.0"))
|
|
1253
|
+
elif not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
|
1254
|
+
checks.append(Check(
|
|
1255
|
+
"基础合规", "version 格式(SemVer)", "FAIL", "BLOCK",
|
|
1256
|
+
f"version='{version}' 不符合 SemVer 格式。必须是「主版本.次版本.修订号」三段纯数字(如 1.0.0),"
|
|
1257
|
+
"不支持 v1.0、1.0、latest 等非标准格式。",
|
|
1258
|
+
suggestion="改为严格的 x.y.z 三段数字格式,如 1.0.0。"))
|
|
1259
|
+
else:
|
|
1260
|
+
checks.append(Check("基础合规", "version 字段", "PASS", "INFO",
|
|
1261
|
+
f"version='{version}' 符合 SemVer 格式"))
|
|
1262
|
+
|
|
1263
|
+
# A9 author 作者字段(按 platform 区分)
|
|
1264
|
+
# - weaver:硬性要求顶层 author = 「泛微网络科技股份有限公司」
|
|
1265
|
+
# - qwenwork:可选字段(author 用于展示给用户的发布方名称,255 字符内)
|
|
1266
|
+
author_locked = spec.get("author_locked")
|
|
1267
|
+
author = parse_frontmatter_field(fm, "author")
|
|
1268
|
+
if author_locked:
|
|
1269
|
+
if not author:
|
|
1270
|
+
checks.append(Check(
|
|
1271
|
+
"基础合规", "author 作者字段(泛微强制)", "FAIL", "BLOCK",
|
|
1272
|
+
"frontmatter 缺少顶层 `author` 字段。"
|
|
1273
|
+
"提交至泛微相关市场的 skill 必须声明作者为「泛微网络科技股份有限公司」。",
|
|
1274
|
+
suggestion=f"在 frontmatter 顶层增加:\nauthor: {author_locked}"
|
|
1275
|
+
))
|
|
1276
|
+
elif author != author_locked:
|
|
1277
|
+
checks.append(Check(
|
|
1278
|
+
"基础合规", "author 作者字段(泛微强制)", "FAIL", "BLOCK",
|
|
1279
|
+
f"作者字段值为「{author}」,不符合要求。泛微相关 skill 的作者必须声明为「{author_locked}」。",
|
|
1280
|
+
suggestion=f"将 author 改为:{author_locked}"
|
|
1281
|
+
))
|
|
1282
|
+
else:
|
|
1283
|
+
checks.append(Check("基础合规", "author 作者字段(泛微强制)", "PASS", "INFO",
|
|
1284
|
+
f"author='{author}' 符合泛微作者要求"))
|
|
1285
|
+
else:
|
|
1286
|
+
# 千问模式:author 是「推荐」字段,不强制;缺失只给 INFO 提示
|
|
1287
|
+
if author:
|
|
1288
|
+
if len(author) > 255:
|
|
1289
|
+
checks.append(Check(
|
|
1290
|
+
"基础合规", "author 长度(≤ 255)", "WARN", "LOW",
|
|
1291
|
+
f"author 长度 {len(author)},超过推荐上限 255 字符。",
|
|
1292
|
+
suggestion="精简至 255 字符以内。"
|
|
1293
|
+
))
|
|
1294
|
+
else:
|
|
1295
|
+
checks.append(Check("基础合规", "author 字段(推荐)", "PASS", "INFO",
|
|
1296
|
+
f"author='{author}'"))
|
|
1297
|
+
else:
|
|
1298
|
+
checks.append(Check("基础合规", "author 字段(推荐)", "INFO", "INFO",
|
|
1299
|
+
"frontmatter 未声明 author(千问推荐字段,可选):"
|
|
1300
|
+
"展示给用户的发布方名称,便于用户识别。"))
|
|
1301
|
+
|
|
1302
|
+
# A5f 千问特有:author_name 字段(必填,255 字符内,新建必填)
|
|
1303
|
+
# 与 author 的区别:author_name 是表单字段(市场列表展示),author 是 frontmatter 字段
|
|
1304
|
+
extra_field = spec.get("extra_name_field")
|
|
1305
|
+
if extra_field:
|
|
1306
|
+
extra_val = parse_frontmatter_field(fm, extra_field)
|
|
1307
|
+
if not extra_val:
|
|
1308
|
+
checks.append(Check(
|
|
1309
|
+
"基础合规", f"{extra_field} 字段(千问硬性)", "FAIL", "BLOCK",
|
|
1310
|
+
f"frontmatter 缺少 `{extra_field}` 字段(千问硬性必填)。"
|
|
1311
|
+
f"这是「创建表单必填字段」对应的 frontmatter 字段,新建技能时必须填写,"
|
|
1312
|
+
f"后续编辑不强求重填。",
|
|
1313
|
+
suggestion=f"补充 {extra_field}: 发布方名称(≤ 255 字符)"))
|
|
1314
|
+
elif len(extra_val) > 255:
|
|
1315
|
+
checks.append(Check(
|
|
1316
|
+
"基础合规", f"{extra_field} 长度(≤ 255)", "FAIL", "BLOCK",
|
|
1317
|
+
f"{extra_field}='{extra_val}' 长度 {len(extra_val)},超过硬性上限 255 字符。",
|
|
1318
|
+
suggestion=f"精简 {extra_field} 至 255 字符以内。"))
|
|
1319
|
+
else:
|
|
1320
|
+
checks.append(Check("基础合规", f"{extra_field} 字段", "PASS", "INFO",
|
|
1321
|
+
f"{extra_field}='{extra_val}'"))
|
|
1322
|
+
|
|
1323
|
+
# A5h 千问特有:argument-hint 字段(推荐,可缺不阻)
|
|
1324
|
+
# 用户点开该技能时预填在输入框里的引导语;缺则没预填,用户自由输入。
|
|
1325
|
+
if platform == "qwenwork":
|
|
1326
|
+
arg_hint = parse_frontmatter_field(fm, "argument-hint")
|
|
1327
|
+
if arg_hint:
|
|
1328
|
+
checks.append(Check("基础合规", "argument-hint 字段", "PASS", "INFO",
|
|
1329
|
+
f"argument-hint='{arg_hint}'(预填引导语已设置)"))
|
|
1330
|
+
else:
|
|
1331
|
+
checks.append(Check("基础合规", "argument-hint 字段", "INFO", "INFO",
|
|
1332
|
+
"未设置 argument-hint(千问推荐,可缺不阻):"
|
|
1333
|
+
"用于在用户输入框预填引导语,如「合同文件或条款文本」。"))
|
|
1334
|
+
|
|
1335
|
+
# A6 目录名一致性(仅真实目录输入有意义;zip 解包临时目录名为随机值,跳过)
|
|
1336
|
+
if source_type == "directory" and name:
|
|
1337
|
+
if skill_dir.name != name:
|
|
1338
|
+
checks.append(Check("基础合规", "目录名与 name 一致", "WARN", "MEDIUM",
|
|
1339
|
+
f"目录名 '{skill_dir.name}' 与 frontmatter name '{name}' 不一致。",
|
|
1340
|
+
suggestion="确保 skill 目录名与 name 完全一致(打包时外层目录即 name)。"))
|
|
1341
|
+
else:
|
|
1342
|
+
checks.append(Check("基础合规", "目录名与 name 一致", "PASS", "INFO", "目录名与 name 一致"))
|
|
1343
|
+
elif source_type in ("zip", "markdown") and name:
|
|
1344
|
+
checks.append(Check("基础合规", "目录名与 name 一致", "INFO", "INFO",
|
|
1345
|
+
"zip / 单 md 输入无法校验目录名,跳过(平台以 name 字段为准)。"))
|
|
1346
|
+
|
|
1347
|
+
# A7 TODO 占位符残留
|
|
1348
|
+
todo_hits = []
|
|
1349
|
+
for m in TODO_PATTERN.finditer(body):
|
|
1350
|
+
todo_hits.append(m.group(0))
|
|
1351
|
+
if todo_hits:
|
|
1352
|
+
checks.append(Check("基础合规", "无 TODO 占位残留", "WARN", "MEDIUM",
|
|
1353
|
+
f"正文仍含占位标记: {', '.join(sorted(set(todo_hits))[:5])}",
|
|
1354
|
+
suggestion="删除模板占位符,填充真实内容。"))
|
|
1355
|
+
else:
|
|
1356
|
+
checks.append(Check("基础合规", "无 TODO 占位残留", "PASS", "INFO", "正文无模板占位符"))
|
|
1357
|
+
|
|
1358
|
+
# A8 正文体量
|
|
1359
|
+
if len(body.strip()) < 100:
|
|
1360
|
+
checks.append(Check("基础合规", "正文内容充实", "WARN", "MEDIUM",
|
|
1361
|
+
f"SKILL.md 正文仅 {len(body.strip())} 字,偏空。",
|
|
1362
|
+
suggestion="补充技能说明、流程、示例。"))
|
|
1363
|
+
else:
|
|
1364
|
+
checks.append(Check("基础合规", "正文内容充实", "PASS", "INFO", f"正文 {len(body.strip())} 字"))
|
|
1365
|
+
|
|
1366
|
+
# A8b 正文档行数(建议 ≤500 行,内容过多时拆分到 references/ 独立文件)
|
|
1367
|
+
body_lines = len(body.splitlines())
|
|
1368
|
+
if body_lines > 500:
|
|
1369
|
+
checks.append(Check("基础合规", "正文档行数≤500", "WARN", "MEDIUM",
|
|
1370
|
+
f"SKILL.md 正文 {body_lines} 行,超过建议上限 500 行。",
|
|
1371
|
+
suggestion="将详细内容拆分为 references/ 目录下的独立文件,正文只保留路由触发与核心说明。"))
|
|
1372
|
+
else:
|
|
1373
|
+
checks.append(Check("基础合规", "正文档行数≤500", "PASS", "INFO", f"正文 {body_lines} 行"))
|
|
1374
|
+
|
|
1375
|
+
# A11d 千问硬性:SKILL.md 正文 ≤ 64 KB(超出部分会被市场详情页截断)
|
|
1376
|
+
if spec.get("max_body_kb"):
|
|
1377
|
+
max_kb = spec["max_body_kb"]
|
|
1378
|
+
body_bytes = len(body.encode("utf-8"))
|
|
1379
|
+
if body_bytes > max_kb * 1024:
|
|
1380
|
+
checks.append(Check(
|
|
1381
|
+
"基础合规", f"SKILL.md 正文 ≤ {max_kb} KB", "FAIL", "BLOCK",
|
|
1382
|
+
f"SKILL.md 正文 UTF-8 字节 {body_bytes / 1024:.2f} KB,"
|
|
1383
|
+
f"超过 {spec['label']}硬性上限 {max_kb} KB,"
|
|
1384
|
+
f"超出部分会被市场详情页说明文档展示时截断(不影响上传成功,但首屏体验劣化)。",
|
|
1385
|
+
suggestion=f"将长知识拆到 references/ 目录,正文只保留路由触发与核心说明,控制在 {max_kb} KB 以内。"
|
|
1386
|
+
))
|
|
1387
|
+
else:
|
|
1388
|
+
checks.append(Check(
|
|
1389
|
+
"基础合规", f"SKILL.md 正文 ≤ {max_kb} KB", "PASS", "INFO",
|
|
1390
|
+
f"SKILL.md 正文 UTF-8 字节 {body_bytes / 1024:.2f} KB(≤ {max_kb} KB)。"
|
|
1391
|
+
))
|
|
1392
|
+
|
|
1393
|
+
# A6c 千问特有:category_code 字段(推荐,14 个固定分类枚举)
|
|
1394
|
+
category_enum = spec.get("category_enum")
|
|
1395
|
+
if category_enum:
|
|
1396
|
+
cat_code = parse_frontmatter_field(fm, "category_code")
|
|
1397
|
+
if cat_code:
|
|
1398
|
+
if cat_code not in category_enum:
|
|
1399
|
+
checks.append(Check(
|
|
1400
|
+
"基础合规", "category_code 分类(千问硬性枚举)", "FAIL", "BLOCK",
|
|
1401
|
+
f"category_code='{cat_code}' 不在千问固定 14 个分类清单内。",
|
|
1402
|
+
suggestion=f"从下列分类中选一个:{', '.join(category_enum)}。",
|
|
1403
|
+
locations=[f"category_code={cat_code}"]))
|
|
1404
|
+
else:
|
|
1405
|
+
checks.append(Check(
|
|
1406
|
+
"基础合规", "category_code 分类(千问硬性枚举)", "PASS", "INFO",
|
|
1407
|
+
f"category_code='{cat_code}' 属于千问固定分类。"))
|
|
1408
|
+
else:
|
|
1409
|
+
checks.append(Check(
|
|
1410
|
+
"基础合规", "category_code 分类(推荐)", "INFO", "INFO",
|
|
1411
|
+
"未设置 category_code(千问推荐字段):"
|
|
1412
|
+
f"从 14 个分类中选一个,便于市场筛选归类:{', '.join(category_enum)}。"))
|
|
1413
|
+
|
|
1414
|
+
# A6e 千问特有:icon 字段(必需,公网可访问的 icon_url)
|
|
1415
|
+
if platform == "qwenwork":
|
|
1416
|
+
icon = parse_frontmatter_field(fm, "icon") or parse_frontmatter_field(fm, "icon_url")
|
|
1417
|
+
if not icon:
|
|
1418
|
+
checks.append(Check(
|
|
1419
|
+
"基础合规", "icon 字段(千问硬性)", "FAIL", "BLOCK",
|
|
1420
|
+
"frontmatter 缺少 `icon` 字段(千问硬性必填)。"
|
|
1421
|
+
"用于在市场列表展示技能图标,必须是公网可访问的 URL(推荐 HTTPS)。",
|
|
1422
|
+
suggestion="补充 icon: https://example.com/icon-240.png "
|
|
1423
|
+
"(规格 240*240 PNG,≤ 2 MB;SVG 免尺寸校验)"))
|
|
1424
|
+
else:
|
|
1425
|
+
if not re.match(r"^https?://", icon):
|
|
1426
|
+
checks.append(Check(
|
|
1427
|
+
"基础合规", "icon 协议(千问)", "WARN", "LOW",
|
|
1428
|
+
f"icon='{icon}' 不是 http(s) URL;千问要求公网可访问。",
|
|
1429
|
+
suggestion="icon 改为公网 URL,如 https://example.com/icon-240.png。"))
|
|
1430
|
+
else:
|
|
1431
|
+
checks.append(Check(
|
|
1432
|
+
"基础合规", "icon 字段(千问硬性)", "PASS", "INFO",
|
|
1433
|
+
f"icon='{icon}'(公网 URL)"))
|
|
1434
|
+
|
|
1435
|
+
# A6f 千问特有:tags 字段(推荐,回车或逗号分隔)
|
|
1436
|
+
if platform == "qwenwork":
|
|
1437
|
+
tags_raw = parse_frontmatter_field(fm, "tags")
|
|
1438
|
+
if tags_raw:
|
|
1439
|
+
# 仅做基本结构校验:含中英文/数字/下划线/逗号分隔即可;详细过滤是平台职责
|
|
1440
|
+
checks.append(Check(
|
|
1441
|
+
"基础合规", "tags 字段(推荐)", "PASS", "INFO",
|
|
1442
|
+
f"tags='{tags_raw}'(当前不在市场界面展示,仅用于平台内部筛选归类)"))
|
|
1443
|
+
else:
|
|
1444
|
+
checks.append(Check(
|
|
1445
|
+
"基础合规", "tags 字段(推荐)", "INFO", "INFO",
|
|
1446
|
+
"未设置 tags(推荐,可选):回车或逗号分隔录入,自动去重。"
|
|
1447
|
+
"当前不在市场界面展示,仅用于平台内部筛选归类,按需填写。"))
|
|
1448
|
+
|
|
1449
|
+
# A6g 千问特有:初始提示词(可选,frontmatter 中以 description 替代——已在 A4 校验)
|
|
1450
|
+
|
|
1451
|
+
# A8c 引用相对路径规范:引用其他文件须使用相对 Skills 根目录的路径
|
|
1452
|
+
# (如 references/api-docs.md、scripts/main.py),且被引用文件须真实存在于包内。
|
|
1453
|
+
bad_refs, missing_refs = [], []
|
|
1454
|
+
ref_candidates = set()
|
|
1455
|
+
# ① Markdown 链接目标:[文本](path)
|
|
1456
|
+
for m in re.finditer(r"\[[^\]]*\]\(([^)\s]+)\)", body):
|
|
1457
|
+
p = m.group(1).strip()
|
|
1458
|
+
if p and not p.startswith(("http://", "https://", "#", "mailto:", "//")):
|
|
1459
|
+
ref_candidates.add(p)
|
|
1460
|
+
# ② 反引号包裹的路径(须带目录段 + 文件扩展名,如 references/api-docs.md;兼容反斜杠与前导 / 以捕获违规)
|
|
1461
|
+
for m in re.finditer(r"`(/?(?:[\w.-]+[/\\])+[\w.-]+\.[A-Za-z0-9]+)`", body):
|
|
1462
|
+
ref_candidates.add(m.group(1).strip())
|
|
1463
|
+
# 占位符/示例路径豁免:<xxx>、{var}、xxx.md、your-xxx、示例/模板字样等,避免文档举例误报
|
|
1464
|
+
_PLACEHOLDER_RE = re.compile(r"[<>{}*]|xxx|yyy|zzz|your[-_]|示例|模板|占位")
|
|
1465
|
+
# 运行时系统路径豁免:/tmp/、/Users/、~/、C:\ 等是文档描述的运行时数据目录,不是「引用包内文件」
|
|
1466
|
+
_RUNTIME_PATH_RE = re.compile(
|
|
1467
|
+
r"^(?:/(?:tmp|var|usr|etc|home|Users|opt|private|Library|System|data|srv|mnt)/|~/|[A-Za-z]:[\\/])",
|
|
1468
|
+
re.IGNORECASE)
|
|
1469
|
+
# 存在性校验仅针对已知资源目录前缀:其余自定义相对路径可能是运行时产物描述,不校验以免误报
|
|
1470
|
+
_RESOURCE_DIR_RE = re.compile(
|
|
1471
|
+
r"^(?:references|scripts|assets|docs|examples|templates|config|prompts|data|output|outputs)/",
|
|
1472
|
+
re.IGNORECASE)
|
|
1473
|
+
for p in sorted(ref_candidates):
|
|
1474
|
+
if _PLACEHOLDER_RE.search(p):
|
|
1475
|
+
continue
|
|
1476
|
+
if _RUNTIME_PATH_RE.match(p):
|
|
1477
|
+
continue # 运行时系统路径(非包内文件引用),跳过
|
|
1478
|
+
if "\\" in p:
|
|
1479
|
+
bad_refs.append(f"{p}(含反斜杠,须用 / 分隔)")
|
|
1480
|
+
continue
|
|
1481
|
+
if p.startswith("/"):
|
|
1482
|
+
bad_refs.append(f"{p}(绝对路径,须改为相对 skill 根目录的路径)")
|
|
1483
|
+
continue
|
|
1484
|
+
norm = p[2:] if p.startswith("./") else p
|
|
1485
|
+
norm = norm.split("#")[0].strip()
|
|
1486
|
+
if norm and _RESOURCE_DIR_RE.match(norm) and not (skill_dir / norm).exists():
|
|
1487
|
+
missing_refs.append(norm)
|
|
1488
|
+
if bad_refs or missing_refs:
|
|
1489
|
+
_parts = []
|
|
1490
|
+
if bad_refs:
|
|
1491
|
+
_parts.append("路径格式问题: " + "; ".join(bad_refs[:5]))
|
|
1492
|
+
if missing_refs:
|
|
1493
|
+
_parts.append("引用文件不存在: " + "; ".join(missing_refs[:5]))
|
|
1494
|
+
checks.append(Check("基础合规", "引用相对路径规范", "WARN", "MEDIUM",
|
|
1495
|
+
"。".join(_parts) + "。引用其他文件时必须使用相对 Skills 根目录的路径。",
|
|
1496
|
+
suggestion="改为如 references/api-docs.md、scripts/main.py 的相对路径,并确保被引用文件已随包分发。"))
|
|
1497
|
+
else:
|
|
1498
|
+
checks.append(Check("基础合规", "引用相对路径规范", "PASS", "INFO",
|
|
1499
|
+
"正文引用路径均为相对路径且文件存在"))
|
|
1500
|
+
|
|
1501
|
+
return checks, (name, body)
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def _is_detector_script(rel: str, text: str) -> bool:
|
|
1505
|
+
"""判断是否为检测工具自身源码(避免规则定义字符串自匹配误报)。"""
|
|
1506
|
+
return rel == "scripts/validate_skill.py" and ("泛微技能检测工具" in text or "weaver-skill-detector" in text)
|
|
1507
|
+
|
|
1508
|
+
|
|
1509
|
+
def _is_detector_doc(rel: str, text: str) -> bool:
|
|
1510
|
+
"""判断是否为检测工具自身说明文档(避免规则说明文字被当成风险命中)。"""
|
|
1511
|
+
if rel not in ("SKILL.md", "references/checklist.md"):
|
|
1512
|
+
return False
|
|
1513
|
+
return "泛微技能检测工具" in text or "weaver-skill-detector" in text
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
def check_weaver_compliance(skill_dir: Path, body: str, source_type: str):
|
|
1517
|
+
"""B. 泛微专项质检(安全合规)。"""
|
|
1518
|
+
checks = []
|
|
1519
|
+
|
|
1520
|
+
# 汇总所有文本文件内容用于扫描(排除检测工具自身源码,避免正则常量自匹配)
|
|
1521
|
+
file_texts = {}
|
|
1522
|
+
for rel, text in iter_text_files(skill_dir):
|
|
1523
|
+
if _is_detector_script(str(rel), text):
|
|
1524
|
+
continue
|
|
1525
|
+
file_texts[str(rel)] = text
|
|
1526
|
+
# 也确保 SKILL.md 正文在列(单 md 情形)
|
|
1527
|
+
all_text = "\n".join(file_texts.values())
|
|
1528
|
+
|
|
1529
|
+
# B1 默认登录地址指向泛微自用系统(核心红线)
|
|
1530
|
+
# 仅在「作为 URL」或「赋值给配置型变量」时判定为默认地址,
|
|
1531
|
+
# 避免 SKILL.md / references 正文为说明规则而提及域名时造成误报。
|
|
1532
|
+
CONFIG_VAR_RE = re.compile(
|
|
1533
|
+
r"^\s*[A-Za-z_\u4e00-\u9fff]*(?:url|host|base|login|endpoint|address|addr|"
|
|
1534
|
+
r"server|domain|site|target|api|default|link)[A-Za-z_\u4e00-\u9fff]*\s*[:=]",
|
|
1535
|
+
re.IGNORECASE,
|
|
1536
|
+
)
|
|
1537
|
+
URL_RE = re.compile(r"https?://", re.IGNORECASE)
|
|
1538
|
+
default_addr_hits = []
|
|
1539
|
+
for rel, text in file_texts.items():
|
|
1540
|
+
for li, line in enumerate(scan_lines(text), start=1):
|
|
1541
|
+
lower = line.lower()
|
|
1542
|
+
for domain in WEAVER_OWNED_DOMAINS:
|
|
1543
|
+
if domain.lower() not in lower:
|
|
1544
|
+
continue
|
|
1545
|
+
is_url = bool(URL_RE.search(line))
|
|
1546
|
+
is_assign = bool(CONFIG_VAR_RE.match(line))
|
|
1547
|
+
if is_url or is_assign:
|
|
1548
|
+
default_addr_hits.append(f"{rel}:{li} → {line.strip()[:120]}")
|
|
1549
|
+
break
|
|
1550
|
+
if default_addr_hits:
|
|
1551
|
+
checks.append(Check(
|
|
1552
|
+
"专项质检", "默认登录地址未指向泛微自用系统", "FAIL", "BLOCK",
|
|
1553
|
+
"检测到 skill 内写死了泛微自用系统域名(如 e-cology.com.cn)。"
|
|
1554
|
+
"若作为默认值,客户下载后会访问到泛微自用系统,存在越权访问与隐私合规风险。",
|
|
1555
|
+
locations=default_addr_hits[:20],
|
|
1556
|
+
suggestion="移除写死的默认地址;改为运行时由用户显式提供自身系统地址,"
|
|
1557
|
+
"或在说明中要求用户填写自有域名,绝不给默认系统地址。"
|
|
1558
|
+
))
|
|
1559
|
+
else:
|
|
1560
|
+
checks.append(Check("专项质检", "默认登录地址未指向泛微自用系统", "PASS", "INFO",
|
|
1561
|
+
"未发现泛微自用域名写死为默认地址。"))
|
|
1562
|
+
|
|
1563
|
+
# B2 硬编码真实 IP
|
|
1564
|
+
ip_hits = []
|
|
1565
|
+
ip_re = re.compile(r"\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b")
|
|
1566
|
+
for rel, text in file_texts.items():
|
|
1567
|
+
text_lines = scan_lines(text) # 每个文件只截断一次,避免逐命中重复构造
|
|
1568
|
+
for m in ip_re.finditer(text):
|
|
1569
|
+
ip = m.group(1)
|
|
1570
|
+
parts = ip.split(".")
|
|
1571
|
+
if not all(0 <= int(p) <= 255 for p in parts):
|
|
1572
|
+
continue
|
|
1573
|
+
if is_safe_ip(ip):
|
|
1574
|
+
continue
|
|
1575
|
+
line_no = text.count("\n", 0, m.start()) + 1
|
|
1576
|
+
snippet = text_lines[line_no - 1].strip() if line_no <= len(text_lines) else ""
|
|
1577
|
+
ip_hits.append(f"{rel}:{line_no} → {ip} ({snippet[:80]})")
|
|
1578
|
+
if ip_hits:
|
|
1579
|
+
checks.append(Check(
|
|
1580
|
+
"专项质检", "无硬编码真实 IP", "WARN", "HIGH",
|
|
1581
|
+
"检测到疑似硬编码 IP 地址(可能是真实服务器地址)。对外分发前必须脱敏。",
|
|
1582
|
+
locations=ip_hits[:20],
|
|
1583
|
+
suggestion="将真实 IP 替换为占位符(如 <服务器IP> 或 192.168.x.x 示例),"
|
|
1584
|
+
"并改为配置/参数传入。"
|
|
1585
|
+
))
|
|
1586
|
+
else:
|
|
1587
|
+
checks.append(Check("专项质检", "无硬编码真实 IP", "PASS", "INFO", "未发现疑似硬编码真实 IP"))
|
|
1588
|
+
|
|
1589
|
+
# B3 硬编码凭证
|
|
1590
|
+
cred_hits = []
|
|
1591
|
+
for rel, text in file_texts.items():
|
|
1592
|
+
text_lines = scan_lines(text) # 每个文件只截断一次,避免逐命中重复构造
|
|
1593
|
+
for pat in CREDENTIAL_PATTERNS:
|
|
1594
|
+
for m in pat.finditer(text):
|
|
1595
|
+
line_no = text.count("\n", 0, m.start()) + 1
|
|
1596
|
+
snippet = text_lines[line_no - 1].strip() if line_no <= len(text_lines) else ""
|
|
1597
|
+
cred_hits.append(f"{rel}:{line_no} → {snippet[:120]}")
|
|
1598
|
+
if cred_hits:
|
|
1599
|
+
checks.append(Check(
|
|
1600
|
+
"专项质检", "无硬编码凭证", "WARN", "HIGH",
|
|
1601
|
+
"检测到疑似硬编码凭证(密码 / token / secret / api_key 等字面量)。",
|
|
1602
|
+
locations=cred_hits[:20],
|
|
1603
|
+
suggestion="移除硬编码凭证;统一通过 E10 命名凭据插件获取(如 key=ETEAMSID),"
|
|
1604
|
+
"严禁将密钥写入脚本或聊天。"
|
|
1605
|
+
))
|
|
1606
|
+
else:
|
|
1607
|
+
checks.append(Check("专项质检", "无硬编码凭证", "PASS", "INFO", "未发现疑似硬编码凭证"))
|
|
1608
|
+
|
|
1609
|
+
# B4 凭证来源建议(若涉及 Weaver API 调用)
|
|
1610
|
+
mentions_api = bool(re.search(r"e10|eteams|e-cology|weaver|api|接口", all_text, re.IGNORECASE))
|
|
1611
|
+
mentions_plugin = any(re.search(h, all_text, re.IGNORECASE) for h in PLUGIN_CREDENTIAL_HINTS)
|
|
1612
|
+
if mentions_api and not mentions_plugin:
|
|
1613
|
+
checks.append(Check(
|
|
1614
|
+
"专项质检", "凭证应通过插件获取", "WARN", "MEDIUM",
|
|
1615
|
+
"skill 涉及 Weaver / API 调用,但未说明凭证通过 E10 插件(命名凭据)获取。",
|
|
1616
|
+
suggestion="补充:凭证须由 E10 命名凭据插件提供,禁止硬编码或 Playwright 登录兜底。"
|
|
1617
|
+
))
|
|
1618
|
+
elif mentions_api and mentions_plugin:
|
|
1619
|
+
checks.append(Check("专项质检", "凭证应通过插件获取", "PASS", "INFO",
|
|
1620
|
+
"已说明使用 E10 插件/命名凭据获取凭证。"))
|
|
1621
|
+
else:
|
|
1622
|
+
checks.append(Check("专项质检", "凭证应通过插件获取", "INFO", "INFO",
|
|
1623
|
+
"未检测到 Weaver API 调用,跳过此项。"))
|
|
1624
|
+
|
|
1625
|
+
# B5 敏感词占位规范(软性)
|
|
1626
|
+
uses_placeholder = "-替换敏感词占位符-" in all_text
|
|
1627
|
+
if uses_placeholder:
|
|
1628
|
+
checks.append(Check("专项质检", "敏感词占位规范", "PASS", "INFO",
|
|
1629
|
+
"已采用 `-替换敏感词占位符-` 占位规范处理敏感内容。"))
|
|
1630
|
+
else:
|
|
1631
|
+
checks.append(Check("专项质检", "敏感词占位规范", "INFO", "INFO",
|
|
1632
|
+
"未检测到敏感词占位处理;若 skill 会向 Weaver 发送内容,"
|
|
1633
|
+
"建议复用 `-替换敏感词占位符-` 规范以避免敏感词拦截。"))
|
|
1634
|
+
|
|
1635
|
+
# -----------------------------------------------------------------------
|
|
1636
|
+
# B6/B7/B8 运行时行为风险(仅扫描脚本类文件)
|
|
1637
|
+
# 子进程调用块上下文:命中行 + 后续 6 行(覆盖 spawn/exec 的 args / command 列表)
|
|
1638
|
+
# -----------------------------------------------------------------------
|
|
1639
|
+
ext_script_calls = []
|
|
1640
|
+
cred_in_subproc = []
|
|
1641
|
+
aes_decrypt_files = set()
|
|
1642
|
+
hardcoded_key_files = set()
|
|
1643
|
+
local_cred_path_hits = []
|
|
1644
|
+
|
|
1645
|
+
for rel, text in file_texts.items():
|
|
1646
|
+
if not rel.lower().endswith(SCRIPT_EXTS):
|
|
1647
|
+
continue
|
|
1648
|
+
lines = scan_lines(text)
|
|
1649
|
+
for i, line in enumerate(lines, start=1):
|
|
1650
|
+
if RISKY_SUBPROCESS_RE.search(line):
|
|
1651
|
+
block = "\n".join(lines[i - 1:i + 6])
|
|
1652
|
+
if EXTERNAL_SCRIPT_RE.search(block):
|
|
1653
|
+
ext_script_calls.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1654
|
+
if CRED_IN_SUBPROC_RE.search(block):
|
|
1655
|
+
cred_in_subproc.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1656
|
+
if AES_DECRYPT_RE.search(line):
|
|
1657
|
+
aes_decrypt_files.add(rel)
|
|
1658
|
+
if HARDCODED_SECRET_RE.search(text):
|
|
1659
|
+
hardcoded_key_files.add(rel)
|
|
1660
|
+
for m in LOCAL_CRED_PATH_RE.finditer(text):
|
|
1661
|
+
local_cred_path_hits.append(f"{rel} → {m.group(0)[:100]}")
|
|
1662
|
+
|
|
1663
|
+
# B6 包外脚本子进程调用
|
|
1664
|
+
if ext_script_calls:
|
|
1665
|
+
checks.append(Check(
|
|
1666
|
+
"专项质检", "无包外脚本子进程调用", "WARN", "MEDIUM",
|
|
1667
|
+
"检测到 skill 通过子进程(spawnSync/spawn/exec/subprocess 等)调用了包外脚本文件。"
|
|
1668
|
+
"这类行为可能在用户环境自动执行外部代码,需审查其来源、参数与权限范围。",
|
|
1669
|
+
locations=ext_script_calls[:20],
|
|
1670
|
+
suggestion="尽量用内置 SDK/库替代子进程调用;若必须调用,应明确脚本来源、"
|
|
1671
|
+
"限制参数范围,并向用户说明所执行的外部脚本。"
|
|
1672
|
+
))
|
|
1673
|
+
else:
|
|
1674
|
+
checks.append(Check("专项质检", "无包外脚本子进程调用", "PASS", "INFO",
|
|
1675
|
+
"未发现调用包外脚本的子进程行为。"))
|
|
1676
|
+
|
|
1677
|
+
# B7 凭证通过命令行参数明文传递
|
|
1678
|
+
if cred_in_subproc:
|
|
1679
|
+
checks.append(Check(
|
|
1680
|
+
"专项质检", "凭证未通过命令行明文传递", "WARN", "HIGH",
|
|
1681
|
+
"检测到子进程(spawnSync/spawn/exec 等)调用附近出现凭证类字段"
|
|
1682
|
+
"(ETEAMSID/token/session/cookie/password/secret 等),疑似将凭证以命令行参数明文传入。",
|
|
1683
|
+
locations=cred_in_subproc[:20],
|
|
1684
|
+
suggestion="严禁将凭证作为命令行参数或 env 明文传入子进程;"
|
|
1685
|
+
"凭证须由 E10 命名凭据插件提供,通过标准输入或安全配置文件传递。"
|
|
1686
|
+
))
|
|
1687
|
+
else:
|
|
1688
|
+
checks.append(Check("专项质检", "凭证未通过命令行明文传递", "PASS", "INFO",
|
|
1689
|
+
"未发现子进程命令行中明文传递凭证。"))
|
|
1690
|
+
|
|
1691
|
+
# B8 硬编码加密密钥解密本地凭证
|
|
1692
|
+
aes_and_key = sorted(aes_decrypt_files & hardcoded_key_files)
|
|
1693
|
+
if aes_and_key:
|
|
1694
|
+
checks.append(Check(
|
|
1695
|
+
"专项质检", "无硬编码加密密钥解密本地凭证", "WARN", "HIGH",
|
|
1696
|
+
"检测到使用 AES 解密且同文件存在硬编码密钥常量,疑似用于解密本地存储的登录会话凭证。",
|
|
1697
|
+
locations=[f"{r}(AES 解密 + 硬编码密钥常量)" for r in aes_and_key][:20],
|
|
1698
|
+
suggestion="不得硬编码加密密钥;密钥与凭证应由 E10 插件安全管理,"
|
|
1699
|
+
"本地凭证文件须脱敏并限定在用户授权范围内读取。"
|
|
1700
|
+
))
|
|
1701
|
+
else:
|
|
1702
|
+
checks.append(Check("专项质检", "无硬编码加密密钥解密本地凭证", "PASS", "INFO",
|
|
1703
|
+
"未发现硬编码密钥 + AES 解密组合。"))
|
|
1704
|
+
|
|
1705
|
+
if local_cred_path_hits:
|
|
1706
|
+
checks.append(Check(
|
|
1707
|
+
"专项质检", "无本地敏感凭证路径访问", "WARN", "MEDIUM",
|
|
1708
|
+
"检测到访问本地敏感凭证路径(如 ~/.e10-cli/profiles/*/auth)。"
|
|
1709
|
+
"对外分发前须确认已脱敏,且仅在用户授权范围内读取。",
|
|
1710
|
+
locations=local_cred_path_hits[:20],
|
|
1711
|
+
suggestion="移除对本地凭证文件的硬编码路径引用;如需读取,须明确授权并脱敏后处理。"
|
|
1712
|
+
))
|
|
1713
|
+
else:
|
|
1714
|
+
checks.append(Check("专项质检", "无本地敏感凭证路径访问", "PASS", "INFO",
|
|
1715
|
+
"未发现访问本地敏感凭证路径。"))
|
|
1716
|
+
|
|
1717
|
+
return checks
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
# ---------------------------------------------------------------------------
|
|
1721
|
+
# 扩展安全检测(对齐官方 expert-reviewer G 组)
|
|
1722
|
+
# ---------------------------------------------------------------------------
|
|
1723
|
+
|
|
1724
|
+
def check_extended_security(skill_dir: Path):
|
|
1725
|
+
"""G 组扩展安全检测:危险命令、个人路径、IDE残留、CDN未锁定、内网域名、虚假承诺、BOM/CRLF。"""
|
|
1726
|
+
checks = []
|
|
1727
|
+
|
|
1728
|
+
file_texts = {}
|
|
1729
|
+
for rel, text in iter_text_files(skill_dir):
|
|
1730
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
1731
|
+
continue
|
|
1732
|
+
file_texts[str(rel)] = text
|
|
1733
|
+
|
|
1734
|
+
# G02: 危险 shell 命令模式
|
|
1735
|
+
dangerous_hits = []
|
|
1736
|
+
for rel, text in file_texts.items():
|
|
1737
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1738
|
+
if DANGEROUS_CMD_RE.search(line):
|
|
1739
|
+
# 豁免否定语境(如「禁止使用 rm -rf /」)
|
|
1740
|
+
if NEGATION_SAFE_RE.search(line):
|
|
1741
|
+
continue
|
|
1742
|
+
dangerous_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1743
|
+
if dangerous_hits:
|
|
1744
|
+
checks.append(Check(
|
|
1745
|
+
"扩展安全", "无危险 shell 命令", "WARN", "HIGH",
|
|
1746
|
+
"检测到危险 shell 命令模式(如 rm -rf /、curl|sh、sudo、chmod 777、dd、fork bomb 等),"
|
|
1747
|
+
"可能在用户环境造成不可逆破坏。",
|
|
1748
|
+
locations=dangerous_hits[:20],
|
|
1749
|
+
suggestion="移除危险命令;如需清理操作应限制范围并加确认逻辑;禁止 curl|sh 远程管道执行。"
|
|
1750
|
+
))
|
|
1751
|
+
else:
|
|
1752
|
+
checks.append(Check("扩展安全", "无危险 shell 命令", "PASS", "INFO",
|
|
1753
|
+
"未检测到危险 shell 命令模式。"))
|
|
1754
|
+
|
|
1755
|
+
# G04a: 开发者个人路径残留
|
|
1756
|
+
personal_hits = []
|
|
1757
|
+
for rel, text in file_texts.items():
|
|
1758
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1759
|
+
if PERSONAL_PATH_RE.search(line):
|
|
1760
|
+
personal_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1761
|
+
if personal_hits:
|
|
1762
|
+
checks.append(Check(
|
|
1763
|
+
"扩展安全", "无开发者个人路径残留", "WARN", "MEDIUM",
|
|
1764
|
+
"检测到开发者个人路径残留(如 /Users/xxx/、C:\\Users\\xxx\\)。"
|
|
1765
|
+
"对外分发后其他用户环境路径不一致,会导致脚本/配置失效。",
|
|
1766
|
+
locations=personal_hits[:20],
|
|
1767
|
+
suggestion="将个人路径替换为相对路径或环境变量(如 $HOME、~/、os.path.expanduser)。"
|
|
1768
|
+
))
|
|
1769
|
+
else:
|
|
1770
|
+
checks.append(Check("扩展安全", "无开发者个人路径残留", "PASS", "INFO",
|
|
1771
|
+
"未检测到个人路径残留。"))
|
|
1772
|
+
|
|
1773
|
+
# G04b: 其他 IDE / 平台配置目录残留
|
|
1774
|
+
ide_hits = []
|
|
1775
|
+
for rel, text in file_texts.items():
|
|
1776
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1777
|
+
if IDE_DIR_RE.search(line):
|
|
1778
|
+
ide_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1779
|
+
if ide_hits:
|
|
1780
|
+
checks.append(Check(
|
|
1781
|
+
"扩展安全", "无 IDE/平台配置目录残留", "WARN", "LOW",
|
|
1782
|
+
"检测到其他 IDE/平台配置目录残留(如 .vscode/、.idea/、.cursor/、.DS_Store)。"
|
|
1783
|
+
"这些文件不应包含在发布包中。",
|
|
1784
|
+
locations=ide_hits[:20],
|
|
1785
|
+
suggestion="从发布包中移除 IDE 配置目录与系统生成文件;添加 .gitignore 排除规则。"
|
|
1786
|
+
))
|
|
1787
|
+
else:
|
|
1788
|
+
checks.append(Check("扩展安全", "无 IDE/平台配置目录残留", "PASS", "INFO",
|
|
1789
|
+
"未检测到 IDE 配置目录残留。"))
|
|
1790
|
+
|
|
1791
|
+
# G04c: CDN @latest 未锁定版本
|
|
1792
|
+
cdn_hits = []
|
|
1793
|
+
for rel, text in file_texts.items():
|
|
1794
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1795
|
+
if CDN_LATEST_RE.search(line):
|
|
1796
|
+
cdn_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1797
|
+
if cdn_hits:
|
|
1798
|
+
checks.append(Check(
|
|
1799
|
+
"扩展安全", "CDN 依赖版本锁定", "WARN", "MEDIUM",
|
|
1800
|
+
"检测到 CDN 引用使用 @latest 未锁定版本(如 cdn.jsdelivr.net/.../@latest)。"
|
|
1801
|
+
"上游更新可能导致引入不兼容或恶意代码。",
|
|
1802
|
+
locations=cdn_hits[:20],
|
|
1803
|
+
suggestion="将 @latest 替换为具体版本号(如 @1.2.3),锁定依赖版本。"
|
|
1804
|
+
))
|
|
1805
|
+
else:
|
|
1806
|
+
checks.append(Check("扩展安全", "CDN 依赖版本锁定", "PASS", "INFO",
|
|
1807
|
+
"未检测到未锁定版本的 CDN 引用。"))
|
|
1808
|
+
|
|
1809
|
+
# G03: 扩展内网域名检测(泛微自用域名已由 B1 覆盖,此处补充通用内网域名)
|
|
1810
|
+
internal_hits = []
|
|
1811
|
+
for rel, text in file_texts.items():
|
|
1812
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1813
|
+
if INTERNAL_DOMAIN_RE.search(line):
|
|
1814
|
+
# 豁免否定语境
|
|
1815
|
+
if NEGATION_SAFE_RE.search(line):
|
|
1816
|
+
continue
|
|
1817
|
+
internal_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1818
|
+
if internal_hits:
|
|
1819
|
+
checks.append(Check(
|
|
1820
|
+
"扩展安全", "无内网域名残留", "WARN", "MEDIUM",
|
|
1821
|
+
"检测到通用内网域名(如 *.woa.com、*.tencent.com、*.internal、*.local)。"
|
|
1822
|
+
"对外分发后这些域名不可达,且可能泄露内部网络拓扑。",
|
|
1823
|
+
locations=internal_hits[:20],
|
|
1824
|
+
suggestion="移除内网域名引用;改为用户提供的外部地址或占位符。"
|
|
1825
|
+
))
|
|
1826
|
+
else:
|
|
1827
|
+
checks.append(Check("扩展安全", "无内网域名残留", "PASS", "INFO",
|
|
1828
|
+
"未检测到通用内网域名残留。"))
|
|
1829
|
+
|
|
1830
|
+
# C08: 平台虚假技术承诺
|
|
1831
|
+
promise_hits = []
|
|
1832
|
+
for rel, text in file_texts.items():
|
|
1833
|
+
if not str(rel).lower().endswith((".md", ".markdown")):
|
|
1834
|
+
continue
|
|
1835
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1836
|
+
if FALSE_PROMISE_RE.search(line):
|
|
1837
|
+
# 豁免否定语境(如「不应声称完全离线」)
|
|
1838
|
+
if NEGATION_SAFE_RE.search(line):
|
|
1839
|
+
continue
|
|
1840
|
+
promise_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1841
|
+
if promise_hits:
|
|
1842
|
+
checks.append(Check(
|
|
1843
|
+
"扩展安全", "无平台虚假技术承诺", "WARN", "MEDIUM",
|
|
1844
|
+
"检测到可能的技术承诺(如「本地AI」「离线运行」「不上云」「数据不出本机」)。"
|
|
1845
|
+
"技能实际运行在云端 Agent 中,这些承诺与平台运行方式不符,可能误导用户。",
|
|
1846
|
+
locations=promise_hits[:20],
|
|
1847
|
+
suggestion="移除或修正不实技术承诺;如实描述技能运行环境(云端执行、需联网等)。"
|
|
1848
|
+
))
|
|
1849
|
+
else:
|
|
1850
|
+
checks.append(Check("扩展安全", "无平台虚假技术承诺", "PASS", "INFO",
|
|
1851
|
+
"未检测到虚假技术承诺。"))
|
|
1852
|
+
|
|
1853
|
+
# B11: BOM / CRLF / 编码规范性
|
|
1854
|
+
bom_files = []
|
|
1855
|
+
crlf_files = []
|
|
1856
|
+
for p in skill_dir.rglob("*"):
|
|
1857
|
+
if not p.is_file():
|
|
1858
|
+
continue
|
|
1859
|
+
if p.suffix.lower() not in TEXT_EXTS:
|
|
1860
|
+
continue
|
|
1861
|
+
if _is_detector_script(str(p.relative_to(skill_dir)), ""):
|
|
1862
|
+
continue
|
|
1863
|
+
try:
|
|
1864
|
+
raw = p.read_bytes()[:4096] # 只读前 4KB 判断 BOM/CRLF
|
|
1865
|
+
except (OSError, IOError):
|
|
1866
|
+
continue
|
|
1867
|
+
if BOM_RE.match(raw):
|
|
1868
|
+
bom_files.append(str(p.relative_to(skill_dir)))
|
|
1869
|
+
if CRLF_RE.search(raw):
|
|
1870
|
+
crlf_files.append(str(p.relative_to(skill_dir)))
|
|
1871
|
+
|
|
1872
|
+
if bom_files:
|
|
1873
|
+
checks.append(Check(
|
|
1874
|
+
"扩展安全", "无 BOM 头残留", "WARN", "LOW",
|
|
1875
|
+
f"检测到 {len(bom_files)} 个文件含 UTF-8 BOM 头(EF BB BF)。"
|
|
1876
|
+
"BOM 在某些解析器中会被当作正文内容,可能导致 YAML frontmatter 解析失败。",
|
|
1877
|
+
locations=bom_files[:10],
|
|
1878
|
+
suggestion="使用 `sed -i '1s/^\\xef\\xbb\\xbf//' <file>` 或编辑器设置「保存时不带 BOM」移除。"
|
|
1879
|
+
))
|
|
1880
|
+
else:
|
|
1881
|
+
checks.append(Check("扩展安全", "无 BOM 头残留", "PASS", "INFO",
|
|
1882
|
+
"未检测到 BOM 头残留。"))
|
|
1883
|
+
|
|
1884
|
+
if crlf_files:
|
|
1885
|
+
checks.append(Check(
|
|
1886
|
+
"扩展安全", "无 CRLF 行尾", "WARN", "LOW",
|
|
1887
|
+
f"检测到 {len(crlf_files)} 个文件使用 CRLF (\\r\\n) 行尾。"
|
|
1888
|
+
"跨平台分发时 CRLF 可能导致 shell 脚本执行异常或 YAML 解析问题。",
|
|
1889
|
+
locations=crlf_files[:10],
|
|
1890
|
+
suggestion="使用 `dos2unix` 或 `sed -i 's/\\r$//' <file>` 统一为 LF 行尾。"
|
|
1891
|
+
))
|
|
1892
|
+
else:
|
|
1893
|
+
checks.append(Check("扩展安全", "无 CRLF 行尾", "PASS", "INFO",
|
|
1894
|
+
"所有文本文件均为 LF 行尾。"))
|
|
1895
|
+
|
|
1896
|
+
return checks
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
# ---------------------------------------------------------------------------
|
|
1900
|
+
# API 请求凭证完整性检测(泛微强制红线)
|
|
1901
|
+
# 被检测 skill 在发起 API 请求时,必须同时携带:
|
|
1902
|
+
# ① Cookie 完整原始串(禁止裁剪/去重/改写)
|
|
1903
|
+
# ② eteamsid
|
|
1904
|
+
# ③ User-Agent(AgentType=<agentType>,IsAgent=true)
|
|
1905
|
+
# 三条缺一不可,作为阻断性规则(BLOCK)。仅当源码确实存在发起 HTTP/API 请求的代码时生效。
|
|
1906
|
+
# ---------------------------------------------------------------------------
|
|
1907
|
+
|
|
1908
|
+
# 源码中出现「发起 HTTP/API 请求」的触发模式(仅在脚本文件中判定,避免文档描述误触发)
|
|
1909
|
+
API_CALL_RE = re.compile(
|
|
1910
|
+
r"\b(requests\.(?:get|post|put|delete|patch|request)|httpx\.(?:get|post|request)|"
|
|
1911
|
+
r"http\.client|urllib\.request|aiohttp\.|session\.(?:get|post|request)|"
|
|
1912
|
+
r"fetch\s*\(|axios(?:\.[a-z]+)?\s*\(|got\s*\(|\bcurl\b|\bwget\b|\.request\s*\()",
|
|
1913
|
+
re.IGNORECASE,
|
|
1914
|
+
)
|
|
1915
|
+
|
|
1916
|
+
# API 触发行的「否定/断言/列举」语境豁免:这些行只是在检查「不应出现某调用」,
|
|
1917
|
+
# 而非真正发起请求(如 `for marker in ("fetch(", ...)` / `assert "fetch(" not in html` /
|
|
1918
|
+
# `token not in html`)。命中即不视为发起 API 请求,避免误阻断。
|
|
1919
|
+
API_CALL_NEGATION_RE = re.compile(
|
|
1920
|
+
r"\b(not in|assert|marker|token|forbidden|blocked|noNetwork|"
|
|
1921
|
+
r"does not contain|不包含|禁止出现|不得包含)\b", re.IGNORECASE)
|
|
1922
|
+
|
|
1923
|
+
# 弱信号动词(fetch/got/axios)若以引号包裹,说明是字符串字面量(被列举/检查的词),非真实调用。
|
|
1924
|
+
API_CALL_QUOTED_LITERAL_RE = re.compile(r"""["'](?:fetch|got|axios)\b""", re.IGNORECASE)
|
|
1925
|
+
|
|
1926
|
+
|
|
1927
|
+
def _line_has_real_api_call(line: str) -> bool:
|
|
1928
|
+
"""判定一行是否「真正发起 API 请求」,排除否定/断言/字面量列举等误触发语境。"""
|
|
1929
|
+
if API_CALL_NEGATION_RE.search(line):
|
|
1930
|
+
return False
|
|
1931
|
+
if not API_CALL_RE.search(line):
|
|
1932
|
+
return False
|
|
1933
|
+
# fetch/got/axios 出现在引号内(如 ("fetch(", ...))视为字面量,不算调用
|
|
1934
|
+
if API_CALL_QUOTED_LITERAL_RE.search(line):
|
|
1935
|
+
return False
|
|
1936
|
+
return True
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
# Cookie 头/参数出现的标记(大小写不敏感)
|
|
1940
|
+
COOKIE_HEADER_RE = re.compile(r"['\"]?Cookie['\"]?\s*[:=]|cookie\s*=", re.IGNORECASE)
|
|
1941
|
+
|
|
1942
|
+
# eteamsid 出现的标记(覆盖 eteamsid / eteams_id / ETEAMSID / eteamsId)
|
|
1943
|
+
ETEAMSID_RE = re.compile(r"eteams[_-]?id", re.IGNORECASE)
|
|
1944
|
+
|
|
1945
|
+
# User-Agent 必须携带 AgentType=<agentType>,IsAgent=true
|
|
1946
|
+
UA_RE = re.compile(r"User-Agent", re.IGNORECASE)
|
|
1947
|
+
AGENT_TYPE_RE = re.compile(r"AgentType\s*=", re.IGNORECASE)
|
|
1948
|
+
IS_AGENT_RE = re.compile(r"IsAgent\s*[:=]\s*true", re.IGNORECASE)
|
|
1949
|
+
|
|
1950
|
+
# Cookie 裁剪/去重/改写的可疑模式(违反「禁止裁剪/去重/改写」)
|
|
1951
|
+
COOKIE_TAMPER_RES = [
|
|
1952
|
+
re.compile(r"cookie\b[^;]{0,50}?\.(?:split|replace)\s*\(", re.IGNORECASE),
|
|
1953
|
+
re.compile(r"dict\s*\([^)]{0,80}?cookie", re.IGNORECASE),
|
|
1954
|
+
re.compile(r"cookie\b[^;]{0,50}?\[[0-9\s:]+[\]:]", re.IGNORECASE),
|
|
1955
|
+
]
|
|
1956
|
+
|
|
1957
|
+
|
|
1958
|
+
def check_api_request_credentials(skill_dir: Path):
|
|
1959
|
+
"""
|
|
1960
|
+
B 红线补充:被检测 skill 在发起 API 请求时,必须同时携带
|
|
1961
|
+
① Cookie 完整原始串(禁止裁剪/去重/改写)
|
|
1962
|
+
② eteamsid
|
|
1963
|
+
③ User-Agent(AgentType=<agentType>,IsAgent=true)
|
|
1964
|
+
三条缺一不可,作为阻断性规则(BLOCK)。仅当源码中确实存在发起 HTTP/API 请求的代码时才生效;
|
|
1965
|
+
纯文档/纯提示词类 skill 不触发。
|
|
1966
|
+
"""
|
|
1967
|
+
# 收集文件:排除检测工具自身(避免规则说明文字 / urllib 自调用误触发)
|
|
1968
|
+
script_texts = {} # 脚本文件(用于判断是否发起 API 请求)
|
|
1969
|
+
all_texts = {} # 全量文本(用于查找凭证标记)
|
|
1970
|
+
for rel, text in iter_text_files(skill_dir):
|
|
1971
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
1972
|
+
continue
|
|
1973
|
+
all_texts[str(rel)] = text
|
|
1974
|
+
if Path(str(rel)).suffix.lower() in SCRIPT_EXTS:
|
|
1975
|
+
script_texts[str(rel)] = text
|
|
1976
|
+
|
|
1977
|
+
# 1) 是否发起 API 请求(仅在脚本文件中判定触发,避免文档描述误触发)
|
|
1978
|
+
api_hits = []
|
|
1979
|
+
for rel, text in script_texts.items():
|
|
1980
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
1981
|
+
if _line_has_real_api_call(line):
|
|
1982
|
+
api_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
1983
|
+
|
|
1984
|
+
if not api_hits:
|
|
1985
|
+
return [Check(
|
|
1986
|
+
"API 凭证完整性", "发起 API 请求时携带完整凭证", "PASS", "INFO",
|
|
1987
|
+
"未检测到发起 HTTP/API 请求的源码(requests/urllib/httpx/fetch/axios/curl 等),"
|
|
1988
|
+
"本规则不适用(仅约束实际对外发起请求的 skill)。"
|
|
1989
|
+
)]
|
|
1990
|
+
|
|
1991
|
+
# 2) 三项凭证标记是否齐全(在包内全量文本中查找;模块化 skill 凭证可能在独立模块)
|
|
1992
|
+
joined = "\n".join(all_texts.values())
|
|
1993
|
+
has_cookie = bool(COOKIE_HEADER_RE.search(joined))
|
|
1994
|
+
has_eteamsid = bool(ETEAMSID_RE.search(joined))
|
|
1995
|
+
has_ua = bool(UA_RE.search(joined) and AGENT_TYPE_RE.search(joined) and IS_AGENT_RE.search(joined))
|
|
1996
|
+
|
|
1997
|
+
missing = []
|
|
1998
|
+
if not has_cookie:
|
|
1999
|
+
missing.append("① Cookie 完整原始串(headers 中未携带 'Cookie' 头/参数)")
|
|
2000
|
+
if not has_eteamsid:
|
|
2001
|
+
missing.append("② eteamsid(源码中未出现 eteamsid 相关字段)")
|
|
2002
|
+
if not has_ua:
|
|
2003
|
+
missing.append("③ User-Agent(AgentType=<agentType>,IsAgent=true)")
|
|
2004
|
+
|
|
2005
|
+
checks = []
|
|
2006
|
+
if missing:
|
|
2007
|
+
checks.append(Check(
|
|
2008
|
+
"API 凭证完整性", "API 请求携带 Cookie+eteamsid+User-Agent", "FAIL", "BLOCK",
|
|
2009
|
+
"被检测 skill 在发起 API 请求时,必须同时携带 ① Cookie 完整原始串 "
|
|
2010
|
+
"(禁止裁剪/去重/改写)② eteamsid ③ User-Agent(AgentType=<agentType>,IsAgent=true),"
|
|
2011
|
+
"三条缺一不可。当前缺失以下项,阻断上架:\n" + "\n".join(f" · {m}" for m in missing),
|
|
2012
|
+
locations=api_hits[:15],
|
|
2013
|
+
suggestion="在请求 headers 中补充:'Cookie': <完整原始 Cookie 串>、"
|
|
2014
|
+
"'eteamsid': <从 E10 插件获取的凭证>、"
|
|
2015
|
+
"'User-Agent': 'AgentType=<agentType>,IsAgent=true'。"
|
|
2016
|
+
"Cookie 必须原样透传,禁止 split / 去重 / 切片 / 改写。"
|
|
2017
|
+
))
|
|
2018
|
+
else:
|
|
2019
|
+
checks.append(Check(
|
|
2020
|
+
"API 凭证完整性", "API 请求携带 Cookie+eteamsid+User-Agent", "PASS", "INFO",
|
|
2021
|
+
"检测到对外 API 请求,且三项强制凭证(Cookie 完整原始串 / eteamsid / "
|
|
2022
|
+
"User-Agent:AgentType=...,IsAgent=true)均已在包内源码中出现。"
|
|
2023
|
+
))
|
|
2024
|
+
|
|
2025
|
+
# 3) Cookie 裁剪/去重/改写嫌疑(违反「禁止裁剪/去重/改写」),作为独立 WARN 提示
|
|
2026
|
+
tamper_hits = []
|
|
2027
|
+
for rel, text in all_texts.items():
|
|
2028
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2029
|
+
for rx in COOKIE_TAMPER_RES:
|
|
2030
|
+
if rx.search(line):
|
|
2031
|
+
tamper_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2032
|
+
break
|
|
2033
|
+
if tamper_hits:
|
|
2034
|
+
checks.append(Check(
|
|
2035
|
+
"API 凭证完整性", "Cookie 未裁剪/去重/改写", "WARN", "MEDIUM",
|
|
2036
|
+
"检测到 Cookie 变量可能存在裁剪/去重/改写操作(如 cookie.split() / dict(cookie) / cookie[..]),"
|
|
2037
|
+
"违反「Cookie 必须完整原始串、禁止裁剪/去重/改写」的要求。",
|
|
2038
|
+
locations=tamper_hits[:15],
|
|
2039
|
+
suggestion="将服务端下发的原始 Cookie 串原样放入请求头(headers['Cookie'] = raw_cookie),"
|
|
2040
|
+
"不要拆分、去重或重新拼装。"
|
|
2041
|
+
))
|
|
2042
|
+
else:
|
|
2043
|
+
checks.append(Check(
|
|
2044
|
+
"API 凭证完整性", "Cookie 未裁剪/去重/改写", "PASS", "INFO",
|
|
2045
|
+
"未检测到 Cookie 裁剪/去重/改写的可疑操作。"
|
|
2046
|
+
))
|
|
2047
|
+
|
|
2048
|
+
return checks
|
|
2049
|
+
|
|
2050
|
+
|
|
2051
|
+
# ---------------------------------------------------------------------------
|
|
2052
|
+
# E10 接口型技能「登录 + 凭证」契约检测(泛微强制红线,仅对 weaver-e10- 前缀技能生效)
|
|
2053
|
+
# 规则 1(B10a,BLOCK):登录必须走登录 skill —— 必须声明/引用 weaver-e10-login(或 e10-login CLI),
|
|
2054
|
+
# 且不得自建登录(Playwright/Selenium 自动登录、账号密码登录、自建 OAuth 等)。
|
|
2055
|
+
# 规则 2(B10c,BLOCK):API 请求必须携带 e10-login 返回的三项用户信息参数 ——
|
|
2056
|
+
# ① Cookie 完整原始串(禁止裁剪/去重/改写)
|
|
2057
|
+
# ② eteamsid
|
|
2058
|
+
# ③ User-Agent(AgentType=<agentType>,IsAgent=true)
|
|
2059
|
+
# 触发范围:name 以 weaver-e10- 开头(weaver-e10-login 自身豁免),且确实存在 API 表面
|
|
2060
|
+
# (脚本内真实 HTTP 调用,或文档中描述了接口请求/curl/请求头)。
|
|
2061
|
+
# ---------------------------------------------------------------------------
|
|
2062
|
+
|
|
2063
|
+
LOGIN_SKILL_NAME = "weaver-e10-login"
|
|
2064
|
+
|
|
2065
|
+
# 「登录走登录 skill」的证据模式(任一命中即认为已接入登录 skill)
|
|
2066
|
+
LOGIN_DEP_RE = re.compile(
|
|
2067
|
+
r"weaver-e10-login" # 技能名(frontmatter 声明或正文引用)
|
|
2068
|
+
r"|\be10[-_]login\b" # e10-login CLI(如 `e10-login whoami`)
|
|
2069
|
+
r"|E10_LOGIN_ENTRY" # 登录入口环境变量
|
|
2070
|
+
r"|登录\s*(?:专用\s*)?s?kill" # 中文表述:登录 skill / 登录技能
|
|
2071
|
+
r"|登录技能",
|
|
2072
|
+
re.IGNORECASE,
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
# 自建登录(绕过登录 skill)的可疑模式:命中即 BLOCK
|
|
2076
|
+
SELF_LOGIN_RES = [
|
|
2077
|
+
re.compile(r"\b(playwright|selenium|puppeteer|pyppeteer)\b", re.IGNORECASE),
|
|
2078
|
+
re.compile(r"扫码\s*登录|二维码\s*登录", re.IGNORECASE),
|
|
2079
|
+
re.compile(r"(账号|用户名|username)[^\n]{0,10}(密码|password)[^\n]{0,10}登录", re.IGNORECASE),
|
|
2080
|
+
re.compile(r"login\s*\(\s*[^)]{0,60}(password|passwd)\s*=", re.IGNORECASE),
|
|
2081
|
+
re.compile(r"oauth2?\s*/\s*(authorize|token)[^\n]{0,40}(client_secret|grant_type)", re.IGNORECASE),
|
|
2082
|
+
]
|
|
2083
|
+
|
|
2084
|
+
# 文档型(无脚本)E10 技能的「API 表面」证据:接口请求示例 / 请求头说明 / 接口地址
|
|
2085
|
+
DOC_API_SURFACE_RES = [
|
|
2086
|
+
re.compile(r"\bcurl\s+-|\bcurl\s+['\"]", re.IGNORECASE),
|
|
2087
|
+
re.compile(r"\b(GET|POST|PUT|DELETE|PATCH)\s+/\S+", re.IGNORECASE),
|
|
2088
|
+
re.compile(r"请求头|请求头部|headers?\s*[::]", re.IGNORECASE),
|
|
2089
|
+
re.compile(r"接口\s*地址|API\s*地址|请求\s*URL|base\s*url", re.IGNORECASE),
|
|
2090
|
+
]
|
|
2091
|
+
|
|
2092
|
+
# E10 生态标识(用于确认该技能确实面向 E10 接口,避免误伤纯 UI/规范类技能)
|
|
2093
|
+
E10_API_CONTEXT_RE = re.compile(
|
|
2094
|
+
r"\bE10\b|eteams|ebuilder|esb\b|/api/|泛微|weaver",
|
|
2095
|
+
re.IGNORECASE,
|
|
2096
|
+
)
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
def collect_api_surface(skill_dir: Path):
|
|
2100
|
+
"""
|
|
2101
|
+
收集 skill 的 API 表面证据。
|
|
2102
|
+
返回 (script_hits, doc_hits, all_texts)
|
|
2103
|
+
script_hits: 脚本文件中「真实发起 HTTP/API 请求」的行(复用 B9 的判定逻辑)
|
|
2104
|
+
doc_hits: Markdown 文档中描述接口请求/请求头/curl 的行
|
|
2105
|
+
all_texts: {相对路径: 全文}(已排除检测器自身文件)
|
|
2106
|
+
"""
|
|
2107
|
+
script_hits, doc_hits = [], []
|
|
2108
|
+
all_texts = {}
|
|
2109
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2110
|
+
rel_s = str(rel)
|
|
2111
|
+
if _is_detector_script(rel_s, text) or _is_detector_doc(rel_s, text):
|
|
2112
|
+
continue
|
|
2113
|
+
all_texts[rel_s] = text
|
|
2114
|
+
is_script = Path(rel_s).suffix.lower() in SCRIPT_EXTS
|
|
2115
|
+
if is_script:
|
|
2116
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2117
|
+
if _line_has_real_api_call(line):
|
|
2118
|
+
script_hits.append(f"{rel_s}:{i} → {line.strip()[:120]}")
|
|
2119
|
+
elif rel_s.lower().endswith((".md", ".markdown")):
|
|
2120
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2121
|
+
if any(rx.search(line) for rx in DOC_API_SURFACE_RES):
|
|
2122
|
+
doc_hits.append(f"{rel_s}:{i} → {line.strip()[:120]}")
|
|
2123
|
+
return script_hits, doc_hits, all_texts
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
def check_weaver_e10_auth_contract(skill_dir: Path, name: str):
|
|
2127
|
+
"""
|
|
2128
|
+
E10 接口型技能专属红线(BLOCK):
|
|
2129
|
+
B10a 登录必须走登录 skill(依赖 weaver-e10-login,且不得自建登录)
|
|
2130
|
+
B10c API 请求必须携带 Cookie 完整原始串 + eteamsid + User-Agent(AgentType=,IsAgent=true)
|
|
2131
|
+
仅对 name 以 weaver-e10- 开头的技能生效;weaver-e10-login 自身豁免。
|
|
2132
|
+
"""
|
|
2133
|
+
checks = []
|
|
2134
|
+
nm = (name or "").strip().strip('"\'`')
|
|
2135
|
+
if not nm.lower().startswith("weaver-e10-"):
|
|
2136
|
+
return [Check("E10 登录与凭证契约", "适用范围内(weaver-e10- 前缀技能)", "PASS", "INFO",
|
|
2137
|
+
f"当前技能 name='{nm}' 非 weaver-e10- 前缀,本组规则不适用。")]
|
|
2138
|
+
if nm.lower() == LOGIN_SKILL_NAME:
|
|
2139
|
+
return [Check("E10 登录与凭证契约", "登录 skill 自身豁免", "PASS", "INFO",
|
|
2140
|
+
"当前技能即 weaver-e10-login,登录契约规则豁免。")]
|
|
2141
|
+
|
|
2142
|
+
script_hits, doc_hits, all_texts = collect_api_surface(skill_dir)
|
|
2143
|
+
if not script_hits and not doc_hits:
|
|
2144
|
+
return [Check("E10 登录与凭证契约", "接口型技能判定", "PASS", "INFO",
|
|
2145
|
+
"未检测到 API 表面(无脚本 HTTP 调用,文档中也无接口请求/请求头描述),"
|
|
2146
|
+
"判定为非接口型技能,登录与凭证契约规则不适用。")]
|
|
2147
|
+
|
|
2148
|
+
surface_desc = (
|
|
2149
|
+
f"脚本内 HTTP 调用 {len(script_hits)} 处" if script_hits else ""
|
|
2150
|
+
) + (";" if script_hits and doc_hits else "") + (
|
|
2151
|
+
f"文档接口描述 {len(doc_hits)} 处" if doc_hits else ""
|
|
2152
|
+
)
|
|
2153
|
+
surface_locs = (script_hits or doc_hits)[:15]
|
|
2154
|
+
|
|
2155
|
+
# ---- B10a 登录必须走登录 skill ----
|
|
2156
|
+
joined = "\n".join(all_texts.values())
|
|
2157
|
+
login_evidence = []
|
|
2158
|
+
for rel, text in all_texts.items():
|
|
2159
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2160
|
+
if LOGIN_DEP_RE.search(line):
|
|
2161
|
+
login_evidence.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2162
|
+
if login_evidence:
|
|
2163
|
+
checks.append(Check(
|
|
2164
|
+
"E10 登录与凭证契约", "登录走登录 skill(依赖 weaver-e10-login)", "PASS", "INFO",
|
|
2165
|
+
f"检测到登录 skill 依赖证据 {len(login_evidence)} 处"
|
|
2166
|
+
f"(示例:{login_evidence[0]}),符合「登录必须走 weaver-e10-login」要求。",
|
|
2167
|
+
locations=login_evidence[:8],
|
|
2168
|
+
))
|
|
2169
|
+
else:
|
|
2170
|
+
checks.append(Check(
|
|
2171
|
+
"E10 登录与凭证契约", "登录走登录 skill(依赖 weaver-e10-login)", "FAIL", "BLOCK",
|
|
2172
|
+
f"当前技能为 E10 接口型技能({surface_desc}),但未发现任何登录 skill 依赖证据:"
|
|
2173
|
+
"既未在 frontmatter `dependencies` 中声明 `weaver-e10-login`,也未在正文/脚本中引用 "
|
|
2174
|
+
"`weaver-e10-login` / `e10-login` CLI / `E10_LOGIN_ENTRY`。"
|
|
2175
|
+
"「登录必须走登录 skill」为泛微强制红线,未声明即阻断上架。",
|
|
2176
|
+
locations=surface_locs,
|
|
2177
|
+
suggestion="在 SKILL.md frontmatter 增加:\n"
|
|
2178
|
+
"dependencies:\n"
|
|
2179
|
+
" - weaver-e10-login\n"
|
|
2180
|
+
"或在正文/脚本中明确:认证与会话由 weaver-e10-login 提供"
|
|
2181
|
+
"(脚本型可调用 `e10-login whoami` 获取 baseUrl/ETEAMSID/cookies)。"
|
|
2182
|
+
))
|
|
2183
|
+
|
|
2184
|
+
# ---- B10b 禁止自建登录(绕过登录 skill)----
|
|
2185
|
+
self_login_hits = []
|
|
2186
|
+
for rel, text in all_texts.items():
|
|
2187
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2188
|
+
for rx in SELF_LOGIN_RES:
|
|
2189
|
+
if rx.search(line):
|
|
2190
|
+
self_login_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2191
|
+
break
|
|
2192
|
+
if self_login_hits:
|
|
2193
|
+
checks.append(Check(
|
|
2194
|
+
"E10 登录与凭证契约", "未自建登录(禁止绕过登录 skill)", "FAIL", "BLOCK",
|
|
2195
|
+
f"检测到自建登录/自动登录实现 {len(self_login_hits)} 处"
|
|
2196
|
+
"(Playwright/Selenium 自动登录、扫码登录、账号密码登录、自建 OAuth 等)。"
|
|
2197
|
+
"E10 技能的登录与会话必须统一由 weaver-e10-login 提供,禁止自行实现登录流程。",
|
|
2198
|
+
locations=self_login_hits[:15],
|
|
2199
|
+
suggestion="删除自建登录逻辑,改为调用 weaver-e10-login(或 `e10-login whoami`)"
|
|
2200
|
+
"获取会话,再使用该会话发起业务请求。"
|
|
2201
|
+
))
|
|
2202
|
+
else:
|
|
2203
|
+
checks.append(Check(
|
|
2204
|
+
"E10 登录与凭证契约", "未自建登录(禁止绕过登录 skill)", "PASS", "INFO",
|
|
2205
|
+
"未检测到自建登录实现(Playwright/Selenium/扫码/账号密码/自建 OAuth)。"
|
|
2206
|
+
))
|
|
2207
|
+
|
|
2208
|
+
# ---- B10c 三项用户信息参数完整(文档型技能补充;脚本型由 B9 覆盖,此处不重复阻断)----
|
|
2209
|
+
if not script_hits and doc_hits:
|
|
2210
|
+
has_cookie = bool(COOKIE_HEADER_RE.search(joined))
|
|
2211
|
+
has_eteamsid = bool(ETEAMSID_RE.search(joined))
|
|
2212
|
+
has_ua = bool(UA_RE.search(joined) and AGENT_TYPE_RE.search(joined) and IS_AGENT_RE.search(joined))
|
|
2213
|
+
missing = []
|
|
2214
|
+
if not has_cookie:
|
|
2215
|
+
missing.append("① Cookie 完整原始串(文档中未说明请求头携带 Cookie)")
|
|
2216
|
+
if not has_eteamsid:
|
|
2217
|
+
missing.append("② eteamsid(文档中未出现 eteamsid 字段)")
|
|
2218
|
+
if not has_ua:
|
|
2219
|
+
missing.append("③ User-Agent(AgentType=<agentType>,IsAgent=true)")
|
|
2220
|
+
if missing:
|
|
2221
|
+
checks.append(Check(
|
|
2222
|
+
"E10 登录与凭证契约", "接口文档声明完整凭证(Cookie+eteamsid+User-Agent)", "FAIL", "BLOCK",
|
|
2223
|
+
"当前技能以接口文档形式发起/描述 API 请求,必须明确要求携带 e10-login 返回的三项用户信息参数,"
|
|
2224
|
+
"缺一不可。当前缺失:\n" + "\n".join(f" · {m}" for m in missing),
|
|
2225
|
+
locations=surface_locs,
|
|
2226
|
+
suggestion="在接口文档的「请求头/认证」章节补充:\n"
|
|
2227
|
+
"Cookie: <e10-login 返回的完整原始 Cookie 串>(禁止裁剪/去重/改写)\n"
|
|
2228
|
+
"eteamsid: <e10-login 返回的 ETEAMSID>\n"
|
|
2229
|
+
"User-Agent: AgentType=<agentType>,IsAgent=true"
|
|
2230
|
+
))
|
|
2231
|
+
else:
|
|
2232
|
+
checks.append(Check(
|
|
2233
|
+
"E10 登录与凭证契约", "接口文档声明完整凭证(Cookie+eteamsid+User-Agent)", "PASS", "INFO",
|
|
2234
|
+
"接口文档已声明三项强制凭证(Cookie 完整原始串 / eteamsid / User-Agent:AgentType=...,IsAgent=true)。"
|
|
2235
|
+
))
|
|
2236
|
+
elif script_hits:
|
|
2237
|
+
checks.append(Check(
|
|
2238
|
+
"E10 登录与凭证契约", "接口文档声明完整凭证(Cookie+eteamsid+User-Agent)", "PASS", "INFO",
|
|
2239
|
+
"脚本型技能的三项凭证校验由「API 凭证完整性」规则覆盖,此处不重复判定。"
|
|
2240
|
+
))
|
|
2241
|
+
|
|
2242
|
+
return checks
|
|
2243
|
+
|
|
2244
|
+
|
|
2245
|
+
# ---------------------------------------------------------------------------
|
|
2246
|
+
# B11 千问连接器就绪性契约(仅 qwenwork 模式触发,BLOCK)
|
|
2247
|
+
# ---------------------------------------------------------------------------
|
|
2248
|
+
# 千问「连接器型 skill」硬性要求:执行前必须做 qw_query 连接器就绪性检查,
|
|
2249
|
+
# 并在 401/403/token 失效时回到 Step 0 重新认证。本检测检查:
|
|
2250
|
+
# ① frontmatter 是否声明连接器依赖;
|
|
2251
|
+
# ② 若是,正文/脚本是否包含 qw_query 调用 + 401/403/token 失效处理分支。
|
|
2252
|
+
QW_CONNECTOR_RE = re.compile(
|
|
2253
|
+
r"qwenwork\.settings\.connector\." # 标准连接器 key 格式(前端处理 key)
|
|
2254
|
+
r"|前置\s*依赖.{0,30}连接器"
|
|
2255
|
+
r"|依赖.{0,5}\*\*?\s*连接器"
|
|
2256
|
+
r"|connector_id|connector_key",
|
|
2257
|
+
re.IGNORECASE,
|
|
2258
|
+
)
|
|
2259
|
+
QW_QUERY_RE = re.compile(r"\bqw_query\b|\bqw_action\b|\bqw_config\b", re.IGNORECASE)
|
|
2260
|
+
QW_REFRESH_RE = re.compile(
|
|
2261
|
+
r"401|403|token\s*(?:失效|过期|invalid)|回到\s*Step\s*0|重新认证",
|
|
2262
|
+
re.IGNORECASE,
|
|
2263
|
+
)
|
|
2264
|
+
|
|
2265
|
+
|
|
2266
|
+
def check_qwenwork_connector_readiness(skill_dir: Path, name: str, platform: str = "weaver"):
|
|
2267
|
+
"""B11: 千问连接器就绪性契约(仅在 platform="qwenwork" 触发,weaver 模式直接返回空)。
|
|
2268
|
+
|
|
2269
|
+
千问规范强制要求:依赖连接器的 skill,执行前必须调用 `qw_query` 查询连接器状态,
|
|
2270
|
+
并在 401/403/token 失效时回到 Step 0 重新走认证分支。本检测通过以下信号判定:
|
|
2271
|
+
① SKILL.md 含连接器依赖声明(`qwenwork.settings.connector.<类别>.<id>` 或「依赖连接器」表述)
|
|
2272
|
+
② 缺 ① 中任一证据 → 不触发本规则(该 skill 非连接器型,无需校验)
|
|
2273
|
+
③ 缺 `qw_query` / `qw_action` / `qw_config` 关键词 → BLOCK,提示补 Step 0
|
|
2274
|
+
④ 缺 401/403/token 失效处理关键词 → WARN,提示补重新认证分支
|
|
2275
|
+
"""
|
|
2276
|
+
# 本检查在 main() 中已用 platform=="qwenwork" 守护,但函数体内仍做一次防御
|
|
2277
|
+
checks = []
|
|
2278
|
+
if platform != "qwenwork":
|
|
2279
|
+
return checks
|
|
2280
|
+
|
|
2281
|
+
skill_md_path = skill_dir / "SKILL.md"
|
|
2282
|
+
if not skill_md_path.exists():
|
|
2283
|
+
return checks
|
|
2284
|
+
|
|
2285
|
+
content = skill_md_path.read_text(encoding="utf-8")
|
|
2286
|
+
fm_match = re.search(r"^---\n(.*?)\n---", content, re.DOTALL | re.MULTILINE)
|
|
2287
|
+
fm = fm_match.group(1) if fm_match else ""
|
|
2288
|
+
body = content[fm_match.end():] if fm_match else content
|
|
2289
|
+
|
|
2290
|
+
# ① 检测是否为连接器型 skill
|
|
2291
|
+
full_text = fm + "\n" + body
|
|
2292
|
+
is_connector_skill = bool(QW_CONNECTOR_RE.search(full_text))
|
|
2293
|
+
|
|
2294
|
+
if not is_connector_skill:
|
|
2295
|
+
checks.append(Check(
|
|
2296
|
+
"千问契约", "B11 连接器就绪性契约", "PASS", "INFO",
|
|
2297
|
+
"未检测到连接器依赖声明(无 `qwenwork.settings.connector.*` / 「依赖连接器」表述),"
|
|
2298
|
+
"本 skill 非连接器型,跳过本检查。"
|
|
2299
|
+
))
|
|
2300
|
+
return checks
|
|
2301
|
+
|
|
2302
|
+
# ② 检查 qw_query / qw_action 调用
|
|
2303
|
+
has_query = bool(QW_QUERY_RE.search(full_text))
|
|
2304
|
+
if not has_query:
|
|
2305
|
+
checks.append(Check(
|
|
2306
|
+
"千问契约", "B11a 连接器就绪性检查(qw_query)", "FAIL", "BLOCK",
|
|
2307
|
+
"frontmatter 含连接器依赖声明(连接器型),但全文未提及 `qw_query` / `qw_action` / `qw_config` 调用。"
|
|
2308
|
+
"千问硬性要求:执行前必须调用 `qw_query` 查询连接器状态(installed/enabled/agentCanEnable)并按结果分支。",
|
|
2309
|
+
suggestion=(
|
|
2310
|
+
"在 SKILL.md 开头「前置依赖 / Step 0」段落增加:\n"
|
|
2311
|
+
"### Step 0 - 连接器就绪性检查(必做,每次执行前)\n"
|
|
2312
|
+
"1. 调用 `qw_query` 查询连接器状态:`key = \"qwenwork.settings.connector.<类别>.<id>\"`\n"
|
|
2313
|
+
"2. 根据返回字段分支处理:\n"
|
|
2314
|
+
" - `installed=false` → `qw_action execute install`\n"
|
|
2315
|
+
" - `enabled=false` & `agentCanEnable=true` → `qw_action enable`\n"
|
|
2316
|
+
" - `enabled=true` & `status!=connected` → `qw_action connect`\n"
|
|
2317
|
+
" - `enabled=true` & `status==connected` → 直接进入 Step 1"
|
|
2318
|
+
)
|
|
2319
|
+
))
|
|
2320
|
+
else:
|
|
2321
|
+
checks.append(Check(
|
|
2322
|
+
"千问契约", "B11a 连接器就绪性检查(qw_query)", "PASS", "INFO",
|
|
2323
|
+
"已检测到 `qw_query` / `qw_action` 调用(Step 0 已实现)。"
|
|
2324
|
+
))
|
|
2325
|
+
|
|
2326
|
+
# ③ 检查 401/403/token 失效处理
|
|
2327
|
+
has_refresh = bool(QW_REFRESH_RE.search(full_text))
|
|
2328
|
+
if not has_refresh:
|
|
2329
|
+
checks.append(Check(
|
|
2330
|
+
"千问契约", "B11b 401/403/token 失效回到 Step 0", "WARN", "MEDIUM",
|
|
2331
|
+
"frontmatter 含连接器依赖声明,但全文未提及 401/403/token 失效处理。"
|
|
2332
|
+
"千问硬性要求:遇到 401/403/token 失效时,必须回到 Step 0 重新走认证分支,不要硬失败。",
|
|
2333
|
+
suggestion=(
|
|
2334
|
+
"在「常见错误与恢复」段落增加:\n"
|
|
2335
|
+
"- **401/403/token 失效** → 回到 Step 0 重新认证分支"
|
|
2336
|
+
)
|
|
2337
|
+
))
|
|
2338
|
+
else:
|
|
2339
|
+
checks.append(Check(
|
|
2340
|
+
"千问契约", "B11b 401/403/token 失效回到 Step 0", "PASS", "INFO",
|
|
2341
|
+
"已检测到 401/403/token 失效处理分支。"
|
|
2342
|
+
))
|
|
2343
|
+
|
|
2344
|
+
return checks
|
|
2345
|
+
|
|
2346
|
+
|
|
2347
|
+
# ---------------------------------------------------------------------------
|
|
2348
|
+
# 结构化文件可解析性检测(对齐官方 A17)
|
|
2349
|
+
# ---------------------------------------------------------------------------
|
|
2350
|
+
|
|
2351
|
+
def check_structured_files(skill_dir: Path):
|
|
2352
|
+
"""A2: 扫描包内所有 .json/.yaml/.yml/.toml 文件,验证可解析性。"""
|
|
2353
|
+
checks = []
|
|
2354
|
+
|
|
2355
|
+
broken_files = []
|
|
2356
|
+
for p in skill_dir.rglob("*"):
|
|
2357
|
+
if not p.is_file():
|
|
2358
|
+
continue
|
|
2359
|
+
if p.suffix.lower() not in STRUCTURED_EXTS:
|
|
2360
|
+
continue
|
|
2361
|
+
rel = str(p.relative_to(skill_dir))
|
|
2362
|
+
if _is_detector_script(rel, "") or _is_detector_doc(rel, ""):
|
|
2363
|
+
continue
|
|
2364
|
+
try:
|
|
2365
|
+
raw = p.read_text(encoding="utf-8")
|
|
2366
|
+
except (UnicodeDecodeError, OSError) as e:
|
|
2367
|
+
broken_files.append(f"{rel} → 编码错误: {e}")
|
|
2368
|
+
continue
|
|
2369
|
+
ext = p.suffix.lower()
|
|
2370
|
+
try:
|
|
2371
|
+
if ext == ".json":
|
|
2372
|
+
json.loads(raw)
|
|
2373
|
+
elif ext in (".yaml", ".yml"):
|
|
2374
|
+
# 无 PyYAML 时做基本语法检查(冒号/缩进明显错误)
|
|
2375
|
+
_basic_yaml_check(raw, rel, broken_files)
|
|
2376
|
+
elif ext == ".toml":
|
|
2377
|
+
# 无 tomllib 时做基本语法检查
|
|
2378
|
+
_basic_toml_check(raw, rel, broken_files)
|
|
2379
|
+
except json.JSONDecodeError as e:
|
|
2380
|
+
broken_files.append(f"{rel} → JSON 解析失败: {e}")
|
|
2381
|
+
except Exception as e:
|
|
2382
|
+
broken_files.append(f"{rel} → 解析失败: {e}")
|
|
2383
|
+
|
|
2384
|
+
if broken_files:
|
|
2385
|
+
checks.append(Check(
|
|
2386
|
+
"基础合规", "结构化文件可解析性", "FAIL", "BLOCK",
|
|
2387
|
+
f"检测到 {len(broken_files)} 个 JSON/YAML/TOML 文件解析失败,"
|
|
2388
|
+
"格式错误会导致平台加载 skill 时直接报错。",
|
|
2389
|
+
locations=broken_files[:20],
|
|
2390
|
+
suggestion="修复格式错误:检查引号配对、逗号尾随、缩进一致性、YAML 冒号后空格等。"
|
|
2391
|
+
))
|
|
2392
|
+
else:
|
|
2393
|
+
# 检查是否有结构化文件
|
|
2394
|
+
has_structured = any(
|
|
2395
|
+
p.suffix.lower() in STRUCTURED_EXTS
|
|
2396
|
+
for p in skill_dir.rglob("*")
|
|
2397
|
+
if p.is_file() and not _is_detector_script(str(p.relative_to(skill_dir)), "")
|
|
2398
|
+
)
|
|
2399
|
+
if has_structured:
|
|
2400
|
+
checks.append(Check("基础合规", "结构化文件可解析性", "PASS", "INFO",
|
|
2401
|
+
"包内所有 JSON/YAML/TOML 文件均可正常解析。"))
|
|
2402
|
+
else:
|
|
2403
|
+
checks.append(Check("基础合规", "结构化文件可解析性", "INFO", "INFO",
|
|
2404
|
+
"包内无 JSON/YAML/TOML 文件,跳过此项。"))
|
|
2405
|
+
|
|
2406
|
+
return checks
|
|
2407
|
+
|
|
2408
|
+
|
|
2409
|
+
def _basic_yaml_check(raw, rel, broken_files):
|
|
2410
|
+
"""无 PyYAML 依赖时的 YAML 基本语法检查。"""
|
|
2411
|
+
lines = raw.splitlines()
|
|
2412
|
+
for i, line in enumerate(lines, start=1):
|
|
2413
|
+
stripped = line.strip()
|
|
2414
|
+
if not stripped or stripped.startswith("#"):
|
|
2415
|
+
continue
|
|
2416
|
+
# 检查 key: value 格式(冒号后须有空格或为空)
|
|
2417
|
+
if ":" in stripped and not stripped.startswith("-"):
|
|
2418
|
+
colon_pos = stripped.index(":")
|
|
2419
|
+
after = stripped[colon_pos + 1:]
|
|
2420
|
+
if after and not after.startswith(" ") and not after.startswith('"') and not after.startswith("'"):
|
|
2421
|
+
# 可能是 URL 中的冒号(http:),跳过
|
|
2422
|
+
if not re.match(r"https?://", stripped[colon_pos - 4:colon_pos + 1] if colon_pos >= 4 else ""):
|
|
2423
|
+
# 只在明确是 key:value(无空格)时报错
|
|
2424
|
+
key_part = stripped[:colon_pos]
|
|
2425
|
+
if re.match(r"^[A-Za-z_\u4e00-\u9fff][\w\u4e00-\u9fff.-]*$", key_part):
|
|
2426
|
+
broken_files.append(f"{rel}:{i} → YAML 语法警告: 冒号后应有空格")
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
def _basic_toml_check(raw, rel, broken_files):
|
|
2430
|
+
"""无 tomllib 时的 TOML 基本语法检查。"""
|
|
2431
|
+
lines = raw.splitlines()
|
|
2432
|
+
for i, line in enumerate(lines, start=1):
|
|
2433
|
+
stripped = line.strip()
|
|
2434
|
+
if not stripped or stripped.startswith("#"):
|
|
2435
|
+
continue
|
|
2436
|
+
# 基本 key = value 检查
|
|
2437
|
+
if "=" in stripped and not stripped.startswith("["):
|
|
2438
|
+
if not re.match(r"^[A-Za-z_][\w-]*\s*=", stripped):
|
|
2439
|
+
# 可能是表内键值对,跳过
|
|
2440
|
+
pass
|
|
2441
|
+
|
|
2442
|
+
|
|
2443
|
+
# ---------------------------------------------------------------------------
|
|
2444
|
+
# 依赖引导完整性检测(对齐官方 G05)
|
|
2445
|
+
# ---------------------------------------------------------------------------
|
|
2446
|
+
|
|
2447
|
+
def check_dependency_guidance(skill_dir: Path):
|
|
2448
|
+
"""A3: 若包内含 scripts/ 或 bin/ 目录,检查是否有依赖安装/使用说明。"""
|
|
2449
|
+
checks = []
|
|
2450
|
+
|
|
2451
|
+
has_scripts = (skill_dir / "scripts").is_dir()
|
|
2452
|
+
has_bin = (skill_dir / "bin").is_dir()
|
|
2453
|
+
has_package_json = (skill_dir / "package.json").exists()
|
|
2454
|
+
has_requirements = any(
|
|
2455
|
+
p.name in ("requirements.txt", "Pipfile", "pyproject.toml")
|
|
2456
|
+
for p in skill_dir.rglob("*") if p.is_file()
|
|
2457
|
+
)
|
|
2458
|
+
|
|
2459
|
+
if not (has_scripts or has_bin or has_package_json or has_requirements):
|
|
2460
|
+
checks.append(Check("基础合规", "依赖引导完整性", "INFO", "INFO",
|
|
2461
|
+
"包内无 scripts/bin/依赖声明文件,跳过此项。"))
|
|
2462
|
+
return checks
|
|
2463
|
+
|
|
2464
|
+
# 检查 SKILL.md 或 references/ 下是否有安装/使用说明
|
|
2465
|
+
guidance_keywords = [
|
|
2466
|
+
r"安装", r"install", r"pip\s+install", r"npm\s+install",
|
|
2467
|
+
r"依赖", r"requirements", r"环境准备", r"前置条件",
|
|
2468
|
+
r"usage", r"用法", r"运行方式", r"快速开始",
|
|
2469
|
+
]
|
|
2470
|
+
guidance_found = False
|
|
2471
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2472
|
+
if _is_detector_script(str(rel), text):
|
|
2473
|
+
continue
|
|
2474
|
+
for kw in guidance_keywords:
|
|
2475
|
+
if re.search(kw, text, re.IGNORECASE):
|
|
2476
|
+
guidance_found = True
|
|
2477
|
+
break
|
|
2478
|
+
if guidance_found:
|
|
2479
|
+
break
|
|
2480
|
+
|
|
2481
|
+
if guidance_found:
|
|
2482
|
+
checks.append(Check("基础合规", "依赖引导完整性", "PASS", "INFO",
|
|
2483
|
+
"包内含脚本/依赖声明,且已提供安装/使用说明。"))
|
|
2484
|
+
else:
|
|
2485
|
+
missing = []
|
|
2486
|
+
if has_scripts:
|
|
2487
|
+
missing.append("scripts/")
|
|
2488
|
+
if has_bin:
|
|
2489
|
+
missing.append("bin/")
|
|
2490
|
+
if has_package_json:
|
|
2491
|
+
missing.append("package.json")
|
|
2492
|
+
if has_requirements:
|
|
2493
|
+
missing.append("Python 依赖声明")
|
|
2494
|
+
checks.append(Check(
|
|
2495
|
+
"基础合规", "依赖引导完整性", "WARN", "MEDIUM",
|
|
2496
|
+
f"包内含 {'、'.join(missing)} 但未找到安装/使用说明。"
|
|
2497
|
+
"用户下载后不知道如何安装依赖、配置环境,可能导致技能无法运行。",
|
|
2498
|
+
suggestion="在 SKILL.md 或 references/ 中补充「环境准备」「安装步骤」「用法说明」章节,"
|
|
2499
|
+
"列明依赖安装命令、版本要求与配置方法。"
|
|
2500
|
+
))
|
|
2501
|
+
|
|
2502
|
+
return checks
|
|
2503
|
+
|
|
2504
|
+
|
|
2505
|
+
# ---------------------------------------------------------------------------
|
|
2506
|
+
# 质量启发式检测(对齐官方 Q 组 + C02)
|
|
2507
|
+
# ---------------------------------------------------------------------------
|
|
2508
|
+
|
|
2509
|
+
def check_quality_heuristics(skill_dir: Path, skill_md: Path, body: str):
|
|
2510
|
+
"""Q 组质量启发式检测:路由触发清晰度、上下文效率、容错降级、字段字数。"""
|
|
2511
|
+
checks = []
|
|
2512
|
+
|
|
2513
|
+
file_texts = {}
|
|
2514
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2515
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
2516
|
+
continue
|
|
2517
|
+
file_texts[str(rel)] = text
|
|
2518
|
+
|
|
2519
|
+
# Q1: 路由触发清晰度——SKILL.md 是否有明确的触发场景/关键词描述
|
|
2520
|
+
trigger_keywords = [
|
|
2521
|
+
r"当用户", r"触发", r"适用场景", r"使用场景", r"何时使用",
|
|
2522
|
+
r"trigger", r"when to use", r"适用范围", r"使用时机",
|
|
2523
|
+
r"关键词", r"触发词", r"识别条件",
|
|
2524
|
+
]
|
|
2525
|
+
has_trigger = False
|
|
2526
|
+
if body:
|
|
2527
|
+
for kw in trigger_keywords:
|
|
2528
|
+
if re.search(kw, body, re.IGNORECASE):
|
|
2529
|
+
has_trigger = True
|
|
2530
|
+
break
|
|
2531
|
+
if has_trigger:
|
|
2532
|
+
checks.append(Check("质量评估", "路由触发清晰度", "PASS", "INFO",
|
|
2533
|
+
"SKILL.md 含明确的触发场景/关键词描述,有利于平台路由匹配。"))
|
|
2534
|
+
else:
|
|
2535
|
+
checks.append(Check(
|
|
2536
|
+
"质量评估", "路由触发清晰度", "WARN", "LOW",
|
|
2537
|
+
"SKILL.md 未检测到明确的触发场景/关键词描述。"
|
|
2538
|
+
"平台依赖描述文本来匹配用户意图,缺少触发词可能导致技能无法被正确路由。",
|
|
2539
|
+
suggestion="在 SKILL.md 中补充「适用场景」「触发条件」章节,列明何时应使用本技能。"
|
|
2540
|
+
))
|
|
2541
|
+
|
|
2542
|
+
# Q2: 上下文效率——检查是否有超大文件(>100KB 的单个 .md 文件)
|
|
2543
|
+
oversized = []
|
|
2544
|
+
for rel, text in file_texts.items():
|
|
2545
|
+
if rel.suffix.lower() in (".md", ".markdown") if hasattr(rel, 'suffix') else str(rel).lower().endswith((".md", ".markdown")):
|
|
2546
|
+
size = len(text.encode("utf-8"))
|
|
2547
|
+
if size > 100 * 1024: # 100KB
|
|
2548
|
+
oversized.append(f"{rel} → {_human_size(size)}")
|
|
2549
|
+
if oversized:
|
|
2550
|
+
checks.append(Check(
|
|
2551
|
+
"质量评估", "上下文效率", "WARN", "LOW",
|
|
2552
|
+
f"检测到 {len(oversized)} 个超大 Markdown 文件(>100KB)。"
|
|
2553
|
+
"过大的参考文件会占用大量上下文窗口,影响 Agent 处理效率。",
|
|
2554
|
+
locations=oversized[:10],
|
|
2555
|
+
suggestion="拆分大文件为多个模块化小文件,或使用渐进披露策略按需加载。"
|
|
2556
|
+
))
|
|
2557
|
+
else:
|
|
2558
|
+
checks.append(Check("质量评估", "上下文效率", "PASS", "INFO",
|
|
2559
|
+
"未检测到超大文件,上下文负载合理。"))
|
|
2560
|
+
|
|
2561
|
+
# Q3: 容错降级——脚本中是否有 try/catch 或 error handling
|
|
2562
|
+
script_files = {rel: text for rel, text in file_texts.items()
|
|
2563
|
+
if str(rel).lower().endswith(SCRIPT_EXTS)}
|
|
2564
|
+
if script_files:
|
|
2565
|
+
has_error_handling = False
|
|
2566
|
+
error_handling_patterns = re.compile(
|
|
2567
|
+
r"\btry\s*[:({]|\bcatch\s*\(|\bexcept\b|\bfinally\b|"
|
|
2568
|
+
r"\.catch\s*\(|\bthrow\b|\braise\b|"
|
|
2569
|
+
r"onError|on_error|error_handler|"
|
|
2570
|
+
r"if\s+__name__|if\s+err|if\s+error",
|
|
2571
|
+
re.IGNORECASE)
|
|
2572
|
+
for rel, text in script_files.items():
|
|
2573
|
+
if error_handling_patterns.search(text):
|
|
2574
|
+
has_error_handling = True
|
|
2575
|
+
break
|
|
2576
|
+
if has_error_handling:
|
|
2577
|
+
checks.append(Check("质量评估", "容错降级", "PASS", "INFO",
|
|
2578
|
+
"脚本中检测到错误处理逻辑(try/catch/except 等)。"))
|
|
2579
|
+
else:
|
|
2580
|
+
checks.append(Check(
|
|
2581
|
+
"质量评估", "容错降级", "WARN", "LOW",
|
|
2582
|
+
"包内含脚本但未检测到错误处理逻辑(try/catch/except 等)。"
|
|
2583
|
+
"缺少容错降级会导致异常时直接崩溃,用户体验不佳。",
|
|
2584
|
+
suggestion="在关键逻辑处添加 try/except(Python)或 try/catch(JS),"
|
|
2585
|
+
"提供友好的错误提示与降级方案。"
|
|
2586
|
+
))
|
|
2587
|
+
else:
|
|
2588
|
+
checks.append(Check("质量评估", "容错降级", "INFO", "INFO",
|
|
2589
|
+
"包内无脚本文件,跳过此项。"))
|
|
2590
|
+
|
|
2591
|
+
# Q4 / C02: display_name 字数检查 + description 字数上限
|
|
2592
|
+
if skill_md and skill_md.exists():
|
|
2593
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
2594
|
+
fm, _, _ = extract_frontmatter(content)
|
|
2595
|
+
if fm:
|
|
2596
|
+
dn = parse_frontmatter_field(fm, "display_name") or ""
|
|
2597
|
+
if len(dn) > 20:
|
|
2598
|
+
checks.append(Check(
|
|
2599
|
+
"质量评估", "display_name 简洁性", "WARN", "LOW",
|
|
2600
|
+
f"display_name 长度 {len(dn)} 字,建议控制在 20 字以内以便展示。",
|
|
2601
|
+
suggestion="精简 display_name,保留核心信息。"
|
|
2602
|
+
))
|
|
2603
|
+
else:
|
|
2604
|
+
checks.append(Check("质量评估", "display_name 简洁性", "PASS", "INFO",
|
|
2605
|
+
f"display_name 长度 {len(dn)} 字,简洁。"))
|
|
2606
|
+
|
|
2607
|
+
desc = parse_frontmatter_field(fm, "description") or ""
|
|
2608
|
+
if len(desc) > 200:
|
|
2609
|
+
checks.append(Check(
|
|
2610
|
+
"质量评估", "description 字数控制", "WARN", "LOW",
|
|
2611
|
+
f"description 长度 {len(desc)} 字,建议控制在 200 字以内(一句话概述),"
|
|
2612
|
+
"详细内容放在 description_zh/description_en。",
|
|
2613
|
+
suggestion="精简 description 为 1-2 句摘要。"
|
|
2614
|
+
))
|
|
2615
|
+
else:
|
|
2616
|
+
checks.append(Check("质量评估", "description 字数控制", "PASS", "INFO",
|
|
2617
|
+
f"description 长度 {len(desc)} 字。"))
|
|
2618
|
+
else:
|
|
2619
|
+
checks.append(Check("质量评估", "字段字数检查", "INFO", "INFO",
|
|
2620
|
+
"无 frontmatter,跳过字数检查。"))
|
|
2621
|
+
else:
|
|
2622
|
+
checks.append(Check("质量评估", "字段字数检查", "INFO", "INFO",
|
|
2623
|
+
"无 SKILL.md,跳过字数检查。"))
|
|
2624
|
+
|
|
2625
|
+
return checks
|
|
2626
|
+
|
|
2627
|
+
|
|
2628
|
+
def check_threat_model(skill_dir: Path):
|
|
2629
|
+
"""
|
|
2630
|
+
威胁捕获模型视图:将 skill 行为映射到 8 类风险卡片。
|
|
2631
|
+
返回 8 个 Check(PASS/INFO/WARN),分别对应一张风险卡片。
|
|
2632
|
+
"""
|
|
2633
|
+
checks = []
|
|
2634
|
+
file_texts = {}
|
|
2635
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2636
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
2637
|
+
continue
|
|
2638
|
+
file_texts[str(rel)] = text
|
|
2639
|
+
|
|
2640
|
+
def _hits(pattern, script_only=False):
|
|
2641
|
+
out = []
|
|
2642
|
+
for rel, text in file_texts.items():
|
|
2643
|
+
if script_only and not rel.lower().endswith(SCRIPT_EXTS):
|
|
2644
|
+
continue
|
|
2645
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2646
|
+
if pattern.search(line):
|
|
2647
|
+
out.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2648
|
+
return out
|
|
2649
|
+
|
|
2650
|
+
def _any(pattern, script_only=False):
|
|
2651
|
+
return bool(_hits(pattern, script_only))
|
|
2652
|
+
|
|
2653
|
+
# 1) 供应链风险
|
|
2654
|
+
supply_hits = _hits(DEPENDENCY_INSTALL_RE)
|
|
2655
|
+
if supply_hits:
|
|
2656
|
+
checks.append(Check(
|
|
2657
|
+
"威胁模型", "供应链风险", "WARN", "MEDIUM",
|
|
2658
|
+
"检测到 skill 执行依赖包安装或远程脚本拉取,存在供应链投毒 / 恶意依赖风险。",
|
|
2659
|
+
locations=supply_hits[:10],
|
|
2660
|
+
suggestion="固定依赖版本并校验来源;避免安装来源不明的包或执行远程脚本。"
|
|
2661
|
+
))
|
|
2662
|
+
else:
|
|
2663
|
+
checks.append(Check("威胁模型", "供应链风险", "PASS", "INFO", "未检测到依赖安装 / 远程脚本拉取行为。"))
|
|
2664
|
+
|
|
2665
|
+
# 2) 命令执行风险
|
|
2666
|
+
cmd_hits = _hits(COMMAND_EXEC_RE, script_only=True)
|
|
2667
|
+
if cmd_hits:
|
|
2668
|
+
checks.append(Check(
|
|
2669
|
+
"威胁模型", "命令执行风险", "WARN", "HIGH",
|
|
2670
|
+
"检测到 eval/exec/spawn/subprocess/system 等命令执行点。",
|
|
2671
|
+
locations=cmd_hits[:10],
|
|
2672
|
+
suggestion="避免动态执行外部命令;必须使用时严格限制命令白名单与参数范围。"
|
|
2673
|
+
))
|
|
2674
|
+
else:
|
|
2675
|
+
checks.append(Check("威胁模型", "命令执行风险", "PASS", "INFO", "未检测到动态命令执行点。"))
|
|
2676
|
+
|
|
2677
|
+
# 3) 网络请求与数据外传
|
|
2678
|
+
net_hits = _hits(NETWORK_RE, script_only=True)
|
|
2679
|
+
if net_hits:
|
|
2680
|
+
checks.append(Check(
|
|
2681
|
+
"威胁模型", "网络请求与数据外传", "WARN", "MEDIUM",
|
|
2682
|
+
"检测到 HTTP / requests / curl / fetch 等网络请求能力,可能将用户数据外传到第三方。",
|
|
2683
|
+
locations=net_hits[:10],
|
|
2684
|
+
suggestion="明确请求目标域名与用途;禁止将用户隐私、会话凭证回传到非授权地址。"
|
|
2685
|
+
))
|
|
2686
|
+
else:
|
|
2687
|
+
checks.append(Check("威胁模型", "网络请求与数据外传", "PASS", "INFO", "未检测到网络请求能力。"))
|
|
2688
|
+
|
|
2689
|
+
# 4) 文件操作与敏感路径
|
|
2690
|
+
file_hits = _hits(FILE_IO_RE, script_only=True)
|
|
2691
|
+
path_hits = _hits(SENSITIVE_PATH_RE, script_only=False)
|
|
2692
|
+
if file_hits or path_hits:
|
|
2693
|
+
checks.append(Check(
|
|
2694
|
+
"威胁模型", "文件操作与敏感路径", "WARN", "MEDIUM",
|
|
2695
|
+
"检测到文件读写操作或敏感路径引用。",
|
|
2696
|
+
locations=(file_hits + path_hits)[:10],
|
|
2697
|
+
suggestion="限制文件操作范围;禁止读取系统凭证、SSH 私钥、浏览器 Cookie 等敏感路径。"
|
|
2698
|
+
))
|
|
2699
|
+
else:
|
|
2700
|
+
checks.append(Check("威胁模型", "文件操作与敏感路径", "PASS", "INFO", "未检测到文件操作或敏感路径引用。"))
|
|
2701
|
+
|
|
2702
|
+
# 5) Prompt 注入风险
|
|
2703
|
+
# 否定性安全声明(如「不要要求用户直接粘贴 ETEAMSID」)属合规正向约束,不计入诱导贴凭证
|
|
2704
|
+
cred_hits = [h for h in _hits(PROMPT_CRED_RE) if not NEGATION_SAFE_RE.search(h)]
|
|
2705
|
+
prompt_hits = cred_hits + _hits(PROMPT_INJECTION_RE)
|
|
2706
|
+
if prompt_hits:
|
|
2707
|
+
checks.append(Check(
|
|
2708
|
+
"威胁模型", "Prompt 注入风险", "WARN", "HIGH",
|
|
2709
|
+
"检测到提示词模板诱导用户粘贴会话凭证,或存在典型 prompt 注入话术。",
|
|
2710
|
+
locations=prompt_hits[:10],
|
|
2711
|
+
suggestion="禁止要求用户将 ETEAMSID/token/password 等凭证明文贴入对话;避免使用忽略指令类话术。"
|
|
2712
|
+
))
|
|
2713
|
+
else:
|
|
2714
|
+
checks.append(Check("威胁模型", "Prompt 注入风险", "PASS", "INFO", "未检测到 prompt 注入或诱导贴凭证行为。"))
|
|
2715
|
+
|
|
2716
|
+
# 6) 远程脚本下载执行
|
|
2717
|
+
remote_hits = _hits(REMOTE_SCRIPT_EXEC_RE)
|
|
2718
|
+
if remote_hits:
|
|
2719
|
+
checks.append(Check(
|
|
2720
|
+
"威胁模型", "远程脚本下载执行", "WARN", "HIGH",
|
|
2721
|
+
"检测到 curl | bash、powershell -enc、远程 .py/.sh 脚本执行等远程代码执行模式。",
|
|
2722
|
+
locations=remote_hits[:10],
|
|
2723
|
+
suggestion="禁止下载并直接执行远程脚本;如需外部脚本应提供内容校验与来源说明。"
|
|
2724
|
+
))
|
|
2725
|
+
else:
|
|
2726
|
+
checks.append(Check("威胁模型", "远程脚本下载执行", "PASS", "INFO", "未检测到远程脚本下载执行。"))
|
|
2727
|
+
|
|
2728
|
+
# 7) 可疑编码/混淆
|
|
2729
|
+
obf_hits = _hits(OBFUSCATION_RE, script_only=True)
|
|
2730
|
+
if obf_hits:
|
|
2731
|
+
checks.append(Check(
|
|
2732
|
+
"威胁模型", "可疑编码/混淆", "WARN", "MEDIUM",
|
|
2733
|
+
r"检测到 eval(atob/base64)、长串 base64、\x/\u 转义序列等可疑混淆特征。",
|
|
2734
|
+
locations=obf_hits[:10],
|
|
2735
|
+
suggestion="避免使用 eval 执行动态字符串;对外分发前解码并审计混淆内容。"
|
|
2736
|
+
))
|
|
2737
|
+
else:
|
|
2738
|
+
checks.append(Check("威胁模型", "可疑编码/混淆", "PASS", "INFO", "未检测到可疑编码/混淆特征。"))
|
|
2739
|
+
|
|
2740
|
+
# 8) 其他安全风险(沙箱绕过、隐私采集、反调试等)
|
|
2741
|
+
sandbox_hits = _hits(SANDBOX_BYPASS_RE)
|
|
2742
|
+
privacy_hits = _hits(OTHER_PRIVACY_RE)
|
|
2743
|
+
if sandbox_hits or privacy_hits:
|
|
2744
|
+
checks.append(Check(
|
|
2745
|
+
"威胁模型", "其他安全风险", "WARN", "HIGH",
|
|
2746
|
+
"检测到沙箱绕过参数(--no-sandbox 等)或屏幕截图、录音、剪贴板读取等隐私采集能力。",
|
|
2747
|
+
locations=(sandbox_hits + privacy_hits)[:10],
|
|
2748
|
+
suggestion="禁止默认关闭浏览器沙箱;采集屏幕/剪贴板/录音等敏感能力必须明示用户并获得授权。"
|
|
2749
|
+
))
|
|
2750
|
+
else:
|
|
2751
|
+
checks.append(Check("威胁模型", "其他安全风险", "PASS", "INFO", "未检测到沙箱绕过或隐私采集特征。"))
|
|
2752
|
+
|
|
2753
|
+
return checks
|
|
2754
|
+
|
|
2755
|
+
|
|
2756
|
+
def check_behavior_graph(skill_dir: Path):
|
|
2757
|
+
"""
|
|
2758
|
+
威胁行为图谱解析:针对截图中展示的具体跨文件/跨行行为做联合检测。
|
|
2759
|
+
"""
|
|
2760
|
+
checks = []
|
|
2761
|
+
file_texts = {}
|
|
2762
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2763
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
2764
|
+
continue
|
|
2765
|
+
file_texts[str(rel)] = text
|
|
2766
|
+
|
|
2767
|
+
all_text = "\n".join(file_texts.values())
|
|
2768
|
+
|
|
2769
|
+
# 1) 沙箱绕过:dangerouslyDisableSandbox + run_in_background 同时出现
|
|
2770
|
+
has_disable = DISABLE_SANDBOX_FLAG_RE.search(all_text)
|
|
2771
|
+
has_bg = RUN_BACKGROUND_FLAG_RE.search(all_text)
|
|
2772
|
+
if has_disable and has_bg:
|
|
2773
|
+
locations = []
|
|
2774
|
+
for rel, text in file_texts.items():
|
|
2775
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2776
|
+
if DISABLE_SANDBOX_FLAG_RE.search(line) or RUN_BACKGROUND_FLAG_RE.search(line):
|
|
2777
|
+
locations.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2778
|
+
checks.append(Check(
|
|
2779
|
+
"行为图谱", "dangerouslyDisableSandbox + run_in_background 绕过权限确认", "WARN", "HIGH",
|
|
2780
|
+
"检测到 skill 同时出现 dangerouslyDisableSandbox: true 与 run_in_background: true,"
|
|
2781
|
+
"可能用于在跳过默认工具权限确认的 Bash 工具中启动浏览器并关闭沙箱。",
|
|
2782
|
+
locations=locations[:10],
|
|
2783
|
+
suggestion="禁止默认禁用沙箱;浏览器启动须经用户授权,保留沙箱保护。"
|
|
2784
|
+
))
|
|
2785
|
+
else:
|
|
2786
|
+
checks.append(Check(
|
|
2787
|
+
"行为图谱", "dangerouslyDisableSandbox + run_in_background 绕过权限确认", "PASS", "INFO",
|
|
2788
|
+
"未检测到 dangerouslyDisableSandbox 与 run_in_background 组合。"))
|
|
2789
|
+
|
|
2790
|
+
# 2) 浏览器启动参数关闭沙箱
|
|
2791
|
+
sandbox_arg_hits = []
|
|
2792
|
+
for rel, text in file_texts.items():
|
|
2793
|
+
if not rel.lower().endswith(SCRIPT_EXTS + (".md", ".markdown", ".txt")):
|
|
2794
|
+
continue
|
|
2795
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2796
|
+
if SANDBOX_BYPASS_RE.search(line):
|
|
2797
|
+
sandbox_arg_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2798
|
+
if sandbox_arg_hits:
|
|
2799
|
+
checks.append(Check(
|
|
2800
|
+
"行为图谱", "浏览器启动参数关闭沙箱", "WARN", "HIGH",
|
|
2801
|
+
"检测到 --no-sandbox、--disable-gpu、--disable-setuid-sandbox 等浏览器启动参数。",
|
|
2802
|
+
locations=sandbox_arg_hits[:10],
|
|
2803
|
+
suggestion="浏览器自动化应保留沙箱;确需关闭时须向用户明确说明风险并获得授权。"
|
|
2804
|
+
))
|
|
2805
|
+
else:
|
|
2806
|
+
checks.append(Check(
|
|
2807
|
+
"行为图谱", "浏览器启动参数关闭沙箱", "PASS", "INFO",
|
|
2808
|
+
"未检测到关闭沙箱的浏览器启动参数。"))
|
|
2809
|
+
|
|
2810
|
+
# 3) 提示词诱导用户明文粘贴 ETEAMSID
|
|
2811
|
+
prompt_cred_hits = []
|
|
2812
|
+
for rel, text in file_texts.items():
|
|
2813
|
+
if not rel.lower().endswith((".md", ".markdown", ".txt")):
|
|
2814
|
+
continue
|
|
2815
|
+
for i, line in enumerate(scan_lines(text), start=1):
|
|
2816
|
+
if PROMPT_CRED_RE.search(line):
|
|
2817
|
+
if NEGATION_SAFE_RE.search(line):
|
|
2818
|
+
continue # 否定性安全声明(如「不要要求用户直接粘贴」),非诱导贴凭证
|
|
2819
|
+
prompt_cred_hits.append(f"{rel}:{i} → {line.strip()[:120]}")
|
|
2820
|
+
if prompt_cred_hits:
|
|
2821
|
+
checks.append(Check(
|
|
2822
|
+
"行为图谱", "提示词诱导用户明文粘贴会话凭证", "WARN", "HIGH",
|
|
2823
|
+
"检测到提示词模板要求用户将 ETEAMSID / token / session 等会话凭证明文粘贴进对话,"
|
|
2824
|
+
"使可复用会话凭证以明文进入跨主体持久化的 Agent 上下文。",
|
|
2825
|
+
locations=prompt_cred_hits[:20],
|
|
2826
|
+
suggestion="严禁在提示词中要求用户贴出凭证;凭证须通过 E10 命名凭据插件安全获取。"
|
|
2827
|
+
))
|
|
2828
|
+
else:
|
|
2829
|
+
checks.append(Check(
|
|
2830
|
+
"行为图谱", "提示词诱导用户明文粘贴会话凭证", "PASS", "INFO",
|
|
2831
|
+
"未检测到诱导用户粘贴会话凭证的提示词。"))
|
|
2832
|
+
|
|
2833
|
+
return checks
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
def check_skill_capabilities(skill_dir: Path):
|
|
2837
|
+
"""
|
|
2838
|
+
技能能力清单:识别 skill 具备的运行时能力,以 INFO 形式列出,便于上架前透明披露。
|
|
2839
|
+
"""
|
|
2840
|
+
file_texts = {}
|
|
2841
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2842
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
2843
|
+
continue
|
|
2844
|
+
file_texts[str(rel)] = text
|
|
2845
|
+
all_text = "\n".join(file_texts.values()).lower()
|
|
2846
|
+
|
|
2847
|
+
caps = []
|
|
2848
|
+
if re.search(r"\b(requests\.|urllib|http\.client|httpx|aiohttp|axios|fetch\(|curl\b|wget\b)\b", all_text):
|
|
2849
|
+
caps.append("HTTP 请求")
|
|
2850
|
+
if EXTERNAL_API_RE.search(all_text):
|
|
2851
|
+
caps.append("调用外部 API")
|
|
2852
|
+
if re.search(r"\b(open\s*\(|read_text\(|read_file\(|readFileSync|readFile\(|fs\.read|Path\([^)]*\)\.read_text)", all_text):
|
|
2853
|
+
caps.append("读取文件")
|
|
2854
|
+
if re.search(r"\b(write_text\(|write_file\(|writeFileSync|writeFile\(|fs\.write|Path\([^)]*\)\.write_text)", all_text):
|
|
2855
|
+
caps.append("写入文件")
|
|
2856
|
+
if DEPENDENCY_INSTALL_RE.search(all_text):
|
|
2857
|
+
caps.append("安装依赖包")
|
|
2858
|
+
if BROWSER_CONTROL_RE.search(all_text):
|
|
2859
|
+
caps.append("控制浏览器")
|
|
2860
|
+
if ENV_READ_RE.search(all_text):
|
|
2861
|
+
caps.append("读取环境变量")
|
|
2862
|
+
if COMMAND_EXEC_RE.search(all_text):
|
|
2863
|
+
caps.append("执行系统命令")
|
|
2864
|
+
if re.search(r"\b(sqlite3|pymysql|psycopg2|sqlalchemy|\.query\(|SELECT\s+.*FROM)\b", all_text, re.IGNORECASE):
|
|
2865
|
+
caps.append("数据库访问")
|
|
2866
|
+
if re.search(r"\b(socket\.|listen\(|connect\(|bind\(|tcp://|udp://|websocket|ws://|wss://)\b", all_text):
|
|
2867
|
+
caps.append("网络监听 / Socket / WebSocket")
|
|
2868
|
+
|
|
2869
|
+
if not caps:
|
|
2870
|
+
caps.append("未识别到特定运行时能力(可能为纯文档/提示词类 skill)")
|
|
2871
|
+
|
|
2872
|
+
return [Check(
|
|
2873
|
+
"技能能力清单", "识别到的能力", "INFO", "INFO",
|
|
2874
|
+
f"本 skill 源码/文档中识别到以下运行时能力:{', '.join(caps)}。",
|
|
2875
|
+
suggestion="上架时建议在说明中如实披露这些能力,便于用户与平台评估权限范围。"
|
|
2876
|
+
)]
|
|
2877
|
+
|
|
2878
|
+
|
|
2879
|
+
SKILL_DEP_FIELDS = ("dependencies", "requires", "depends_on", "depends", "skills", "uses")
|
|
2880
|
+
|
|
2881
|
+
# 中文引述场景下的非技能名词,避免把「使用本技能」「结合通用技能」等常见表述误判为依赖
|
|
2882
|
+
DEP_STOPWORDS = {
|
|
2883
|
+
"本", "该", "此", "某", "一个", "一种", "多个", "相关", "其他", "其它", "对应",
|
|
2884
|
+
"以下", "上述", "如上", "我们", "你们", "他们", "平台", "工作", "检测", "分析",
|
|
2885
|
+
"通用", "标准", "规范", "基础", "核心", "高级", "专属", "内置", "外部", "已有",
|
|
2886
|
+
"当前", "整个", "所有", "这些", "那些", "这种", "此类", "一切", "全部",
|
|
2887
|
+
"自身", "自己", "本技能", "该技能", "此技能", "其他技能",
|
|
2888
|
+
# 常见路径段,避免把 /tmp、/usr、/data 等误判为技能依赖
|
|
2889
|
+
"tmp", "usr", "home", "var", "etc", "opt", "bin", "lib", "dev", "src", "root",
|
|
2890
|
+
"user", "app", "data", "docs", "doc", "test", "tests", "logs", "cache", "mnt",
|
|
2891
|
+
# 口语/疑问词与通用路径段,避免把「使用哪个技能」「/api/xxx」误判为依赖
|
|
2892
|
+
"哪个", "什么", "如何", "怎么", "哪些", "这个", "那个", "此处",
|
|
2893
|
+
"api", "count", "v1", "v2", "rest", "graphql", "oauth", "openapi", "swagger",
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
|
|
2897
|
+
def looks_like_skill_name(name: str) -> bool:
|
|
2898
|
+
"""
|
|
2899
|
+
稳健判定「看起来像个泛微生态技能名」。仅接受一种高置信形状:
|
|
2900
|
+
weaver-e10- 前缀 + 连字符:weaver-e10- 开头且必须含连字符
|
|
2901
|
+
(如 weaver-e10-login、weaver-e10-hrm)。
|
|
2902
|
+
依赖检测**只关注 weaver-e10- 前缀**的泛微生态技能(合作方要求),weaver- 但非
|
|
2903
|
+
e10-/e10-/eteams- 等前缀视为平台模块名/内部引用,不作为技能依赖识别。
|
|
2904
|
+
这样可排除:weaver-ebuilder-*/weaver-openserver-* 等内部接口模块、单单词
|
|
2905
|
+
(send/list/api)、中文片段(以下外部/调用本)、URL 路径段(/references/、/send/)、
|
|
2906
|
+
文档内函数/参数名(hrm-employee-query、emp-by-ids、to-uid)、全大写常量/环境变量
|
|
2907
|
+
(ETEAMSID、E10_LOGIN_ENTRY,无连字符故不命中)。
|
|
2908
|
+
说明:frontmatter 中**显式声明**的依赖(dependencies/requires 字段)同样受
|
|
2909
|
+
weaver-e10- 前缀约束——非 weaver-e10- 前缀的一律不识别为技能依赖。
|
|
2910
|
+
"""
|
|
2911
|
+
s = (name or "").strip().strip('"\'`').strip()
|
|
2912
|
+
if not s or len(s) < 2:
|
|
2913
|
+
return False
|
|
2914
|
+
return bool(re.fullmatch(r"weaver-e10-[\w-]+", s, re.IGNORECASE))
|
|
2915
|
+
|
|
2916
|
+
|
|
2917
|
+
def check_skill_dependencies(skill_dir: Path, skill_md: Path = None, market_catalog: dict = None, live_market: bool = False):
|
|
2918
|
+
"""
|
|
2919
|
+
技能依赖清单:识别 skill 显式声明(frontmatter dependencies/requires 等字段)
|
|
2920
|
+
或正文引用(skill: 调用、/斜杠命令、「使用 xxx 技能」表述)的其他技能,
|
|
2921
|
+
以 INFO 形式透明披露依赖关系,便于上架前评估依赖面与联动安装。
|
|
2922
|
+
|
|
2923
|
+
market_catalog: 可选,市场已收录技能清单(name(lower) -> info)。传入后对每个依赖标注可达性。
|
|
2924
|
+
live_market: 可选,True 时联网动态核查 skillhub.cn 市场(覆盖 market_catalog,按 slug 精确匹配)。
|
|
2925
|
+
二者都不传则仅静态列出依赖(零联网)。
|
|
2926
|
+
"""
|
|
2927
|
+
deps, seen = [], set()
|
|
2928
|
+
self_names = {Path(skill_dir).name.lower(), "weaver-skill-detector", "validate_skill"}
|
|
2929
|
+
|
|
2930
|
+
def add(name, strict: bool = False):
|
|
2931
|
+
name = (name or "").strip().strip('"\'`').strip()
|
|
2932
|
+
if not name or len(name) < 2:
|
|
2933
|
+
return
|
|
2934
|
+
low = name.lower()
|
|
2935
|
+
if low in seen or low in self_names or low in DEP_STOPWORDS:
|
|
2936
|
+
return
|
|
2937
|
+
# 合作方要求:依赖检测只关注 weaver-e10- 前缀的泛微生态技能。
|
|
2938
|
+
# 所有来源(frontmatter 显式声明 / 斜杠命令 / 中文引述 / 反引号标识符 / 由 xxx 提供)
|
|
2939
|
+
# 一律统一收口于此:非 weaver-e10- 前缀不识别为技能依赖
|
|
2940
|
+
# (weaver-ebuilder-/weaver-openserver-/e10-/eteams- 视为平台模块/内部引用)。
|
|
2941
|
+
if not low.startswith("weaver-e10-"):
|
|
2942
|
+
return
|
|
2943
|
+
# strict=True:启发式命中(斜杠命令/中文引述/由 xxx 提供/反引号标识符)必须通过
|
|
2944
|
+
# 「看起来像技能名」判定,过滤单单词与中文/路径噪声;frontmatter 显式声明走 strict=False。
|
|
2945
|
+
if strict and not looks_like_skill_name(name):
|
|
2946
|
+
return
|
|
2947
|
+
seen.add(low)
|
|
2948
|
+
deps.append(name)
|
|
2949
|
+
|
|
2950
|
+
# 1) SKILL.md frontmatter 显式声明
|
|
2951
|
+
if skill_md is not None and skill_md.exists():
|
|
2952
|
+
text = skill_md.read_text(encoding="utf-8", errors="replace")
|
|
2953
|
+
fm, _, _ = extract_frontmatter(text)
|
|
2954
|
+
if fm:
|
|
2955
|
+
nm = parse_frontmatter_field(fm, "name")
|
|
2956
|
+
if nm:
|
|
2957
|
+
self_names.add(nm.strip().strip('"\'`').lower())
|
|
2958
|
+
for key in SKILL_DEP_FIELDS:
|
|
2959
|
+
# ① 内联值:dependencies: a, b 或 dependencies: [a, b]
|
|
2960
|
+
val = parse_frontmatter_field(fm, key)
|
|
2961
|
+
if val:
|
|
2962
|
+
for part in re.split(r"[,,;;]", val):
|
|
2963
|
+
part = part.strip().strip('[]"\'`')
|
|
2964
|
+
if part and not part.startswith("-"):
|
|
2965
|
+
add(part) # frontmatter 显式声明:高置信度,不走 strict 判定
|
|
2966
|
+
# ② YAML 块列表:key: 换行后缩进的 "- item" 行
|
|
2967
|
+
block = re.search(r"^\s*" + re.escape(key) + r"\s*:\s*\n((?:\s*-\s+.*\n?)+)", fm, re.MULTILINE)
|
|
2968
|
+
if block:
|
|
2969
|
+
for m in re.finditer(r"^\s*-\s+(.+)$", block.group(1), re.MULTILINE):
|
|
2970
|
+
add(m.group(1)) # frontmatter 显式声明:高置信度,不走 strict 判定
|
|
2971
|
+
# ③ description 中的依赖表述:「接口地址与认证由 weaver-e10-login 提供」
|
|
2972
|
+
for m in re.finditer(r"^\s*description(?:_\w+)?\s*:\s*(.+)$", fm, re.MULTILINE):
|
|
2973
|
+
for dm in re.finditer(r"由\s*([A-Za-z0-9][A-Za-z0-9._-]{1,60})\s*(?:提供|负责|承担)", m.group(1)):
|
|
2974
|
+
add(dm.group(1), strict=True)
|
|
2975
|
+
|
|
2976
|
+
# 2) 文档引用:仅从 Markdown 文档提取。
|
|
2977
|
+
# - 主 SKILL.md:提示词所在,识别 skill: 调用 / 斜杠命令 / 中文引述 / 「由 xxx 提供」
|
|
2978
|
+
# - references/*.md:多为接口手册,/api/xxx 类路径不应误判为技能依赖,仅识别中文引述与「由 xxx 提供」
|
|
2979
|
+
for rel, text in iter_text_files(skill_dir):
|
|
2980
|
+
if _is_detector_script(str(rel), text) or _is_detector_doc(str(rel), text):
|
|
2981
|
+
continue
|
|
2982
|
+
rel_low = str(rel).lower()
|
|
2983
|
+
if not rel_low.endswith((".md", ".markdown")):
|
|
2984
|
+
continue
|
|
2985
|
+
is_main = rel.name.upper() == "SKILL.MD"
|
|
2986
|
+
if is_main:
|
|
2987
|
+
# Skill 工具调用:skill: "xxx" / skill = xxx
|
|
2988
|
+
for m in re.finditer(r"\bskill\s*[:=]\s*['\"]?([A-Za-z0-9][A-Za-z0-9._/-]{1,60})['\"]?", text):
|
|
2989
|
+
add(m.group(1), strict=True)
|
|
2990
|
+
# 斜杠命令 /xxx:真正的 skill 斜杠命令是孤立 token(如 /commit、/review-pr),其后不接 `/`。
|
|
2991
|
+
# 负向前瞻排除 `/`:接 `/` 的是路径/URL 片段(如 /references/、/viewport/、/api/ebuilder/...、
|
|
2992
|
+
# skillhub.cn/skills/...),并非技能依赖,避免误判。命中再经 strict 标识符形状校验。
|
|
2993
|
+
for m in re.finditer(r"(?<![:/\w<>])\/([a-z][a-z0-9_-]{2,40})(?![A-Za-z0-9_/-])", text):
|
|
2994
|
+
add(m.group(1), strict=True)
|
|
2995
|
+
# 依赖清单式列举:「只读依赖:`a`、`b`、`c`」「依赖:`x`」(容错 markdown 加粗 ** 与空格)
|
|
2996
|
+
for m in re.finditer(r"依赖[*\s]*[::][^。\n]*", text):
|
|
2997
|
+
for part in re.findall(r"[`'](weaver-e10-[\w-]+)[`']", m.group(0)):
|
|
2998
|
+
add(part, strict=True)
|
|
2999
|
+
# 泛微生态高精度信号:反引号/单引号包裹的 weaver-e10- 前缀带连字符标识符
|
|
3000
|
+
# (如 `weaver-e10-login`)。self_names 已排除检测器自身(weaver-skill-detector)。
|
|
3001
|
+
for m in re.finditer(r"[`'](weaver-e10-[\w-]+)[`']", text, re.IGNORECASE):
|
|
3002
|
+
add(m.group(1), strict=True)
|
|
3003
|
+
# 中文引述:「使用 xxx 技能」「调用 xxx skill」「委托 xxx skill」——命中须通过 strict 标识符形状校验,
|
|
3004
|
+
# 因此「依赖以下外部 skill」「调用本 skill」「结合通用的技能」均不会误判为依赖。
|
|
3005
|
+
for m in re.finditer(r"(?:使用|加载|调用|引用|依赖|基于|借助|通过|结合|委托)\s*([`']?[\u4e00-\u9fa5A-Za-z0-9_-]{2,40}[`']?)\s*(?:技能|skill(?!\.md))\b", text, re.IGNORECASE):
|
|
3006
|
+
g = m.group(1)
|
|
3007
|
+
if g.startswith("的"):
|
|
3008
|
+
continue
|
|
3009
|
+
# 自指片段(调用本/本技能/该技能/此技能等)指称本 skill 自身,非外部依赖,应跳过
|
|
3010
|
+
if re.search(r"(本|该|此|自身|自己|这个|那个)(技能|skill)?\s*$", g):
|
|
3011
|
+
continue
|
|
3012
|
+
# 非反引号中文片段若以「的」结尾(如 执行的/可执行的),属修饰语而非技能名,跳过
|
|
3013
|
+
if not g.startswith("`") and g.endswith("的"):
|
|
3014
|
+
continue
|
|
3015
|
+
# 反引号包裹的视为代码标识符(如 `weaver-e10-login`),不受中文短语长度限制;
|
|
3016
|
+
# 非反引号的中文短语仍限制 ≤16 字,避免长句误判为依赖
|
|
3017
|
+
if not g.startswith("`") and len(g) > 16:
|
|
3018
|
+
continue
|
|
3019
|
+
add(g, strict=True)
|
|
3020
|
+
# 「由 xxx 提供/负责」表述(容错「会话」等中间词:由 weaver-e10-login 会话提供)
|
|
3021
|
+
for m in re.finditer(r"由\s*([A-Za-z0-9][A-Za-z0-9._-]{1,60})\s*(?:会话\s*)?(?:提供|负责|承担)", text):
|
|
3022
|
+
add(m.group(1), strict=True)
|
|
3023
|
+
|
|
3024
|
+
if not deps:
|
|
3025
|
+
detail = "未识别到本 skill 依赖的其他技能(可能为独立技能,无需联动安装)。"
|
|
3026
|
+
else:
|
|
3027
|
+
detail = f"本 skill 显式声明或正文引用以下其他技能:{', '.join(deps)}。"
|
|
3028
|
+
|
|
3029
|
+
# 动态市场核查(联网):覆盖静态清单,按 slug 精确匹配 skillhub.cn
|
|
3030
|
+
# add() 已统一收口为 weaver-e10- 前缀依赖(合作方要求:只检测 weaver-e10- 开头),
|
|
3031
|
+
# 此处 _is_weaver_dep 作为保险保留,防止未来扩展非前缀依赖时漏掉过滤。
|
|
3032
|
+
def _is_weaver_dep(d: str) -> bool:
|
|
3033
|
+
return d.lower().startswith("weaver-e10-")
|
|
3034
|
+
|
|
3035
|
+
market_deps = [d for d in deps if _is_weaver_dep(d)] if deps else []
|
|
3036
|
+
if live_market and market_deps:
|
|
3037
|
+
try:
|
|
3038
|
+
market_catalog = query_skillhub_market(market_deps)
|
|
3039
|
+
skipped_n = len(deps) - len(market_deps)
|
|
3040
|
+
log(f"已动态核查 skillhub.cn 市场: {len(market_deps)} 个 weaver-e10- 前缀依赖"
|
|
3041
|
+
+ (f"({skipped_n} 个非 weaver-e10- 前缀依赖跳过核查)" if skipped_n else ""))
|
|
3042
|
+
except Exception as e:
|
|
3043
|
+
log(f"⚠️ 市场动态核查失败(将仅静态列出依赖,不标注可达性): {e}")
|
|
3044
|
+
market_catalog = None
|
|
3045
|
+
|
|
3046
|
+
dep_status = None
|
|
3047
|
+
if market_catalog is not None:
|
|
3048
|
+
dep_status = []
|
|
3049
|
+
for d in deps:
|
|
3050
|
+
if not _is_weaver_dep(d):
|
|
3051
|
+
dep_status.append({"name": d, "in_market": None, "market": "", "url": "",
|
|
3052
|
+
"error": "", "skipped": True})
|
|
3053
|
+
continue
|
|
3054
|
+
info = market_catalog.get(d.lower()) or {"found": False, "market": "", "url": "", "error": ""}
|
|
3055
|
+
dep_status.append({
|
|
3056
|
+
"name": d,
|
|
3057
|
+
"in_market": bool(info.get("found")),
|
|
3058
|
+
"market": info.get("market", ""),
|
|
3059
|
+
"url": info.get("url", ""),
|
|
3060
|
+
"error": info.get("error", ""),
|
|
3061
|
+
"skipped": False,
|
|
3062
|
+
})
|
|
3063
|
+
extra = {"deps": dep_status, "market_checked": market_catalog is not None} if dep_status is not None else None
|
|
3064
|
+
|
|
3065
|
+
return [Check(
|
|
3066
|
+
"技能依赖清单", "依赖的其他技能", "INFO", "INFO", detail,
|
|
3067
|
+
suggestion="上架时建议如实披露依赖关系,确保被依赖技能已上架、名称一致且版本兼容。"
|
|
3068
|
+
+ (" 已对照市场清单标注各依赖可达性。" if extra else ""),
|
|
3069
|
+
extra=extra
|
|
3070
|
+
)]
|
|
3071
|
+
|
|
3072
|
+
|
|
3073
|
+
def _human_size(num: int) -> str:
|
|
3074
|
+
"""把字节数转换为人类可读大小。"""
|
|
3075
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
3076
|
+
size = float(num)
|
|
3077
|
+
for unit in units:
|
|
3078
|
+
if size < 1024 or unit == units[-1]:
|
|
3079
|
+
return f"{size:.2f} {unit}" if unit != "B" else f"{int(size)} B"
|
|
3080
|
+
size /= 1024
|
|
3081
|
+
return f"{size:.2f} TB"
|
|
3082
|
+
|
|
3083
|
+
|
|
3084
|
+
def load_market_catalog(path: str) -> dict:
|
|
3085
|
+
"""
|
|
3086
|
+
加载「市场已收录技能清单」为 {name(lower): info} 映射,供依赖可达性标注使用。
|
|
3087
|
+
|
|
3088
|
+
info 结构: {"found": bool, "market": str, "url": str}
|
|
3089
|
+
- found : 是否在目标市场可搜(缺失键视为 found=False)
|
|
3090
|
+
- market: 市场/平台名称(如 skillhub.cn),用于报告中展示可达性来源
|
|
3091
|
+
- url : 可选,技能在市场中的地址
|
|
3092
|
+
|
|
3093
|
+
支持三种 JSON 格式:
|
|
3094
|
+
- 列表 :["skill-a", "skill-b"] -> 均标记 found=True(市场名留空,由图例说明平台)
|
|
3095
|
+
- 简单对象:{"skill-a": true, "skill-b": false} -> 显式标注可达性
|
|
3096
|
+
- 富对象 :{"skill-a": {"found": true, "market": "skillhub.cn", "url": "https://..."}}
|
|
3097
|
+
也可简写为 {"skill-a": "skillhub.cn"}(字符串视为市场名,found=True)
|
|
3098
|
+
"""
|
|
3099
|
+
p = Path(path).expanduser()
|
|
3100
|
+
if not p.exists():
|
|
3101
|
+
raise FileNotFoundError(f"市场清单文件不存在: {path}")
|
|
3102
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
3103
|
+
|
|
3104
|
+
def norm(v):
|
|
3105
|
+
if isinstance(v, bool):
|
|
3106
|
+
return {"found": v, "market": "", "url": ""}
|
|
3107
|
+
if isinstance(v, str):
|
|
3108
|
+
return {"found": True, "market": v.strip(), "url": ""}
|
|
3109
|
+
if isinstance(v, dict):
|
|
3110
|
+
found = bool(v.get("found", True)) and not bool(v.get("not_found", False))
|
|
3111
|
+
return {"found": found, "market": v.get("market", "") or "", "url": v.get("url", "") or ""}
|
|
3112
|
+
return {"found": False, "market": "", "url": ""}
|
|
3113
|
+
|
|
3114
|
+
catalog = {}
|
|
3115
|
+
if isinstance(data, list):
|
|
3116
|
+
for item in data:
|
|
3117
|
+
if isinstance(item, str) and item.strip():
|
|
3118
|
+
catalog[item.strip().lower()] = {"found": True, "market": "", "url": ""}
|
|
3119
|
+
elif isinstance(data, dict):
|
|
3120
|
+
for k, v in data.items():
|
|
3121
|
+
catalog[str(k).strip().lower()] = norm(v)
|
|
3122
|
+
return catalog
|
|
3123
|
+
|
|
3124
|
+
|
|
3125
|
+
def query_skillhub_market(names):
|
|
3126
|
+
"""
|
|
3127
|
+
动态联网核查给定技能名是否已在 skillhub.cn 市场发布(依赖可达性)。
|
|
3128
|
+
|
|
3129
|
+
返回 {name(lower): {"found": bool, "market": "skillhub.cn", "url": str, "error": str}}。
|
|
3130
|
+
使用 skillhub.cn 公开检索接口 GET /api/skills?page=1&pageSize=24&sortBy=score&order=desc&keyword=<name>
|
|
3131
|
+
(与 skillhub.cn 前端实际发出的请求一致),按 **slug 精确相等**(或 canonicalName 以 /<name> 结尾)
|
|
3132
|
+
判定,避免子串误命中
|
|
3133
|
+
(例如 keyword=weaver-e10-login 会同时召回 weaver-e10-jucailin,必须精确匹配)。
|
|
3134
|
+
|
|
3135
|
+
设计取舍:本工具默认仍是「纯静态、零联网」。此函数为可选的 --market-live 模式提供
|
|
3136
|
+
联网能力,把「是否可搜」的判定交给 skillhub.cn 实时返回,而非依赖一份写死的清单。
|
|
3137
|
+
"""
|
|
3138
|
+
catalog = {}
|
|
3139
|
+
for name in names:
|
|
3140
|
+
low = name.lower()
|
|
3141
|
+
entry = {"found": False, "market": "skillhub.cn", "url": "", "error": ""}
|
|
3142
|
+
try:
|
|
3143
|
+
q = urllib.parse.quote(name)
|
|
3144
|
+
url = f"{SKILLHUB_API}?page=1&pageSize=24&sortBy=score&order=desc&keyword={q}"
|
|
3145
|
+
req = urllib.request.Request(
|
|
3146
|
+
url, headers={"User-Agent": "weaver-skill-detector/1.0 (+https://skillhub.cn)"}
|
|
3147
|
+
)
|
|
3148
|
+
with urllib.request.urlopen(req, timeout=15) as resp:
|
|
3149
|
+
payload = json.loads(resp.read().decode("utf-8"))
|
|
3150
|
+
skills = (payload.get("data") or {}).get("skills") or []
|
|
3151
|
+
for s in skills:
|
|
3152
|
+
slug = (s.get("slug") or "").lower()
|
|
3153
|
+
ns = s.get("namespace") or {}
|
|
3154
|
+
canonical = (ns.get("canonicalName") or "") if isinstance(ns, dict) else ""
|
|
3155
|
+
if slug == low or canonical.lower().endswith("/" + low):
|
|
3156
|
+
entry = {
|
|
3157
|
+
"found": True,
|
|
3158
|
+
"market": "skillhub.cn",
|
|
3159
|
+
"url": f"https://skillhub.cn/skills/{canonical.lstrip('@')}" if canonical
|
|
3160
|
+
else f"https://skillhub.cn/skills/{slug}",
|
|
3161
|
+
"error": "",
|
|
3162
|
+
}
|
|
3163
|
+
break
|
|
3164
|
+
except Exception as e: # 网络/解析失败:标记错误,交由调用方以「核查失败」呈现
|
|
3165
|
+
entry["error"] = str(e)
|
|
3166
|
+
catalog[low] = entry
|
|
3167
|
+
return catalog
|
|
3168
|
+
|
|
3169
|
+
|
|
3170
|
+
def calc_file_info(target: Path, source_type: str):
|
|
3171
|
+
"""计算被检测对象的基础信息(MD5 / SHA256 / 大小)。"""
|
|
3172
|
+
if not target.exists():
|
|
3173
|
+
return None
|
|
3174
|
+
|
|
3175
|
+
md5 = hashlib.md5()
|
|
3176
|
+
sha = hashlib.sha256()
|
|
3177
|
+
|
|
3178
|
+
if source_type == "directory":
|
|
3179
|
+
total = 0
|
|
3180
|
+
files = sorted(p for p in target.rglob("*") if p.is_file())
|
|
3181
|
+
for f in files:
|
|
3182
|
+
total += f.stat().st_size
|
|
3183
|
+
rel = str(f.relative_to(target)).replace("\\", "/")
|
|
3184
|
+
md5.update(rel.encode("utf-8"))
|
|
3185
|
+
md5.update(b"\x00")
|
|
3186
|
+
sha.update(rel.encode("utf-8"))
|
|
3187
|
+
sha.update(b"\x00")
|
|
3188
|
+
with open(f, "rb") as fh:
|
|
3189
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
3190
|
+
md5.update(chunk)
|
|
3191
|
+
sha.update(chunk)
|
|
3192
|
+
return {
|
|
3193
|
+
"md5": md5.hexdigest(),
|
|
3194
|
+
"sha256": sha.hexdigest(),
|
|
3195
|
+
"size": total,
|
|
3196
|
+
"size_human": _human_size(total),
|
|
3197
|
+
"scope": "目录递归",
|
|
3198
|
+
}
|
|
3199
|
+
|
|
3200
|
+
# zip / markdown / 其他文件:对文件本身计算
|
|
3201
|
+
total = target.stat().st_size
|
|
3202
|
+
with open(target, "rb") as fh:
|
|
3203
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
3204
|
+
md5.update(chunk)
|
|
3205
|
+
sha.update(chunk)
|
|
3206
|
+
return {
|
|
3207
|
+
"md5": md5.hexdigest(),
|
|
3208
|
+
"sha256": sha.hexdigest(),
|
|
3209
|
+
"size": total,
|
|
3210
|
+
"size_human": _human_size(total),
|
|
3211
|
+
"scope": "文件本身",
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
|
|
3215
|
+
# ---------------------------------------------------------------------------
|
|
3216
|
+
# 报告生成
|
|
3217
|
+
# ---------------------------------------------------------------------------
|
|
3218
|
+
|
|
3219
|
+
def summarize(checks):
|
|
3220
|
+
errors = [c for c in checks if c.severity == "BLOCK" and c.status == "FAIL"]
|
|
3221
|
+
warns = [c for c in checks if c.status == "WARN"]
|
|
3222
|
+
if errors:
|
|
3223
|
+
verdict = "FAIL"
|
|
3224
|
+
elif warns:
|
|
3225
|
+
verdict = "WARN"
|
|
3226
|
+
else:
|
|
3227
|
+
verdict = "PASS"
|
|
3228
|
+
return verdict, len(errors), len(warns)
|
|
3229
|
+
|
|
3230
|
+
|
|
3231
|
+
# ---------------------------------------------------------------------------
|
|
3232
|
+
# 约束归属(WB = WorkBuddy 平台约束 / WV = 泛微专属约束)
|
|
3233
|
+
# WB :skillhub.cn 平台加载器与上传校验要求,任何作者的 skill 都必须满足,违反即被平台拒收。
|
|
3234
|
+
# WV :泛微生态内部治理要求(品牌、E10 账号与接口调用契约、安全合规),平台不强制但泛微不放行。
|
|
3235
|
+
# WB+WV :平台有通用要求,泛微在其上追加了更严的约束。
|
|
3236
|
+
# 详见 references/checklist.md 第 0 节「约束归属总览」。
|
|
3237
|
+
# ---------------------------------------------------------------------------
|
|
3238
|
+
CATEGORY_ORIGIN = {
|
|
3239
|
+
"基础合规": "WB+WV",
|
|
3240
|
+
"专项质检": "WV",
|
|
3241
|
+
"API 凭证完整性": "WV",
|
|
3242
|
+
"E10 登录与凭证契约": "WV",
|
|
3243
|
+
"扩展安全": "WB",
|
|
3244
|
+
"质量评估": "WB",
|
|
3245
|
+
"技能能力清单": "WB",
|
|
3246
|
+
"技能依赖清单": "WB+WV",
|
|
3247
|
+
"威胁模型": "WB",
|
|
3248
|
+
"行为图谱": "WB",
|
|
3249
|
+
"千问契约": "QW",
|
|
3250
|
+
}
|
|
3251
|
+
ORIGIN_DESC = {
|
|
3252
|
+
"WB": "WorkBuddy 平台约束",
|
|
3253
|
+
"WV": "泛微专属约束",
|
|
3254
|
+
"WB+WV": "平台通用 + 泛微追加",
|
|
3255
|
+
"QW": "千问办公平台约束",
|
|
3256
|
+
"QW+WB": "千问平台 + WorkBuddy 通用",
|
|
3257
|
+
"QW+WV": "千问平台 + 泛微追加",
|
|
3258
|
+
}
|
|
3259
|
+
ORIGIN_ORDER = ("WB", "WV", "WB+WV", "QW", "QW+WB", "QW+WV")
|
|
3260
|
+
|
|
3261
|
+
# ---------------------------------------------------------------------------
|
|
3262
|
+
# 多平台规范(千问办公生态接入开发指南 2026 整合)
|
|
3263
|
+
# ---------------------------------------------------------------------------
|
|
3264
|
+
# 默认 weaver(泛微生态)保持既有行为;--platform qwenwork 时启用千问专属规则。
|
|
3265
|
+
# 字段别名机制:千问规范的 frontmatter 字段命名与泛微不一致(如 displayName / name_en),
|
|
3266
|
+
# 解析时按当前 platform 自动选用对应 key 集合;缺失信息给出差异化提示。
|
|
3267
|
+
PLATFORMS = {
|
|
3268
|
+
"weaver": {
|
|
3269
|
+
"label": "泛微生态",
|
|
3270
|
+
"origin_chip": "WB+WV",
|
|
3271
|
+
"publish_label": "WorkBuddy 泛微上架",
|
|
3272
|
+
# frontmatter 字段:千问与泛微在「中文展示名 / 英文展示名 / 中文描述」上的命名差异
|
|
3273
|
+
"name_cn_field": "display_name", # 中文展示名
|
|
3274
|
+
"name_cn_prefix_required": "泛微", # display_name 必须以「泛微」开头
|
|
3275
|
+
"name_en_field": "display_name_en", # 英文展示名
|
|
3276
|
+
"desc_cn_field": "description_zh", # 中文描述
|
|
3277
|
+
"desc_en_field": "description_en", # 英文描述
|
|
3278
|
+
"extra_name_field": None, # 额外必填名(千问特有 author_name)
|
|
3279
|
+
# 打包硬性
|
|
3280
|
+
"max_entries": 300, # zip 内条目数
|
|
3281
|
+
"max_zip_mb": None, # zip 大小限制(None = 不限制)
|
|
3282
|
+
"skill_md_path_required": "exists", # SKILL.md 是否存在即可
|
|
3283
|
+
# 正文长度
|
|
3284
|
+
"max_body_kb": None, # SKILL.md 正文 KB 限制(None = 不限制)
|
|
3285
|
+
# 必填 frontmatter 字段集合(用于 frontmatter 完整性 + 语法校验的关键 key 集合)
|
|
3286
|
+
"required_fm_keys": ("author", "version", "name", "description",
|
|
3287
|
+
"description_zh", "description_en",
|
|
3288
|
+
"display_name", "display_name_en"),
|
|
3289
|
+
# 是否需要 name 以 weaver- 开头 / 是否需要 author 锁定为「泛微网络科技股份有限公司」
|
|
3290
|
+
"name_prefix_required": "weaver-",
|
|
3291
|
+
"author_locked": "泛微网络科技股份有限公司",
|
|
3292
|
+
# 分类是否限定枚举
|
|
3293
|
+
"category_enum": None,
|
|
3294
|
+
},
|
|
3295
|
+
"qwenwork": {
|
|
3296
|
+
"label": "千问办公生态",
|
|
3297
|
+
"origin_chip": "QW",
|
|
3298
|
+
"publish_label": "千问上架",
|
|
3299
|
+
"name_cn_field": "displayName", # 千问用 displayName 作为中文展示名
|
|
3300
|
+
"name_cn_prefix_required": None, # 千问 displayName 无前缀要求
|
|
3301
|
+
"name_en_field": "name_en", # 千问用 name_en 作为英文展示名
|
|
3302
|
+
"desc_cn_field": "description", # 千问 description 直接是中文
|
|
3303
|
+
"desc_en_field": "description_en",
|
|
3304
|
+
"extra_name_field": "author_name", # 千问特有必填 author_name
|
|
3305
|
+
"max_entries": 999, # 千问硬性 < 1000
|
|
3306
|
+
"max_zip_mb": 10, # 千问硬性 ≤ 10 MB
|
|
3307
|
+
"skill_md_path_required": "exact", # 千问硬性 <name>/SKILL.md 精确路径
|
|
3308
|
+
"max_body_kb": 64, # 千问硬性 ≤ 64 KB
|
|
3309
|
+
"required_fm_keys": ("name", "displayName", "name_en",
|
|
3310
|
+
"description", "description_en"),
|
|
3311
|
+
"name_prefix_required": None, # 千问不强求 weaver- 前缀(无品牌前缀要求)
|
|
3312
|
+
"author_locked": None, # 千问不锁 author 为固定值
|
|
3313
|
+
"category_enum": ("content_creation", "visual_creative", "data_analysis",
|
|
3314
|
+
"consulting_research", "productivity", "marketing_sales",
|
|
3315
|
+
"ecommerce_retail", "product_development", "hr_recruiting",
|
|
3316
|
+
"finance_accounting", "legal_affairs", "investment",
|
|
3317
|
+
"education", "other"),
|
|
3318
|
+
},
|
|
3319
|
+
}
|
|
3320
|
+
VALID_PLATFORMS = tuple(PLATFORMS.keys())
|
|
3321
|
+
|
|
3322
|
+
|
|
3323
|
+
def _origin_of(cat: str):
|
|
3324
|
+
return CATEGORY_ORIGIN.get(cat, "")
|
|
3325
|
+
|
|
3326
|
+
|
|
3327
|
+
def render_report(target, source_type, checks, verdict, n_err, n_warn, file_info=None,
|
|
3328
|
+
platform: str = "weaver"):
|
|
3329
|
+
lines = []
|
|
3330
|
+
lines.append("=" * 64)
|
|
3331
|
+
lines.append("泛微技能检测工具 · 检测报告")
|
|
3332
|
+
lines.append("=" * 64)
|
|
3333
|
+
lines.append(f"被检测对象 : {target}")
|
|
3334
|
+
lines.append(f"输入类型 : {source_type}")
|
|
3335
|
+
_spec = PLATFORMS.get(platform, PLATFORMS["weaver"])
|
|
3336
|
+
_default_tag = " · 默认" if platform == "weaver" else " · 显式指定"
|
|
3337
|
+
lines.append(f"检测平台 : {_spec['label']}(--platform {platform}{_default_tag})")
|
|
3338
|
+
if file_info:
|
|
3339
|
+
lines.append(f"文件大小 : {file_info['size_human']} ({file_info['size']} bytes, {file_info['scope']})")
|
|
3340
|
+
lines.append(f"MD5 : {file_info['md5']}")
|
|
3341
|
+
lines.append(f"SHA256 : {file_info['sha256']}")
|
|
3342
|
+
lines.append("")
|
|
3343
|
+
lines.append("-" * 64)
|
|
3344
|
+
|
|
3345
|
+
by_cat = {}
|
|
3346
|
+
for c in checks:
|
|
3347
|
+
by_cat.setdefault(c.category, []).append(c)
|
|
3348
|
+
|
|
3349
|
+
icon = {"PASS": "✅", "FAIL": "❌", "WARN": "⚠️", "INFO": "ℹ️"}
|
|
3350
|
+
for cat, items in by_cat.items():
|
|
3351
|
+
_org = _origin_of(cat)
|
|
3352
|
+
lines.append(f"【{cat}" + (f" · 归属 {_org}" if _org else "") + "】")
|
|
3353
|
+
for c in items:
|
|
3354
|
+
lines.append(f" {icon.get(c.status, '·')} [{c.status}] {c.name}")
|
|
3355
|
+
lines.append(f" 说明: {c.detail}")
|
|
3356
|
+
if c.locations:
|
|
3357
|
+
lines.append(f" 位置: {c.locations[0]}")
|
|
3358
|
+
for loc in c.locations[1:5]:
|
|
3359
|
+
lines.append(f" {loc}")
|
|
3360
|
+
if len(c.locations) > 5:
|
|
3361
|
+
lines.append(f" …等 {len(c.locations)} 处")
|
|
3362
|
+
if c.suggestion:
|
|
3363
|
+
lines.append(f" 建议: {c.suggestion}")
|
|
3364
|
+
if c.category == "技能依赖清单" and getattr(c, "extra", None) and c.extra.get("market_checked"):
|
|
3365
|
+
for d in c.extra.get("deps") or []:
|
|
3366
|
+
suffix = f"({d['market']})" if d.get("market") else ""
|
|
3367
|
+
if d.get("skipped"):
|
|
3368
|
+
lines.append(f" 市场可达性: {d['name']} → 跳过(非 weaver-e10- 前缀)")
|
|
3369
|
+
else:
|
|
3370
|
+
lines.append(f" 市场可达性: {d['name']} → {'市场可搜' if d['in_market'] else '市场未收录'}{suffix}")
|
|
3371
|
+
lines.append("-" * 64)
|
|
3372
|
+
|
|
3373
|
+
lines.append("综合判定 : " + ("不通过 ❌ FAIL" if verdict == "FAIL"
|
|
3374
|
+
else ("通过(含警告)⚠️ WARN" if verdict == "WARN"
|
|
3375
|
+
else "通过 ✅ PASS")))
|
|
3376
|
+
lines.append("发布结论 : " + ("✅ 可发布(无阻断性风险)" if n_err == 0
|
|
3377
|
+
else f"⛔ 不可发布(含 {n_err} 项阻断性风险)"))
|
|
3378
|
+
if n_err == 0:
|
|
3379
|
+
lines.append("上架提醒 : ⚠️ 提交前请确认市场现有版本号,新版本号必须递增"
|
|
3380
|
+
"(严格大于市场已发布版本),否则上架不通过。")
|
|
3381
|
+
lines.append(f"阻断项 : {n_err} 项")
|
|
3382
|
+
lines.append(f"警告项 : {n_warn} 项")
|
|
3383
|
+
lines.append("-" * 64)
|
|
3384
|
+
lines.append("归属图例 : WB = WorkBuddy 平台约束(违反即被平台拒收/加载异常)")
|
|
3385
|
+
lines.append(" WV = 泛微专属约束(平台不拦,但泛微发布门禁不放行)")
|
|
3386
|
+
lines.append(" WB+WV = 平台通用要求 + 泛微追加约束")
|
|
3387
|
+
if platform == "qwenwork":
|
|
3388
|
+
lines.append(" QW = 千问办公平台约束(违反即被千问拒收)")
|
|
3389
|
+
lines.append(" QW+WB = 千问独有约束 + WorkBuddy 通用要求")
|
|
3390
|
+
lines.append(" QW+WV = 千问与泛微双平台上架的叠加约束")
|
|
3391
|
+
lines.append("=" * 64)
|
|
3392
|
+
return "\n".join(lines)
|
|
3393
|
+
|
|
3394
|
+
|
|
3395
|
+
# ---------------------------------------------------------------------------
|
|
3396
|
+
# HTML 报告渲染
|
|
3397
|
+
# ---------------------------------------------------------------------------
|
|
3398
|
+
|
|
3399
|
+
def _esc(s):
|
|
3400
|
+
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">"))
|
|
3401
|
+
|
|
3402
|
+
|
|
3403
|
+
def render_html(target, source_type, checks, verdict, n_err, n_warn, file_info=None,
|
|
3404
|
+
platform: str = "weaver"):
|
|
3405
|
+
"""生成「腾讯安全云鼎实验室」风格的 HTML 安全评估报告。"""
|
|
3406
|
+
icon = {"PASS": "✅", "FAIL": "❌", "WARN": "⚠️", "INFO": "ℹ️"}
|
|
3407
|
+
platform_label = PLATFORMS.get(platform, PLATFORMS["weaver"])["label"]
|
|
3408
|
+
platform_code = platform
|
|
3409
|
+
# 默认平台为 weaver(泛微 + WorkBuddy + 通用);千问规范仅在显式指定 --platform qwenwork 时启用
|
|
3410
|
+
platform_default_tag = ",默认" if platform == "weaver" else ",显式指定"
|
|
3411
|
+
# 千问图例仅在千问模式下展示,避免默认报告出现与本次检测无关的规范说明
|
|
3412
|
+
qw_legend_html = "" if platform != "qwenwork" else """
|
|
3413
|
+
<span><span class="origin-chip origin-qw">QW</span> <b>千问办公平台约束</b>:千问生态接入开发指南 2026(zip ≤ 10 MB / 条目 < 1000 / 14 个分类枚举 / 连接器契约等)</span>
|
|
3414
|
+
<span><span class="origin-chip origin-qwwb">QW+WB</span> <b>千问+WorkBuddy 叠加</b>:千问独有约束,且与 WorkBuddy 通用要求共存</span>
|
|
3415
|
+
<span><span class="origin-chip origin-qwwv">QW+WV</span> <b>千问+泛微叠加</b>:同一 skill 既要满足千问上架又要满足泛微上架时的叠加约束</span>
|
|
3416
|
+
"""
|
|
3417
|
+
cls = {"PASS": "pass", "FAIL": "fail", "WARN": "warn", "INFO": "info"}
|
|
3418
|
+
verdict_label = {"FAIL": "不通过", "WARN": "通过(含警告)", "PASS": "通过"}[verdict]
|
|
3419
|
+
verdict_tag = {"FAIL": "高危风险", "WARN": "可疑风险", "PASS": "安全"}[verdict]
|
|
3420
|
+
verdict_cls = {"FAIL": "v-fail", "WARN": "v-warn", "PASS": "v-pass"}[verdict]
|
|
3421
|
+
|
|
3422
|
+
# 发布结论:仅以「是否存在阻断性项」为准。有阻断 → 不可发布;无阻断 → 可发布(含警告亦可发布)。
|
|
3423
|
+
n_block = sum(1 for c in checks if c.severity == "BLOCK" and c.status == "FAIL")
|
|
3424
|
+
publishable = n_block == 0
|
|
3425
|
+
publish_label = "可发布" if publishable else "不可发布"
|
|
3426
|
+
publish_cls = "pub-ok" if publishable else "pub-no"
|
|
3427
|
+
publish_icon = "✅" if publishable else "⛔"
|
|
3428
|
+
if publishable:
|
|
3429
|
+
publish_sub = ("未发现阻断性合规/安全问题,可正常上架发布。"
|
|
3430
|
+
"请注意:提交前请确认市场现有版本号,新版本号必须递增"
|
|
3431
|
+
"(严格大于市场已发布版本),否则上架不通过。")
|
|
3432
|
+
else:
|
|
3433
|
+
publish_sub = f"存在 {n_block} 项阻断性风险,必须先修复并通过复检后方可提交。"
|
|
3434
|
+
|
|
3435
|
+
cap_check = None
|
|
3436
|
+
dep_check = None
|
|
3437
|
+
by_cat = {}
|
|
3438
|
+
for c in checks:
|
|
3439
|
+
if c.category == "技能能力清单":
|
|
3440
|
+
cap_check = c
|
|
3441
|
+
elif c.category == "技能依赖清单":
|
|
3442
|
+
dep_check = c
|
|
3443
|
+
else:
|
|
3444
|
+
by_cat.setdefault(c.category, []).append(c)
|
|
3445
|
+
|
|
3446
|
+
n_pass = len([c for c in checks if c.status == "PASS"])
|
|
3447
|
+
total = len(checks)
|
|
3448
|
+
|
|
3449
|
+
def _calc_score(checks):
|
|
3450
|
+
score = 100
|
|
3451
|
+
for c in checks:
|
|
3452
|
+
if c.status in ("FAIL", "WARN"):
|
|
3453
|
+
if c.severity == "BLOCK":
|
|
3454
|
+
score -= 35
|
|
3455
|
+
elif c.severity == "HIGH":
|
|
3456
|
+
score -= 18
|
|
3457
|
+
elif c.severity == "MEDIUM":
|
|
3458
|
+
score -= 8
|
|
3459
|
+
elif c.severity == "LOW":
|
|
3460
|
+
score -= 3
|
|
3461
|
+
return max(0, min(100, score))
|
|
3462
|
+
|
|
3463
|
+
score = _calc_score(checks)
|
|
3464
|
+
|
|
3465
|
+
def _risk_summary():
|
|
3466
|
+
# 检查名多为否定式需求(如「无硬编码凭证」);WARN/FAIL 表示需求被违反,
|
|
3467
|
+
# 摘要应翻转成正向发现(去掉「无」前缀),避免“无…却是可疑项”的反读歧义。
|
|
3468
|
+
def _positive_label(name: str) -> str:
|
|
3469
|
+
return name[1:] if name.startswith("无") else name
|
|
3470
|
+
|
|
3471
|
+
if verdict == "FAIL":
|
|
3472
|
+
blocks = [_positive_label(c.name) for c in checks if c.severity == "BLOCK" and c.status == "FAIL"]
|
|
3473
|
+
highs = [_positive_label(c.name) for c in checks if c.severity == "HIGH" and c.status in ("FAIL", "WARN")]
|
|
3474
|
+
parts = []
|
|
3475
|
+
if blocks:
|
|
3476
|
+
parts.append(f"阻断项:{', '.join(blocks[:3])}" + ("..." if len(blocks) > 3 else ""))
|
|
3477
|
+
if highs:
|
|
3478
|
+
parts.append(f"高危项:{', '.join(highs[:3])}" + ("..." if len(highs) > 3 else ""))
|
|
3479
|
+
if not parts:
|
|
3480
|
+
parts.append("存在合规或安全隐患")
|
|
3481
|
+
return "检测到" + ";".join(parts) + "。该 skill 存在严重合规或安全隐患,建议立即修复后再提交。"
|
|
3482
|
+
elif verdict == "WARN":
|
|
3483
|
+
warns = [_positive_label(c.name) for c in checks if c.status == "WARN"]
|
|
3484
|
+
return f"检测到可疑项:{', '.join(warns[:4])}" + ("..." if len(warns) > 4 else "") + "。当前无阻断项,但建议在发布前人工复核并修复。"
|
|
3485
|
+
return "未发现阻断项与可疑项,静态检测通过。"
|
|
3486
|
+
|
|
3487
|
+
def _fix_suggestions():
|
|
3488
|
+
sugs, seen = [], set()
|
|
3489
|
+
for c in checks:
|
|
3490
|
+
if c.status in ("FAIL", "WARN") and c.suggestion:
|
|
3491
|
+
key = c.suggestion.strip()
|
|
3492
|
+
if key and key not in seen:
|
|
3493
|
+
seen.add(key)
|
|
3494
|
+
sugs.append(c.suggestion)
|
|
3495
|
+
return sugs
|
|
3496
|
+
|
|
3497
|
+
suggestions = _fix_suggestions()
|
|
3498
|
+
|
|
3499
|
+
skill_name = Path(target).stem
|
|
3500
|
+
skill_version = "-"
|
|
3501
|
+
for c in checks:
|
|
3502
|
+
if c.name == "name 命名规范" and c.status == "PASS":
|
|
3503
|
+
m = re.search(r"name='([^']+)'", c.detail)
|
|
3504
|
+
if m:
|
|
3505
|
+
skill_name = m.group(1)
|
|
3506
|
+
if "version" in c.detail.lower():
|
|
3507
|
+
m = re.search(r"version[=:]\s*['\"]?([0-9.]+)", c.detail)
|
|
3508
|
+
if m:
|
|
3509
|
+
skill_version = m.group(1)
|
|
3510
|
+
|
|
3511
|
+
sha256_short = file_info["sha256"][:14] + "..." if file_info and file_info.get("sha256") else "-"
|
|
3512
|
+
content_hash = sha256_short
|
|
3513
|
+
|
|
3514
|
+
n_issues = n_err + n_warn
|
|
3515
|
+
n_malicious = len([c for c in checks if c.severity == "BLOCK" and c.status == "FAIL"])
|
|
3516
|
+
n_suspicious = n_warn
|
|
3517
|
+
n_categories = len(by_cat)
|
|
3518
|
+
|
|
3519
|
+
THREAT_ICONS = {
|
|
3520
|
+
"供应链风险": "📦", "命令执行风险": "⌨️", "网络请求与数据外传": "🌐",
|
|
3521
|
+
"文件操作与敏感路径": "📁", "Prompt 注入风险": "💬", "远程脚本下载执行": "⬇️",
|
|
3522
|
+
"可疑编码/混淆": "🔣", "其他安全风险": "🛡️",
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
def _esc(s):
|
|
3526
|
+
return (str(s).replace("&", "&").replace("<", "<").replace(">", ">"))
|
|
3527
|
+
|
|
3528
|
+
def _locations_html(c):
|
|
3529
|
+
if not c.locations:
|
|
3530
|
+
return ""
|
|
3531
|
+
lis = "".join(f"<li>{_esc(loc)}</li>" for loc in c.locations[:20])
|
|
3532
|
+
more = f"<li class='more'>…等 {len(c.locations)} 处</li>" if len(c.locations) > 20 else ""
|
|
3533
|
+
return f"<ul>{lis}{more}</ul>"
|
|
3534
|
+
|
|
3535
|
+
def _capability_cloud(c):
|
|
3536
|
+
cleaned = c.detail.replace("本 skill 源码/文档中识别到以下运行时能力:", "").replace("。", "")
|
|
3537
|
+
caps = [x.strip() for x in re.split(r"[,,]", cleaned) if x.strip()]
|
|
3538
|
+
chips = "".join(f"<span class='cap-chip'>{_esc(x)}</span>" for x in caps)
|
|
3539
|
+
return chips if chips else "<span class='cap-empty'>未识别到特定运行时能力</span>"
|
|
3540
|
+
|
|
3541
|
+
def _dependency_cloud(c):
|
|
3542
|
+
m = re.search(r"以下其他技能[::](.*?)[。.]?\s*$", c.detail)
|
|
3543
|
+
if not m:
|
|
3544
|
+
return "<span class='cap-empty'>未识别到显式依赖的其他技能</span>"
|
|
3545
|
+
names = [x.strip() for x in re.split(r"[,,]", m.group(1)) if x.strip()]
|
|
3546
|
+
if not names:
|
|
3547
|
+
return "<span class='cap-empty'>未识别到显式依赖的其他技能</span>"
|
|
3548
|
+
extra = getattr(c, "extra", None) or {}
|
|
3549
|
+
market_checked = extra.get("market_checked")
|
|
3550
|
+
dep_map = {d["name"].lower(): d for d in (extra.get("deps") or [])}
|
|
3551
|
+
chips = []
|
|
3552
|
+
for x in names:
|
|
3553
|
+
if market_checked:
|
|
3554
|
+
d = dep_map.get(x.lower()) or {}
|
|
3555
|
+
if d.get("skipped"):
|
|
3556
|
+
badge = "<span class='dep-badge skip' title='非 weaver-e10- 前缀依赖,不做市场可达性核查'>跳过核查</span>"
|
|
3557
|
+
elif d.get("error"):
|
|
3558
|
+
badge = "<span class='dep-badge err'>核查失败</span>"
|
|
3559
|
+
elif d.get("in_market"):
|
|
3560
|
+
src = f" · {_esc(d['market'])}" if d.get("market") else ""
|
|
3561
|
+
label = f"市场可搜{src}"
|
|
3562
|
+
if d.get("url"):
|
|
3563
|
+
badge = (f"<a class='dep-badge ok' href='{_esc(d['url'])}' "
|
|
3564
|
+
f"target='_blank' rel='noopener'>{label}</a>")
|
|
3565
|
+
else:
|
|
3566
|
+
badge = f"<span class='dep-badge ok'>{label}</span>"
|
|
3567
|
+
else:
|
|
3568
|
+
badge = "<span class='dep-badge no'>市场未收录</span>"
|
|
3569
|
+
chips.append(f"<span class='dep-chip'>{_esc(x)}{badge}</span>")
|
|
3570
|
+
else:
|
|
3571
|
+
chips.append(f"<span class='dep-chip'>{_esc(x)}</span>")
|
|
3572
|
+
return "".join(chips)
|
|
3573
|
+
|
|
3574
|
+
# 引擎 chip 颜色:行为安全按 c.status,AI 引擎固定绿占位(未启用独立 AI 模型;扫描器自身作为 AI 引擎对外披露),
|
|
3575
|
+
# 静态引擎按 c.locations 是否非空(命中=蓝,未命中=灰)。
|
|
3576
|
+
ENGINE_CLASSES = {
|
|
3577
|
+
"行为安全": { # 行为安全 / 行为可疑 / 行为风险 跟随 status
|
|
3578
|
+
"PASS": "engine-pass", "INFO": "engine-pass",
|
|
3579
|
+
"WARN": "engine-warn", "FAIL": "engine-fail",
|
|
3580
|
+
},
|
|
3581
|
+
"AI 引擎": { # 评估通过 = 占位绿
|
|
3582
|
+
"PASS": "engine-purple", "INFO": "engine-purple",
|
|
3583
|
+
"WARN": "engine-warn", "FAIL": "engine-fail",
|
|
3584
|
+
},
|
|
3585
|
+
"静态引擎": { # 命中=蓝;空=灰
|
|
3586
|
+
"HIT": "engine-blue", "EMPTY": "engine-grey",
|
|
3587
|
+
},
|
|
3588
|
+
}
|
|
3589
|
+
ENGINE_LABEL = {"行为安全": "行为安全", "AI 引擎": "AI 引擎", "静态引擎": "静态引擎"}
|
|
3590
|
+
ENGINE_REASON = {
|
|
3591
|
+
"行为安全": ("行为可疑" if False else "行为安全"), # 实际渲染按 cls
|
|
3592
|
+
"AI 引擎": "AI 引擎",
|
|
3593
|
+
"静态引擎": ("静态命中" if False else "静态无命中"),
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
def _engine_label(engine_name: str, status: str, has_static_hit: bool):
|
|
3597
|
+
"""返回 (chip_text, css_class)。"""
|
|
3598
|
+
if engine_name == "静态引擎":
|
|
3599
|
+
return ("静态命中" if has_static_hit else "静态无命中",
|
|
3600
|
+
ENGINE_CLASSES["静态引擎"]["HIT" if has_static_hit else "EMPTY"])
|
|
3601
|
+
if engine_name == "行为安全":
|
|
3602
|
+
text = "行为可疑" if status in ("WARN", "FAIL") else "行为安全"
|
|
3603
|
+
cls = ENGINE_CLASSES["行为安全"][status]
|
|
3604
|
+
else: # AI 引擎
|
|
3605
|
+
text = ENGINE_LABEL[engine_name]
|
|
3606
|
+
cls = ENGINE_CLASSES[engine_name]["PASS"]
|
|
3607
|
+
return text, cls
|
|
3608
|
+
|
|
3609
|
+
def _threat_card(c):
|
|
3610
|
+
status_tip = {"PASS": "安全", "WARN": "可疑", "FAIL": "风险", "INFO": "提示"}[c.status]
|
|
3611
|
+
ticon = THREAT_ICONS.get(c.name, "🔍")
|
|
3612
|
+
bg_cls = {"PASS": "pass", "WARN": "warn", "FAIL": "fail", "INFO": "info"}[c.status]
|
|
3613
|
+
has_hit = bool(c.locations)
|
|
3614
|
+
chips_html = "".join(
|
|
3615
|
+
f'<span class="engine-chip {_engine_label(eng, c.status, has_hit)[1]}" title="{_esc(eng)}">'
|
|
3616
|
+
f'{_esc(_engine_label(eng, c.status, has_hit)[0])}</span>'
|
|
3617
|
+
for eng in ("行为安全", "AI 引擎", "静态引擎")
|
|
3618
|
+
)
|
|
3619
|
+
return f"""
|
|
3620
|
+
<div class="tcard {bg_cls}">
|
|
3621
|
+
<div class="tcard-icon-bg {bg_cls}-bg">{ticon}</div>
|
|
3622
|
+
<div class="tcard-main">
|
|
3623
|
+
<div class="tcard-name">{_esc(c.name)}{chips_html}</div>
|
|
3624
|
+
<div class="tcard-status"><span class="status-dot {bg_cls}"></span>{status_tip}</div>
|
|
3625
|
+
</div>
|
|
3626
|
+
</div>"""
|
|
3627
|
+
|
|
3628
|
+
def _behavior_card(c):
|
|
3629
|
+
loc_html = _locations_html(c)
|
|
3630
|
+
bg_cls = {"PASS": "pass", "WARN": "warn", "FAIL": "fail", "INFO": "info"}[c.status]
|
|
3631
|
+
has_hit = bool(c.locations)
|
|
3632
|
+
chips_html = "".join(
|
|
3633
|
+
f'<span class="engine-chip {_engine_label(eng, c.status, has_hit)[1]}" title="{_esc(eng)}">'
|
|
3634
|
+
f'{_esc(_engine_label(eng, c.status, has_hit)[0])}</span>'
|
|
3635
|
+
for eng in ("行为安全", "AI 引擎", "静态引擎")
|
|
3636
|
+
)
|
|
3637
|
+
log_li = "<li>" + " </li>"
|
|
3638
|
+
if c.locations:
|
|
3639
|
+
log_li = "".join(
|
|
3640
|
+
f"<li><span class='log-ok'>✓</span><span class='log-text'>{_esc(loc)}</span></li>"
|
|
3641
|
+
for loc in c.locations[:20]
|
|
3642
|
+
)
|
|
3643
|
+
more = f"<li class='more'>…等 {len(c.locations)} 处</li>" if len(c.locations) > 20 else ""
|
|
3644
|
+
log_li = log_li + more
|
|
3645
|
+
else:
|
|
3646
|
+
log_li = f"<li><span class='log-ok'>✓</span><span class='log-text'>未发现{_esc(c.name)}相关的可疑行为。</span></li>"
|
|
3647
|
+
return f"""
|
|
3648
|
+
<div class="behavior-card {bg_cls}">
|
|
3649
|
+
<div class="behavior-head">
|
|
3650
|
+
{chips_html}
|
|
3651
|
+
<span class="behavior-title">{_esc(c.name)}</span>
|
|
3652
|
+
</div>
|
|
3653
|
+
<div class="behavior-detail">{_esc(c.detail)}</div>
|
|
3654
|
+
<div class="behavior-log">
|
|
3655
|
+
<div class="log-title">🔍 检测详情日志</div>
|
|
3656
|
+
<ul>{log_li}</ul>
|
|
3657
|
+
</div>
|
|
3658
|
+
</div>"""
|
|
3659
|
+
|
|
3660
|
+
def _generic_check(c):
|
|
3661
|
+
loc_html = _locations_html(c)
|
|
3662
|
+
sugg_html = f"<div class='suggestion'>💡 建议:{_esc(c.suggestion)}</div>" if c.suggestion else ""
|
|
3663
|
+
return f"""
|
|
3664
|
+
<div class="check {cls.get(c.status, 'info')}">
|
|
3665
|
+
<div class="check-head">
|
|
3666
|
+
<span class="badge">{icon.get(c.status, '·')} {c.status}</span>
|
|
3667
|
+
<span class="check-name">{_esc(c.name)}</span>
|
|
3668
|
+
<span class="sev">{_esc(c.severity)}</span>
|
|
3669
|
+
</div>
|
|
3670
|
+
<div class="check-detail">{_esc(c.detail)}</div>
|
|
3671
|
+
{sugg_html}
|
|
3672
|
+
</div>"""
|
|
3673
|
+
|
|
3674
|
+
sections_html = []
|
|
3675
|
+
threat_cards = by_cat.get("威胁模型") or []
|
|
3676
|
+
beh_cards = by_cat.get("行为图谱") or []
|
|
3677
|
+
|
|
3678
|
+
# ===== 威胁模型 8 卡网格(C. 威胁捕获模型视图)=====
|
|
3679
|
+
threat_grid_html = ""
|
|
3680
|
+
if threat_cards:
|
|
3681
|
+
cards_html = "".join(_threat_card(c) for c in threat_cards)
|
|
3682
|
+
threat_grid_html = f'<div class="threat-grid">{cards_html}</div>'
|
|
3683
|
+
|
|
3684
|
+
# ===== 行为图谱 tab + 卡片列表(D. 威胁行为图谱解析)=====
|
|
3685
|
+
behavior_html = ""
|
|
3686
|
+
if beh_cards:
|
|
3687
|
+
n_mal = sum(1 for c in beh_cards if c.status == "FAIL")
|
|
3688
|
+
n_sus = sum(1 for c in beh_cards if c.status == "WARN")
|
|
3689
|
+
n_safe = sum(1 for c in beh_cards if c.status in ("PASS", "INFO"))
|
|
3690
|
+
tabs = (
|
|
3691
|
+
f'<span class="tab active">全部引擎结果 ({len(beh_cards)})</span>'
|
|
3692
|
+
f'<span class="tab">检出恶意 ({n_mal})</span>'
|
|
3693
|
+
f'<span class="tab">检出可疑 ({n_sus})</span>'
|
|
3694
|
+
f'<span class="tab">评估安全 ({n_safe})</span>'
|
|
3695
|
+
)
|
|
3696
|
+
cards_html = "".join(_behavior_card(c) for c in beh_cards)
|
|
3697
|
+
behavior_html = f"""
|
|
3698
|
+
<section class="block">
|
|
3699
|
+
<div class="section-head">
|
|
3700
|
+
<h2>威胁行为图谱解析<span class="origin-chip origin-wb" title="WorkBuddy 平台约束">WB</span></h2>
|
|
3701
|
+
<div class="section-tabs">{tabs}</div>
|
|
3702
|
+
</div>
|
|
3703
|
+
{cards_html}
|
|
3704
|
+
</section>"""
|
|
3705
|
+
|
|
3706
|
+
# 威胁捕获模型视图(C 组)
|
|
3707
|
+
threat_section_html = ""
|
|
3708
|
+
if threat_cards:
|
|
3709
|
+
threat_section_html = f"""
|
|
3710
|
+
<section class="block">
|
|
3711
|
+
<div class="section-head">
|
|
3712
|
+
<h2>威胁捕获模型视图<span class="origin-chip origin-wb" title="WorkBuddy 平台约束">WB</span><span class="cap-sub" style="font-weight:400;">8 类风险卡片,每类由 行为安全 / AI 引擎 / 静态引擎 三个引擎并行评估</span></h2>
|
|
3713
|
+
</div>
|
|
3714
|
+
{threat_grid_html}
|
|
3715
|
+
</section>"""
|
|
3716
|
+
|
|
3717
|
+
# 行为图谱(D 组)跟在威胁模型区块之后
|
|
3718
|
+
threat_section_html = threat_section_html + behavior_html
|
|
3719
|
+
|
|
3720
|
+
# ===== 其他分组的详细 check 列表 =====
|
|
3721
|
+
for cat, items in by_cat.items():
|
|
3722
|
+
if cat in ("威胁模型", "行为图谱"):
|
|
3723
|
+
continue # 已单独渲染在上方两个特殊区块
|
|
3724
|
+
else:
|
|
3725
|
+
rows = "".join(_generic_check(c) for c in items)
|
|
3726
|
+
_org = _origin_of(cat)
|
|
3727
|
+
_org_chip = (
|
|
3728
|
+
f'<span class="origin-chip origin-{_org.lower().replace("+", "")}" '
|
|
3729
|
+
f'title="{_esc(ORIGIN_DESC.get(_org, ""))}">{_esc(_org)}</span>'
|
|
3730
|
+
if _org else ""
|
|
3731
|
+
)
|
|
3732
|
+
sections_html.append(f"""
|
|
3733
|
+
<section class="block">
|
|
3734
|
+
<h2>{_esc(cat)}{_org_chip}</h2>
|
|
3735
|
+
{rows}
|
|
3736
|
+
</section>""")
|
|
3737
|
+
|
|
3738
|
+
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
3739
|
+
|
|
3740
|
+
summary_html = _risk_summary()
|
|
3741
|
+
fix_list_html = "".join(f"<li>{_esc(s)}</li>" for s in suggestions) if suggestions else "<li>暂无待修复建议</li>"
|
|
3742
|
+
cap_html = _capability_cloud(cap_check) if cap_check else "<span class='muted'>未生成能力清单</span>"
|
|
3743
|
+
dep_html = _dependency_cloud(dep_check) if dep_check else "<span class='muted'>未生成依赖清单</span>"
|
|
3744
|
+
dep_market_checked = bool(dep_check and getattr(dep_check, "extra", None) and dep_check.extra.get("market_checked"))
|
|
3745
|
+
|
|
3746
|
+
ring_color = "#13a35a" if score >= 80 else "#dd8c0a" if score >= 50 else "#e23b3b"
|
|
3747
|
+
dasharray = 2 * 3.14159 * 70
|
|
3748
|
+
dashoffset = dasharray * (1 - score / 100)
|
|
3749
|
+
|
|
3750
|
+
CSS = """
|
|
3751
|
+
<style>
|
|
3752
|
+
:root {
|
|
3753
|
+
--bg:#f6f8fb; --card:#ffffff; --ink:#1b2436; --muted:#69748a; --line:#e7ecf4;
|
|
3754
|
+
--brand:#2f5cff; --brand2:#6a8bff; --brand-soft:#eef3ff;
|
|
3755
|
+
--pass:#13a35a; --fail:#e23b3b; --warn:#dd8c0a; --info:#1f9bd1;
|
|
3756
|
+
--pass-bg:#e8f8ef; --fail-bg:#fdeded; --warn-bg:#fdf2e1; --info-bg:#e7f4fb;
|
|
3757
|
+
--radius:18px; --shadow:0 8px 28px rgba(27,42,71,.07);
|
|
3758
|
+
}
|
|
3759
|
+
* { box-sizing:border-box; }
|
|
3760
|
+
body { margin:0; color:var(--ink); background:var(--bg);
|
|
3761
|
+
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
|
3762
|
+
line-height:1.65; -webkit-font-smoothing:antialiased; padding-bottom:48px; }
|
|
3763
|
+
.wrap { max-width:1100px; margin:0 auto; padding:0 20px; }
|
|
3764
|
+
.report-header { display:flex; align-items:center; justify-content:space-between;
|
|
3765
|
+
padding:22px 4px 18px; }
|
|
3766
|
+
.rh-left { display:flex; align-items:center; gap:10px; font-size:18px; font-weight:800; }
|
|
3767
|
+
.rh-icon { width:36px; height:36px; border-radius:10px;
|
|
3768
|
+
background:linear-gradient(135deg,var(--brand),var(--brand2)); color:#fff;
|
|
3769
|
+
display:flex; align-items:center; justify-content:center; font-size:20px; }
|
|
3770
|
+
.rh-right { font-size:13px; color:var(--muted); font-weight:600; }
|
|
3771
|
+
.dashboard { display:grid; grid-template-columns:340px 1fr; gap:18px; margin-top:18px; }
|
|
3772
|
+
.dash-left { display:flex; flex-direction:column; gap:16px; }
|
|
3773
|
+
.dash-left > .card { flex:1; }
|
|
3774
|
+
.dash-right { display:flex; flex-direction:column; gap:16px; }
|
|
3775
|
+
.card { background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
|
|
3776
|
+
padding:22px; box-shadow:var(--shadow); }
|
|
3777
|
+
.score-card { display:flex; flex-direction:column; align-items:center; text-align:center; }
|
|
3778
|
+
.score-ring { position:relative; width:180px; height:180px; }
|
|
3779
|
+
.score-ring svg { transform:rotate(-90deg); }
|
|
3780
|
+
.score-num { position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
|
|
3781
|
+
font-size:42px; font-weight:800; color:var(--ink); }
|
|
3782
|
+
.score-label { font-size:13px; color:var(--muted); margin-top:10px; }
|
|
3783
|
+
.meta-list { width:100%; margin-top:10px; }
|
|
3784
|
+
.meta-row { display:flex; align-items:center; justify-content:space-between; padding:11px 0;
|
|
3785
|
+
border-bottom:1px solid var(--line); font-size:13px; }
|
|
3786
|
+
.meta-row:last-child { border-bottom:none; }
|
|
3787
|
+
.meta-row .k { color:var(--muted); }
|
|
3788
|
+
.meta-row .v { font-weight:600; max-width:180px; text-align:right; word-break:break-all; }
|
|
3789
|
+
.meta-row code.v { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;
|
|
3790
|
+
background:#f4f7fb; padding:3px 8px; border-radius:6px; }
|
|
3791
|
+
.verdict-tag { display:inline-block; padding:3px 10px; border-radius:999px; font-size:12px; font-weight:700; }
|
|
3792
|
+
.verdict-tag.pass { background:var(--pass-bg); color:var(--pass); }
|
|
3793
|
+
.verdict-tag.warn { background:var(--warn-bg); color:var(--warn); }
|
|
3794
|
+
.verdict-tag.fail { background:var(--fail-bg); color:var(--fail); }
|
|
3795
|
+
.verdict-tag.pub-ok { background:var(--pass-bg); color:var(--pass); font-size:13px; padding:4px 12px; }
|
|
3796
|
+
.verdict-tag.pub-no { background:var(--fail-bg); color:var(--fail); font-size:13px; padding:4px 12px; }
|
|
3797
|
+
.publish-banner { display:flex; align-items:center; gap:16px; border-radius:var(--radius);
|
|
3798
|
+
padding:20px 26px; margin-top:16px; margin-bottom:22px; box-shadow:0 10px 30px rgba(27,42,71,.12); }
|
|
3799
|
+
.publish-banner .pb-icon { font-size:40px; line-height:1; filter:drop-shadow(0 2px 4px rgba(0,0,0,.15)); }
|
|
3800
|
+
.publish-banner .pb-main { font-size:25px; font-weight:900; letter-spacing:2px; }
|
|
3801
|
+
.publish-banner .pb-sub { font-size:13.5px; margin-top:5px; opacity:.94; line-height:1.6; }
|
|
3802
|
+
.publish-banner.ok { background:linear-gradient(135deg,#13a35a 0%,#1ec07a 100%); color:#fff; }
|
|
3803
|
+
.publish-banner.no { background:linear-gradient(135deg,#e23b3b 0%,#ff5b5b 100%); color:#fff; }
|
|
3804
|
+
.publish-banner .pb-pulse { width:12px; height:12px; border-radius:50%; background:#fff; opacity:.9;
|
|
3805
|
+
box-shadow:0 0 0 0 rgba(255,255,255,.7); animation:pbpulse 1.8s infinite; }
|
|
3806
|
+
@keyframes pbpulse { 0%{box-shadow:0 0 0 0 rgba(255,255,255,.6);} 70%{box-shadow:0 0 0 12px rgba(255,255,255,0);} 100%{box-shadow:0 0 0 0 rgba(255,255,255,0);} }
|
|
3807
|
+
.stat-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
|
3808
|
+
.stat-card { background:var(--card); border:1px solid var(--line); border-radius:14px;
|
|
3809
|
+
padding:16px; text-align:center; box-shadow:var(--shadow); }
|
|
3810
|
+
.stat-card .n { font-size:28px; font-weight:800; color:var(--ink); }
|
|
3811
|
+
.stat-card .t { font-size:12px; color:var(--muted); margin-top:4px; }
|
|
3812
|
+
.stat-card.fail .n { color:var(--fail); }
|
|
3813
|
+
.stat-card.warn .n { color:var(--warn); }
|
|
3814
|
+
.stat-card.info .n { color:var(--brand); }
|
|
3815
|
+
.summary-box { background:#fff9f0; border:1px solid #ffe4c2; border-radius:14px; padding:16px 18px; }
|
|
3816
|
+
.summary-box h3 { margin:0 0 10px; font-size:14px; display:flex; align-items:center; gap:8px; color:#92400e; }
|
|
3817
|
+
.summary-box p { margin:0; font-size:13.5px; color:#78350f; line-height:1.7; }
|
|
3818
|
+
.fix-box h3 { margin:0 0 12px; font-size:14px; display:flex; align-items:center; gap:8px; }
|
|
3819
|
+
.fix-box ol { margin:0; padding-left:18px; font-size:13px; color:#3a4456; line-height:1.8; }
|
|
3820
|
+
.cap-section { background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
|
|
3821
|
+
padding:18px 22px; box-shadow:var(--shadow); margin-top:0; }
|
|
3822
|
+
.cap-head { display:flex; align-items:baseline; gap:10px; margin-bottom:14px;
|
|
3823
|
+
font-size:15px; font-weight:700; }
|
|
3824
|
+
.cap-sub { font-size:12px; color:var(--muted); font-weight:400; }
|
|
3825
|
+
.cap-chips { display:flex; flex-wrap:wrap; gap:12px; }
|
|
3826
|
+
.cap-chip { display:inline-flex; align-items:center; gap:6px; border:1.5px solid var(--brand);
|
|
3827
|
+
color:var(--brand); background:#fff; font-size:13px; font-weight:600;
|
|
3828
|
+
padding:8px 16px; border-radius:999px; box-shadow:0 2px 8px rgba(47,92,255,.10); }
|
|
3829
|
+
.cap-empty { color:var(--muted); font-size:13px; padding:4px 0; }
|
|
3830
|
+
.dep-head { display:flex; align-items:baseline; gap:10px; margin:18px 0 14px; padding-top:16px;
|
|
3831
|
+
border-top:1px dashed var(--line); font-size:14px; font-weight:700; }
|
|
3832
|
+
.dep-chip { display:inline-flex; align-items:center; gap:6px; border:1.5px solid #dd8c0a;
|
|
3833
|
+
color:#9a6500; background:#fffaf0; font-size:13px; font-weight:600;
|
|
3834
|
+
padding:8px 16px; border-radius:999px; box-shadow:0 2px 8px rgba(221,140,10,.12); }
|
|
3835
|
+
.dep-badge { font-size:11px; font-weight:700; padding:2px 9px; border-radius:999px; white-space:nowrap; }
|
|
3836
|
+
.dep-badge.ok { background:var(--pass-bg); color:var(--pass); }
|
|
3837
|
+
.dep-badge.no { background:#eef2f7; color:#64748b; }
|
|
3838
|
+
.dep-badge.err { background:#fff4e5; color:#b45309; }
|
|
3839
|
+
.dep-badge.skip { background:#eef2f7; color:#94a3b8; font-weight:600; }
|
|
3840
|
+
a.dep-badge.ok { text-decoration:none; }
|
|
3841
|
+
.dep-legend { margin-top:12px; font-size:12px; color:var(--muted); line-height:1.7; }
|
|
3842
|
+
.dep-legend .ok { color:var(--pass); font-weight:700; }
|
|
3843
|
+
.dep-legend .no { color:#64748b; font-weight:700; }
|
|
3844
|
+
.dep-legend .err { color:#b45309; font-weight:700; }
|
|
3845
|
+
.dep-legend .skip { color:#94a3b8; font-weight:700; }
|
|
3846
|
+
.block { background:var(--card); border:1px solid var(--line); border-radius:var(--radius);
|
|
3847
|
+
padding:22px 24px; margin-top:18px; box-shadow:var(--shadow); }
|
|
3848
|
+
.block h2 { margin:0 0 18px; font-size:17px; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
3849
|
+
/* 约束归属徽标:WB = WorkBuddy 平台约束 / WV = 泛微专属约束 / WB+WV = 叠加 /
|
|
3850
|
+
QW = 千问办公平台约束 / QW+WB = 千问+WorkBuddy 叠加 / QW+WV = 千问+泛微叠加 */
|
|
3851
|
+
.origin-chip { font-size:11px; font-weight:700; line-height:1; padding:4px 8px; border-radius:999px;
|
|
3852
|
+
border:1px solid transparent; letter-spacing:.02em; }
|
|
3853
|
+
.origin-wb { background:#eef2ff; color:#3730a3; border-color:#c7d2fe; }
|
|
3854
|
+
.origin-wv { background:#fff1e6; color:#9a3412; border-color:#fed7aa; }
|
|
3855
|
+
.origin-wbwv { background:#ecfdf5; color:#065f46; border-color:#a7f3d0; }
|
|
3856
|
+
.origin-qw { background:#fef3c7; color:#92400e; border-color:#fde68a; }
|
|
3857
|
+
.origin-qwwb { background:#ede9fe; color:#5b21b6; border-color:#ddd6fe; }
|
|
3858
|
+
.origin-qwwv { background:#fce7f3; color:#9d174d; border-color:#fbcfe8; }
|
|
3859
|
+
.origin-legend { display:flex; flex-wrap:wrap; gap:10px 18px; margin-top:12px; font-size:12px;
|
|
3860
|
+
color:var(--muted); }
|
|
3861
|
+
.origin-legend b { color:var(--text); }
|
|
3862
|
+
.section-head { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap;
|
|
3863
|
+
gap:12px; margin-bottom:18px; }
|
|
3864
|
+
.section-head h2 { margin:0; }
|
|
3865
|
+
.section-tabs { display:flex; gap:8px; }
|
|
3866
|
+
.tab { font-size:12px; padding:6px 12px; border-radius:8px; background:#f4f7fb; color:var(--muted);
|
|
3867
|
+
cursor:default; }
|
|
3868
|
+
.tab.active { background:var(--brand-soft); color:var(--brand); font-weight:700; }
|
|
3869
|
+
.threat-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr)); gap:14px; }
|
|
3870
|
+
.tcard { display:flex; align-items:center; gap:12px; border:1px solid var(--line);
|
|
3871
|
+
border-radius:14px; padding:14px; background:#fff; }
|
|
3872
|
+
.tcard-icon-bg { width:42px; height:42px; border-radius:12px; display:flex; align-items:center;
|
|
3873
|
+
justify-content:center; font-size:20px; flex:none; }
|
|
3874
|
+
.pass-bg { background:var(--pass-bg); }
|
|
3875
|
+
.warn-bg { background:var(--warn-bg); }
|
|
3876
|
+
.fail-bg { background:var(--fail-bg); }
|
|
3877
|
+
.info-bg { background:var(--info-bg); }
|
|
3878
|
+
.tcard-main { flex:1; min-width:0; }
|
|
3879
|
+
.tcard-name { font-weight:700; font-size:14px; margin-bottom:4px; }
|
|
3880
|
+
.tcard-status { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:5px; }
|
|
3881
|
+
.status-dot { width:7px; height:7px; border-radius:50%; display:inline-block; }
|
|
3882
|
+
.status-dot.pass { background:var(--pass); }
|
|
3883
|
+
.status-dot.warn { background:var(--warn); }
|
|
3884
|
+
.status-dot.fail { background:var(--fail); }
|
|
3885
|
+
.status-dot.info { background:var(--info); }
|
|
3886
|
+
.behavior-card { border:1px solid var(--line); border-radius:14px; padding:16px 18px; margin-bottom:14px;
|
|
3887
|
+
background:#fff; }
|
|
3888
|
+
.behavior-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px; }
|
|
3889
|
+
.tag { font-size:11px; font-weight:700; padding:3px 10px; border-radius:6px; flex:none; }
|
|
3890
|
+
.tag-red { background:#fde8e8; color:var(--fail); }
|
|
3891
|
+
.tag-green { background:#e6f6ec; color:var(--pass); }
|
|
3892
|
+
.tag-purple { background:#ede9fe; color:#7c3aed; }
|
|
3893
|
+
.tag-blue { background:#e7f4fb; color:var(--info); }
|
|
3894
|
+
.behavior-title { font-weight:700; font-size:15px; }
|
|
3895
|
+
.behavior-detail { color:#3a4456; font-size:13.5px; margin-bottom:12px; }
|
|
3896
|
+
.behavior-log { background:#0f172a; color:#e2e8f0; border-radius:12px; padding:14px 16px;
|
|
3897
|
+
font-size:12.5px; }
|
|
3898
|
+
.behavior-log .log-title { color:#94a3b8; font-size:11px; margin-bottom:8px; letter-spacing:1px; }
|
|
3899
|
+
.behavior-log ul { margin:0; padding-left:0; list-style:none; }
|
|
3900
|
+
.behavior-log li { padding:3px 0; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
3901
|
+
white-space:pre-wrap; word-break:break-all; }
|
|
3902
|
+
.behavior-log li.more { color:#fbbf24; }
|
|
3903
|
+
.check { display:flex; flex-direction:column; gap:8px; border:1px solid var(--line);
|
|
3904
|
+
border-radius:13px; padding:14px 16px; margin-bottom:12px; background:#fcfdff;
|
|
3905
|
+
border-left:5px solid var(--info); }
|
|
3906
|
+
.check.pass { border-left-color:var(--pass); background:#f7fdf9; }
|
|
3907
|
+
.check.fail { border-left-color:var(--fail); background:var(--fail-bg); }
|
|
3908
|
+
.check.warn { border-left-color:var(--warn); background:var(--warn-bg); }
|
|
3909
|
+
.check-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
3910
|
+
.check-name { font-weight:700; font-size:15px; }
|
|
3911
|
+
.badge { font-size:12px; font-weight:700; padding:3px 10px; border-radius:999px;
|
|
3912
|
+
background:#eef2fb; color:var(--brand); white-space:nowrap; }
|
|
3913
|
+
.check.fail .badge { background:var(--fail-bg); color:var(--fail); }
|
|
3914
|
+
.check.warn .badge { background:var(--warn-bg); color:var(--warn); }
|
|
3915
|
+
.check.pass .badge { background:var(--pass-bg); color:var(--pass); }
|
|
3916
|
+
.sev { margin-left:auto; font-size:11px; color:var(--muted); border:1px solid var(--line);
|
|
3917
|
+
padding:2px 9px; border-radius:7px; }
|
|
3918
|
+
.check-detail { color:#3a4456; font-size:13.5px; white-space:pre-wrap; }
|
|
3919
|
+
.suggestion { font-size:12.5px; color:#475569; background:#eef3ff; border-radius:9px; padding:9px 12px; margin-top:6px; }
|
|
3920
|
+
.muted { color:var(--muted); font-size:13px; }
|
|
3921
|
+
/* ===== 威胁模型 / 行为图谱:引擎 chip 与深色日志 ===== */
|
|
3922
|
+
.engine-chip { font-size:10.5px; font-weight:700; padding:3px 9px; border-radius:999px;
|
|
3923
|
+
border:1px solid transparent; line-height:1; flex:none; white-space:nowrap; }
|
|
3924
|
+
.engine-pass { background:#e6f6ec; color:#15803d; border-color:#bbf7d0; }
|
|
3925
|
+
.engine-warn { background:#fdf2e1; color:#9a6500; border-color:#fde68a; }
|
|
3926
|
+
.engine-fail { background:#fde8e8; color:#dc2626; border-color:#fecaca; }
|
|
3927
|
+
.engine-purple { background:#ede9fe; color:#7c3aed; border-color:#ddd6fe; }
|
|
3928
|
+
.engine-blue { background:#e7f4fb; color:#1f9bd1; border-color:#bae6fd; }
|
|
3929
|
+
.engine-grey { background:#f4f6fb; color:#94a3b8; border-color:#e2e8f0; }
|
|
3930
|
+
.tcard-name { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
|
3931
|
+
.log-ok { color:#4ade80; margin-right:8px; font-weight:700; }
|
|
3932
|
+
.log-text { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; color:#cbd5e1;
|
|
3933
|
+
white-space:pre-wrap; word-break:break-all; }
|
|
3934
|
+
footer { text-align:center; color:var(--muted); font-size:12px; margin-top:28px; }
|
|
3935
|
+
@media (max-width:900px) { .dashboard { grid-template-columns:1fr; } .stat-grid { grid-template-columns:repeat(2,1fr); } }
|
|
3936
|
+
@media (max-width:540px) { .stat-grid { grid-template-columns:1fr; } .section-head { flex-direction:column; align-items:flex-start; } }
|
|
3937
|
+
</style>
|
|
3938
|
+
"""
|
|
3939
|
+
|
|
3940
|
+
BODY = f"""
|
|
3941
|
+
<body>
|
|
3942
|
+
<div class="wrap">
|
|
3943
|
+
<div class="report-header">
|
|
3944
|
+
<div class="rh-left"><div class="rh-icon">🛡️</div> Skills 安全评估报告</div>
|
|
3945
|
+
<div class="rh-right">泛微技能检测工具 · weaver-skill-detector</div>
|
|
3946
|
+
</div>
|
|
3947
|
+
|
|
3948
|
+
<div class="publish-banner {publish_cls}">
|
|
3949
|
+
<div class="pb-icon">{publish_icon}</div>
|
|
3950
|
+
<div>
|
|
3951
|
+
<div class="pb-main">{publish_label}</div>
|
|
3952
|
+
<div class="pb-sub">{publish_sub}</div>
|
|
3953
|
+
</div>
|
|
3954
|
+
<div class="pb-pulse" style="margin-left:auto;"></div>
|
|
3955
|
+
</div>
|
|
3956
|
+
|
|
3957
|
+
<div class="cap-section">
|
|
3958
|
+
<div class="cap-head">🧩 技能能力清单<span class="cap-sub">运行时识别到的能力(上架时建议如实披露权限范围)</span></div>
|
|
3959
|
+
<div class="cap-chips">{cap_html}</div>
|
|
3960
|
+
<div class="dep-head">🔗 依赖的其他技能<span class="cap-sub">{("已动态核查 skillhub.cn 市场可达性(点击徽标跳转)" if dep_market_checked else "frontmatter 声明或正文引用的 weaver-e10- 前缀技能(需同步上架)")}</span></div>
|
|
3961
|
+
<div class="cap-chips">{dep_html}</div>
|
|
3962
|
+
{('<div class="dep-legend"><span class="ok">市场可搜</span>:已在 skillhub.cn 实时核查命中(点击可跳转技能页),消费方可直接获取而无需额外分发 · <span class="no">市场未收录</span>:skillhub.cn 未检索到该 slug,需随包另行分发或内部安装后方可联动 · <span class="skip">跳过核查</span>:非 weaver- 前缀依赖(内部引用/误报),不做市场可达性标注 · <span class="err">核查失败</span>:联网核查异常(如网络受限),请以手动核实为准</div>' if dep_market_checked else "")}
|
|
3963
|
+
</div>
|
|
3964
|
+
|
|
3965
|
+
<div class="dashboard">
|
|
3966
|
+
<div class="dash-left">
|
|
3967
|
+
<div class="card score-card">
|
|
3968
|
+
<div class="score-ring">
|
|
3969
|
+
<svg width="180" height="180" viewBox="0 0 180 180">
|
|
3970
|
+
<circle cx="90" cy="90" r="70" fill="none" stroke="#e7ecf4" stroke-width="12"/>
|
|
3971
|
+
<circle cx="90" cy="90" r="70" fill="none" stroke="{ring_color}" stroke-width="12"
|
|
3972
|
+
stroke-linecap="round" stroke-dasharray="{dasharray:.2f}" stroke-dashoffset="{dashoffset:.2f}"/>
|
|
3973
|
+
</svg>
|
|
3974
|
+
<div class="score-num">{score}</div>
|
|
3975
|
+
</div>
|
|
3976
|
+
<div class="score-label">安全健康度评分</div>
|
|
3977
|
+
<div class="meta-list">
|
|
3978
|
+
<div class="meta-row"><span class="k">Skill 名称</span><span class="v">{_esc(skill_name)}</span></div>
|
|
3979
|
+
<div class="meta-row"><span class="k">版本</span><span class="v">{_esc(skill_version)}</span></div>
|
|
3980
|
+
<div class="meta-row"><span class="k">检测平台</span><span class="v">{_esc(platform_label)}(<code>--platform {_esc(platform_code)}</code>{platform_default_tag})</span></div>
|
|
3981
|
+
<div class="meta-row"><span class="k">内容 Hash</span><code class="v">{_esc(content_hash)}</code></div>
|
|
3982
|
+
<div class="meta-row"><span class="k">发布结论</span><span class="v"><span class="verdict-tag {publish_cls}">{publish_label}</span></span></div>
|
|
3983
|
+
</div>
|
|
3984
|
+
</div>
|
|
3985
|
+
</div>
|
|
3986
|
+
|
|
3987
|
+
<div class="dash-right">
|
|
3988
|
+
<div class="stat-grid">
|
|
3989
|
+
<div class="stat-card {'fail' if n_issues else 'info'}">
|
|
3990
|
+
<div class="n">{n_issues}</div>
|
|
3991
|
+
<div class="t">发现问题总数</div>
|
|
3992
|
+
<div class="t" style="font-size:11px;color:var(--muted);">需确认点</div>
|
|
3993
|
+
</div>
|
|
3994
|
+
<div class="stat-card fail">
|
|
3995
|
+
<div class="n">{n_malicious}</div>
|
|
3996
|
+
<div class="t">检出恶意行为</div>
|
|
3997
|
+
<div class="t" style="font-size:11px;color:var(--muted);">平台已标记</div>
|
|
3998
|
+
</div>
|
|
3999
|
+
<div class="stat-card warn">
|
|
4000
|
+
<div class="n">{n_suspicious}</div>
|
|
4001
|
+
<div class="t">检出可疑行为</div>
|
|
4002
|
+
<div class="t" style="font-size:11px;color:var(--muted);">平台已标记</div>
|
|
4003
|
+
</div>
|
|
4004
|
+
<div class="stat-card info">
|
|
4005
|
+
<div class="n">{n_categories}</div>
|
|
4006
|
+
<div class="t">引擎覆盖类别</div>
|
|
4007
|
+
<div class="t" style="font-size:11px;color:var(--muted);">安全检测面</div>
|
|
4008
|
+
</div>
|
|
4009
|
+
</div>
|
|
4010
|
+
|
|
4011
|
+
<div class="summary-box">
|
|
4012
|
+
<h3>🛡️ 总体风险摘要</h3>
|
|
4013
|
+
<p>{_esc(summary_html)}</p>
|
|
4014
|
+
</div>
|
|
4015
|
+
|
|
4016
|
+
<div class="card fix-box">
|
|
4017
|
+
<h3>🛠️ 修复建议指南</h3>
|
|
4018
|
+
<ol>{fix_list_html}</ol>
|
|
4019
|
+
</div>
|
|
4020
|
+
</div>
|
|
4021
|
+
</div>
|
|
4022
|
+
|
|
4023
|
+
{threat_section_html}
|
|
4024
|
+
|
|
4025
|
+
{''.join(sections_html)}
|
|
4026
|
+
|
|
4027
|
+
<section class="block">
|
|
4028
|
+
<h2>约束归属图例</h2>
|
|
4029
|
+
<div class="origin-legend">
|
|
4030
|
+
<span><span class="origin-chip origin-wb">WB</span> <b>WorkBuddy 平台约束</b>:skillhub.cn 加载器与上传校验要求,任何作者的 skill 都必须满足,违反即被平台拒收或加载异常</span>
|
|
4031
|
+
<span><span class="origin-chip origin-wv">WV</span> <b>泛微专属约束</b>:泛微生态内部治理要求(品牌规范、E10 账号与接口调用契约、安全合规),平台不拦但泛微发布门禁不放行</span>
|
|
4032
|
+
<span><span class="origin-chip origin-wbwv">WB+WV</span> <b>叠加</b>:平台有通用要求,泛微在其上追加了更严的约束</span>
|
|
4033
|
+
{qw_legend_html}
|
|
4034
|
+
</div>
|
|
4035
|
+
</section>
|
|
4036
|
+
|
|
4037
|
+
<footer>本报告由「泛微技能检测工具 weaver-skill-detector」自动生成 · 生成时间:{_esc(now)} · 仅供提交前安全合规自检参考</footer>
|
|
4038
|
+
</div>
|
|
4039
|
+
</body>
|
|
4040
|
+
"""
|
|
4041
|
+
|
|
4042
|
+
HEAD = f"""<head>
|
|
4043
|
+
<meta charset="utf-8">
|
|
4044
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
4045
|
+
<title>Skills 安全评估报告 · {_esc(publish_label)}</title>
|
|
4046
|
+
</head>"""
|
|
4047
|
+
|
|
4048
|
+
return f"<!DOCTYPE html>\n<html lang=\"zh-CN\">\n{HEAD}\n{CSS}\n{BODY}\n</html>"
|
|
4049
|
+
|
|
4050
|
+
# ---------------------------------------------------------------------------
|
|
4051
|
+
# 入口
|
|
4052
|
+
# ---------------------------------------------------------------------------
|
|
4053
|
+
|
|
4054
|
+
def main():
|
|
4055
|
+
args = sys.argv[1:]
|
|
4056
|
+
if not args:
|
|
4057
|
+
log("用法: python validate_skill.py <skill.zip|.md|dir> [--json] [--out report.md] "
|
|
4058
|
+
"[--html report.html] [--market-catalog catalog.json] [--market-live] [--no-html]\n"
|
|
4059
|
+
" [--platform {weaver,qwenwork}]")
|
|
4060
|
+
sys.exit(2)
|
|
4061
|
+
|
|
4062
|
+
target_str = args[0]
|
|
4063
|
+
as_json = "--json" in args
|
|
4064
|
+
out_path = None
|
|
4065
|
+
html_path = None
|
|
4066
|
+
no_html = "--no-html" in args
|
|
4067
|
+
market_catalog_path = None
|
|
4068
|
+
market_live = "--market-live" in args
|
|
4069
|
+
platform = "weaver" # 默认 weaver(保持向后兼容)
|
|
4070
|
+
for i, a in enumerate(args):
|
|
4071
|
+
if a == "--out" and i + 1 < len(args):
|
|
4072
|
+
out_path = args[i + 1]
|
|
4073
|
+
elif a == "--html" and i + 1 < len(args):
|
|
4074
|
+
html_path = args[i + 1]
|
|
4075
|
+
elif a == "--market-catalog" and i + 1 < len(args):
|
|
4076
|
+
market_catalog_path = args[i + 1]
|
|
4077
|
+
elif a == "--platform" and i + 1 < len(args):
|
|
4078
|
+
platform = args[i + 1]
|
|
4079
|
+
if platform not in VALID_PLATFORMS:
|
|
4080
|
+
log(f"❌ --platform 仅支持: {VALID_PLATFORMS}(收到: {platform})")
|
|
4081
|
+
sys.exit(2)
|
|
4082
|
+
|
|
4083
|
+
target = Path(target_str).expanduser().resolve()
|
|
4084
|
+
root, source_type, cleanup, err, zip_raw_names = prepare_skill_source(target)
|
|
4085
|
+
if err:
|
|
4086
|
+
log(f"❌ {err}")
|
|
4087
|
+
sys.exit(2)
|
|
4088
|
+
|
|
4089
|
+
# 默认 HTML 输出:未显式指定 --html 且未禁用时,自动生成到「输入同目录/<输入名>-report.html」。
|
|
4090
|
+
# zip/md 输入 → 去扩展名 + "-report.html"(如 weaver-e10-hrm.zip → weaver-e10-hrm-report.html);
|
|
4091
|
+
# 目录输入 → 同级 "<目录名>-report.html"。
|
|
4092
|
+
if html_path is None and not no_html:
|
|
4093
|
+
if target.is_file():
|
|
4094
|
+
html_path = str(target.with_suffix("")) + "-report.html"
|
|
4095
|
+
else:
|
|
4096
|
+
html_path = str(target.parent / (target.name + "-report.html"))
|
|
4097
|
+
log(f"默认 HTML 输出: {html_path}")
|
|
4098
|
+
|
|
4099
|
+
market_catalog = None
|
|
4100
|
+
if market_catalog_path:
|
|
4101
|
+
try:
|
|
4102
|
+
market_catalog = load_market_catalog(market_catalog_path)
|
|
4103
|
+
log(f"已加载市场清单: {len(market_catalog)} 项(依赖可达性标注已启用)")
|
|
4104
|
+
except Exception as e:
|
|
4105
|
+
log(f"❌ 市场清单加载失败: {e}")
|
|
4106
|
+
sys.exit(2)
|
|
4107
|
+
|
|
4108
|
+
try:
|
|
4109
|
+
skill_md, skill_dir = find_skill_md(root)
|
|
4110
|
+
checks = []
|
|
4111
|
+
info = None
|
|
4112
|
+
# A 组打包约束红线:文件总数(按 platform 取阈值)/ 仅纯文本(禁止二进制),与 SKILL.md 是否存在无关,始终执行
|
|
4113
|
+
checks.extend(check_package_constraints(root, platform=platform, target=target))
|
|
4114
|
+
# A 组:SKILL.md 文件名大小写精确(skill.md/Skill.md 等变体 → BLOCK),始终执行
|
|
4115
|
+
checks.extend(check_skill_md_naming(root))
|
|
4116
|
+
if skill_md is not None:
|
|
4117
|
+
struct_checks, info = check_structure(root, skill_md, skill_dir, source_type, zip_raw_names, platform=platform)
|
|
4118
|
+
checks.extend(struct_checks)
|
|
4119
|
+
name, body = info if info else (None, "")
|
|
4120
|
+
if skill_dir is not None:
|
|
4121
|
+
checks.extend(check_weaver_compliance(skill_dir, body or "", source_type))
|
|
4122
|
+
# A2: 结构化文件可解析性(JSON/YAML/TOML)
|
|
4123
|
+
checks.extend(check_structured_files(skill_dir))
|
|
4124
|
+
# A3: 依赖引导完整性
|
|
4125
|
+
checks.extend(check_dependency_guidance(skill_dir))
|
|
4126
|
+
# G 组扩展安全检测(危险命令/个人路径/IDE残留/CDN/内网域名/虚假承诺/BOM/CRLF)
|
|
4127
|
+
checks.extend(check_extended_security(skill_dir))
|
|
4128
|
+
# B 红线补充:API 请求凭证完整性(Cookie+eteamsid+User-Agent,BLOCK)
|
|
4129
|
+
checks.extend(check_api_request_credentials(skill_dir))
|
|
4130
|
+
# B10 红线补充:E10 接口型技能「登录 + 凭证」契约(仅 weaver-e10- 前缀技能,BLOCK)
|
|
4131
|
+
checks.extend(check_weaver_e10_auth_contract(skill_dir, name))
|
|
4132
|
+
# B11:千问连接器就绪性契约(仅 qwenwork 模式,BLOCK;函数内自带平台守护)
|
|
4133
|
+
checks.extend(check_qwenwork_connector_readiness(skill_dir, name, platform=platform))
|
|
4134
|
+
# Q 组质量启发式检测(路由触发/上下文效率/容错降级/字段字数)
|
|
4135
|
+
checks.extend(check_quality_heuristics(skill_dir, skill_md, body or ""))
|
|
4136
|
+
# C 威胁捕获模型视图 + D 威胁行为图谱解析(8 类风险卡片 + 跨文件/跨行具体行为,INFO/WARN)
|
|
4137
|
+
checks.extend(check_threat_model(skill_dir))
|
|
4138
|
+
checks.extend(check_behavior_graph(skill_dir))
|
|
4139
|
+
checks.extend(check_skill_capabilities(skill_dir))
|
|
4140
|
+
checks.extend(check_skill_dependencies(skill_dir, skill_md, market_catalog, live_market=market_live))
|
|
4141
|
+
else:
|
|
4142
|
+
# 无 SKILL.md:若已存在大小写变体(skill.md/Skill.md 等),精确大小写检查已给出 BLOCK,
|
|
4143
|
+
# 此处不再重复「未找到 SKILL.md」,避免提示歧义。
|
|
4144
|
+
has_case_variant = any(
|
|
4145
|
+
p.is_file() and p.name.lower() == "skill.md" and p.name != "SKILL.md"
|
|
4146
|
+
for p in root.rglob("*")
|
|
4147
|
+
)
|
|
4148
|
+
if not has_case_variant:
|
|
4149
|
+
checks.append(Check("基础合规", "SKILL.md 存在", "FAIL", "BLOCK",
|
|
4150
|
+
"未找到 SKILL.md。",
|
|
4151
|
+
suggestion="提供标准 skill 包(含 SKILL.md)。"))
|
|
4152
|
+
|
|
4153
|
+
file_info = calc_file_info(target, source_type)
|
|
4154
|
+
|
|
4155
|
+
verdict, n_err, n_warn = summarize(checks)
|
|
4156
|
+
report = render_report(target_str, source_type, checks, verdict, n_err, n_warn,
|
|
4157
|
+
file_info, platform=platform)
|
|
4158
|
+
|
|
4159
|
+
if as_json:
|
|
4160
|
+
payload = {
|
|
4161
|
+
"target": str(target),
|
|
4162
|
+
"source_type": source_type,
|
|
4163
|
+
"verdict": verdict,
|
|
4164
|
+
"blocking": n_err,
|
|
4165
|
+
"warnings": n_warn,
|
|
4166
|
+
"file_info": file_info,
|
|
4167
|
+
"checks": [c.to_dict() for c in checks],
|
|
4168
|
+
}
|
|
4169
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
4170
|
+
else:
|
|
4171
|
+
print(report)
|
|
4172
|
+
|
|
4173
|
+
if out_path:
|
|
4174
|
+
Path(out_path).write_text(report, encoding="utf-8")
|
|
4175
|
+
log(f"报告已写出: {out_path}")
|
|
4176
|
+
|
|
4177
|
+
if html_path:
|
|
4178
|
+
html = render_html(target_str, source_type, checks, verdict, n_err, n_warn,
|
|
4179
|
+
file_info, platform=platform)
|
|
4180
|
+
Path(html_path).write_text(html, encoding="utf-8")
|
|
4181
|
+
log(f"HTML 报告已写出: {html_path}")
|
|
4182
|
+
|
|
4183
|
+
sys.exit(1 if verdict == "FAIL" else 0)
|
|
4184
|
+
finally:
|
|
4185
|
+
if cleanup:
|
|
4186
|
+
shutil.rmtree(root, ignore_errors=True)
|
|
4187
|
+
|
|
4188
|
+
|
|
4189
|
+
if __name__ == "__main__":
|
|
4190
|
+
main()
|