release-skill 0.1.3 → 0.1.4

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 (95) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.codex-plugin/plugin.json +3 -3
  4. package/CHANGELOG.md +29 -0
  5. package/INSTALL.md +69 -32
  6. package/INSTALL.zh-CN.md +64 -26
  7. package/README.md +101 -37
  8. package/README.zh-CN.md +87 -28
  9. package/adapters/claude/.claude-plugin/marketplace.json +1 -1
  10. package/adapters/claude/.claude-plugin/plugin.json +1 -1
  11. package/adapters/claude/bin/release-skill.bundle.mjs +79284 -0
  12. package/adapters/claude/bin/release-skill.mjs +34 -0
  13. package/adapters/claude/native/safe-write/binding.gyp +40 -0
  14. package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  15. package/adapters/claude/native/safe-write/prebuilds.json +24 -0
  16. package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
  17. package/adapters/claude/schemas/.render-manifest.json +37 -0
  18. package/adapters/claude/schemas/approval-record.schema.json +115 -0
  19. package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
  20. package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
  21. package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
  22. package/adapters/claude/schemas/evidence-event.schema.json +89 -0
  23. package/adapters/claude/schemas/release-plan.schema.json +860 -0
  24. package/adapters/claude/schemas/release-project.schema.json +736 -0
  25. package/adapters/claude/schemas/release-run.schema.json +342 -0
  26. package/adapters/claude/skills/release-assess/SKILL.md +5 -6
  27. package/adapters/claude/skills/release-help/SKILL.md +11 -18
  28. package/adapters/claude/skills/release-prepare/SKILL.md +6 -6
  29. package/adapters/claude/skills/release-publish/SKILL.md +1 -4
  30. package/adapters/claude/skills/release-reconcile/SKILL.md +5 -5
  31. package/adapters/claude/skills/release-setup/SKILL.md +64 -80
  32. package/adapters/claude/skills/release-verify/SKILL.md +4 -7
  33. package/adapters/codex/.codex-plugin/plugin.json +2 -2
  34. package/adapters/codex/bin/release-skill.bundle.mjs +79284 -0
  35. package/adapters/codex/bin/release-skill.mjs +34 -0
  36. package/adapters/codex/native/safe-write/binding.gyp +40 -0
  37. package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
  38. package/adapters/codex/native/safe-write/prebuilds.json +24 -0
  39. package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
  40. package/adapters/codex/schemas/.render-manifest.json +37 -0
  41. package/adapters/codex/schemas/approval-record.schema.json +115 -0
  42. package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
  43. package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
  44. package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
  45. package/adapters/codex/schemas/evidence-event.schema.json +89 -0
  46. package/adapters/codex/schemas/release-plan.schema.json +860 -0
  47. package/adapters/codex/schemas/release-project.schema.json +736 -0
  48. package/adapters/codex/schemas/release-run.schema.json +342 -0
  49. package/adapters/codex/skills/release-assess/SKILL.md +12 -6
  50. package/adapters/codex/skills/release-help/SKILL.md +18 -18
  51. package/adapters/codex/skills/release-prepare/SKILL.md +13 -6
  52. package/adapters/codex/skills/release-publish/SKILL.md +8 -4
  53. package/adapters/codex/skills/release-reconcile/SKILL.md +12 -5
  54. package/adapters/codex/skills/release-setup/SKILL.md +71 -80
  55. package/adapters/codex/skills/release-verify/SKILL.md +11 -7
  56. package/bin/release-skill-cli.mjs +807 -0
  57. package/bin/release-skill.bundle.mjs +79284 -0
  58. package/bin/release-skill.mjs +23 -788
  59. package/package.json +6 -2
  60. package/references/02-project-config.md +2 -2
  61. package/schemas/release-plan.schema.json +7 -1
  62. package/schemas/release-project.schema.json +8 -0
  63. package/scripts/build-bundle.mjs +133 -0
  64. package/skills/release-assess/SKILL.md +5 -6
  65. package/skills/release-help/SKILL.md +11 -18
  66. package/skills/release-prepare/SKILL.md +6 -6
  67. package/skills/release-publish/SKILL.md +1 -4
  68. package/skills/release-reconcile/SKILL.md +5 -5
  69. package/skills/release-setup/SKILL.md +64 -80
  70. package/skills/release-verify/SKILL.md +4 -7
  71. package/skills-src/release-assess/SKILL.md +5 -6
  72. package/skills-src/release-help/SKILL.md +11 -18
  73. package/skills-src/release-prepare/SKILL.md +6 -6
  74. package/skills-src/release-publish/SKILL.md +1 -4
  75. package/skills-src/release-reconcile/SKILL.md +5 -5
  76. package/skills-src/release-setup/SKILL.md +64 -80
  77. package/skills-src/release-verify/SKILL.md +4 -7
  78. package/src/adapters/npm.mjs +5 -13
  79. package/src/adapters/plugin-marketplace.mjs +67 -31
  80. package/src/artifacts/policy.mjs +4 -7
  81. package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
  82. package/src/commands/prepare.mjs +22 -1
  83. package/src/commands/publish.mjs +1 -0
  84. package/src/commands/setup.mjs +667 -28
  85. package/src/core/approval.mjs +4 -6
  86. package/src/core/config.mjs +8 -8
  87. package/src/core/pkg-root.mjs +22 -0
  88. package/src/core/plan.mjs +9 -4
  89. package/src/core/run.mjs +4 -4
  90. package/src/core/trusted-resource.mjs +96 -0
  91. package/src/docs/version-gate.mjs +164 -0
  92. package/src/producers/build-adapters.mjs +512 -55
  93. package/src/snapshot/frozen.mjs +133 -3
  94. package/src/snapshot/public-map.mjs +7 -4
  95. package/src/snapshot/scan.mjs +2 -1
