release-skill 0.2.6 → 0.2.8

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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codebuddy-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +2 -2
  5. package/.kimi-plugin/plugin.json +1 -1
  6. package/CHANGELOG.md +42 -0
  7. package/INSTALL.md +12 -12
  8. package/INSTALL.zh-CN.md +10 -10
  9. package/README.md +26 -11
  10. package/README.zh-CN.md +22 -11
  11. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  12. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  13. package/adapters/claude/bin/release-skill.bundle.mjs +2619 -1728
  14. package/adapters/claude/schemas/.render-manifest.json +2 -2
  15. package/adapters/claude/schemas/release-project.schema.json +60 -0
  16. package/adapters/claude/skills/release-prepare/SKILL.md +5 -0
  17. package/adapters/claude/skills/release-setup/SKILL.md +10 -1
  18. package/adapters/claude/skills/release-verify/SKILL.md +4 -2
  19. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  20. package/adapters/codex/bin/release-skill.bundle.mjs +2619 -1728
  21. package/adapters/codex/schemas/.render-manifest.json +2 -2
  22. package/adapters/codex/schemas/release-project.schema.json +60 -0
  23. package/adapters/codex/skills/release-prepare/SKILL.md +5 -0
  24. package/adapters/codex/skills/release-setup/SKILL.md +10 -1
  25. package/adapters/codex/skills/release-verify/SKILL.md +4 -2
  26. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  27. package/adapters/kimi/bin/release-skill.bundle.mjs +2619 -1728
  28. package/adapters/kimi/schemas/.render-manifest.json +2 -2
  29. package/adapters/kimi/schemas/release-project.schema.json +60 -0
  30. package/adapters/kimi/skills/release-prepare/SKILL.md +5 -0
  31. package/adapters/kimi/skills/release-setup/SKILL.md +10 -1
  32. package/adapters/kimi/skills/release-verify/SKILL.md +4 -2
  33. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  34. package/adapters/workbuddy/bin/release-skill.bundle.mjs +2619 -1728
  35. package/adapters/workbuddy/schemas/.render-manifest.json +2 -2
  36. package/adapters/workbuddy/schemas/release-project.schema.json +60 -0
  37. package/adapters/workbuddy/skills/release-prepare/SKILL.md +5 -0
  38. package/adapters/workbuddy/skills/release-setup/SKILL.md +10 -1
  39. package/adapters/workbuddy/skills/release-verify/SKILL.md +4 -2
  40. package/bin/release-skill.bundle.mjs +2619 -1728
  41. package/package.json +1 -1
  42. package/schemas/.render-manifest.json +2 -2
  43. package/schemas/release-project.schema.json +60 -0
  44. package/skills/release-prepare/SKILL.md +5 -0
  45. package/skills/release-setup/SKILL.md +10 -1
  46. package/skills/release-verify/SKILL.md +4 -2
  47. package/skills-src/release-prepare/SKILL.md +5 -0
  48. package/skills-src/release-setup/SKILL.md +10 -1
  49. package/skills-src/release-verify/SKILL.md +4 -2
  50. package/src/adapters/npm.mjs +54 -2
  51. package/src/adapters/plugin-marketplace.mjs +4 -29
  52. package/src/commands/prepare.mjs +49 -3
  53. package/src/commands/publish.mjs +2 -2
  54. package/src/commands/reconcile.mjs +39 -0
  55. package/src/commands/setup.mjs +168 -0
  56. package/src/commands/verify.mjs +28 -1
  57. package/src/core/config.mjs +48 -0
  58. package/src/core/public-surface.mjs +509 -0
  59. package/src/npm/npm-entry-closure.mjs +195 -0
  60. package/src/platforms/codebuddy.mjs +19 -11
  61. package/src/platforms/codex.mjs +18 -10
  62. package/src/platforms/kimi.mjs +26 -39
  63. package/src/snapshot/frozen.mjs +51 -19
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "release-skill",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -26,8 +26,8 @@
26
26
  "bytes": 35256
27
27
  },
28
28
  "release-project.schema.json": {
29
- "digest": "a3d46be1e1e8105103a505c6d58d14f3733c8da91ab8289e097ffd259bf1cd93",
30
- "bytes": 30982
29
+ "digest": "7ac18070ac41327a330ca35e32f466e3f2a8eb2b7df3a5755568e00f991ffdc1",
30
+ "bytes": 32497
31
31
  },
32
32
  "release-run.schema.json": {
33
33
  "digest": "bf3c8fb4ca2fc078272a45a6ee6bf663fd2420a722b2f995dbaabf2635e2179e",
@@ -455,6 +455,9 @@
455
455
  ]
456
456
  }
457
457
  },
