@yottameta/yotta-dev-mcp 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.review-allowlist.json +29 -0
- package/CHANGELOG.md +34 -3
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/SKILL.md +18 -4
- package/package.json +2 -1
- package/references/tools.md +28 -6
- package/scripts/dev_architecture.py +23 -0
- package/scripts/dev_common.py +12 -2
- package/scripts/dev_engine.py +264 -13
- package/scripts/dev_rules.py +1 -1
- package/scripts/dev_selftest.py +36 -0
- package/scripts/yotta_dev_mcp.py +5 -0
- package/server.json +2 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"path": "yotta_dev_mcp.py",
|
|
6
|
+
"rule": "debug-print",
|
|
7
|
+
"line": 710,
|
|
8
|
+
"reason": "CLI --version output"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "yotta_dev_mcp.py",
|
|
12
|
+
"rule": "debug-print",
|
|
13
|
+
"line": 713,
|
|
14
|
+
"reason": "CLI --help usage line"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "yotta_dev_mcp.py",
|
|
18
|
+
"rule": "debug-print",
|
|
19
|
+
"line": 714,
|
|
20
|
+
"reason": "CLI --help core profile line"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "yotta_dev_mcp.py",
|
|
24
|
+
"rule": "debug-print",
|
|
25
|
+
"line": 715,
|
|
26
|
+
"reason": "CLI --help full profile line"
|
|
27
|
+
}
|
|
28
|
+
]
|
|
29
|
+
}
|
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## v0.2.5 (2026-10-31)
|
|
4
|
+
|
|
5
|
+
安全热修批次(ClawHub 0.2.4 扫描复核):
|
|
6
|
+
|
|
7
|
+
- `review_diff`:`base` 先解析为 commit SHA(`git rev-parse --verify --end-of-options`),
|
|
8
|
+
拒绝以 `-` 开头、含空白、超长或无法解析的 revision;防止 `--output=` 等 Git 选项注入
|
|
9
|
+
在仓库外写文件。
|
|
10
|
+
- `workflow_state`:原子写入改为同目录随机独占临时文件 + `os.replace`;备份同样先写
|
|
11
|
+
随机临时文件;拒绝目标 / 祖先路径中的 symlink、junction 等 reparse point;预置
|
|
12
|
+
`.tmp` / `.bak` 链接不会再重定向写入。
|
|
13
|
+
- 回归:新增 option injection、valid base、temp / backup / hardlink / junction 用例;
|
|
14
|
+
全量测试 **199(3 skip)**。
|
|
15
|
+
|
|
16
|
+
## v0.2.4 (2026-10-31)
|
|
17
|
+
|
|
18
|
+
外部缺陷报告修复批次:
|
|
19
|
+
|
|
20
|
+
- `architecture_review`:UTF-8 BOM 解码修正;规则零证据(`decided_imports=0`)改判
|
|
21
|
+
`UNKNOWN` 并输出 `rule-no-evidence` / `rule-no-source-modules`。
|
|
22
|
+
- `review_code` / `review_diff`:`eval-exec` 排除 `.exec(`;单行字符串 / 注释遮罩;
|
|
23
|
+
新增 `.review-allowlist.json`(或 `.yotta/review-allowlist.json`)精确豁免
|
|
24
|
+
(path + rule + line + reason,无通配符,非法配置 fail-closed)。
|
|
25
|
+
- legacy `initialize` 返回当前工具档位 instructions;SKILL.md 增加运行期档位自检。
|
|
26
|
+
- `repo_map` / `review_code` 忽略 `temp` / `tmp` / `.yottameta` / `_bak*` /
|
|
27
|
+
`vendor` / `third_party`。
|
|
28
|
+
- `scan_secrets`:补 6-7 字符弱口令规则并去重高熵重复;`find_code` 支持
|
|
29
|
+
`def ` / `class ` / `function ` 前缀。
|
|
30
|
+
- `self_test` source 档增加 SKILL.md「当前版本」正文检查;SKILL.md / CHANGELOG
|
|
31
|
+
版本节对齐。
|
|
32
|
+
- 回归:P0 / P1 / P2 新增用例;全量测试基线 176(1 skip)。
|
|
33
|
+
|
|
1
34
|
## v0.2.3 (2026-10-01)
|
|
2
35
|
|
|
3
36
|
- 安装器卫生批次:`bin/install.js` / `install.sh` 统一(未知参数报错 exit 2、`--help` / `--version`、残留清理白名单、嵌套载荷保留);由模板单一真源渲染,接入漂移门禁。
|
|
@@ -14,8 +47,6 @@
|
|
|
14
47
|
- `--help` / `--version` 支持;非法分组值以中文用法错误 + 退出码 2 拒绝。
|
|
15
48
|
- 回归:新增 7 项分档用例(默认 full / core 列表与顺序 / core 拒绝 full 专属工具 / schema 预算 / 非法值拒绝 / stdio 两种端到端);元开全量 189/189(1 skip)。
|
|
16
49
|
|
|
17
|
-
# Changelog
|
|
18
|
-
|
|
19
50
|
## v0.2.1 (2026-09-25)
|
|
20
51
|
|
|
21
52
|
**缺陷修复批次(LedgerHouse 实测 + 元开自审)**
|
|
@@ -38,7 +69,7 @@
|
|
|
38
69
|
`file://` 百分号编码路径、常见二进制 / 源码 / 文档后缀、带 `sha*` / hash /
|
|
39
70
|
checksum / digest / integrity 上下文的 32/40/64/128 位十六进制;
|
|
40
71
|
`api-key` / token / AWS / 私钥规则不放宽,真随机高熵 token 仍会命中。
|
|
41
|
-
- `review_code` / `repo_map`
|
|
72
|
+
- `review_code` / `repo_map` 共用目录排除规则:新增 `.workflow` / `.codex` /
|
|
42
73
|
`.cursor` / `.claude` / `.agents` / `scratch` / `_probe` / `probe` /
|
|
43
74
|
`sandbox` / `debug` 等目录与 `probe_*` / `_probe_*` / `scratch_*` /
|
|
44
75
|
`tmp_*` / `temp_*` / `*_tmp` 文件名兜底;`scan_secrets` 仍扫描全部文件,
|
package/README.md
CHANGED
|
@@ -18,14 +18,14 @@ Deterministic local development tools exposed over a stdio MCP server.
|
|
|
18
18
|
| `find_code` | Locate symbols and text with bounded results. |
|
|
19
19
|
| `compress_output` | Compress long logs while keeping errors and head/tail context. |
|
|
20
20
|
| `review_code` | Apply deterministic review rules with file, line and evidence. |
|
|
21
|
-
| `review_diff` | Review only added lines in a unified diff. |
|
|
21
|
+
| `review_diff` | Review only added lines in a unified diff; `base` resolves to a commit SHA. |
|
|
22
22
|
| `mcp_doctor` | Inspect installed skills and MCP JSON configuration files. |
|
|
23
23
|
| `scan_secrets` | Scan for credentials and high-entropy tokens with redacted evidence. |
|
|
24
24
|
| `scan_dependencies` | Check manifests, lockfiles, insecure sources and typosquat suspicion. |
|
|
25
25
|
| `check_publish_readiness` | Check version alignment, release files and package metadata. |
|
|
26
26
|
| `run_checks` | Run whitelisted tests, lint or compile checks; execution is explicit. |
|
|
27
27
|
| `scaffold_skill` | Plan or create a minimal skill scaffold; dry-run by default. |
|
|
28
|
-
| `workflow_state` | Read `.workflow` and optionally append an explicit log entry. |
|
|
28
|
+
| `workflow_state` | Read `.workflow` and optionally append an explicit log entry; writes are atomic and reject link paths. |
|
|
29
29
|
|
|
30
30
|
Tools are read-only by default; `run_checks`, the L2-L4 policy checks in
|
|
31
31
|
`verify_change`, the test subset in `self_test`, and `run_adapter` with
|
package/README.zh-CN.md
CHANGED
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
| `find_code` | 符号与文本定位,结果有数量上限。 |
|
|
19
19
|
| `compress_output` | 压缩长日志,保留错误、错误栈和首尾上下文。 |
|
|
20
20
|
| `review_code` | 规则化代码评审,输出文件、行号、证据与建议。 |
|
|
21
|
-
| `review_diff` | 只评审 unified diff
|
|
21
|
+
| `review_diff` | 只评审 unified diff 的新增行;`base` 先解析为 commit SHA。 |
|
|
22
22
|
| `mcp_doctor` | 只读检查技能目录与 MCP JSON 配置。 |
|
|
23
23
|
| `scan_secrets` | 密钥 / 凭据 / 高熵令牌扫描,证据强制脱敏。 |
|
|
24
24
|
| `scan_dependencies` | 依赖清单、lockfile、来源与 typosquat 启发式检查。 |
|
|
25
25
|
| `check_publish_readiness` | 版本对齐、发布文件与包元数据检查。 |
|
|
26
26
|
| `run_checks` | 运行白名单测试 / lint / compile,执行必须显式开启。 |
|
|
27
27
|
| `scaffold_skill` | 规划或生成最小技能脚手架,默认 dry-run。 |
|
|
28
|
-
| `workflow_state` | 读取 `.workflow
|
|
28
|
+
| `workflow_state` | 读取 `.workflow`,可选显式追加快照日志;写入原子化并拒绝链接路径。 |
|
|
29
29
|
|
|
30
30
|
默认只读;`run_checks`、`verify_change` 的 L2-L4 策略检查、`self_test` 的测试子集,
|
|
31
31
|
以及 `run_adapter` 的 `action=run` 必须显式 `allow_execute=true`,
|
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yotta-dev-mcp
|
|
3
3
|
description: 元开(yotta-dev-mcp)—— 本地、确定性的开发工具 MCP,把只读默认的开发能力暴露为 stdio MCP server:repo_map / system_model / architecture_review / impact_analysis / verify_change / self_test / run_adapter / find_code / compress_output / review_code / review_diff / mcp_doctor / scan_secrets / scan_dependencies / check_publish_readiness / run_checks / scaffold_skill / workflow_state。触发:让 AI 在陌生项目里先做结构盘点、构建系统模型或架构契约(.yotta/architecture.json)、按契约评审架构、分析改动影响锥与回归面、按验证阶梯产出证据账本、对元开自身做完整性 / 反证自测、探测或显式运行 import-linter / dependency-cruiser / Repomix 可选适配器、定位代码、评审改动、扫描密钥/依赖、检查发布就绪、运行白名单检查、生成脚手架或读取 .workflow 状态时;或用户说 元开 / 开发能力 MCP / yotta-dev-mcp / 代码库地图 / 系统模型 / 架构契约 / 架构评审 / 影响分析 / 验证账本 / 反证自测 / 代码评审 MCP / 适配器 等。边界:Python 3.8+ 标准库、离线默认;除 run_checks(显式 allow_execute)、verify_change 的 L2-L4 策略检查(显式 allow_execute)与 self_test 的测试子集(显式 allow_execute)、run_adapter 的 action=run(显式 allow_execute)、scaffold_skill / workflow_state 的显式 apply 外均为只读;不上传源码、不自动修改、不提交、不联网查询包是否存在、不自动安装或下载适配器。
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.5
|
|
5
5
|
license: MIT
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -49,6 +49,11 @@ python scripts/yotta_dev_mcp.py --tools full # 18 工具(默认)
|
|
|
49
49
|
`full` 暴露全部工具,适合按需调用、不常驻的场景。`core` 模式下调用 full 专属工具会返回
|
|
50
50
|
明确提示(提示用 `--tools full` 重启)。
|
|
51
51
|
|
|
52
|
+
运行期自检:先看客户端 `tools/list` 实际返回。只有 5 个工具即 core 档;需要
|
|
53
|
+
`system_model` / `architecture_review` 等 full 专属工具时,用 `--tools full` 重启。
|
|
54
|
+
服务端不会在 `tools/list` 之外补发隐藏工具,legacy `initialize` 与 modern
|
|
55
|
+
`server/discover` 都会在 instructions 中报告当前档位。
|
|
56
|
+
|
|
52
57
|
自动接入说明:AI 首次协助配置时,必须先展示目标配置文件、完整配置片段和影响,
|
|
53
58
|
并获得用户明确同意后再写入 `mcpServers`。用户拒绝时不要写文件,直接提供上面的启动命令
|
|
54
59
|
让用户手动接入。
|
|
@@ -62,19 +67,19 @@ python scripts/yotta_dev_mcp.py --tools full # 18 工具(默认)
|
|
|
62
67
|
| `architecture_review` | 按契约评审依赖规则、边界可见性与数据归属;critical / high 判 FAIL,medium / low 只告警 | 否 |
|
|
63
68
|
| `impact_analysis` | 变更影响锥:直接消费者、受影响层 / 边界 / 存储 / 不变量、映射测试、风险分级与回滚探针 | 否 |
|
|
64
69
|
| `verify_change` | L0-L5 验证阶梯与证据账本:L0 / L1 默认,L2-L4 需策略声明 + 显式执行,L5 始终留在未验证项 | 仅显式 allow_execute 的 L2-L4 |
|
|
65
|
-
| `self_test` |
|
|
70
|
+
| `self_test` | source 档:文件 / 版本 / 工具契约 / 写闸门 / 反证;installed 档:文件 / 版本 / 反证(3 项) | 仅显式 allow_execute 的测试子集 |
|
|
66
71
|
| `run_adapter` | 探测或显式运行 import-linter / dependency-cruiser / Repomix;固定 argv、项目内 cwd、有界输出与哈希 | 仅 action=run + 显式 allow_execute |
|
|
67
72
|
| `find_code` | 符号 / 文本定位,结果有上限 | 否 |
|
|
68
73
|
| `compress_output` | 保留错误与首尾的长输出压缩 | 否 |
|
|
69
74
|
| `review_code` | 规则化代码评审,带行号与建议;忽略 `.workflow` / `scratch` / `_probe` 等临时目录 | 否 |
|
|
70
|
-
| `review_diff` | 只评审 diff
|
|
75
|
+
| `review_diff` | 只评审 diff 的新增行;`base` 先解析为 commit SHA,拒绝选项注入 | 否 |
|
|
71
76
|
| `mcp_doctor` | 技能版本与多宿主 MCP 配置体检;返回 coverage,只列 server 名,不含 command / env | 否 |
|
|
72
77
|
| `scan_secrets` | 密钥 / 凭据 / 高熵令牌扫描(强制脱敏;路径 / 哈希 / 文件名噪声过滤) | 否 |
|
|
73
78
|
| `scan_dependencies` | 依赖清单、lockfile、来源与 typosquat 启发式检查 | 否 |
|
|
74
79
|
| `check_publish_readiness` | 版本四件、发布文件、仓库与 publishConfig 检查 | 否 |
|
|
75
80
|
| `run_checks` | 白名单测试 / lint / compile 并返回结构化摘要 | 仅显式 allow_execute |
|
|
76
81
|
| `scaffold_skill` | 生成最小技能脚手架,默认 dry-run | 仅显式 apply |
|
|
77
|
-
| `workflow_state` | 读取 `.workflow
|
|
82
|
+
| `workflow_state` | 读取 `.workflow`,可选显式追加日志;写入拒绝链接路径并使用原子替换 | 仅显式 apply |
|
|
78
83
|
|
|
79
84
|
详细契约见 `references/tools.md`;架构契约、评审语义与影响锥字段见 `references/architecture-contract.md`。
|
|
80
85
|
|
|
@@ -83,12 +88,21 @@ python scripts/yotta_dev_mcp.py --tools full # 18 工具(默认)
|
|
|
83
88
|
- 会执行项目代码的工具默认关闭:`run_checks`、`verify_change` 的 L2-L4 策略检查、`self_test` 的测试子集都必须显式 `allow_execute=true`,且只运行白名单检查或已声明的策略检查。
|
|
84
89
|
- 外部适配器只在用户已安装时接入:`run_adapter` 不安装、不下载、不访问远端、不接收任意命令参数;工具缺失或配置缺失一律返回 `UNKNOWN` 并给出 next_step。
|
|
85
90
|
- `scaffold_skill` / `workflow_state` 默认只预览,显式 `apply=true` 才写入;脚手架拒绝覆盖已有非空目录,状态写入使用原子替换并为被覆盖文件保留 `.bak`。
|
|
91
|
+
- `workflow_state` 写入会拒绝目标或祖先路径中的 symlink / junction 等链接重定向;
|
|
92
|
+
临时文件使用随机独占名,避免可预测 `.tmp` / `.bak` 路径被预置链接利用。
|
|
93
|
+
- `review_code` / `review_diff` 支持 `.review-allowlist.json`(或 `.yotta/review-allowlist.json`)精确豁免(path + rule + line + reason,无通配符);非法配置 fail-closed,不抑制 findings。
|
|
94
|
+
- `review_diff` 的 `base` 必须是可解析 Git revision;以 `-` 开头、含空白或无法解析的值会被拒绝,
|
|
95
|
+
防止 `--output=` 等选项注入。
|
|
86
96
|
- 不联网,不查询包是否存在于公共仓库。
|
|
87
97
|
- 不读取或修改 YottaCode 仓库。
|
|
88
98
|
- 结论是确定性静态判断,不替代人工评审与最终决策。
|
|
89
99
|
|
|
90
100
|
## 当前版本
|
|
91
101
|
|
|
102
|
+
- v0.2.5(2026-10-31):安全热修——`review_diff` 的 `base` 先解析为 commit SHA,拒绝 Git 选项注入;`workflow_state` 改为随机独占临时文件 + 原子替换,备份同样原子化,并拒绝 symlink / junction 等链接路径;新增 option injection、temp / backup / hardlink / junction 回归。
|
|
103
|
+
- v0.2.4(2026-10-31):外部缺陷报告修复——`architecture_review` 修 BOM 假 PASS 与零证据 PASS;legacy `initialize` 报告当前工具档位;`review_code` / `review_diff` 修 `.exec(` 与字符串内待办标记误报并新增精确 allowlist;补齐临时 / 备份目录忽略;`scan_secrets` 补弱口令并去重;`find_code` 支持 `def ` 前缀;`self_test` 增加当前版本正文检查。
|
|
104
|
+
- v0.2.3(2026-10-01):安装器卫生批次——`bin/install.js` / `install.sh` 统一,未知参数 exit 2、`--help` / `--version`、残留清理白名单与嵌套载荷保留。
|
|
105
|
+
- v0.2.2(2026-09-26):新增 `--tools core|full` 工具分组;core 5 工具 / full 18 工具,core 拒绝 full 专属工具。
|
|
92
106
|
- v0.2.1(2026-09-25):缺陷修复批次——`mcp_doctor` 改为多宿主 / 多根 / JSON+JSONC+TOML 子集发现并返回 coverage;`self_test(mode="installed")` 按精简分发副本 / npm / plugin / r0 形态判断 banner;`repo_map` 修复 `from . import X` 解析;`scan_secrets` 过滤路径 / 哈希 / 文件名噪声;`review_code` / `repo_map` 忽略 `.workflow` / `scratch` / `_probe` 等临时目录。
|
|
93
107
|
- v0.2.0(2026-09-25):新增 `system_model`、`architecture_review`、`impact_analysis`、`verify_change`、`self_test`、`run_adapter`,以及 `.yotta/architecture.json` 与可选 `.yotta/verification.json` 契约(版本 1);工具总数 18,原 12 个工具行为不变。
|
|
94
108
|
- v0.1.1:品牌显示名统一为「元开」;功能与 12 个工具不变。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yottameta/yotta-dev-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "YuanKai (yotta-dev-mcp) - deterministic local development tools exposed over stdio MCP: repo_map, system_model, architecture_review, impact_analysis, verify_change, self_test, run_adapter, find_code, compress_output, review_code, review_diff, mcp_doctor, scan_secrets, scan_dependencies, check_publish_readiness, run_checks, scaffold_skill and workflow_state. Reads an optional .yotta/architecture.json contract plus .yotta/verification.json policy for architecture reviews, change impact cones and the L0-L5 evidence ledger. Optional explicit adapters integrate import-linter, dependency-cruiser and Repomix without auto-install or network access. Python 3.8+ standard library, offline by default; writes and command execution require explicit flags.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"keywords": [
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"CHANGELOG.md",
|
|
21
21
|
"install.sh",
|
|
22
22
|
"server.json",
|
|
23
|
+
".review-allowlist.json",
|
|
23
24
|
"references",
|
|
24
25
|
"scripts",
|
|
25
26
|
"assets",
|
package/references/tools.md
CHANGED
|
@@ -124,10 +124,11 @@ Input:
|
|
|
124
124
|
- `timeout` (optional, default 120, 1-600).
|
|
125
125
|
|
|
126
126
|
Source mode checks required files, version alignment across `package.json` /
|
|
127
|
-
`SKILL.md` / `CHANGELOG.md` / `server.json` / engine `VERSION`,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
`SKILL.md` / `CHANGELOG.md` / `server.json` / engine `VERSION`, the SKILL.md
|
|
128
|
+
current-version body, protocol tool names and
|
|
129
|
+
`inputSchema.additionalProperties=false`, fail-closed defaults for every write or
|
|
130
|
+
execute gate, and counterexamples. Installed mode runs three checks: files,
|
|
131
|
+
skill-version and verifier-counterexamples; source-only checks are out of scope.
|
|
131
132
|
|
|
132
133
|
The counterexample suite is in-process and deterministic: a seeded forbidden
|
|
133
134
|
dependency must be `FAIL`, removing the rule must stop the failure, an invalid
|
|
@@ -202,7 +203,9 @@ Each finding contains `path`, `line`, `rule`, `severity`, `evidence`, `suggestio
|
|
|
202
203
|
Input:
|
|
203
204
|
|
|
204
205
|
- `diff_text` or `path` (one required).
|
|
205
|
-
- `base` (optional git revision when `path` is used).
|
|
206
|
+
- `base` (optional git revision when `path` is used). It is resolved to a commit SHA
|
|
207
|
+
before `git diff` runs; option-like, whitespace-containing, oversized or
|
|
208
|
+
unresolvable revisions are rejected.
|
|
206
209
|
- `max_findings` (optional).
|
|
207
210
|
|
|
208
211
|
Only added lines are reviewed. Output: `files`, `findings`, `truncated`.
|
|
@@ -212,6 +215,23 @@ directories, scratch / probe / sandbox / debug directories and probe/temp
|
|
|
212
215
|
file names do not flood the result. `review_diff` only reviews added lines,
|
|
213
216
|
so it is unaffected by directory traversal.
|
|
214
217
|
|
|
218
|
+
Optional `.review-allowlist.json` (or `.yotta/review-allowlist.json`) suppresses
|
|
219
|
+
known-intentional findings by exact path + rule + line. No wildcards are
|
|
220
|
+
supported; `reason` is required. The root-level file is copied by the installer,
|
|
221
|
+
so installed copies keep the same exemptions.
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"version": 1,
|
|
226
|
+
"entries": [
|
|
227
|
+
{"path": "scripts/example.py", "rule": "debug-print", "line": 12, "reason": "CLI help output"}
|
|
228
|
+
]
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Output adds `suppressed`, `suppressed_findings` and `allowlist_errors`.
|
|
233
|
+
An invalid allowlist fails closed: findings are not suppressed.
|
|
234
|
+
|
|
215
235
|
## mcp_doctor
|
|
216
236
|
|
|
217
237
|
Input:
|
|
@@ -287,4 +307,6 @@ Input: `root`, `action` (`read` / `append-log` / `append-file`), optional
|
|
|
287
307
|
`date`, `text`, `file`, explicit `apply=true`.
|
|
288
308
|
|
|
289
309
|
Output: `ok`, `files`, `missing`, `excerpts` for read; `target`, `applied`,
|
|
290
|
-
`preview` for writes. Writes
|
|
310
|
+
`preview` for writes. Writes use a random exclusive temporary file plus
|
|
311
|
+
`os.replace`, keep a `.bak` of an existing file, and reject target or ancestor
|
|
312
|
+
paths that are symlinks, junctions or other reparse points.
|
|
@@ -28,6 +28,10 @@ def _review_rules(contract, model, unknown_sink, violation_sink):
|
|
|
28
28
|
checked = []
|
|
29
29
|
for rule in contract["rules"]:
|
|
30
30
|
severity = rule.get("severity") or dev_contract.RULE_DEFAULT_SEVERITY
|
|
31
|
+
source_modules = [
|
|
32
|
+
item["id"] for item in model["modules"]
|
|
33
|
+
if module_layers.get(item["id"]) == rule["from"]
|
|
34
|
+
]
|
|
31
35
|
violations = 0
|
|
32
36
|
undecided = 0
|
|
33
37
|
decisive = 0
|
|
@@ -82,6 +86,24 @@ def _review_rules(contract, model, unknown_sink, violation_sink):
|
|
|
82
86
|
status = "WARN"
|
|
83
87
|
elif undecided:
|
|
84
88
|
status = "UNKNOWN"
|
|
89
|
+
elif decisive == 0:
|
|
90
|
+
status = "UNKNOWN"
|
|
91
|
+
if source_modules:
|
|
92
|
+
unknown_sink.append({
|
|
93
|
+
"kind": "rule-no-evidence",
|
|
94
|
+
"id": rule["id"],
|
|
95
|
+
"rule": rule["id"],
|
|
96
|
+
"detail": "the from layer has modules but no decisive import was found",
|
|
97
|
+
"next_step": "add an import that exercises this rule or remove the rule",
|
|
98
|
+
})
|
|
99
|
+
else:
|
|
100
|
+
unknown_sink.append({
|
|
101
|
+
"kind": "rule-no-source-modules",
|
|
102
|
+
"id": rule["id"],
|
|
103
|
+
"rule": rule["id"],
|
|
104
|
+
"detail": "the from layer has no modules to review",
|
|
105
|
+
"next_step": "add modules to the from layer or remove the rule",
|
|
106
|
+
})
|
|
85
107
|
else:
|
|
86
108
|
status = "PASS"
|
|
87
109
|
checked.append({
|
|
@@ -92,6 +114,7 @@ def _review_rules(contract, model, unknown_sink, violation_sink):
|
|
|
92
114
|
"severity": severity,
|
|
93
115
|
"claim": rule.get("claim"),
|
|
94
116
|
"status": status,
|
|
117
|
+
"source_modules": len(source_modules),
|
|
95
118
|
"decided_imports": decisive,
|
|
96
119
|
"violations": violations,
|
|
97
120
|
"undecided_imports": undecided,
|
package/scripts/dev_common.py
CHANGED
|
@@ -13,7 +13,11 @@ IGNORE_DIRS = {
|
|
|
13
13
|
".workflow", ".codex", ".cursor", ".claude", ".agents",
|
|
14
14
|
"scratch", "_scratch", "_probe", "probe", "probes",
|
|
15
15
|
"sandbox", ".sandbox", "debug", "_debug",
|
|
16
|
+
"temp", "tmp", ".yottameta", "vendor", "third_party",
|
|
16
17
|
}
|
|
18
|
+
IGNORE_DIR_PATTERNS = (
|
|
19
|
+
re.compile(r"(?i)^_bak(?:[_-].*)?$"),
|
|
20
|
+
)
|
|
17
21
|
IGNORE_TEMP_FILE_RE = re.compile(
|
|
18
22
|
r"(?i)^(?:probe[_-].*|_probe[_-].*|scratch[_-].*|_scratch[_-].*|tmp_.*|temp_.*|.*_tmp)"
|
|
19
23
|
r"\.(?:py|js|jsx|mjs|cjs|ts|tsx|sh|ps1|go|rs|java|kt|kts|rb|php)$"
|
|
@@ -83,6 +87,12 @@ def source_exts():
|
|
|
83
87
|
return set(SOURCE_EXTS)
|
|
84
88
|
|
|
85
89
|
|
|
90
|
+
def _is_ignored_dir(name):
|
|
91
|
+
return name in IGNORE_DIRS or any(
|
|
92
|
+
pattern.match(name) for pattern in IGNORE_DIR_PATTERNS
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
86
96
|
def _json_safe(value):
|
|
87
97
|
if isinstance(value, Path):
|
|
88
98
|
return str(value)
|
|
@@ -100,7 +110,7 @@ def _read_text(path):
|
|
|
100
110
|
data = path.read_bytes()
|
|
101
111
|
if b"\x00" in data[:4096]:
|
|
102
112
|
raise ValueError("二进制文件不参与文本扫描: %s" % path)
|
|
103
|
-
return data.decode("utf-8", errors="replace")
|
|
113
|
+
return data.decode("utf-8-sig", errors="replace")
|
|
104
114
|
|
|
105
115
|
|
|
106
116
|
def _iter_files(root, extensions=None, max_files=5000, all_files=False, ignore_temp=True):
|
|
@@ -108,7 +118,7 @@ def _iter_files(root, extensions=None, max_files=5000, all_files=False, ignore_t
|
|
|
108
118
|
extensions = set(extensions or TEXT_EXTS)
|
|
109
119
|
count = 0
|
|
110
120
|
for current, dirs, files in os.walk(str(root)):
|
|
111
|
-
dirs[:] = sorted(d for d in dirs if
|
|
121
|
+
dirs[:] = sorted(d for d in dirs if not _is_ignored_dir(d))
|
|
112
122
|
for name in sorted(files):
|
|
113
123
|
if ignore_temp and IGNORE_TEMP_FILE_RE.match(name):
|
|
114
124
|
continue
|
package/scripts/dev_engine.py
CHANGED
|
@@ -13,8 +13,10 @@ import math
|
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
15
|
import shutil
|
|
16
|
+
import stat
|
|
16
17
|
import subprocess
|
|
17
18
|
import sys
|
|
19
|
+
import tempfile
|
|
18
20
|
from pathlib import Path
|
|
19
21
|
|
|
20
22
|
import dev_contract
|
|
@@ -41,7 +43,7 @@ from dev_model import (
|
|
|
41
43
|
)
|
|
42
44
|
from dev_rules import REVIEW_RULES
|
|
43
45
|
|
|
44
|
-
VERSION = "0.2.
|
|
46
|
+
VERSION = "0.2.5"
|
|
45
47
|
|
|
46
48
|
|
|
47
49
|
|
|
@@ -100,7 +102,13 @@ def repo_map(path, max_files=2000):
|
|
|
100
102
|
|
|
101
103
|
|
|
102
104
|
def _classify_match(line, query):
|
|
103
|
-
|
|
105
|
+
normalized = re.sub(
|
|
106
|
+
r"^\s*(?:async\s+)?(?:def|class|function|export\s+function|const|let|var)\s+",
|
|
107
|
+
"",
|
|
108
|
+
str(query),
|
|
109
|
+
).strip()
|
|
110
|
+
target = normalized or str(query)
|
|
111
|
+
escaped = re.escape(target)
|
|
104
112
|
if re.search(r"^\s*(?:async\s+)?def\s+%s\b" % escaped, line):
|
|
105
113
|
return "definition"
|
|
106
114
|
if re.search(r"^\s*class\s+%s\b" % escaped, line):
|
|
@@ -205,10 +213,125 @@ def compress_output(text=None, file=None, max_chars=4000, head_lines=40, tail_li
|
|
|
205
213
|
}
|
|
206
214
|
|
|
207
215
|
|
|
216
|
+
def _split_code_comment(line_text, rel):
|
|
217
|
+
language = _language(rel)
|
|
218
|
+
if language in ("python", "shell"):
|
|
219
|
+
markers = ("#",)
|
|
220
|
+
elif language in ("javascript", "typescript"):
|
|
221
|
+
markers = ("//", "/*")
|
|
222
|
+
else:
|
|
223
|
+
markers = ("#", "//", "/*")
|
|
224
|
+
stripped = line_text.lstrip()
|
|
225
|
+
if stripped.startswith("*") and not stripped.startswith("*/"):
|
|
226
|
+
return "", line_text
|
|
227
|
+
quote = None
|
|
228
|
+
escaped = False
|
|
229
|
+
index = 0
|
|
230
|
+
while index < len(line_text):
|
|
231
|
+
char = line_text[index]
|
|
232
|
+
if quote:
|
|
233
|
+
if escaped:
|
|
234
|
+
escaped = False
|
|
235
|
+
elif char == "\\":
|
|
236
|
+
escaped = True
|
|
237
|
+
elif char == quote:
|
|
238
|
+
quote = None
|
|
239
|
+
elif char in ("'", '"', "`"):
|
|
240
|
+
quote = char
|
|
241
|
+
else:
|
|
242
|
+
for marker in markers:
|
|
243
|
+
if line_text.startswith(marker, index):
|
|
244
|
+
return line_text[:index], line_text[index:]
|
|
245
|
+
index += 1
|
|
246
|
+
return line_text, ""
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _mask_strings(code):
|
|
250
|
+
chars = list(code)
|
|
251
|
+
quote = None
|
|
252
|
+
escaped = False
|
|
253
|
+
for index, char in enumerate(chars):
|
|
254
|
+
if quote:
|
|
255
|
+
if escaped:
|
|
256
|
+
escaped = False
|
|
257
|
+
chars[index] = " "
|
|
258
|
+
elif char == "\\":
|
|
259
|
+
escaped = True
|
|
260
|
+
chars[index] = " "
|
|
261
|
+
elif char == quote:
|
|
262
|
+
quote = None
|
|
263
|
+
else:
|
|
264
|
+
chars[index] = " "
|
|
265
|
+
elif char in ("'", '"', "`"):
|
|
266
|
+
quote = char
|
|
267
|
+
return "".join(chars)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _allowlist_candidates(root):
|
|
271
|
+
current = Path(root).resolve()
|
|
272
|
+
candidates = []
|
|
273
|
+
for base in (current, *current.parents):
|
|
274
|
+
candidates.append(base / ".yotta" / "review-allowlist.json")
|
|
275
|
+
candidates.append(base / ".review-allowlist.json")
|
|
276
|
+
if (base / ".git").exists():
|
|
277
|
+
break
|
|
278
|
+
return candidates
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _load_review_allowlist(root):
|
|
282
|
+
for candidate in _allowlist_candidates(root):
|
|
283
|
+
if not candidate.is_file():
|
|
284
|
+
continue
|
|
285
|
+
try:
|
|
286
|
+
data = json.loads(_read_text(candidate))
|
|
287
|
+
except Exception as exc: # noqa: BLE001
|
|
288
|
+
return {}, [{"path": str(candidate), "detail": str(exc)}]
|
|
289
|
+
entries = data.get("entries") if isinstance(data, dict) else None
|
|
290
|
+
if not isinstance(entries, list):
|
|
291
|
+
return {}, [{"path": str(candidate), "detail": "entries must be a list"}]
|
|
292
|
+
allowlist = {}
|
|
293
|
+
errors = []
|
|
294
|
+
for entry in entries:
|
|
295
|
+
if not isinstance(entry, dict):
|
|
296
|
+
errors.append({"path": str(candidate), "detail": "entry must be an object"})
|
|
297
|
+
continue
|
|
298
|
+
rel = entry.get("path")
|
|
299
|
+
rule = entry.get("rule")
|
|
300
|
+
line = entry.get("line")
|
|
301
|
+
reason = entry.get("reason")
|
|
302
|
+
if not isinstance(rel, str) or not isinstance(rule, str) or not reason:
|
|
303
|
+
errors.append({"path": str(candidate), "detail": "path, rule and reason are required"})
|
|
304
|
+
continue
|
|
305
|
+
if isinstance(line, bool) or not isinstance(line, int) or line < 1:
|
|
306
|
+
errors.append({"path": str(candidate), "detail": "line must be a positive integer"})
|
|
307
|
+
continue
|
|
308
|
+
normalized = rel.replace("\\", "/")
|
|
309
|
+
while normalized.startswith("./"):
|
|
310
|
+
normalized = normalized[2:]
|
|
311
|
+
allowlist[(normalized, rule, line)] = str(reason)
|
|
312
|
+
return allowlist, errors
|
|
313
|
+
return {}, []
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _apply_review_allowlist(findings, allowlist):
|
|
317
|
+
kept = []
|
|
318
|
+
suppressed = []
|
|
319
|
+
for item in findings:
|
|
320
|
+
key = (item["path"], item["rule"], item["line"])
|
|
321
|
+
if key in allowlist:
|
|
322
|
+
suppressed.append(dict(item, reason=allowlist[key]))
|
|
323
|
+
else:
|
|
324
|
+
kept.append(item)
|
|
325
|
+
return kept, suppressed
|
|
326
|
+
|
|
327
|
+
|
|
208
328
|
def _review_line(rel, line_no, line_text):
|
|
209
329
|
findings = []
|
|
330
|
+
code, comment = _split_code_comment(line_text, rel)
|
|
331
|
+
code = _mask_strings(code)
|
|
210
332
|
for rule, severity, pattern, suggestion in REVIEW_RULES:
|
|
211
|
-
if
|
|
333
|
+
target = comment if rule == "todo-comment" else code
|
|
334
|
+
if pattern.search(target):
|
|
212
335
|
findings.append({
|
|
213
336
|
"path": rel,
|
|
214
337
|
"line": line_no,
|
|
@@ -222,6 +345,8 @@ def _review_line(rel, line_no, line_text):
|
|
|
222
345
|
|
|
223
346
|
def review_code(path=None, text=None, max_findings=200):
|
|
224
347
|
findings = []
|
|
348
|
+
allowlist = {}
|
|
349
|
+
allowlist_errors = []
|
|
225
350
|
if text is not None:
|
|
226
351
|
for line_no, line in enumerate(str(text).splitlines(), 1):
|
|
227
352
|
findings.extend(_review_line("<text>", line_no, line))
|
|
@@ -231,6 +356,8 @@ def review_code(path=None, text=None, max_findings=200):
|
|
|
231
356
|
root = Path(path)
|
|
232
357
|
if not root.exists():
|
|
233
358
|
raise ValueError("路径不存在: %s" % path)
|
|
359
|
+
allowlist_root = root.parent if root.is_file() else root
|
|
360
|
+
allowlist, allowlist_errors = _load_review_allowlist(allowlist_root)
|
|
234
361
|
files = [root] if root.is_file() else list(_iter_files(root, extensions=source_exts()))
|
|
235
362
|
base = root.parent if root.is_file() else root
|
|
236
363
|
for file_path in files:
|
|
@@ -241,9 +368,16 @@ def review_code(path=None, text=None, max_findings=200):
|
|
|
241
368
|
rel = _rel(base, file_path)
|
|
242
369
|
for line_no, line in enumerate(lines, 1):
|
|
243
370
|
findings.extend(_review_line(rel, line_no, line))
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
371
|
+
kept, suppressed = _apply_review_allowlist(findings, allowlist)
|
|
372
|
+
kept.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
|
|
373
|
+
truncated = len(kept) > max_findings
|
|
374
|
+
return {
|
|
375
|
+
"findings": kept[:max_findings],
|
|
376
|
+
"truncated": truncated,
|
|
377
|
+
"suppressed": len(suppressed),
|
|
378
|
+
"suppressed_findings": suppressed[:max_findings],
|
|
379
|
+
"allowlist_errors": allowlist_errors,
|
|
380
|
+
}
|
|
247
381
|
|
|
248
382
|
|
|
249
383
|
def _parse_added_lines(diff_text):
|
|
@@ -266,13 +400,38 @@ def _parse_added_lines(diff_text):
|
|
|
266
400
|
line_no += 1
|
|
267
401
|
|
|
268
402
|
|
|
403
|
+
def _resolve_git_commit(path, revision):
|
|
404
|
+
value = str(revision or "").strip()
|
|
405
|
+
if (
|
|
406
|
+
not value
|
|
407
|
+
or len(value) > 200
|
|
408
|
+
or value.startswith("-")
|
|
409
|
+
or any(char.isspace() for char in value)
|
|
410
|
+
):
|
|
411
|
+
raise ValueError("base 不是有效 revision")
|
|
412
|
+
command = [
|
|
413
|
+
"git", "-C", str(path), "rev-parse", "--verify", "--quiet",
|
|
414
|
+
"--end-of-options", value + "^{commit}",
|
|
415
|
+
]
|
|
416
|
+
proc = subprocess.run(command, capture_output=True, text=True)
|
|
417
|
+
if proc.returncode != 0:
|
|
418
|
+
raise ValueError("base revision 无法解析: %s" % value)
|
|
419
|
+
commit = proc.stdout.strip()
|
|
420
|
+
if not re.match(r"^[0-9a-fA-F]{40,64}$", commit):
|
|
421
|
+
raise ValueError("base revision 解析结果非法")
|
|
422
|
+
return commit
|
|
423
|
+
|
|
424
|
+
|
|
269
425
|
def review_diff(diff_text=None, path=None, base=None, max_findings=200):
|
|
426
|
+
allowlist = {}
|
|
427
|
+
allowlist_errors = []
|
|
270
428
|
if diff_text is None:
|
|
271
429
|
if not path:
|
|
272
430
|
raise ValueError("review_diff 需要 diff_text 或 path")
|
|
431
|
+
allowlist, allowlist_errors = _load_review_allowlist(path)
|
|
273
432
|
command = ["git", "-C", str(path), "diff", "--no-ext-diff", "--unified=0"]
|
|
274
433
|
if base:
|
|
275
|
-
command.append(
|
|
434
|
+
command.append(_resolve_git_commit(path, base))
|
|
276
435
|
proc = subprocess.run(command, capture_output=True, text=True)
|
|
277
436
|
if proc.returncode != 0:
|
|
278
437
|
raise ValueError("git diff 失败: %s" % (proc.stderr.strip() or proc.stdout.strip()))
|
|
@@ -283,9 +442,17 @@ def review_diff(diff_text=None, path=None, base=None, max_findings=200):
|
|
|
283
442
|
if rel not in files:
|
|
284
443
|
files.append(rel)
|
|
285
444
|
findings.extend(_review_line(rel, line_no, line))
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
445
|
+
kept, suppressed = _apply_review_allowlist(findings, allowlist)
|
|
446
|
+
kept.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
|
|
447
|
+
truncated = len(kept) > max_findings
|
|
448
|
+
return {
|
|
449
|
+
"files": files,
|
|
450
|
+
"findings": kept[:max_findings],
|
|
451
|
+
"truncated": truncated,
|
|
452
|
+
"suppressed": len(suppressed),
|
|
453
|
+
"suppressed_findings": suppressed[:max_findings],
|
|
454
|
+
"allowlist_errors": allowlist_errors,
|
|
455
|
+
}
|
|
289
456
|
|
|
290
457
|
|
|
291
458
|
def _default_skill_dirs():
|
|
@@ -307,6 +474,9 @@ def mcp_doctor(skills_dirs=None, config_paths=None, include_defaults=None):
|
|
|
307
474
|
SECRET_KEY_RE = re.compile(
|
|
308
475
|
r"""(?i)\b(api[_-]?key|access[_-]?key|secret|token|password|passwd|pwd)\b\s*[:=]\s*["']?([^"'\s#]{8,})"""
|
|
309
476
|
)
|
|
477
|
+
WEAK_PASSWORD_RE = re.compile(
|
|
478
|
+
r"""(?i)\b(password|passwd|pwd)\b\s*[:=]\s*["']?([^"'\s#]{6,7})"""
|
|
479
|
+
)
|
|
310
480
|
AWS_KEY_RE = re.compile(r"\bAKIA[0-9A-Z]{16}\b")
|
|
311
481
|
PRIVATE_KEY_RE = re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")
|
|
312
482
|
HIGH_ENTROPY_RE = re.compile(r"[A-Za-z0-9_+/=\-]{32,}")
|
|
@@ -381,6 +551,7 @@ def scan_secrets(path=None, text=None, max_findings=200, include_git_history=Fal
|
|
|
381
551
|
findings = []
|
|
382
552
|
for rel, content in entries:
|
|
383
553
|
for line_no, line in enumerate(content.splitlines(), 1):
|
|
554
|
+
specific_spans = []
|
|
384
555
|
if PRIVATE_KEY_RE.search(line):
|
|
385
556
|
findings.append({
|
|
386
557
|
"path": rel, "line": line_no, "rule": "private-key",
|
|
@@ -393,6 +564,7 @@ def scan_secrets(path=None, text=None, max_findings=200, include_git_history=Fal
|
|
|
393
564
|
"severity": "critical", "evidence": _redact(match.group(0)),
|
|
394
565
|
"suggestion": "Rotate the key and move it to a secret manager.",
|
|
395
566
|
})
|
|
567
|
+
specific_spans.append(match.span(0))
|
|
396
568
|
for match in SECRET_KEY_RE.finditer(line):
|
|
397
569
|
name = match.group(1).lower()
|
|
398
570
|
rule = "api-key" if "api" in name or "access" in name else (
|
|
@@ -403,12 +575,25 @@ def scan_secrets(path=None, text=None, max_findings=200, include_git_history=Fal
|
|
|
403
575
|
"severity": "high", "evidence": "%s=%s" % (match.group(1), _redact(match.group(2))),
|
|
404
576
|
"suggestion": "Remove the literal credential and load it from the environment or a secret store.",
|
|
405
577
|
})
|
|
578
|
+
specific_spans.append(match.span(2))
|
|
579
|
+
for match in WEAK_PASSWORD_RE.finditer(line):
|
|
580
|
+
findings.append({
|
|
581
|
+
"path": rel, "line": line_no, "rule": "weak-password",
|
|
582
|
+
"severity": "medium",
|
|
583
|
+
"evidence": "%s=%s" % (match.group(1), _redact(match.group(2))),
|
|
584
|
+
"suggestion": "Use a longer password and load it from the environment or a secret store.",
|
|
585
|
+
})
|
|
586
|
+
specific_spans.append(match.span(2))
|
|
406
587
|
for match in HIGH_ENTROPY_RE.finditer(line):
|
|
407
588
|
token = match.group(0)
|
|
408
589
|
if (
|
|
409
590
|
_entropy(token) >= 4.0
|
|
410
591
|
and not PRIVATE_KEY_RE.search(line)
|
|
411
592
|
and not _high_entropy_noise(line, token, match.start(), match.end())
|
|
593
|
+
and not any(
|
|
594
|
+
start < match.end() and match.start() < end
|
|
595
|
+
for start, end in specific_spans
|
|
596
|
+
)
|
|
412
597
|
):
|
|
413
598
|
findings.append({
|
|
414
599
|
"path": rel, "line": line_no, "rule": "high-entropy-token",
|
|
@@ -756,15 +941,80 @@ def scaffold_skill(name, output_dir, apply=False, description=None):
|
|
|
756
941
|
}
|
|
757
942
|
|
|
758
943
|
|
|
944
|
+
def _write_temp_text(path, content):
|
|
945
|
+
descriptor, name = tempfile.mkstemp(
|
|
946
|
+
prefix="." + path.name + ".", suffix=".tmp", dir=str(path.parent),
|
|
947
|
+
)
|
|
948
|
+
try:
|
|
949
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
950
|
+
handle.write(content)
|
|
951
|
+
except BaseException:
|
|
952
|
+
try:
|
|
953
|
+
os.unlink(name)
|
|
954
|
+
except OSError:
|
|
955
|
+
pass
|
|
956
|
+
raise
|
|
957
|
+
return Path(name)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _write_temp_bytes(path, data):
|
|
961
|
+
descriptor, name = tempfile.mkstemp(
|
|
962
|
+
prefix="." + path.name + ".", suffix=".tmp", dir=str(path.parent),
|
|
963
|
+
)
|
|
964
|
+
try:
|
|
965
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
966
|
+
handle.write(data)
|
|
967
|
+
except BaseException:
|
|
968
|
+
try:
|
|
969
|
+
os.unlink(name)
|
|
970
|
+
except OSError:
|
|
971
|
+
pass
|
|
972
|
+
raise
|
|
973
|
+
return Path(name)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _preserve_mode(source, target):
|
|
977
|
+
try:
|
|
978
|
+
os.chmod(str(target), os.stat(str(source)).st_mode & 0o777)
|
|
979
|
+
except OSError:
|
|
980
|
+
pass
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _is_link_like(path):
|
|
984
|
+
try:
|
|
985
|
+
info = os.lstat(str(path))
|
|
986
|
+
except OSError:
|
|
987
|
+
return False
|
|
988
|
+
if stat.S_ISLNK(info.st_mode):
|
|
989
|
+
return True
|
|
990
|
+
attributes = getattr(info, "st_file_attributes", 0)
|
|
991
|
+
reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
992
|
+
return bool(attributes & reparse_point)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _assert_no_symlink_path(path, root):
|
|
996
|
+
root = Path(root)
|
|
997
|
+
current = Path(path)
|
|
998
|
+
while True:
|
|
999
|
+
if _is_link_like(current):
|
|
1000
|
+
raise ValueError("拒绝通过符号链接写入: %s" % current)
|
|
1001
|
+
if current == root or current.parent == current:
|
|
1002
|
+
break
|
|
1003
|
+
current = current.parent
|
|
1004
|
+
|
|
1005
|
+
|
|
759
1006
|
def _atomic_write(path, content):
|
|
760
1007
|
path = Path(path)
|
|
761
1008
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
762
1009
|
backup = None
|
|
763
1010
|
if path.exists():
|
|
764
1011
|
backup = path.with_suffix(path.suffix + ".bak")
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1012
|
+
temporary_backup = _write_temp_bytes(backup, path.read_bytes())
|
|
1013
|
+
_preserve_mode(path, temporary_backup)
|
|
1014
|
+
os.replace(str(temporary_backup), str(backup))
|
|
1015
|
+
temporary = _write_temp_text(path, content)
|
|
1016
|
+
if path.exists():
|
|
1017
|
+
_preserve_mode(path, temporary)
|
|
768
1018
|
os.replace(str(temporary), str(path))
|
|
769
1019
|
return str(backup) if backup else None
|
|
770
1020
|
|
|
@@ -803,6 +1053,7 @@ def workflow_state(root, action="read", date=None, text=None, file=None, apply=F
|
|
|
803
1053
|
body = (text or "").rstrip() + "\n"
|
|
804
1054
|
preview = body
|
|
805
1055
|
if apply:
|
|
1056
|
+
_assert_no_symlink_path(target, Path(root))
|
|
806
1057
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
807
1058
|
if target.exists():
|
|
808
1059
|
existing = _read_text(target)
|
package/scripts/dev_rules.py
CHANGED
|
@@ -12,7 +12,7 @@ import re
|
|
|
12
12
|
REVIEW_RULES = [
|
|
13
13
|
("bare-except", "medium", re.compile(r"^\s*except\s*:"),
|
|
14
14
|
"Catch a specific exception instead of a bare except."),
|
|
15
|
-
("eval-exec", "high", re.compile(r"\
|
|
15
|
+
("eval-exec", "high", re.compile(r"(?<![\w.$])(?:eval|exec)\s*\("),
|
|
16
16
|
"Avoid dynamic evaluation; use explicit parsing or dispatch."),
|
|
17
17
|
("shell-true", "high", re.compile(r"\bshell\s*=\s*True\b"),
|
|
18
18
|
"Do not invoke a shell with untrusted input; pass an argument list."),
|
package/scripts/dev_selftest.py
CHANGED
|
@@ -284,6 +284,33 @@ def _self_test_version_check(root, mode):
|
|
|
284
284
|
}], "align package, SKILL, CHANGELOG, server and engine versions"
|
|
285
285
|
return "PASS", [], None
|
|
286
286
|
|
|
287
|
+
def _self_test_skill_version_body(root):
|
|
288
|
+
skill = root / "SKILL.md"
|
|
289
|
+
if not skill.is_file():
|
|
290
|
+
return "UNKNOWN", [{
|
|
291
|
+
"code": "skill-missing", "path": "SKILL.md",
|
|
292
|
+
"detail": "SKILL.md is missing",
|
|
293
|
+
}], "restore SKILL.md"
|
|
294
|
+
text = _read_text(skill)
|
|
295
|
+
version = _frontmatter_version(text)
|
|
296
|
+
if not version:
|
|
297
|
+
return "FAIL", [{
|
|
298
|
+
"code": "missing-version", "path": "SKILL.md",
|
|
299
|
+
"detail": "SKILL.md has no version field",
|
|
300
|
+
}], "restore a valid SKILL.md version"
|
|
301
|
+
section = re.search(r"(?ms)^##\s+当前版本\s*$(.*?)(?=^##\s+|\Z)", text)
|
|
302
|
+
if not section:
|
|
303
|
+
return "UNKNOWN", [{
|
|
304
|
+
"code": "version-section-missing", "path": "SKILL.md",
|
|
305
|
+
"detail": "SKILL.md has no 当前版本 section",
|
|
306
|
+
}], "add a 当前版本 section"
|
|
307
|
+
if not re.search(r"(?m)^\s*-\s*v?%s\b" % re.escape(version), section.group(1)):
|
|
308
|
+
return "FAIL", [{
|
|
309
|
+
"code": "version-section-drift", "path": "SKILL.md",
|
|
310
|
+
"detail": "current version %s is not listed in 当前版本" % version,
|
|
311
|
+
}], "add the current version to 当前版本"
|
|
312
|
+
return "PASS", [], None
|
|
313
|
+
|
|
287
314
|
def _self_test_tool_contracts(root):
|
|
288
315
|
protocol_path = root / "scripts" / "yotta_dev_mcp.py"
|
|
289
316
|
engine_path = root / "scripts" / "dev_engine.py"
|
|
@@ -420,6 +447,15 @@ def self_test(path, mode="auto", allow_execute=False, timeout=120):
|
|
|
420
447
|
checks = [files_check, version_check]
|
|
421
448
|
unverified = []
|
|
422
449
|
if mode == "source":
|
|
450
|
+
body_status, body_evidence, body_next = _self_test_skill_version_body(root)
|
|
451
|
+
checks.append({
|
|
452
|
+
"id": "skill-version-body",
|
|
453
|
+
"status": body_status,
|
|
454
|
+
"severity": "high",
|
|
455
|
+
"claim": "SKILL.md current-version section lists the frontmatter version",
|
|
456
|
+
"evidence": body_evidence,
|
|
457
|
+
"next_step": body_next,
|
|
458
|
+
})
|
|
423
459
|
tool_status, tool_evidence, tool_next = _self_test_tool_contracts(root)
|
|
424
460
|
checks.append({
|
|
425
461
|
"id": "tool-contracts",
|
package/scripts/yotta_dev_mcp.py
CHANGED
|
@@ -660,6 +660,11 @@ def handle_message(msg, tool_profile=None):
|
|
|
660
660
|
"protocolVersion": MCP_PROTOCOL_LEGACY,
|
|
661
661
|
"capabilities": {"tools": {}},
|
|
662
662
|
"serverInfo": SERVER_INFO,
|
|
663
|
+
"instructions": (
|
|
664
|
+
"yotta-dev-mcp exposes deterministic local development tools. "
|
|
665
|
+
"Current tool profile: %s (%d tools). Use --tools full to expose all tools."
|
|
666
|
+
% (normalize_tool_profile(tool_profile), len(mcp_tools(tool_profile)))
|
|
667
|
+
),
|
|
663
668
|
},
|
|
664
669
|
}
|
|
665
670
|
if method == "ping":
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/YottaMeta/yotta-dev-mcp",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.2.
|
|
9
|
+
"version": "0.2.5",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "@yottameta/yotta-dev-mcp",
|
|
14
|
-
"version": "0.2.
|
|
14
|
+
"version": "0.2.5",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|