@@ -2,19 +2,180 @@
2
2
  * Pure producer: build adapters from skills and plugin templates.
3
3
  *
4
4
  * Reads skill metadata from skills-src/ and plugin.json templates,
5
- * generates adapter directories for each platform (claude, codex).
5
+ * generates self-contained adapter directories for each platform (claude, codex).
6
+ *
7
+ * Each adapter root contains:
8
+ * - Plugin manifest (.claude-plugin/ or .codex-plugin/)
9
+ * - Skills with host-specific root resolution
10
+ * - bin/release-skill.mjs (wrapper) + bin/release-skill.bundle.mjs (self-contained bundle)
11
+ * - schemas/ (JSON Schema files)
12
+ * - native/safe-write/ (prebuilds.json + platform binaries)
6
13
  *
7
14
  * Deterministic: sorted directory enumeration, no timestamps, no randomness.
8
15
  *
9
16
  * @module producers/build-adapters
10
17
  */
11
18
 
12
- import { readdir, readFile, writeFile, mkdir } from 'node:fs/promises';
13
- import { join, dirname } from 'node:path';
19
+ import { readdir, mkdir, lstat, realpath, open, rm } from 'node:fs/promises';
20
+ import { constants as fsConstants } from 'node:fs';
21
+ import { join, dirname, relative, isAbsolute, sep, resolve, posix as pathPosix } from 'node:path';
14
22
  import { createHash } from 'node:crypto';
15
23
 
16
24
  import { canonicalJson, sha256Hex } from '../core/digest.mjs';
17
25
 
