release-skill 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) 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 +21 -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 +1579 -1186
  14. package/adapters/claude/skills/release-prepare/SKILL.md +5 -0
  15. package/adapters/claude/skills/release-setup/SKILL.md +10 -1
  16. package/adapters/claude/skills/release-verify/SKILL.md +4 -2
  17. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  18. package/adapters/codex/bin/release-skill.bundle.mjs +1579 -1186
  19. package/adapters/codex/skills/release-prepare/SKILL.md +5 -0
  20. package/adapters/codex/skills/release-setup/SKILL.md +10 -1
  21. package/adapters/codex/skills/release-verify/SKILL.md +4 -2
  22. package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
  23. package/adapters/kimi/bin/release-skill.bundle.mjs +1579 -1186
  24. package/adapters/kimi/skills/release-prepare/SKILL.md +5 -0
  25. package/adapters/kimi/skills/release-setup/SKILL.md +10 -1
  26. package/adapters/kimi/skills/release-verify/SKILL.md +4 -2
  27. package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
  28. package/adapters/workbuddy/bin/release-skill.bundle.mjs +1579 -1186
  29. package/adapters/workbuddy/skills/release-prepare/SKILL.md +5 -0
  30. package/adapters/workbuddy/skills/release-setup/SKILL.md +10 -1
  31. package/adapters/workbuddy/skills/release-verify/SKILL.md +4 -2
  32. package/bin/release-skill.bundle.mjs +1579 -1186
  33. package/package.json +1 -1
  34. package/skills/release-prepare/SKILL.md +5 -0
  35. package/skills/release-setup/SKILL.md +10 -1
  36. package/skills/release-verify/SKILL.md +4 -2
  37. package/skills-src/release-prepare/SKILL.md +5 -0
  38. package/skills-src/release-setup/SKILL.md +10 -1
  39. package/skills-src/release-verify/SKILL.md +4 -2
  40. package/src/adapters/npm.mjs +54 -2
  41. package/src/adapters/plugin-marketplace.mjs +4 -29
  42. package/src/commands/prepare.mjs +3 -3
  43. package/src/commands/publish.mjs +2 -2
  44. package/src/commands/reconcile.mjs +39 -0
  45. package/src/commands/setup.mjs +168 -0
  46. package/src/commands/verify.mjs +28 -1
  47. package/src/npm/npm-entry-closure.mjs +195 -0
  48. package/src/platforms/codebuddy.mjs +19 -11
  49. package/src/platforms/codex.mjs +18 -10
  50. package/src/platforms/kimi.mjs +26 -39
  51. 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.7",
4
4
  "description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
5
5
  "author": {
6
6
  "name": "广州市风荷科技有限公司"
@@ -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 (完整绑定验证)
@@ -58,7 +58,7 @@ import {
58
58
  } from '../core/source-authority.mjs';
59
59
  import { acquireProjectLock } from '../artifacts/project-lock.mjs';
60
60
  import { assertPreviousPublicBaselineTarget, observePreviousPublicBaseline } from '../core/previous-public-baseline.mjs';
61
- import { verifyFrozenNpmTarballIdentity } from '../adapters/npm.mjs';
61
+ import { verifyFrozenNpmTarballContract } from '../adapters/npm.mjs';
62
62
  import { createProductionPrepareRunDir } from '../core/run.mjs';
63
63
  import { PLATFORMS } from '../platforms/registry.mjs';
64
64
  import { validateMarketplaceSourceSelection, MARKETPLACE_SOURCE_TYPES, resolvePluginManifestFromMarketplaceEntrySource, resolveMarketplaceRoot } from '../adapters/plugin-marketplace.mjs';
@@ -930,13 +930,13 @@ async function buildProductionAssets(
930
930
  tarballDir: resolveUnitScopedPath(resolve(runDir, 'tarballs'), unit.id),
931
931
  expectedSnapshotDigest: sealed.digest,
932
932
  });
933
- await verifyFrozenNpmTarballIdentity({
933
+ await verifyFrozenNpmTarballContract({
934
934
  package: npmDistribution.package,
935
935
  version,
936
936
  tarballPath: relative(root, npm.tarballPath),
937
937
  tarballSha256: npm.sha256,
938
938
  integrity: npm.integrity,
939
- }, root);
939
+ }, root, resolveUnitScopedPath(resolve(runDir, 'tarballs'), unit.id));
940
940
  }
941
941
 
