@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.
@@ -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` 共用忽略规则:新增 `.workflow` / `.codex` /
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.3
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` | 文件 / 版本 / 工具契约 / 写闸门 / seeded defect 与 mutation 反证自测 | 仅显式 allow_execute 的测试子集 |
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`,可选显式追加日志 | 仅显式 apply |
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",
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",
@@ -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`, protocol tool names
128
- and `inputSchema.additionalProperties=false`, fail-closed defaults for every write or
129
- execute gate, and counterexamples. Installed mode checks `SKILL.md` and the installed
130
- asset payload, and records source-only checks as out of scope.
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 are atomic and keep a `.bak` of an existing file.
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,
@@ -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 d not in IGNORE_DIRS)
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
@@ -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.3"
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
- escaped = re.escape(query)
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 pattern.search(line_text):
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
- findings.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
245
- truncated = len(findings) > max_findings
246
- return {"findings": findings[:max_findings], "truncated": truncated}
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(str(base))
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
- findings.sort(key=lambda item: (item["path"], item["line"], item["rule"]))
287
- truncated = len(findings) > max_findings
288
- return {"files": files, "findings": findings[:max_findings], "truncated": truncated}
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
- shutil.copy2(str(path), str(backup))
766
- temporary = path.with_suffix(path.suffix + ".tmp")
767
- temporary.write_text(content, encoding="utf-8")
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)
@@ -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"\b(?:eval|exec)\s*\("),
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."),
@@ -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",
@@ -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.3",
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.3",
14
+ "version": "0.2.5",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  }