26
+ /**
27
+ * Assert that a resolved real path is contained within the allowed root.
28
+ *
29
+ * @param {string} resolvedPath - Absolute resolved (realpath) path.
30
+ * @param {string} root - Absolute package root.
31
+ * @param {string} label - Human-readable label for error messages.
32
+ */
33
+ function assertWithinRoot(resolvedPath, root, label) {
34
+ const rel = relative(root, resolvedPath);
35
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
36
+ throw new Error(
37
+ `SECURITY: ${label} escapes package root: resolved=${resolvedPath} root=${root}`,
38
+ );
39
+ }
40
+ }
41
+
42
+ function assertRegularSingleLink(fileStat, label) {
43
+ if (fileStat.isSymbolicLink()) throw new Error(`SECURITY: ${label} is a symlink`);
44
+ if (!fileStat.isFile()) throw new Error(`SECURITY: ${label} is not a regular file`);
45
+ if (fileStat.nlink !== 1) throw new Error(`SECURITY: ${label} has an unexpected hard-link count`);
46
+ }
47
+
48
+ async function assertTrustedDirectory(dirPath, physicalRoot, label) {
49
+ let dirStat;
50
+ try {
51
+ dirStat = await lstat(dirPath);
52
+ } catch (error) {
53
+ if (error?.code === 'ENOENT') throw new Error(`SECURITY: ${label} is missing`);
54
+ throw error;
55
+ }
56
+ if (dirStat.isSymbolicLink()) throw new Error(`SECURITY: ${label} is a symlink`);
57
+ if (!dirStat.isDirectory()) throw new Error(`SECURITY: ${label} is not a directory`);
58
+ const physicalPath = await realpath(dirPath);
59
+ assertWithinRoot(physicalPath, physicalRoot, label);
60
+ return physicalPath;
61
+ }
62
+
63
+ async function readTrustedFile(filePath, physicalRoot, label, encoding = null) {
64
+ let lexicalStat;
65
+ try {
66
+ lexicalStat = await lstat(filePath);
67
+ } catch (error) {
68
+ if (error?.code === 'ENOENT') throw new Error(`SECURITY: ${label} is missing`);
69
+ throw error;
70
+ }
71
+ assertRegularSingleLink(lexicalStat, label);
72
+ const physicalPath = await realpath(filePath);
73
+ assertWithinRoot(physicalPath, physicalRoot, label);
74
+
75
+ const noFollow = fsConstants.O_NOFOLLOW ?? 0;
76
+ const handle = await open(physicalPath, fsConstants.O_RDONLY | noFollow);
77
+ try {
78
+ const openedStat = await handle.stat();
79
+ assertRegularSingleLink(openedStat, label);
80
+ if (openedStat.dev !== lexicalStat.dev || openedStat.ino !== lexicalStat.ino) {
81
+ throw new Error(`SECURITY: ${label} changed during trusted open`);
82
+ }
83
+ const content = await handle.readFile(encoding ? { encoding } : undefined);
84
+ const finalStat = await handle.stat();
85
+ if (finalStat.dev !== openedStat.dev
86
+ || finalStat.ino !== openedStat.ino
87
+ || finalStat.size !== openedStat.size
88
+ || finalStat.mtimeMs !== openedStat.mtimeMs) {
89
+ throw new Error(`SECURITY: ${label} changed during read`);
90
+ }
91
+ return content;
92
+ } finally {
93
+ await handle.close();
94
+ }
95
+ }
96
+
97
+ async function assertSafeNewOutputRoot(requestedPath, label) {
98
+ if (typeof requestedPath !== 'string' || requestedPath.trim() === '') {
99
+ throw new Error(`SECURITY: ${label} must be a non-empty path`);
100
+ }
101
+ const absolutePath = resolve(requestedPath);
102
+ try {
103
+ const targetStat = await lstat(absolutePath);
104
+ if (targetStat.isSymbolicLink()) {
105
+ throw new Error(`SECURITY: ${label} is a symlink: ${absolutePath}`);
106
+ }
107
+ throw new Error(`SECURITY: ${label} already exists: ${absolutePath}`);
108
+ } catch (error) {
109
+ if (error?.code !== 'ENOENT') throw error;
110
+ }
111
+
112
+ let cursor = dirname(absolutePath);
113
+ while (true) {
114
+ const ancestorStat = await lstat(cursor);
115
+ if (ancestorStat.isSymbolicLink()) {
116
+ const physicalAncestor = await realpath(cursor);
117
+ const allowedSystemAlias = (cursor === '/var' && physicalAncestor === '/private/var')
118
+ || (cursor === '/tmp' && physicalAncestor === '/private/tmp');
119
+ if (!allowedSystemAlias) {
120
+ throw new Error(`SECURITY: ${label} ancestor is a symlink: ${cursor}`);
121
+ }
122
+ } else if (!ancestorStat.isDirectory()) {
123
+ throw new Error(`SECURITY: ${label} ancestor is not a directory: ${cursor}`);
124
+ }
125
+ const parent = dirname(cursor);
126
+ if (parent === cursor) break;
127
+ cursor = parent;
128
+ }
129
+ return absolutePath;
130
+ }
131
+
132
+ function assertDisjointOutputRoots(entries) {
133
+ for (let index = 0; index < entries.length; index += 1) {
134
+ for (let otherIndex = index + 1; otherIndex < entries.length; otherIndex += 1) {
135
+ const [leftName, leftPath] = entries[index];
136
+ const [rightName, rightPath] = entries[otherIndex];
137
+ const leftToRight = relative(leftPath, rightPath);
138
+ const rightToLeft = relative(rightPath, leftPath);
139
+ const rightInsideLeft = leftToRight === ''
140
+ || (!leftToRight.startsWith(`..${sep}`) && leftToRight !== '..' && !isAbsolute(leftToRight));
141
+ const leftInsideRight = rightToLeft === ''
142
+ || (!rightToLeft.startsWith(`..${sep}`) && rightToLeft !== '..' && !isAbsolute(rightToLeft));
143
+ if (rightInsideLeft || leftInsideRight) {
144
+ throw new Error(`SECURITY: output roots overlap: ${leftName}=${leftPath} ${rightName}=${rightPath}`);
145
+ }
146
+ }
147
+ }
148
+ }
149
+
150
+ async function assertOwnedOutputRoot(rootPath, identity, label) {
151
+ const current = await lstat(rootPath);
152
+ if (current.isSymbolicLink() || !current.isDirectory()
153
+ || current.dev !== identity.dev || current.ino !== identity.ino) {
154
+ throw new Error(`SECURITY: ${label} changed during output generation`);
155
+ }
156
+ }
157
+
158
+ async function ensureTrustedOutputDirectory(rootPath, rootIdentity, relativeDir, label) {
159
+ let cursor = rootPath;
160
+ for (const segment of relativeDir.split(sep).filter(Boolean)) {
161
+ await assertOwnedOutputRoot(rootPath, rootIdentity, label);
162
+ cursor = join(cursor, segment);
163
+ try {
164
+ await mkdir(cursor, { mode: 0o700 });
165
+ } catch (error) {
166
+ if (error?.code !== 'EEXIST') throw error;
167
+ }
168
+ const current = await lstat(cursor);
169
+ if (current.isSymbolicLink() || !current.isDirectory()) {
170
+ throw new Error(`SECURITY: ${label} output directory is untrusted: ${cursor}`);
171
+ }
172
+ const physicalCursor = await realpath(cursor);
173
+ const physicalRoot = await realpath(rootPath);
174
+ assertWithinRoot(physicalCursor, physicalRoot, `${label} output directory`);
175
+ }
176
+ return cursor;
177
+ }
178
+
18
179
  /**
19
180
  * Compute an implementation digest from the source bytes of this module.
20
181
  *
@@ -29,7 +190,7 @@ export function computeBuildAdaptersDigest(sourceBytes) {
29
190
  return `sha256:${h.digest('hex')}`;
30
191
  }
31
192
 
32
- // Platform definitions (same as legacy script)
193
+ // Platform definitions
33
194
  const PLATFORMS = [
34
195
  {
35
196
  name: 'claude',
@@ -46,27 +207,44 @@ const PLATFORMS = [
46
207
  },
47
208
  ];
48
209
 
210
+ const REQUIRED_SCHEMA_FILES = Object.freeze([
211
+ 'approval-record.schema.json',
212
+ 'artifact-policy.schema.json',
213
+ 'release-plan.schema.json',
214
+ 'release-project.schema.json',
215
+ 'release-run.schema.json',
216
+ ]);
217
+
49
218
  /**
50
219
  * Collect skill metadata from skills-src/.
51
220
  *
52
221
  * @param {string} srcDir - Absolute path to skills-src/.
53
222
  * @returns {Promise<Array<{name:string,description:string,content:string}>>}
54
223
  */