942
942
  assets.push({
@@ -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
  // =======================================================================
@@ -24,6 +24,7 @@ import addFormats from 'ajv-formats';
24
24
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
25
25
  import { acquireProjectLock } from '../artifacts/project-lock.mjs';
26
26
  import { getPlatform, PLATFORMS } from '../platforms/registry.mjs';
27
+ import { checkNpmEntryClosure } from '../npm/npm-entry-closure.mjs';
27
28
  import {
28
29
  CONFIG_EXISTS,
29
30
  CONFIG_INVALID,
@@ -201,6 +202,8 @@ function summarizeLegacyReleaseConfig(value, path) {
201
202
  tagPrefix: optionalString(repo.tagPrefix),
202
203
  npmPackage: optionalString(repo.npmPackage),
203
204
  npmPackageDeclared: Object.hasOwn(repo, 'npmPackage'),
205
+ npmRequiredPathCandidates: stringList(repo.npmRequiredPackagePaths),
206
+ npmRequiredPathsDeclared: Object.hasOwn(repo, 'npmRequiredPackagePaths'),
204
207
  docsSource: optionalString(repo.docsSource),
205
208
  requiredPathCandidates: stringList(repo.requiredPackagePaths),
206
209
  snapshotCommands: Array.isArray(repo.snapshotCommands) ? repo.snapshotCommands : [],
@@ -223,6 +226,8 @@ function summarizeLegacyReleaseConfig(value, path) {
223
226
  npmPackageDeclared: plugins.some((plugin) => (
224
227
  plugin && typeof plugin === 'object' && !Array.isArray(plugin) && Object.hasOwn(plugin, 'npmPackage')
225
228
  )),
229
+ npmRequiredPathCandidates: stringList(value.npmRequiredPackagePaths),
230
+ npmRequiredPathsDeclared: Object.hasOwn(value, 'npmRequiredPackagePaths'),
226
231
  docsSource: null,
227
232
  requiredPathCandidates: stringList(value.requiredPaths),
228
233
  snapshotCommands: Array.isArray(value.snapshotCommands) ? value.snapshotCommands : [],
@@ -467,6 +472,111 @@ async function discoverUnitGit(unitAbsDir, parentRoot) {
467
472
  };
468
473
  }
469
474
 
475
+ async function pathIsGitIgnored(unitAbsDir, target) {
476
+ // --no-index also classifies generated paths that do not exist yet.
477
+ try {
478
+ await execFile('git', ['check-ignore', '--no-index', '--quiet', '--', target], {
479
+ cwd: unitAbsDir,
480
+ shell: false,
481
+ timeout: 5000,
482
+ });
483
+ return true;
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+
489
+ async function classifyNpmEntryCandidate(unitAbsDir, target, trackedFiles) {
490
+ const tracked = trackedFiles.includes(target);
491
+ const ignored = await pathIsGitIgnored(unitAbsDir, target);
492
+ let stat = null;
493
+ try {
494
+ stat = await lstat(join(unitAbsDir, target));
495
+ } catch (error) {
496
+ if (error.code !== 'ENOENT') throw error;
497
+ }
498
+ const exists = stat !== null;
499
+ const regular = stat?.isFile() === true && stat?.isSymbolicLink() !== true;
500
+ const state = exists && !regular
501
+ ? 'NON_REGULAR'
502
+ : exists && tracked
503
+ ? 'TRACKED_PRESENT'
504
+ : exists && ignored
505
+ ? 'IGNORED_PRESENT'
506
+ : exists
507
+ ? 'UNTRACKED_PRESENT'
508
+ : ignored
509
+ ? 'IGNORED_MISSING'
510
+ : 'MISSING';
511
+ return { path: target, state, exists, tracked, ignored };
512
+ }
513
+
514
+ async function discoverNpmEntryCandidates(root, pkg, matchingLegacyUnits, unitGitEntry) {
515
+ const emptyIndex = new Map();
516
+ const semantic = checkNpmEntryClosure(pkg.entryManifest, emptyIndex);
517
+ const byPath = new Map();
518
+ const add = (target, source) => {
519
+ const current = byPath.get(target) ?? { path: target, sources: new Set() };
520
+ current.sources.add(source);
521
+ byPath.set(target, current);
522
+ };
523
+ for (const entry of semantic.entries) add(entry.target, `package.json:${entry.field}`);
524
+
525
+ const diagnostics = [
526
+ ...semantic.errors
527
+ .filter((error) => error.reason !== 'entry_missing')
528
+ .map((error) => ({ code: 'NPM_ENTRY_DECLARATION_INVALID', ...error })),
529
+ ...semantic.diagnostics.map((diagnostic) => ({
530
+ code: 'NPM_ENTRY_WILDCARD_REQUIRES_REVIEW',
531
+ ...diagnostic,
532
+ })),
533
+ ];
534
+ const declaredLegacyPaths = new Set();
535
+ const legacyCoverageDeclared = matchingLegacyUnits.some((unit) => unit.npmRequiredPathsDeclared);
536
+ for (const legacy of matchingLegacyUnits) {
537
+ for (const target of legacy.npmRequiredPathCandidates) {
538
+ const checked = checkNpmEntryClosure({ main: target }, emptyIndex);
539
+ const normalized = checked.entries[0]?.target;
540
+ if (!normalized) {
541
+ diagnostics.push({
542
+ code: 'LEGACY_NPM_REQUIRED_PATH_INVALID',
543
+ path: target,
544
+ reason: checked.errors[0]?.reason ?? 'invalid_path',
545
+ });
546
+ continue;
547
+ }
548
+ declaredLegacyPaths.add(normalized);
549
+ add(normalized, 'public-release.json:npmRequiredPackagePaths');
550
+ }
551
+ }
552
+ if (legacyCoverageDeclared) {
553
+ for (const entry of semantic.entries) {
554
+ if (!declaredLegacyPaths.has(entry.target)) {
555
+ diagnostics.push({
556
+ code: 'LEGACY_NPM_ENTRY_COVERAGE_MISSING',
557
+ path: entry.target,
558
+ field: entry.field,
559
+ });
560
+ }
561
+ }
562
+ }
563
+
564
+ const unitAbsDir = resolve(root, pkg.directory);
565
+ const candidates = [];
566
+ for (const item of [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path))) {
567
+ candidates.push({
568
+ ...await classifyNpmEntryCandidate(
569
+ unitAbsDir,
570
+ item.path,
571
+ unitGitEntry?.trackedFiles ?? [],
572
+ ),
573
+ sources: [...item.sources].sort(),
574
+ });
575
+ }
576
+ diagnostics.sort((a, b) => canonicalJson(a).localeCompare(canonicalJson(b)));
577
+ return { candidates, diagnostics };
578
+ }
579
+
470
580
  async function discoverFacts(root) {
471
581
  const files = await walkDiscoveryFiles(root);
472
582
  const packageFiles = files.filter((path) => (
@@ -494,6 +604,11 @@ async function discoverFacts(root) {
494
604
  private: pkg.private === true,
495
605
  repository: parseGithubRepo(pkg.repository),
496
606
  publishRegistry: typeof pkg.publishConfig?.registry === 'string' ? pkg.publishConfig.registry : null,
607
+ entryManifest: Object.fromEntries(
608
+ ['bin', 'main', 'module', 'types', 'typings', 'exports']
609
+ .filter((field) => Object.hasOwn(pkg, field))
610
+ .map((field) => [field, pkg[field]]),
611
+ ),
497
612
  files: Array.isArray(pkg.files) ? pkg.files.filter((item) => typeof item === 'string').sort() : [],
498
613
  scripts: Object.fromEntries(Object.entries(pkg.scripts ?? {})
499
614
  .filter(([, value]) => typeof value === 'string')
@@ -557,6 +672,22 @@ async function discoverFacts(root) {
557
672
  for (const pkg of packages) {
558
673
  const unitAbsDir = resolve(root, pkg.directory);
559
674
  unitGit[pkg.directory] = await discoverUnitGit(unitAbsDir, root);
675
+ const inferredId = safeUnitId(pkg, pkg.directory);
676
+ const matchingLegacyUnits = legacyReleaseConfigs
677
+ .flatMap((config) => config.releaseUnits)
678
+ .filter((unit) => (
679
+ unit.id === inferredId ||
680
+ unit.source === pkg.directory ||
681
+ unit.source === dirname(pkg.path)
682
+ ));
683
+ const npmDiscovery = await discoverNpmEntryCandidates(
684
+ root,
685
+ pkg,
686
+ matchingLegacyUnits,
687
+ unitGit[pkg.directory],
688
+ );
689
+ pkg.npmEntryCandidates = npmDiscovery.candidates;
690
+ pkg.npmEntryDiagnostics = npmDiscovery.diagnostics;
560
691
  }
561
692
 
562
693
  return { git, packages, manifests, skills, legacyReleaseConfigs, fileDigests, unitGit };
@@ -871,6 +1002,8 @@ function buildCandidates(facts) {
871
1002
  publicFileMappingCandidates: mappingResult.mappings,
872
1003
  publicFileMappingConflicts: mappingResult.conflicts,
873
1004
  requiredPublicFileCandidates,
1005
+ npmEntryCandidates: pkg.npmEntryCandidates ?? [],
1006
+ npmEntryDiagnostics: pkg.npmEntryDiagnostics ?? [],
874
1007
  entrySkillCandidates: entrySkillCandidatesForUnit(facts, pkg, id),
875
1008
  ...(hasAuthorityConflict ? {
876
1009
  authorityConflict: {
@@ -1027,6 +1160,24 @@ function buildRecommendedProposal(facts, candidates) {
1027
1160
  assumptions,
1028
1161
  };
1029
1162
  }
1163
+ if (
1164
+ unit.distributionCandidates.includes('npm') &&
1165
+ (
1166
+ (unit.npmEntryCandidates ?? []).some((candidate) => candidate.state !== 'TRACKED_PRESENT') ||
1167
+ (unit.npmEntryDiagnostics ?? []).length > 0
1168
+ )
1169
+ ) {
1170
+ return {
1171
+ answers: null,
1172
+ conflicts: [{
1173
+ code: 'NPM_ENTRY_REVIEW_REQUIRED',
1174
+ unit: unit.id,
1175
+ candidates: unit.npmEntryCandidates,
1176
+ diagnostics: unit.npmEntryDiagnostics,
1177
+ }],
1178
+ assumptions,
1179
+ };
1180
+ }
1030
1181
 
1031
1182
  // Infer npm publisher: prefer legacy owner, then parse from repo slug
1032
1183
  const repoOwner = publicRepo.includes('/') ? publicRepo.split('/')[0] : null;
@@ -1245,6 +1396,15 @@ function buildDecisionsRequired(candidates, localOnly) {
1245
1396
  id: `unit:${unit.id}:distributions-and-files`,
1246
1397
  description: `逐项确认渠道 ${JSON.stringify(unit.distributionCandidates)}、公开文件边界和 requiredPublicFiles;候选不是授权。`,
1247
1398
  });
1399
+ if (
1400
+ (unit.npmEntryCandidates ?? []).length > 0 ||
1401
+ (unit.npmEntryDiagnostics ?? []).length > 0
1402
+ ) {
1403
+ decisions.push({
1404
+ id: `unit:${unit.id}:npm-entry-closure`,
1405
+ description: '审阅 package.json 入口及旧 npmRequiredPackagePaths 的 tracked/untracked/ignored/missing 状态;setup 只报告候选,不自动写入 publicFiles 或 requiredPublicFiles。',
1406
+ });
1407
+ }
1248
1408
  }
1249
1409
  decisions.push({
1250
1410
  id: 'verification-gates',
@@ -1271,6 +1431,8 @@ function buildCompactSummary(report) {
1271
1431
  distributionCandidates: [...unit.distributionCandidates].sort(),
1272
1432
  publicFileMappingCount: (unit.publicFileMappingCandidates ?? []).length,
1273
1433
  requiredPublicFileCount: (unit.requiredPublicFileCandidates ?? []).length,
1434
+ npmEntryCandidates: unit.npmEntryCandidates ?? [],
1435
+ npmEntryDiagnostics: unit.npmEntryDiagnostics ?? [],
1274
1436
  })),
1275
1437
  gateCandidates: (report.gateCandidates ?? []).map((gate) => ({
1276
1438
  id: gate.id,
@@ -1519,6 +1681,12 @@ export async function setupProject({ root, answersPath, write = false, confirmSe
1519
1681
  ...(unconfiguredGateCandidateIds.length > 0
1520
1682
  ? ['存在未配置的验证候选;逐项审阅副作用后决定是否人工注册。']
1521
1683
  : []),
1684
+ ...(candidates.units.some((unit) => (
1685
+ (unit.npmEntryCandidates ?? []).some((candidate) => candidate.state !== 'TRACKED_PRESENT') ||
1686
+ (unit.npmEntryDiagnostics ?? []).length > 0
1687
+ ))
1688
+ ? ['npm 入口候选存在未跟踪、被忽略、缺失、非普通文件或声明覆盖疑点;人工增量修复配置或构建流程,setup 不自动改写。']
1689
+ : []),
1522
1690
  ],
1523
1691
  },
1524
1692
  recommendedGateIds: [],