458
+ "expectedPublicSurface": {
459
+ "$ref": "#/definitions/expectedPublicSurface"
460
+ },
458
461
  "publicFiles": {
459
462
  "type": "array",
460
463
  "minItems": 1,
@@ -761,6 +764,63 @@
761
764
  }
762
765
  },
763
766
  "definitions": {
767
+ "expectedPublicSurface": {
768
+ "type": "object",
769
+ "additionalProperties": false,
770
+ "required": [
771
+ "scanRoots"
772
+ ],
773
+ "properties": {
774
+ "scanRoots": {
775
+ "type": "array",
776
+ "minItems": 1,
777
+ "items": {
778
+ "$ref": "#/definitions/publicSurfaceScanRoot"
779
+ }
780
+ }
781
+ }
782
+ },
783
+ "publicSurfaceScanRoot": {
784
+ "type": "object",
785
+ "additionalProperties": false,
786
+ "required": [
787
+ "include",
788
+ "exclude"
789
+ ],
790
+ "properties": {
791
+ "sourceScope": {
792
+ "type": "string",
793
+ "enum": [
794
+ "unit",
795
+ "workspace"
796
+ ],
797
+ "default": "unit"
798
+ },
799
+ "root": {
800
+ "type": "string",
801
+ "minLength": 1,
802
+ "default": ".",
803
+ "pattern": "^(?:\\.$|(?!\\/)(?!\\.\\/)(?!.*\\\\)(?!.*:)(?!\\.\\.(?:\\/|$))(?!.*\\/\\.\\.\\/)(?!.*\\/\\.\\.$)(?!.*\\/\\.\\/)(?!.*\\/\\/)(?!.*\\0)(?!.*\\/\\.$)(?!.*\\/\\/$)(?!.*\\/$).*)$"
804
+ },
805
+ "include": {
806
+ "type": "array",
807
+ "minItems": 1,
808
+ "uniqueItems": true,
809
+ "items": {
810
+ "type": "string",
811
+ "minLength": 1
812
+ }
813
+ },
814
+ "exclude": {
815
+ "type": "array",
816
+ "uniqueItems": true,
817
+ "items": {
818
+ "type": "string",
819
+ "minLength": 1
820
+ }
821
+ }
822
+ }
823
+ },
764
824
  "hook": {
765
825
  "type": "object",
766
826
  "required": [
@@ -36,6 +36,10 @@ description: Freeze an immutable release plan with local configuration, document
36
36
 
37
37
  若用户明确要求 GitHub+npm 生产发布,加入 `--production`。该模式还会封存独立
38
38
  Git commit/tree 和 npm tarball,并把路径、SHA/integrity、branch/tag 写入计划。
39
+ 每个 npm tarball 在计划落盘前必须静态验证 `package.json` 的具体
40
+ `bin`/`main`/`module`/`types`/`typings`/`exports` 入口均为 tarball 内普通文件;
41
+ 该门禁不依赖项目是否配置 `requiredPublicFiles` 或 `smokeBin`。通配符 exports 不做
42
+ 猜测展开;它与 fallback array 都属于首版最小边界外的阻断形态。
39
43
  每个 release unit 必须显式配置 `previousPublicBaseline`。只有确认不存在前序公开
40
44
  版本时用 `mode: none`;已有版本必须用 `mode: bound` + 精确 repo/ref/commit,并以
41
45
  `--online --production` 逐 unit 观察 ref→commit mapping。默认 observer 不下载远端
@@ -79,6 +83,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" prepare --root <path> --offli
79
83
  | GATE_FAILED (gate 授权) | 向用户展示 snapshot gate 命令和风险,获得授权后加 `--acknowledge-gate-side-effects` 重试 |
80
84
  | GATE_FAILED (bound + offline) | 改用 `--online --production`,不得把 unobserved-offline plan 交给 publish |
81
85
  | GATE_FAILED (前序基线漂移) | 先取得并比较实际远端内容;人工选择 merge/adopt/reject。merge/adopt 都必须把接受内容落回 human-owned 权威源,并把 `previousPublicBaseline` 更新为接受状态的精确 repo/ref/commit 后重新 online production prepare;reject 停止调查,禁止改 `mode: none` 绕过 |
86
+ | GATE_FAILED (`npm-entry-closure`) | 修复打包内容或入口声明后重新 prepare;不得用 `requiredPublicFiles`/`smokeBin` 缺省绕过 |
82
87
  | GATE_FAILED (其他) | 修复门失败原因后重试;以 CLI exit code 为准 |
83
88
  | RELEASE_DOCS_STALE | 文档相对说明源已陈旧;按详情运行只读演练,展示文件/语种/版本/摘要,经用户授权“本地发布文档写入”后执行写入,审阅提交再重新 prepare |
84
89
  | RELEASE_DOCS_INVALID / TRANSLATION_MISSING / CONFLICT / REFRESH_STALE | 修复配置/说明源/目标或重新演练取得新 `refreshDigest`;不得扩大写入范围绕过 |
@@ -14,6 +14,9 @@ description: "首次接入 release-skill:只读发现发布单元与个性化
14
14
  - 默认只读。README、slogan、CHANGELOG、业务脚本和已有配置均为人工权威内容,不得重写或覆盖。
15
15
  - 发现脚本不等于选择或授权。间接脚本以 `SIDE_EFFECTS_UNPROVEN` 排除;项目特有 hook/gate 只能人工增量注册。
16
16
  - 仓库、公开文件等证据冲突时停止自动提案,交给人工修正权威事实后重新发现。
17
+ - npm 单元只读提取 `package.json` 的具体入口和旧 `npmRequiredPackagePaths`,报告
18
+ tracked/untracked/ignored/missing/non-regular 状态与覆盖漂移;这些是人工复核候选,
19
+ 不得自动写入 `publicFiles` 或 `requiredPublicFiles`。
17
20
  - 写入只允许 create-once(仅创建一次),必须同时提供 answers 与精确 `setupDigest`;无安全原生写入能力时失败关闭。
18
21
  - Agent 的多次 shell 调用彼此独立;只能用首轮打印的会话目录绝对路径续接,不得假设变量仍存在。
19
22
 
@@ -43,7 +46,13 @@ node -e 'const fs=require("node:fs");const r=JSON.parse(fs.readFileSync(process.
43
46
  node -e 'const fs=require("node:fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if((r.proposalConflicts??[]).length){console.error("proposal conflicts require human resolution");process.exit(2)}if(!r.recommendedAnswers){console.error("recommendedAnswers missing");process.exit(2)}fs.writeFileSync(process.argv[2],JSON.stringify(r.recommendedAnswers,null,2)+"\n",{flag:"wx",mode:0o600})' "$REPORT" "$ANSWERS"
44
47
  ```
45
48
 
46
- 若 `proposalConflicts` 非空,暂停,让用户修正仓库/映射权威事实,删除本会话目录后从第 2 步重跑,不得猜测选边。无冲突时可人工增量编辑 `answers.json`:hook 写入 `recommendedAnswers.projectConfig.hooks` 对应的 `projectConfig.hooks`;gate 写入 `verificationGates`,并把同一 id 加入 `selectedGateIds`。人工文件保持 `mode: preserve`,跨单元共享源使用 `sourceScope: workspace`。
49
+ 若 `proposalConflicts` 非空,暂停,让用户修正仓库/映射权威事实或 npm 入口候选,
50
+ 删除本会话目录后从第 2 步重跑,不得猜测选边。构建后才产生且被忽略的入口应先由
51
+ 人工确认构建流程与最终 tarball 边界,不能因为 setup 发现了路径就自动信任或纳入公开
52
+ 配置。无冲突时可人工增量编辑 `answers.json`:hook 写入
53
+ `recommendedAnswers.projectConfig.hooks` 对应的 `projectConfig.hooks`;gate 写入
54
+ `verificationGates`,并把同一 id 加入 `selectedGateIds`。人工文件保持
55
+ `mode: preserve`,跨单元共享源使用 `sourceScope: workspace`。
47
56
 
48
57
  3. 使用上一步打印的两个绝对字面量重新赋值,运行绑定 dry-run;任何人工编辑后都必须重跑本步:
49
58
 
@@ -17,7 +17,7 @@ verify 只接受 `PUBLISHED` 状态的源 run;`VERIFIED` 是终态,不会再
17
17
 
18
18
  ## 职责与边界
19
19
 
20
- 验证远端所有 action 的实际状态与冻结计划一致。执行精确 `<package>@<version>` npm 安装到隔离目录,验证包名、版本、bin 路径安全和 CLI 烟雾输出。对每个声明的 marketplace distribution 执行全新隔离消费者安装验证。
20
+ 验证远端所有 action 的实际状态与冻结计划一致。执行精确 `<package>@<version>` npm 安装到隔离目录,验证包名、版本、静态入口闭包,并在配置时验证 bin 路径安全和 CLI 烟雾输出。对每个声明的 marketplace distribution 执行全新隔离消费者安装验证。
21
21
 
22
22
  **阶段通过规则**: 只有 CLI exit code 0 和结构化状态码 `VERIFIED` 才是完整终态。
23
23
 
@@ -55,8 +55,10 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" verify --root <path> --plan <
55
55
  - 在 `os.tmpdir()` 创建隔离目录
56
56
  - 执行 `npm install <package>@<version>` 带安全标志
57
57
  - 验证安装的 `package.json` name 和 version 精确匹配
58
+ - 无条件静态验证已安装包中具体 `bin`/`main`/`module`/`types`/`typings`/`exports`
59
+ 目标是普通文件;失败时不得写入 `VERIFIED`
58
60
  - 若配置了 `smokeBin`:验证 bin 路径安全(无逃逸、无 symlink),从精确安装根、隔离 HOME 和最小环境执行并验证输出;这会运行已安装代码,必须显式授权
59
- - 若未配置 `smokeBin`:仅安装 + name/version 检查即通过
61
+ - 若未配置 `smokeBin`:跳过运行代码,但静态入口闭包检查仍然强制执行
60
62
 
61
63
  ## 常见错误
62
64
 
@@ -36,6 +36,10 @@ description: Freeze an immutable release plan with local configuration, document
36
36
 
37
37
  若用户明确要求 GitHub+npm 生产发布,加入 `--production`。该模式还会封存独立
38
38
  Git commit/tree 和 npm tarball,并把路径、SHA/integrity、branch/tag 写入计划。
39
+ 每个 npm tarball 在计划落盘前必须静态验证 `package.json` 的具体
40
+ `bin`/`main`/`module`/`types`/`typings`/`exports` 入口均为 tarball 内普通文件;
41
+ 该门禁不依赖项目是否配置 `requiredPublicFiles` 或 `smokeBin`。通配符 exports 不做
42
+ 猜测展开;它与 fallback array 都属于首版最小边界外的阻断形态。
39
43
  每个 release unit 必须显式配置 `previousPublicBaseline`。只有确认不存在前序公开
40
44
  版本时用 `mode: none`;已有版本必须用 `mode: bound` + 精确 repo/ref/commit,并以
41
45
  `--online --production` 逐 unit 观察 ref→commit mapping。默认 observer 不下载远端
@@ -79,6 +83,7 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" prepare --root <path> --offli
79
83
  | GATE_FAILED (gate 授权) | 向用户展示 snapshot gate 命令和风险,获得授权后加 `--acknowledge-gate-side-effects` 重试 |
80
84
  | GATE_FAILED (bound + offline) | 改用 `--online --production`,不得把 unobserved-offline plan 交给 publish |
81
85
  | GATE_FAILED (前序基线漂移) | 先取得并比较实际远端内容;人工选择 merge/adopt/reject。merge/adopt 都必须把接受内容落回 human-owned 权威源,并把 `previousPublicBaseline` 更新为接受状态的精确 repo/ref/commit 后重新 online production prepare;reject 停止调查,禁止改 `mode: none` 绕过 |
86
+ | GATE_FAILED (`npm-entry-closure`) | 修复打包内容或入口声明后重新 prepare;不得用 `requiredPublicFiles`/`smokeBin` 缺省绕过 |
82
87
  | GATE_FAILED (其他) | 修复门失败原因后重试;以 CLI exit code 为准 |
83
88
  | RELEASE_DOCS_STALE | 文档相对说明源已陈旧;按详情运行只读演练,展示文件/语种/版本/摘要,经用户授权“本地发布文档写入”后执行写入,审阅提交再重新 prepare |
84
89
  | RELEASE_DOCS_INVALID / TRANSLATION_MISSING / CONFLICT / REFRESH_STALE | 修复配置/说明源/目标或重新演练取得新 `refreshDigest`;不得扩大写入范围绕过 |
@@ -14,6 +14,9 @@ description: "首次接入 release-skill:只读发现发布单元与个性化
14
14
  - 默认只读。README、slogan、CHANGELOG、业务脚本和已有配置均为人工权威内容,不得重写或覆盖。
15
15
  - 发现脚本不等于选择或授权。间接脚本以 `SIDE_EFFECTS_UNPROVEN` 排除;项目特有 hook/gate 只能人工增量注册。
16
16
  - 仓库、公开文件等证据冲突时停止自动提案,交给人工修正权威事实后重新发现。
17
+ - npm 单元只读提取 `package.json` 的具体入口和旧 `npmRequiredPackagePaths`,报告
18
+ tracked/untracked/ignored/missing/non-regular 状态与覆盖漂移;这些是人工复核候选,
19
+ 不得自动写入 `publicFiles` 或 `requiredPublicFiles`。
17
20
  - 写入只允许 create-once(仅创建一次),必须同时提供 answers 与精确 `setupDigest`;无安全原生写入能力时失败关闭。
18
21
  - Agent 的多次 shell 调用彼此独立;只能用首轮打印的会话目录绝对路径续接,不得假设变量仍存在。
19
22
 
@@ -43,7 +46,13 @@ node -e 'const fs=require("node:fs");const r=JSON.parse(fs.readFileSync(process.
43
46
  node -e 'const fs=require("node:fs");const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));if((r.proposalConflicts??[]).length){console.error("proposal conflicts require human resolution");process.exit(2)}if(!r.recommendedAnswers){console.error("recommendedAnswers missing");process.exit(2)}fs.writeFileSync(process.argv[2],JSON.stringify(r.recommendedAnswers,null,2)+"\n",{flag:"wx",mode:0o600})' "$REPORT" "$ANSWERS"
44
47
  ```
45
48
 
46
- 若 `proposalConflicts` 非空,暂停,让用户修正仓库/映射权威事实,删除本会话目录后从第 2 步重跑,不得猜测选边。无冲突时可人工增量编辑 `answers.json`:hook 写入 `recommendedAnswers.projectConfig.hooks` 对应的 `projectConfig.hooks`;gate 写入 `verificationGates`,并把同一 id 加入 `selectedGateIds`。人工文件保持 `mode: preserve`,跨单元共享源使用 `sourceScope: workspace`。
49
+ 若 `proposalConflicts` 非空,暂停,让用户修正仓库/映射权威事实或 npm 入口候选,
50
+ 删除本会话目录后从第 2 步重跑,不得猜测选边。构建后才产生且被忽略的入口应先由
51
+ 人工确认构建流程与最终 tarball 边界,不能因为 setup 发现了路径就自动信任或纳入公开
52
+ 配置。无冲突时可人工增量编辑 `answers.json`:hook 写入
53
+ `recommendedAnswers.projectConfig.hooks` 对应的 `projectConfig.hooks`;gate 写入
54
+ `verificationGates`,并把同一 id 加入 `selectedGateIds`。人工文件保持
55
+ `mode: preserve`,跨单元共享源使用 `sourceScope: workspace`。
47
56
 
48
57
  3. 使用上一步打印的两个绝对字面量重新赋值,运行绑定 dry-run;任何人工编辑后都必须重跑本步:
49
58
 
@@ -17,7 +17,7 @@ verify 只接受 `PUBLISHED` 状态的源 run;`VERIFIED` 是终态,不会再
17
17
 
18
18
  ## 职责与边界
19
19
 
20
- 验证远端所有 action 的实际状态与冻结计划一致。执行精确 `<package>@<version>` npm 安装到隔离目录,验证包名、版本、bin 路径安全和 CLI 烟雾输出。对每个声明的 marketplace distribution 执行全新隔离消费者安装验证。
20
+ 验证远端所有 action 的实际状态与冻结计划一致。执行精确 `<package>@<version>` npm 安装到隔离目录,验证包名、版本、静态入口闭包,并在配置时验证 bin 路径安全和 CLI 烟雾输出。对每个声明的 marketplace distribution 执行全新隔离消费者安装验证。
21
21
 
22
22
  **阶段通过规则**: 只有 CLI exit code 0 和结构化状态码 `VERIFIED` 才是完整终态。
23
23
 
@@ -55,8 +55,10 @@ node "${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs" verify --root <path> --plan <
55
55
  - 在 `os.tmpdir()` 创建隔离目录
56
56
  - 执行 `npm install <package>@<version>` 带安全标志
57
57
  - 验证安装的 `package.json` name 和 version 精确匹配
58
+ - 无条件静态验证已安装包中具体 `bin`/`main`/`module`/`types`/`typings`/`exports`
59
+ 目标是普通文件;失败时不得写入 `VERIFIED`
58
60
  - 若配置了 `smokeBin`:验证 bin 路径安全(无逃逸、无 symlink),从精确安装根、隔离 HOME 和最小环境执行并验证输出;这会运行已安装代码,必须显式授权
59
- - 若未配置 `smokeBin`:仅安装 + name/version 检查即通过
61
+ - 若未配置 `smokeBin`:跳过运行代码,但静态入口闭包检查仍然强制执行
60
62
 
61
63
  ## 常见错误
62
64
 
@@ -16,6 +16,11 @@ import {
16
16
  createResult,
17
17
  matchObservation,
18
18
  } from './contract.mjs';
19
+ import {
20
+ checkNpmEntryClosure,
21
+ buildTarballFileIndex,
22
+ } from '../npm/npm-entry-closure.mjs';
23
+ import { ReleaseError, GATE_FAILED } from '../core/errors.mjs';
19
24
 
20
25
  const execFile = promisify(execFileCb);
21
26
  const NAME = 'npm';
@@ -349,6 +354,53 @@ export async function verifyFrozenNpmTarballIdentity(action, root) {
349
354
  });
350
355
  }
351
356
 
357
+ async function verifyNpmTarballBufferContract(buffer, action, tarballDir) {
358
+ const manifest = extractManifestFromTarball(buffer, {
359
+ name: action.package,
360
+ version: action.version,
361
+ });
362
+ const fileIndex = await buildTarballFileIndex(buffer, tarballDir);
363
+ const closureResult = checkNpmEntryClosure(manifest, fileIndex);
364
+ if (closureResult.errors.length > 0) {
365
+ throw new ReleaseError(
366
+ GATE_FAILED,
367
+ `npm entry closure check failed for ${action.package}@${action.version}: ${closureResult.errors.length} error(s)`,
368
+ {
369
+ gate: 'npm-entry-closure',
370
+ package: action.package,
371
+ version: action.version,
372
+ entries: closureResult.entries,
373
+ errors: closureResult.errors,
374
+ diagnostics: closureResult.diagnostics,
375
+ },
376
+ );
377
+ }
378
+ return { manifest, closureResult };
379
+ }
380
+
381
+ /**
382
+ * Shared frozen tarball contract helper: reads the verified tarball bytes ONCE
383
+ * and performs both identity verification (name/version from manifest) and
384
+ * entry closure validation (bin/main/module/types/typings/exports targets
385
+ * exist as regular files inside the tarball).
386
+ *
387
+ * The same Buffer that passes `readVerifiedTarballBytes` is reused for both
388
+ * the existing identity reader and the controlled extraction/file-index
389
+ * boundary. This adds no new tar parser and performs no second source read.
390
+ *
391
+ * @param {object} action - The frozen action with tarballPath, tarballSha256,
392
+ * integrity, package, and version.
393
+ * @param {string} root - Absolute project root.
394
+ * @param {string} [tarballDir] - Temp directory for tar extraction (defaults to os.tmpdir()).
395
+ * @returns {Promise<{ manifest: object, closureResult: object }>}
396
+ * @throws {ReleaseError} GATE_FAILED with details.gate='npm-entry-closure'
397
+ * when any declared entry is missing or invalid.
398
+ */
399
+ export async function verifyFrozenNpmTarballContract(action, root, tarballDir) {
400
+ const buffer = await readVerifiedTarballBytes(action, root);
401
+ return verifyNpmTarballBufferContract(buffer, action, tarballDir);
402
+ }
403
+
352
404
  /**
353
405
  * Read and verify the frozen tarball, then write it to a controlled named
354
406
  * temp file under a temp directory adjacent to the source. Returns the temp
@@ -700,7 +752,7 @@ export function createNpmAdapter(deps = {}) {
700
752
  }
701
753
  const cwd = resolvePackageCwd(action.cwd, context.root);
702
754
  if (action.tarballPath) {
703
- await verifyFrozenNpmTarballIdentity(action, context.root);
755
+ await verifyFrozenNpmTarballContract(action, context.root);
704
756
  }
705
757
 
706
758
  let whoamiUser;
@@ -775,7 +827,7 @@ export function createNpmAdapter(deps = {}) {
775
827
  throw new Error('frozen npm tarball requires a stable Buffer publish capability');
776
828
  }
777
829
  const buffer = await readVerifiedTarballBytes(action, context.root);
778
- const manifest = extractManifestFromTarball(buffer, { name: action.package, version: action.version });
830
+ const { manifest } = await verifyNpmTarballBufferContract(buffer, action);
779
831
  let token;
780
832
  try {
781
833
  token = await resolveAuthToken({ registry: normalizedRegistry, cwd, exec, env: authEnv });
@@ -2393,7 +2393,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2393
2393
 
2394
2394
  // A non-automatable platform (kimi) has NO scriptable install CLI.
2395
2395
  // It is handled entirely by its manual-requirement strategy, which
2396
- // uses a stable plan-digest-keyed home and deliberately SKIPS the
2396
+ // uses a stable plugin-level authority directory and deliberately SKIPS the
2397
2397
  // per-run isolated consumer dir and its runDir containment check
2398
2398
  // (that model only fits structured-cli platforms, which exec a real
2399
2399
  // CLI into a per-run HOME).
@@ -2913,25 +2913,12 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2913
2913
  });
2914
2914
  }
2915
2915
 
2916
- // installPath 验证:必须在 managed root 内,不得逃逸,不得是符号链接
2916
+ // installPath 验证:必须是真实目录,不得是符号链接。
2917
+ // 安装路径不要求位于 attestationDir 内(用户使用实际全局/用户安装目录)。
2917
2918
  let verifiedInstallPath = null;
2918
2919
  if (normalizedAttestation.installPath) {
2919
- const managedRoot = resolve(attestationDir, 'kimi-home', 'plugins', 'managed');
2920
2920
  const installPathAbs = resolve(normalizedAttestation.installPath);
2921
2921
 
2922
- // 先验证 managed root 存在(KIMI_CODE_HOME 检查)
2923
- const managedRootReal = await realpath(managedRoot).catch(() => null);
2924
- if (!managedRootReal) {
2925
- return createResult({
2926
- actionType,
2927
- status: ActionStatus.OBSERVED,
2928
- observation: {
2929
- installed: false,
2930
- error: 'KIMI_CODE_HOME does not exist',
2931
- },
2932
- });
2933
- }
2934
-
2935
2922
  // 检查 installPath 词法路径是否存在(lstat 在 realpath 之前)
2936
2923
  let lexicalStat;
2937
2924
  try {
@@ -2968,18 +2955,6 @@ export function createPluginMarketplaceAdapter(deps = {}) {
2968
2955
  },
2969
2956
  });
2970
2957
  }
2971
- const rel = relative(managedRootReal, installPathReal);
2972
- const sep = process.platform === 'win32' ? '\\' : '/';
2973
- if (rel === '' || rel === '..' || isAbsolute(rel) || rel.startsWith(`..${sep}`)) {
2974
- return createResult({
2975
- actionType,
2976
- status: ActionStatus.OBSERVED,
2977
- observation: {
2978
- installed: false,
2979
- error: `kimi attestation installPath escapes the managed root: ${normalizedAttestation.installPath}`,
2980
- },
2981
- });
2982
- }
2983
2958
  verifiedInstallPath = installPathReal;
2984
2959
  }
2985
2960
 
@@ -3283,7 +3258,7 @@ export function createPluginMarketplaceAdapter(deps = {}) {
3283
3258
  try {
3284
3259
  codexRequirement = JSON.parse(await readFile(resolve(codexAttestationDir, CODEX_REQUIREMENT_FILE), 'utf8'));
3285
3260
  } catch {
3286
- // No requirement file — must not read isolated attestation
3261
+ // No requirement file — must not read the stable attestation
3287
3262
  }
3288
3263
 
3289
3264
  // Validate requirement binds to this action and plan (完整绑定验证)
@@ -29,6 +29,7 @@ import { loadProjectConfig } from '../core/config.mjs';
29
29
  import { captureBaseline } from '../core/baseline.mjs';
30
30
  import { runHook } from '../core/hooks.mjs';
31
31
  import { computeHookCacheKey, readHookCache, writeHookCache } from '../core/hook-cache.mjs';
32
+ import { assertExpectedPublicSurface } from '../core/public-surface.mjs';
32
33
  import { runSnapshotVerificationGates } from '../core/verification-gates.mjs';
33
34
  import { createEvidenceWriter } from '../core/evidence.mjs';
34
35
  import { computePlanDigest, writePlanAtomic, writePlanImmutable } from '../core/plan.mjs';
@@ -58,7 +59,7 @@ import {
58
59
  } from '../core/source-authority.mjs';
59
60
  import { acquireProjectLock } from '../artifacts/project-lock.mjs';
60
61
  import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
61
- import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
62
+ import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
62
63
  import { createProductionPrepareRunDir } from '../core/run.mjs';
63
64
  import { PLATFORMS } from '../platforms/registry.mjs';
64
65
  import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
@@ -930,13 +931,13 @@ async function buildProductionAssets(
930
931
  tarballDir: resolveUnitScopedPath(resolve(runDir, 'tarballs'), unit.id),
931
932
  expectedSnapshotDigest: sealed.digest,
932
933
  });
933
- await verifyFrozenNpmTarballIdentity({
934
+ await verifyFrozenNpmTarballContract({
934
935
  package: npmDistribution.package,
935
936
  version,
936
937
  tarballPath: relative(root, npm.tarballPath),
937
938
  tarballSha256: npm.sha256,
938
939
  integrity: npm.integrity,
939
- }, root);
940
+ }, root, resolveUnitScopedPath(resolve(runDir, 'tarballs'), unit.id));
940
941
  }
941
942
 
942
943
  assets.push({
@@ -1925,6 +1926,51 @@ export async function prepareRelease(options) {
1925
1926
  });
1926
1927
  }
1927
1928
 
1929
+ // --- Step 3b.1: Validate the post-build expected public surface ---
1930
+ // This gate runs after every declared hook (and the post-hook docs check)
1931
+ // but before source-authority closure, baseline, snapshots, remote reads,
1932
+ // and plan write. Therefore build-generated files must be explicitly
1933
+ // classified and every included source must already exist in publicFiles
1934
+ // before downstream authorities bind the release inputs.
1935
+ for (const unit of configUnits) {
1936
+ if (!unit?.expectedPublicSurface) continue;
1937
+ await evidence.append({
1938
+ phase: 'public-surface',
1939
+ status: 'started',
1940
+ unitId: unit.id,
1941
+ });
1942
+ try {
1943
+ const surface = await assertExpectedPublicSurface({
1944
+ root: realRoot,
1945
+ unit,
1946
+ });
1947
+ await evidence.append({
1948
+ phase: 'public-surface',
1949
+ status: 'completed',
1950
+ unitId: unit.id,
1951
+ ...surface.summary,
1952
+ });
1953
+ } catch (error) {
1954
+ await evidence.append({
1955
+ phase: 'public-surface',
1956
+ status: 'failed',
1957
+ unitId: unit.id,
1958
+ reason: error.details?.reason ?? 'PUBLIC_SURFACE_CHECK_FAILED',
1959
+ error: {
1960
+ code: error.code,
1961
+ message: error.message,
1962
+ },
1963
+ diagnostics: {
1964
+ missingMappings: error.details?.missingMappings ?? [],
1965
+ unexpectedMappings: error.details?.unexpectedMappings ?? [],
1966
+ unclassifiedFiles: error.details?.unclassifiedFiles ?? [],
1967
+ ambiguousFiles: error.details?.ambiguousFiles ?? [],
1968
+ },
1969
+ });
1970
+ throw error;
1971
+ }
1972
+ }
1973
+
1928
1974
  // --- Step 3c: Source authority content closure gate ---
1929
1975
  // After hooks complete, compute the deterministic source-input closure
1930
1976
  // and verify that closure inputs are clean (no staged/unstaged/untracked
@@ -77,7 +77,7 @@ import {
77
77
  verifyFrozenGitRepository,
78
78
  verifyFrozenSnapshot,
79
79
  } from '../snapshot/frozen.mjs';
80
- import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
80
+ import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
81
81
 
82
82
  function assertInsideAssetRoot(assetRoot, candidate, label) {
83
83
  const rel = relative(assetRoot, candidate);
@@ -607,7 +607,7 @@ export async function publishRelease(options) {
607
607
  if (!npmDistribution) {
608
608
  throw new ReleaseError(GATE_FAILED, `unit "${unit.id}" has a frozen npm tarball but no npm distribution`);
609
609
  }
610
- await verifyFrozenNpmTarballIdentity({
610
+ await verifyFrozenNpmTarballContract({
611
611
  package: npmDistribution.package,
612
612
  version: unit.targetVersion,
613
613
  tarballPath: frozen.npm.tarballPath,
@@ -62,6 +62,7 @@ import { assertTransition, PARTIAL, PUBLISHED, BLOCKED } from '../core/state-mac
62
62
  import { verifySourceAuthorityReceipt } from '../core/source-authority.mjs';
63
63
  import { matchObservation } from '../adapters/contract.mjs';
64
64
  import { observeWithRetry, clampPolicyToTimeout, DEFAULT_OBSERVE_RETRY_POLICY } from '../core/observe-retry.mjs';
65
+ import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
65
66
 
66
67
  // ---------------------------------------------------------------------------
67
68
  // Constants
@@ -210,6 +211,44 @@ export async function reconcileRelease(options) {
210
211
 
211
212
  await evidence.append({ phase: 'safety-gate', gate: 'action-completeness', status: 'passed' });
212
213
 
214
+ // =======================================================================
215
+ // Safety Gate 2c: Unconditional frozen npm entry-closure recheck
216
+ // This is plan-global and intentionally precedes source-run observation:
217
+ // a previously consistent npm checkpoint must not exempt a bad frozen
218
+ // tarball while another action is being reconciled.
219
+ // =======================================================================
220
+ for (const unit of plan.units ?? []) {
221
+ const frozen = unit.frozenSnapshot;
222
+ if (!frozen?.npm) continue;
223
+ const npmDistribution = (unit.distributions ?? []).find((distribution) => distribution.type === 'npm');
224
+ if (!npmDistribution) {
225
+ throw new ReleaseError(
226
+ GATE_FAILED,
227
+ `unit "${unit.id}" has a frozen npm tarball but no npm distribution`,
228
+ { gate: 'npm-entry-closure', unitId: unit.id },
229
+ );
230
+ }
231
+ await evidence.append({
232
+ phase: 'safety-gate',
233
+ gate: 'npm-entry-closure',
234
+ unitId: unit.id,
235
+ status: 'started',
236
+ });
237
+ await verifyFrozenNpmTarballContract({
238
+ package: npmDistribution.package,
239
+ version: unit.targetVersion,
240
+ tarballPath: frozen.npm.tarballPath,
241
+ tarballSha256: frozen.npm.tarballSha256,
242
+ integrity: frozen.npm.integrity,
243
+ }, root);
244
+ await evidence.append({
245
+ phase: 'safety-gate',
246
+ gate: 'npm-entry-closure',
247
+ unitId: unit.id,
248
+ status: 'passed',
249
+ });
250
+ }
251
+
213
252
  // =======================================================================
214
253
  // Safety Gate 3: Load and validate source run
215
254
  // =======================================================================