55
- async function collectSkills(srcDir) {
56
- const entries = await readdir(srcDir, { withFileTypes: true });
57
- const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
224
+ async function collectSkills(srcDir, physicalRoot) {
225
+ const physicalSrcDir = await assertTrustedDirectory(srcDir, physicalRoot, 'skills-src/');
226
+ const entries = await readdir(physicalSrcDir, { withFileTypes: true });
227
+ for (const entry of entries) {
228
+ if (entry.isSymbolicLink()) {
229
+ throw new Error(`SECURITY: skills-src/${entry.name} is a symlink`);
230
+ }
231
+ }
232
+ const dirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
58
233
  dirs.sort();
59
234
 
60
235
  const skills = [];
61
236
  for (const dirName of dirs) {
62
- const skillMdPath = join(srcDir, dirName, 'SKILL.md');
63
- let content;
64
- try {
65
- content = await readFile(skillMdPath, 'utf-8');
66
- } catch (err) {
67
- if (err.code === 'ENOENT') continue;
68
- throw err;
69
- }
237
+ const skillDirPath = join(physicalSrcDir, dirName);
238
+ const skillMdPath = join(skillDirPath, 'SKILL.md');
239
+
240
+ // Reject symlinks at any level: skill directory itself must resolve within root.
241
+ await assertTrustedDirectory(skillDirPath, physicalRoot, `skill directory ${dirName}`);
242
+ const content = await readTrustedFile(
243
+ skillMdPath,
244
+ physicalRoot,
245
+ `skills-src/${dirName}/SKILL.md`,
246
+ 'utf-8',
247
+ );
70
248
 
71
249
  // Extract description: first paragraph of the first ## section.
72
250
  const lines = content.split('\n');
@@ -94,24 +272,105 @@ async function collectSkills(srcDir) {
94
272
  return skills;
95
273
  }
96
274
 
275
+ /**
276
+ * Preamble prepended to every Codex SKILL.md.
277
+ *
278
+ * Codex agents load SKILL.md as data, not as a shell script.
279
+ * BASH_SOURCE / $0 do NOT point to SKILL.md — they point to the
280
+ * invoking shell or are unset entirely (verified in zsh -c / bash -c).
281
+ * The agent MUST resolve the entry point from the SKILL.md file path
282
+ * that the host already provided/loaded.
283
+ */
284
+ const CODEX_PREAMBLE = `\
285
+ > **Codex 安装入口解析协议**:在调用 CLI 前,Agent 必须从宿主当前已加载技能的元数据中取得本 \`SKILL.md\` 的实际绝对路径,并将该字面量记为 \`SKILL_FILE\`。\n\
286
+ > \`SKILL_FILE\` 不是环境变量;禁止从工作目录、可执行搜索路径、源码仓库或 shell 调用上下文猜测。若宿主未提供该绝对路径,立即停止并报告安装定位失败。\n\
287
+ > 对 \`SKILL_FILE\` 执行 \`realpath\`,取其目录向上两级得到 \`PLUGIN_ROOT\`;校验真实技能路径匹配 \`PLUGIN_ROOT/skills/*/SKILL.md\`。\n\
288
+ > 令 \`RELEASE_SKILL_ENTRY=PLUGIN_ROOT/bin/release-skill.mjs\`,对入口执行 \`realpath\` containment、\`lstat\` 非符号链接且为普通文件校验。\n\
289
+ > 每一次 shell 工具调用都必须在同一个调用中用上述已验证绝对值设置 \`RELEASE_SKILL_ENTRY\`,然后执行 \`node "$RELEASE_SKILL_ENTRY" ...\`;不得依赖前一次 shell 的变量。\n\
290
+ >\n\
291
+ `;
292
+
293
+ /**
294
+ * Render SKILL.md content for a specific platform.
295
+ *
296
+ * - Claude: verbatim copy (uses ${CLAUDE_PLUGIN_ROOT})
297
+ * - Codex: replaces the Claude-only path with $RELEASE_SKILL_ENTRY,
298
+ * and prepends the path resolution protocol preamble.
299
+ *
300
+ * @param {string} content - Canonical SKILL.md content.
301
+ * @param {string} platformName - 'claude' or 'codex'.
302
+ * @returns {string}
303
+ */
304
+ function renderSkillForPlatform(content, platformName) {
305
+ if (platformName === 'claude') return content;
306
+
307
+ if (platformName === 'codex') {
308
+ let rendered = content.replaceAll(
309
+ '${CLAUDE_PLUGIN_ROOT}/bin/release-skill.mjs',
310
+ '$RELEASE_SKILL_ENTRY',
311
+ );
312
+ // A remaining bare host-root reference has no safe Codex equivalent.
313
+ if (rendered.includes('${CLAUDE_PLUGIN_ROOT}')) {
314
+ throw new Error('Codex rendering found an unsupported bare CLAUDE_PLUGIN_ROOT reference');
315
+ }
316
+ // Preserve YAML frontmatter as the first bytes so the host can discover
317
+ // the skill. Insert the host-specific protocol immediately after it.
318
+ const frontmatterEnd = rendered.indexOf('\n---\n', 4);
319
+ if (!rendered.startsWith('---\n') || frontmatterEnd < 0) {
320
+ throw new Error('Codex rendering requires a valid leading YAML frontmatter block');
321
+ }
322
+ const insertionPoint = frontmatterEnd + '\n---\n'.length;
323
+ return `${rendered.slice(0, insertionPoint)}\n${CODEX_PREAMBLE}${rendered.slice(insertionPoint)}`;
324
+ }
325
+
326
+ return content;
327
+ }
328
+
329
+ /**
330
+ * Collect binary file paths from a source directory recursively.
331
+ * Returns array of relative paths.
332
+ */
333
+ async function collectBinaryFiles(baseDir, physicalRoot, relPrefix = '') {
334
+ const results = [];
335
+ const physicalDir = await assertTrustedDirectory(
336
+ baseDir,
337
+ physicalRoot,
338
+ relPrefix || relative(physicalRoot, baseDir),
339
+ );
340
+ const entries = await readdir(physicalDir, { withFileTypes: true });
341
+ for (const entry of entries) {
342
+ const relPath = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
343
+ if (entry.isSymbolicLink()) {
344
+ throw new Error(`SECURITY: symlink in resource tree: ${relPath}`);
345
+ }
346
+ if (entry.isDirectory()) {
347
+ results.push(...await collectBinaryFiles(join(physicalDir, entry.name), physicalRoot, relPath));
348
+ } else if (entry.isFile()) {
349
+ results.push({ relPath, sourcePath: join(physicalDir, entry.name) });
350
+ } else {
351
+ throw new Error(`SECURITY: resource is not a regular file: ${relPath}`);
352
+ }
353
+ }
354
+ return results;
355
+ }
356
+
97
357
  /**
98
358
  * Generate the full file tree for an adapter platform.
99
359
  *
100
360
  * @param {object} platform - Platform definition.
101
361
  * @param {Array} skills - Skill metadata array.
102
362
  * @param {object} templateJson - Parsed plugin.json template.
363
+ * @param {string} root - Package root (for reading binary assets).
103
364
  * @param {object|null} marketplaceTemplateJson - Parsed marketplace template.
104
- * @returns {Promise<Array<{relPath:string,content:string}>>}
365
+ * @returns {Promise<Array<{relPath:string,content:Buffer,isBinary:boolean}>>}
105
366
  */
106
- async function generateAdapterFiles(platform, skills, templateJson, marketplaceTemplateJson = null) {
367
+ async function generateAdapterFiles(platform, skills, templateJson, root, marketplaceTemplateJson = null) {
107
368
  const files = [];
108
369
 
109
370
  // Transform plugin.json: rewrite source paths
110
371
  const adapted = JSON.parse(JSON.stringify(templateJson));
111
372
 
112
373
  // Preserve platform-supported directory auto-discovery in generated adapters.
113
- // Codex validation requires the canonical "skills" directory, and Claude
114
- // also accepts the same plugin-root-relative directory contract.
115
374
  if (typeof adapted.skills === 'string') {
116
375
  adapted.skills = './skills/';
117
376
  } else if (Array.isArray(adapted.skills)) {
@@ -123,12 +382,13 @@ async function generateAdapterFiles(platform, skills, templateJson, marketplaceT
123
382
  const pluginJsonContent = JSON.stringify(adapted, null, 2) + '\n';
124
383
  files.push({
125
384
  relPath: join(platform.pluginDirName, 'plugin.json'),
126
- content: pluginJsonContent,
385
+ content: Buffer.from(pluginJsonContent, 'utf-8'),
386
+ isBinary: false,
127
387
  });
128
388
 
129
389
  // Generate marketplace.json for platforms that need it
130
390
  if (platform.hasMarketplace) {
131
- const marketplace = JSON.parse(JSON.stringify(marketplaceTemplateJson ?? {}));
391
+ const marketplace = JSON.parse(JSON.stringify(marketplaceTemplateJson));
132
392
  marketplace.name = adapted.name;
133
393
  marketplace.description = adapted.description;
134
394
  marketplace.owner = marketplace.owner ?? adapted.author;
@@ -142,15 +402,106 @@ async function generateAdapterFiles(platform, skills, templateJson, marketplaceT
142
402
  const marketplaceContent = JSON.stringify(marketplace, null, 2) + '\n';
143
403
  files.push({
144
404
  relPath: join(platform.pluginDirName, 'marketplace.json'),
145
- content: marketplaceContent,
405
+ content: Buffer.from(marketplaceContent, 'utf-8'),
406
+ isBinary: false,
146
407
  });
147
408
  }
148
409
 
149
- // Copy SKILL.md files verbatim
410
+ // Copy SKILL.md files with host-specific rendering
150
411
  for (const skill of skills) {
412
+ const rendered = renderSkillForPlatform(skill.content, platform.name);
151
413
  files.push({
152
414
  relPath: join('skills', skill.name, 'SKILL.md'),
153
- content: skill.content,
415
+ content: Buffer.from(rendered, 'utf-8'),
416
+ isBinary: false,
417
+ });
418
+ }
419
+
420
+ // Copy bin/ entry point and bundle (text files)
421
+ const binFiles = ['release-skill.mjs', 'release-skill.bundle.mjs'];
422
+ for (const name of binFiles) {
423
+ const srcPath = join(root, 'bin', name);
424
+ const content = await readTrustedFile(srcPath, root, `bin/${name}`);
425
+ files.push({
426
+ relPath: join('bin', name),
427
+ content,
428
+ isBinary: false,
429
+ });
430
+ }
431
+
432
+ // Copy schemas/ directory
433
+ const schemaFiles = await collectBinaryFiles(join(root, 'schemas'), root);
434
+ const schemaPaths = new Set(schemaFiles.map(({ relPath }) => relPath));
435
+ for (const requiredSchema of REQUIRED_SCHEMA_FILES) {
436
+ if (!schemaPaths.has(requiredSchema)) {
437
+ throw new Error(`SECURITY: required schema is missing: schemas/${requiredSchema}`);
438
+ }
439
+ }
440
+ for (const { relPath: rel, sourcePath } of schemaFiles) {
441
+ const content = await readTrustedFile(sourcePath, root, `schemas/${rel}`);
442
+ files.push({
443
+ relPath: join('schemas', rel),
444
+ content,
445
+ isBinary: false,
446
+ });
447
+ }
448
+
449
+ // Copy native/safe-write/ directory (prebuilds.json, prebuilds/, src/).
450
+ // Exclude build/ — it contains platform-specific build artifacts that are
451
+ // regenerated by node-gyp and should not be in the published adapter.
452
+ const nativeBase = join(root, 'native', 'safe-write');
453
+ const nativeFiles = await collectBinaryFiles(nativeBase, root);
454
+ if (!nativeFiles.some(({ relPath }) => relPath === 'prebuilds.json')) {
455
+ throw new Error('SECURITY: required native resource is missing: native/safe-write/prebuilds.json');
456
+ }
457
+
458
+ // Semantic closure: validate prebuilds.json declarations against collected files.
459
+ const prebuildsEntry = nativeFiles.find(({ relPath }) => relPath === 'prebuilds.json');
460
+ const prebuildsRaw = await readTrustedFile(prebuildsEntry.sourcePath, root, 'native/safe-write/prebuilds.json', 'utf-8');
461
+ const prebuildsJson = JSON.parse(prebuildsRaw);
462
+ if (!prebuildsJson.prebuilds || typeof prebuildsJson.prebuilds !== 'object'
463
+ || Array.isArray(prebuildsJson.prebuilds)
464
+ || Object.keys(prebuildsJson.prebuilds).length === 0) {
465
+ throw new Error('SECURITY: prebuilds.json missing "prebuilds" object');
466
+ }
467
+ for (const [platformId, declaration] of Object.entries(prebuildsJson.prebuilds)) {
468
+ if (!declaration || typeof declaration !== 'object' || Array.isArray(declaration)
469
+ || !declaration.path || typeof declaration.path !== 'string') {
470
+ throw new Error(`SECURITY: prebuilds.json platform "${platformId}" missing "path"`);
471
+ }
472
+ const portablePath = declaration.path.replace(/\\/g, '/');
473
+ const normalised = pathPosix.normalize(portablePath);
474
+ if (portablePath !== normalised
475
+ || pathPosix.isAbsolute(normalised)
476
+ || normalised === '.'
477
+ || normalised.split('/').some((segment) => segment === '..' || segment === '')) {
478
+ throw new Error(`SECURITY: prebuilds.json path escapes collection tree: ${declaration.path}`);
479
+ }
480
+ if (!/^[a-f0-9]{64}$/.test(declaration.sha256 ?? '')) {
481
+ throw new Error(`SECURITY: prebuilds.json platform "${platformId}" has invalid sha256`);
482
+ }
483
+ if (normalised === 'build' || normalised.startsWith('build/')) {
484
+ throw new Error(`SECURITY: prebuilds.json declares an output-excluded build resource: ${declaration.path}`);
485
+ }
486
+ const matching = nativeFiles.find(({ relPath }) => relPath === normalised);
487
+ if (!matching) {
488
+ throw new Error(`SECURITY: prebuilds.json declares missing file: ${declaration.path}`);
489
+ }
490
+ const declaredContent = await readTrustedFile(matching.sourcePath, root, `native/safe-write/${normalised}`);
491
+ assertRegularSingleLink(await lstat(matching.sourcePath), `native/safe-write/${normalised}`);
492
+ const actualHash = sha256Hex(declaredContent);
493
+ if (declaration.sha256 !== actualHash) {
494
+ throw new Error(`SECURITY: prebuilds.json SHA-256 mismatch for ${declaration.path}: declared=${declaration.sha256} actual=${actualHash}`);
495
+ }
496
+ }
497
+ for (const { relPath: rel, sourcePath } of nativeFiles) {
498
+ // Skip build/ directory — platform-specific build artifacts.
499
+ if (rel.startsWith('build/') || rel.startsWith('build\\')) continue;
500
+ const content = await readTrustedFile(sourcePath, root, `native/safe-write/${rel}`);
501
+ files.push({
502
+ relPath: join('native', 'safe-write', rel),
503
+ content,
504
+ isBinary: true,
154
505
  });
155
506
  }
156
507
 
@@ -164,51 +515,157 @@ async function generateAdapterFiles(platform, skills, templateJson, marketplaceT
164
515
  *
165
516
  * @param {object} options
166
517
  * @param {string} [options.inputs] - Root directory path. Defaults to package root.
167
- * @param {string} options.output - Output directory path.
518
+ * @param {string} [options.output] - Single-platform output directory path.
519
+ * @param {Record<string,string>} [options.outputRoots] - Per-platform output roots.
168
520
  * @param {string} [options.platformFilter] - Only generate for this platform name.
169
521
  */
170
- export async function produceBuildAdapters({ inputs, output, platformFilter } = {}) {
522
+ export async function produceBuildAdapters({ inputs, output, outputRoots, platformFilter } = {}) {
171
523
  const defaultRoot = new URL('../..', import.meta.url).pathname;
172
- const root = inputs ?? defaultRoot;
524
+ const requestedRoot = inputs ?? defaultRoot;
525
+
526
+ // ── Phase 1: Validate input root is not a symlink ──
527
+ let rootStat;
528
+ try {
529
+ rootStat = await lstat(requestedRoot);
530
+ } catch (error) {
531
+ if (error?.code === 'ENOENT') throw new Error(`SECURITY: input root is missing: ${requestedRoot}`);
532
+ throw error;
533
+ }
534
+ if (rootStat.isSymbolicLink()) {
535
+ throw new Error(`SECURITY: input root is a symlink: ${requestedRoot}`);
536
+ }
537
+ if (!rootStat.isDirectory()) throw new Error('SECURITY: input root is not a directory');
538
+ const root = await realpath(requestedRoot);
173
539
  const srcDir = join(root, 'skills-src');
174
540
 
175
- const skills = await collectSkills(srcDir);
541
+ if (platformFilter && !PLATFORMS.some(({ name }) => name === platformFilter)) {
542
+ throw new Error(`Unsupported adapter platform: ${platformFilter}`);
543
+ }
544
+ const selectedPlatforms = PLATFORMS.filter(({ name }) => !platformFilter || name === platformFilter);
545
+ if (!outputRoots && selectedPlatforms.length !== 1) {
546
+ throw new Error('SECURITY: multi-platform generation requires outputRoots');
547
+ }
176
548
 
177
- const outputs = [];
549
+ // ── Phase 2: Collect skills (validates symlinks inside) ──
550
+ const skills = await collectSkills(srcDir, root);
551
+ if (skills.length === 0) {
552
+ throw new Error('At least one skill is required; found 0 in skills-src/');
553
+ }
554
+
555
+ // ── Phase 3: Validate all platform inputs (no writes yet) ──
556
+ for (const platform of selectedPlatforms) {
557
+ await readTrustedFile(
558
+ join(root, platform.pluginDirName, platform.templateFileName),
559
+ root,
560
+ `${platform.pluginDirName}/${platform.templateFileName}`,
561
+ 'utf-8',
562
+ );
563
+ if (platform.hasMarketplace) {
564
+ await readTrustedFile(
565
+ join(root, platform.pluginDirName, platform.marketplaceFileName),
566
+ root,
567
+ `${platform.pluginDirName}/${platform.marketplaceFileName}`,
568
+ 'utf-8',
569
+ );
570
+ }
571
+ }
178
572
 
179
- for (const platform of PLATFORMS) {
180
- if (platformFilter && platform.name !== platformFilter) continue;
573
+ // ── Phase 4: Validate every output root before any target is created ──
574
+ const outputRootEntries = [];
575
+ for (const platform of selectedPlatforms) {
576
+ const requestedOutput = outputRoots?.[platform.name] ?? output;
577
+ const safeRoot = await assertSafeNewOutputRoot(requestedOutput, `${platform.name} output root`);
578
+ outputRootEntries.push([platform.name, safeRoot]);
579
+ }
580
+ assertDisjointOutputRoots(outputRootEntries);
581
+ const safeOutputRoots = new Map(outputRootEntries);
582
+
583
+ // ── Phase 5: Generate files (all inputs already validated) ──
584
+ const allPlatformFiles = [];
585
+ for (const platform of selectedPlatforms) {
181
586
  const templatePath = join(root, platform.pluginDirName, platform.templateFileName);
182
- const templateRaw = await readFile(templatePath, 'utf-8');
587
+ const templateRaw = await readTrustedFile(
588
+ templatePath,
589
+ root,
590
+ `${platform.pluginDirName}/${platform.templateFileName}`,
591
+ 'utf-8',
592
+ );
183
593
  const templateJson = JSON.parse(templateRaw);
184
594
  let marketplaceTemplateJson = null;
185
595
  if (platform.hasMarketplace) {
186
- try {
187
- marketplaceTemplateJson = JSON.parse(
188
- await readFile(join(root, platform.pluginDirName, platform.marketplaceFileName), 'utf-8'),
189
- );
190
- } catch (error) {
191
- if (error.code !== 'ENOENT') throw error;
192
- }
596
+ marketplaceTemplateJson = JSON.parse(
597
+ await readTrustedFile(
598
+ join(root, platform.pluginDirName, platform.marketplaceFileName),
599
+ root,
600
+ `${platform.pluginDirName}/${platform.marketplaceFileName}`,
601
+ 'utf-8',
602
+ ),
603
+ );
193
604
  }
194
605
 
195
- const files = await generateAdapterFiles(platform, skills, templateJson, marketplaceTemplateJson);
606
+ const files = await generateAdapterFiles(platform, skills, templateJson, root, marketplaceTemplateJson);
607
+ allPlatformFiles.push({ platform, files });
608
+ }
196
609
 
197
- for (const file of files) {
198
- const dstPath = join(output, file.relPath);
199
- await mkdir(dirname(dstPath), { recursive: true });
200
- const content = Buffer.from(file.content, 'utf-8');
201
- await writeFile(dstPath, content);
610
+ // ── Phase 6: Create all roots, then write with exclusive/no-follow opens ──
611
+ const outputs = [];
612
+ const createdRoots = [];
613
+ try {
614
+ for (const [platformName, rootPath] of outputRootEntries) {
615
+ await mkdir(rootPath, { mode: 0o700 });
616
+ const identity = await lstat(rootPath);
617
+ if (identity.isSymbolicLink() || !identity.isDirectory()) {
618
+ throw new Error(`SECURITY: ${platformName} output root was not created safely`);
619
+ }
620
+ createdRoots.push({ platformName, rootPath, identity });
621
+ }
622
+
623
+ for (const { platform, files } of allPlatformFiles) {
624
+ const rootPath = safeOutputRoots.get(platform.name);
625
+ const rootRecord = createdRoots.find((entry) => entry.platformName === platform.name);
626
+ for (const file of files) {
627
+ const parentDir = await ensureTrustedOutputDirectory(
628
+ rootPath,
629
+ rootRecord.identity,
630
+ dirname(file.relPath),
631
+ `${platform.name} output root`,
632
+ );
633
+ await assertOwnedOutputRoot(rootPath, rootRecord.identity, `${platform.name} output root`);
634
+ const dstPath = join(parentDir, file.relPath.split(sep).at(-1));
635
+ const createFlags = fsConstants.O_CREAT
636
+ | fsConstants.O_WRONLY
637
+ | fsConstants.O_EXCL
638
+ | (fsConstants.O_NOFOLLOW ?? 0);
639
+ const handle = await open(dstPath, createFlags, 0o600);
640
+ try {
641
+ await handle.writeFile(file.content);
642
+ const writtenStat = await handle.stat();
643
+ assertRegularSingleLink(writtenStat, `${platform.name}/${file.relPath}`);
644
+ } finally {
645
+ await handle.close();
646
+ }
202
647
 
203
- outputs.push(Object.freeze({
204
- path: file.relPath,
205
- type: 'blob',
206
- mode: '100644',
207
- content,
208
- sha256: sha256Hex(content),
209
- size: content.length,
210
- }));
648
+ outputs.push(Object.freeze({
649
+ platform: platform.name,
650
+ path: file.relPath,
651
+ type: 'blob',
652
+ mode: '100644',
653
+ content: file.content,
654
+ sha256: sha256Hex(file.content),
655
+ size: file.content.length,
656
+ }));
657
+ }
658
+ }
659
+ } catch (error) {
660
+ for (const { rootPath, identity } of createdRoots.reverse()) {
661
+ try {
662
+ await assertOwnedOutputRoot(rootPath, identity, 'output root cleanup');
663
+ await rm(rootPath, { recursive: true, force: false });
664
+ } catch {
665
+ // Fail closed: never follow or remove a root whose identity changed.
666
+ }
211
667
  }
668
+ throw error;
212
669
  }
213
670
 
214
671
  return Object.freeze({
@@ -224,8 +681,8 @@ export async function produceBuildAdapters({ inputs, output, platformFilter } =
224
681
  * @returns {string}
225
682
  */
226
683
  export function digestEntryOutputs(entries) {
227
- const canonical = entries.map(({ path, type, mode, sha256, size }) => ({
228
- path, type, mode, size, sha256,
684
+ const canonical = entries.map(({ platform, path, type, mode, sha256, size }) => ({
685
+ ...(platform ? { platform } : {}), path, type, mode, size, sha256,
229
686
  }));
230
687
  return `sha256:${sha256Hex(canonicalJson(canonical))}`;
231
688
  }