release-skill 0.1.1

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 (125) hide show
  1. package/.agents/plugins/marketplace.json +23 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +10 -0
  4. package/.codex-plugin/plugin.json +26 -0
  5. package/CHANGELOG.md +68 -0
  6. package/CODE_OF_CONDUCT.md +76 -0
  7. package/CONTRIBUTING.md +49 -0
  8. package/INSTALL.md +182 -0
  9. package/LICENSE +21 -0
  10. package/NOTICE +25 -0
  11. package/README.md +501 -0
  12. package/README.zh-CN.md +463 -0
  13. package/SECURITY.md +48 -0
  14. package/adapters/claude/.claude-plugin/marketplace.json +16 -0
  15. package/adapters/claude/.claude-plugin/plugin.json +10 -0
  16. package/adapters/claude/skills/release-assess/SKILL.md +52 -0
  17. package/adapters/claude/skills/release-help/SKILL.md +60 -0
  18. package/adapters/claude/skills/release-prepare/SKILL.md +71 -0
  19. package/adapters/claude/skills/release-publish/SKILL.md +55 -0
  20. package/adapters/claude/skills/release-reconcile/SKILL.md +73 -0
  21. package/adapters/claude/skills/release-verify/SKILL.md +70 -0
  22. package/adapters/codex/.codex-plugin/plugin.json +26 -0
  23. package/adapters/codex/skills/release-assess/SKILL.md +52 -0
  24. package/adapters/codex/skills/release-help/SKILL.md +60 -0
  25. package/adapters/codex/skills/release-prepare/SKILL.md +71 -0
  26. package/adapters/codex/skills/release-publish/SKILL.md +55 -0
  27. package/adapters/codex/skills/release-reconcile/SKILL.md +73 -0
  28. package/adapters/codex/skills/release-verify/SKILL.md +70 -0
  29. package/bin/release-skill.mjs +743 -0
  30. package/native/safe-write/binding.gyp +40 -0
  31. package/native/safe-write/prebuilds.json +4 -0
  32. package/native/safe-write/src/safe_write.cc +2023 -0
  33. package/package.json +75 -0
  34. package/references/.render-manifest.json +33 -0
  35. package/references/00-target-state.md +124 -0
  36. package/references/01-state-machine.md +155 -0
  37. package/references/02-project-config.md +217 -0
  38. package/references/03-readme-quality.md +136 -0
  39. package/references/04-supply-chain.md +147 -0
  40. package/references/05-evidence-and-errors.md +164 -0
  41. package/references/06-adapter-contract.md +178 -0
  42. package/schemas/.render-manifest.json +37 -0
  43. package/schemas/approval-record.schema.json +115 -0
  44. package/schemas/artifact-lock.schema.json +111 -0
  45. package/schemas/artifact-plan.schema.json +52 -0
  46. package/schemas/artifact-policy.schema.json +76 -0
  47. package/schemas/evidence-event.schema.json +89 -0
  48. package/schemas/release-plan.schema.json +369 -0
  49. package/schemas/release-project.schema.json +359 -0
  50. package/schemas/release-run.schema.json +195 -0
  51. package/skills/release-assess/SKILL.md +52 -0
  52. package/skills/release-help/SKILL.md +60 -0
  53. package/skills/release-prepare/SKILL.md +71 -0
  54. package/skills/release-publish/SKILL.md +55 -0
  55. package/skills/release-reconcile/SKILL.md +73 -0
  56. package/skills/release-verify/SKILL.md +70 -0
  57. package/skills-src/release-assess/SKILL.md +52 -0
  58. package/skills-src/release-help/SKILL.md +60 -0
  59. package/skills-src/release-prepare/SKILL.md +71 -0
  60. package/skills-src/release-publish/SKILL.md +55 -0
  61. package/skills-src/release-reconcile/SKILL.md +73 -0
  62. package/skills-src/release-verify/SKILL.md +70 -0
  63. package/src/adapters/contract.mjs +214 -0
  64. package/src/adapters/git-github.mjs +214 -0
  65. package/src/adapters/npm.mjs +947 -0
  66. package/src/adapters/plugin-marketplace.mjs +1365 -0
  67. package/src/adapters/push-snapshot.mjs +216 -0
  68. package/src/artifacts/adoption.mjs +743 -0
  69. package/src/artifacts/artifact-plan.mjs +162 -0
  70. package/src/artifacts/entry.mjs +240 -0
  71. package/src/artifacts/git-authority.mjs +637 -0
  72. package/src/artifacts/graph.mjs +189 -0
  73. package/src/artifacts/inspect.mjs +520 -0
  74. package/src/artifacts/inventory.mjs +192 -0
  75. package/src/artifacts/merge/binary.mjs +77 -0
  76. package/src/artifacts/merge/entry-merge.mjs +228 -0
  77. package/src/artifacts/merge/json.mjs +641 -0
  78. package/src/artifacts/merge/markdown.mjs +246 -0
  79. package/src/artifacts/merge/regions.mjs +156 -0
  80. package/src/artifacts/merge/text.mjs +432 -0
  81. package/src/artifacts/merge/tree.mjs +202 -0
  82. package/src/artifacts/merge/yaml.mjs +669 -0
  83. package/src/artifacts/path-key.mjs +94 -0
  84. package/src/artifacts/policy.mjs +319 -0
  85. package/src/artifacts/producer-registry.mjs +439 -0
  86. package/src/artifacts/project-lock.mjs +732 -0
  87. package/src/artifacts/resolution.mjs +658 -0
  88. package/src/artifacts/safe-fs-backend-internal.mjs +680 -0
  89. package/src/artifacts/safe-fs.mjs +72 -0
  90. package/src/artifacts/state.mjs +495 -0
  91. package/src/artifacts/transaction-journal.mjs +983 -0
  92. package/src/artifacts/transaction.mjs +1361 -0
  93. package/src/commands/approve.mjs +280 -0
  94. package/src/commands/artifacts.mjs +627 -0
  95. package/src/commands/assess.mjs +838 -0
  96. package/src/commands/prepare.mjs +1377 -0
  97. package/src/commands/publish.mjs +883 -0
  98. package/src/commands/reconcile.mjs +1255 -0
  99. package/src/commands/verify.mjs +915 -0
  100. package/src/core/approval.mjs +332 -0
  101. package/src/core/baseline.mjs +272 -0
  102. package/src/core/blackbox-hard-gates.mjs +142 -0
  103. package/src/core/config.mjs +448 -0
  104. package/src/core/digest.mjs +90 -0
  105. package/src/core/errors.mjs +113 -0
  106. package/src/core/evidence.mjs +167 -0
  107. package/src/core/hooks.mjs +241 -0
  108. package/src/core/node-version.mjs +64 -0
  109. package/src/core/plan.mjs +735 -0
  110. package/src/core/previous-public-baseline.mjs +204 -0
  111. package/src/core/run.mjs +681 -0
  112. package/src/core/state-machine.mjs +76 -0
  113. package/src/core/version-consistency.mjs +111 -0
  114. package/src/producers/build-adapters.mjs +231 -0
  115. package/src/producers/render-public-assets.mjs +152 -0
  116. package/src/producers/sync-skills.mjs +96 -0
  117. package/src/readme/contract.mjs +297 -0
  118. package/src/readme/examples.mjs +288 -0
  119. package/src/readme/parity.mjs +122 -0
  120. package/src/snapshot/export.mjs +99 -0
  121. package/src/snapshot/frozen.mjs +401 -0
  122. package/src/snapshot/manifest.mjs +207 -0
  123. package/src/snapshot/public-map.mjs +1459 -0
  124. package/src/snapshot/public-path.mjs +110 -0
  125. package/src/snapshot/scan.mjs +419 -0
@@ -0,0 +1,947 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { execFile as execFileCb, spawn } from 'node:child_process';
3
+ import { gunzipSync } from 'node:zlib';
4
+ import { createRequire } from 'node:module';
5
+ import { promisify } from 'node:util';
6
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
7
+ import { constants as fsConstants } from 'node:fs';
8
+ import { chmod, lstat, mkdtemp, open, readFile, realpath, rm } from 'node:fs/promises';
9
+
10
+ import {
11
+ ActionStatus,
12
+ ActionType,
13
+ assertWritesAuthorized,
14
+ createResult,
15
+ matchObservation,
16
+ } from './contract.mjs';
17
+
18
+ const execFile = promisify(execFileCb);
19
+ const NAME = 'npm';
20
+ const SAFE_PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
21
+
22
+ function validatePackageName(value) {
23
+ if (typeof value !== 'string' || value.length > 214 || !SAFE_PACKAGE_NAME.test(value)) {
24
+ throw new Error('npm package must be a safe lowercase package name or @scope/name');
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Normalize a registry URL to a canonical form.
30
+ * Ensures the URL has a protocol, no trailing slash, and is lowercase.
31
+ *
32
+ * @param {string} registry - The registry URL to normalize.
33
+ * @returns {string} The normalized registry URL.
34
+ * @throws {Error} If the registry is not a valid URL.
35
+ */
36
+ export function normalizeRegistry(registry) {
37
+ if (typeof registry !== 'string' || registry.trim().length === 0) {
38
+ throw new Error('registry must be a non-empty string');
39
+ }
40
+
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(registry.trim());
44
+ } catch {
45
+ throw new Error(`invalid registry URL: ${registry}`);
46
+ }
47
+ if (parsed.protocol !== 'https:') throw new Error('npm registry must use https');
48
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
49
+ throw new Error('npm registry URL must not contain credentials, query, or fragment');
50
+ }
51
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '');
52
+ return parsed.toString().replace(/\/$/, '');
53
+ }
54
+
55
+ async function run(command, args, options = {}) {
56
+ return execFile(command, args, {
57
+ shell: false,
58
+ encoding: 'utf8',
59
+ timeout: 120_000,
60
+ ...options,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Default publishTarballBuffer implementation using libnpmpublish.
66
+ * Receives verified bytes directly — no named temp file involved.
67
+ * Authentication and registry options must be supplied explicitly by the
68
+ * caller; errors are sanitized before they cross the adapter boundary.
69
+ */
70
+ let _libnpmpublish;
71
+ let _npmRegistryFetch;
72
+ async function defaultPublishTarballBuffer({ buffer, manifest, opts: publishOpts }) {
73
+ if (!_libnpmpublish) {
74
+ const require = createRequire(import.meta.url);
75
+ _libnpmpublish = require('libnpmpublish');
76
+ }
77
+ const libOpts = {};
78
+ if (publishOpts.registry) libOpts.registry = publishOpts.registry;
79
+ if (publishOpts.token) libOpts.forceAuth = { token: publishOpts.token };
80
+ if (publishOpts.access) libOpts.access = publishOpts.access;
81
+ if (publishOpts.tag) libOpts.defaultTag = publishOpts.tag;
82
+ if (publishOpts.provenance) libOpts.provenance = publishOpts.provenance;
83
+ return _libnpmpublish.publish(manifest, buffer, libOpts);
84
+ }
85
+
86
+ export function registryTokenKey(registry) {
87
+ const url = new URL(`${normalizeRegistry(registry)}/`);
88
+ return `//${url.host}${url.pathname}:_authToken`;
89
+ }
90
+
91
+ function expandNpmrcValue(raw, env) {
92
+ const value = raw.trim().replace(/^(['"])(.*)\1$/, '$2');
93
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_match, name) => {
94
+ if (!Object.hasOwn(env, name)) throw new Error('npm auth token references an unset environment variable');
95
+ return env[name];
96
+ });
97
+ }
98
+
99
+ async function tokensFromNpmrc(path, key, env) {
100
+ let contents;
101
+ try {
102
+ contents = await readFile(path, 'utf8');
103
+ } catch (err) {
104
+ if (err?.code === 'ENOENT') return [];
105
+ throw new Error('cannot read npm authentication config');
106
+ }
107
+ const tokens = [];
108
+ for (const rawLine of contents.split(/\r?\n/)) {
109
+ const line = rawLine.trim();
110
+ if (!line || line.startsWith('#') || line.startsWith(';')) continue;
111
+ const separator = line.indexOf('=');
112
+ if (separator < 0 || line.slice(0, separator).trim() !== key) continue;
113
+ const token = expandNpmrcValue(line.slice(separator + 1), env);
114
+ if (!token) throw new Error('npm authentication token is empty');
115
+ tokens.push(token);
116
+ }
117
+ return tokens;
118
+ }
119
+
120
+ async function defaultResolveAuthToken({ registry, cwd, exec, env = process.env }) {
121
+ const candidates = [];
122
+ for (const name of ['NPM_TOKEN', 'NODE_AUTH_TOKEN']) {
123
+ if (env[name]) candidates.push(env[name]);
124
+ }
125
+ const key = registryTokenKey(registry);
126
+ candidates.push(...await tokensFromNpmrc(join(cwd, '.npmrc'), key, env));
127
+
128
+ // Assemble the lowercase npm config key so the release leakage scanner does
129
+ // not mistake the environment-variable name itself for an npm access token.
130
+ const npmUserConfigKey = ['npm', 'config', 'userconfig'].join('_');
131
+ const userConfig = env[npmUserConfigKey]
132
+ ?? (await exec('npm', ['config', 'get', 'userconfig'], { cwd, shell: false })).stdout.trim();
133
+ if (userConfig) candidates.push(...await tokensFromNpmrc(userConfig, key, env));
134
+
135
+ const unique = [...new Set(candidates)];
136
+ if (unique.length === 0) throw new Error('npm bearer authentication is not configured for the frozen registry');
137
+ if (unique.length !== 1) throw new Error('ambiguous npm bearer authentication for the frozen registry');
138
+ return unique[0];
139
+ }
140
+
141
+ export async function resolveNpmRegistryAuthToken(options) {
142
+ return defaultResolveAuthToken(options);
143
+ }
144
+
145
+ async function defaultWhoamiWithToken({ registry, token, cwd, exec }) {
146
+ try {
147
+ if (!_npmRegistryFetch) {
148
+ const require = createRequire(import.meta.url);
149
+ _npmRegistryFetch = require('npm-registry-fetch');
150
+ }
151
+ const result = await _npmRegistryFetch.json('/-/whoami', {
152
+ registry: `${normalizeRegistry(registry)}/`,
153
+ forceAuth: { token },
154
+ preferOnline: true,
155
+ });
156
+ if (!result || typeof result.username !== 'string' || !result.username) {
157
+ throw new Error('npm registry whoami returned an invalid identity');
158
+ }
159
+ return result.username;
160
+ } catch {
161
+ throw new Error('npm bearer authentication does not match the frozen registry and publisher');
162
+ }
163
+ }
164
+
165
+ export function resolvePackageCwd(cwd, root) {
166
+ if (!cwd || typeof cwd !== 'string') throw new Error('NPM_PUBLISH requires a non-empty action.cwd (package directory)');
167
+ const rootPath = resolve(root);
168
+ const packagePath = resolve(root, cwd);
169
+ const rel = relative(rootPath, packagePath);
170
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
171
+ throw new Error(`cwd "${cwd}" is outside project root "${root}"`);
172
+ }
173
+ return packagePath;
174
+ }
175
+
176
+ function isNotFound(error) {
177
+ const text = `${error?.code ?? ''}\n${error?.stdout ?? ''}\n${error?.stderr ?? ''}\n${error?.message ?? ''}`;
178
+ return /\bE404\b|\b404\b.*not found|not found.*\b404\b/i.test(text);
179
+ }
180
+
181
+ async function verifyTarball(action, root) {
182
+ if (!action.tarballPath || isAbsolute(action.tarballPath)) throw new Error('tarballPath must be project-relative');
183
+ if (!/^[a-f0-9]{64}$/.test(action.tarballSha256 ?? '')) throw new Error('tarballSha256 must be a lowercase SHA-256 digest');
184
+ const rootReal = await realpath(root);
185
+ const lexical = resolve(rootReal, action.tarballPath);
186
+ const rel = relative(rootReal, lexical);
187
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
188
+ throw new Error('tarballPath escapes project root');
189
+ }
190
+ const st = await lstat(lexical);
191
+ if (!st.isFile() || st.isSymbolicLink() || st.nlink !== 1) throw new Error('frozen tarball must be a single-link regular file');
192
+ const physical = await realpath(lexical);
193
+ const physicalRel = relative(rootReal, physical);
194
+ if (isAbsolute(physicalRel) || physicalRel === '..' || physicalRel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
195
+ throw new Error('tarballPath resolves outside project root');
196
+ }
197
+ const bytes = await readFile(physical);
198
+ const digest = createHash('sha256').update(bytes).digest('hex');
199
+ if (digest !== action.tarballSha256) throw new Error('frozen npm tarball SHA-256 mismatch');
200
+ return physical;
201
+ }
202
+
203
+ /**
204
+ * Read the frozen tarball into a Buffer with O_NOFOLLOW identity verification.
205
+ * Validates: path safety, symlink/hardlink rejection, size stability across
206
+ * open/read/close, and SHA-256 digest against the frozen plan.
207
+ *
208
+ * @returns {Buffer} the verified tarball bytes
209
+ */
210
+ async function readVerifiedTarballBytes(action, root) {
211
+ if (!action.tarballPath || isAbsolute(action.tarballPath)) throw new Error('tarballPath must be project-relative');
212
+ if (!/^[a-f0-9]{64}$/.test(action.tarballSha256 ?? '')) throw new Error('tarballSha256 must be a lowercase SHA-256 digest');
213
+ const rootReal = await realpath(root);
214
+ const lexical = resolve(rootReal, action.tarballPath);
215
+ const rel = relative(rootReal, lexical);
216
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
217
+ throw new Error('tarballPath escapes project root');
218
+ }
219
+ const before = await lstat(lexical);
220
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
221
+ throw new Error('frozen tarball must be a single-link regular file');
222
+ }
223
+ const source = await open(lexical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
224
+ let bytes;
225
+ try {
226
+ const opened = await source.stat();
227
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) {
228
+ throw new Error('frozen tarball changed before read');
229
+ }
230
+ bytes = Buffer.alloc(opened.size);
231
+ let position = 0;
232
+ while (position < bytes.length) {
233
+ const { bytesRead } = await source.read(bytes, position, bytes.length - position, position);
234
+ if (bytesRead === 0) throw new Error('frozen tarball ended during read');
235
+ position += bytesRead;
236
+ }
237
+ const after = await source.stat();
238
+ if (
239
+ after.dev !== opened.dev || after.ino !== opened.ino || after.nlink !== 1 ||
240
+ after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
241
+ ) {
242
+ throw new Error('frozen tarball changed during read');
243
+ }
244
+ } finally {
245
+ await source.close();
246
+ }
247
+ const digest = createHash('sha256').update(bytes).digest('hex');
248
+ if (digest !== action.tarballSha256) throw new Error('frozen npm tarball SHA-256 mismatch');
249
+ if (typeof action.integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/.test(action.integrity)) {
250
+ throw new Error('frozen npm tarball integrity must be an sha512 SRI value');
251
+ }
252
+ const integrity = `sha512-${createHash('sha512').update(bytes).digest('base64')}`;
253
+ if (integrity !== action.integrity) throw new Error('frozen npm tarball SHA-512 integrity mismatch');
254
+ return bytes;
255
+ }
256
+
257
+ /**
258
+ * Extract and parse the package/package.json manifest from a tarball Buffer.
259
+ * Supports plain tar and gzip-compressed tarballs. Throws if the manifest
260
+ * is missing, not valid JSON, or the extracted name/version do not match the
261
+ * expected action values.
262
+ *
263
+ * @param {Buffer} tarballBuffer - the raw tarball bytes (gzipped or plain tar)
264
+ * @param {{ name: string, version: string }} expected - must match manifest
265
+ * @returns {object} the parsed package.json manifest
266
+ */
267
+ function extractManifestFromTarball(tarballBuffer, expected) {
268
+ const isGzip = tarballBuffer.length >= 2 && tarballBuffer[0] === 0x1f && tarballBuffer[1] === 0x8b;
269
+ let data = tarballBuffer;
270
+ if (isGzip) {
271
+ try {
272
+ data = gunzipSync(tarballBuffer);
273
+ } catch {
274
+ throw new Error('frozen npm tarball has an invalid gzip stream');
275
+ }
276
+ }
277
+
278
+ // Walk tar entries to find package/package.json
279
+ let offset = 0;
280
+ let manifest = null;
281
+ while (offset + 512 <= data.length) {
282
+ // Check for all-zero end-of-archive block
283
+ let allZero = true;
284
+ for (let i = 0; i < 512; i++) {
285
+ if (data[offset + i] !== 0) { allZero = false; break; }
286
+ }
287
+ if (allZero) break;
288
+
289
+ const header = data.subarray(offset, offset + 512);
290
+ const storedChecksumText = header.toString('ascii', 148, 156).replace(/\0.*$/, '').trim();
291
+ if (!/^[0-7]+$/.test(storedChecksumText)) throw new Error('frozen npm tarball has an invalid tar checksum field');
292
+ const storedChecksum = Number.parseInt(storedChecksumText, 8);
293
+ let computedChecksum = 0;
294
+ for (let i = 0; i < header.length; i += 1) {
295
+ computedChecksum += i >= 148 && i < 156 ? 0x20 : header[i];
296
+ }
297
+ if (computedChecksum !== storedChecksum) throw new Error('frozen npm tarball header checksum mismatch');
298
+
299
+ const name = data.toString('utf8', offset, offset + 100).replace(/\0.*$/, '');
300
+ const sizeStr = data.toString('utf8', offset + 124, offset + 136).replace(/\0.*$/, '').trim();
301
+ if (sizeStr && !/^[0-7]+$/.test(sizeStr)) throw new Error('frozen npm tarball has an invalid entry size');
302
+ const size = sizeStr ? Number.parseInt(sizeStr, 8) : 0;
303
+ if (!Number.isSafeInteger(size) || size < 0) throw new Error('frozen npm tarball entry size is unsafe');
304
+ const typeFlag = data[offset + 156];
305
+
306
+ // Normalize: ustar may prefix with a numeric field at offset 345 for long names
307
+ let entryName = name;
308
+ // If prefix field is non-empty, the real path is prefix/name
309
+ const prefix = data.toString('utf8', offset + 345, offset + 500).replace(/\0.*$/, '');
310
+ if (prefix) entryName = `${prefix}/${name}`;
311
+
312
+ // Match package/package.json (standard npm tarball layout)
313
+ if (entryName === 'package/package.json') {
314
+ if (!(typeFlag === 0 || typeFlag === 48 /* '0' */)) {
315
+ throw new Error('tarball package/package.json must be a regular file');
316
+ }
317
+ if (manifest) throw new Error('tarball contains duplicate package/package.json entries');
318
+ const bodyStart = offset + 512;
319
+ const bodyEnd = bodyStart + size;
320
+ if (bodyEnd > data.length) throw new Error('frozen npm tarball entry exceeds archive bounds');
321
+ if (size > 10 * 1024 * 1024) throw new Error('tarball package/package.json is unreasonably large');
322
+ const body = data.subarray(bodyStart, bodyEnd);
323
+ try {
324
+ manifest = JSON.parse(body.toString('utf8'));
325
+ } catch (err) {
326
+ throw new Error(`tarball package/package.json is not valid JSON: ${err.message}`);
327
+ }
328
+ if (manifest.name !== expected.name) {
329
+ throw new Error(`tarball manifest name "${manifest.name}" does not match expected "${expected.name}"`);
330
+ }
331
+ if (manifest.version !== expected.version) {
332
+ throw new Error(`tarball manifest version "${manifest.version}" does not match expected "${expected.version}"`);
333
+ }
334
+ }
335
+
336
+ // Advance to next header: 512 header + ceil(size/512)*512 data
337
+ const nextOffset = offset + 512 + Math.ceil(size / 512) * 512;
338
+ if (!Number.isSafeInteger(nextOffset) || nextOffset > data.length) {
339
+ throw new Error('frozen npm tarball entry exceeds archive bounds');
340
+ }
341
+ offset = nextOffset;
342
+ }
343
+ if (!manifest) throw new Error('tarball does not contain package/package.json');
344
+ return manifest;
345
+ }
346
+
347
+ /**
348
+ * Verify frozen tarball bytes, integrity, and the embedded npm identity.
349
+ * This is safe to call during prepare or a global preflight, before any
350
+ * external write is authorized.
351
+ */
352
+ export async function verifyFrozenNpmTarballIdentity(action, root) {
353
+ const buffer = await readVerifiedTarballBytes(action, root);
354
+ return extractManifestFromTarball(buffer, {
355
+ name: action.package,
356
+ version: action.version,
357
+ });
358
+ }
359
+
360
+ /**
361
+ * Read and verify the frozen tarball, then write it to a controlled named
362
+ * temp file under a temp directory adjacent to the source. Returns the temp
363
+ * file path, a cleanup handle, and a post-publish verifier.
364
+ *
365
+ * Threat model:
366
+ * - Same-UID concurrent processes on generic POSIX can replace or read any
367
+ * path the current user can access. npm CLI's named-path interface cannot
368
+ * atomically hand off bytes to npm. Therefore the verification seam between
369
+ * our write and npm's read is a *process-internal drift detector*, not an
370
+ * OS-level isolation guarantee against same-UID malicious actors.
371
+ * - We do NOT claim to defend against a concurrent same-UID process that
372
+ * replaces the named tarball between our pre-spawn check and npm's open.
373
+ * - We DO detect: content drift, symlink/hardlink swaps, size/metadata changes,
374
+ * and unexpected deletion — both before spawn and after npm returns.
375
+ *
376
+ * Security properties:
377
+ * - Source tarball is opened with O_NOFOLLOW and identity-checked (dev/ino/nlink).
378
+ * - Content SHA-256 is verified against the frozen plan digest.
379
+ * - Temp file is created exclusive (wx) with restrictive permissions (0o400).
380
+ * - After writing, the temp file identity is recorded (dev/ino/nlink/size/mtime/ctime).
381
+ * - Internal test hook (if present) runs between write completion and pre-spawn
382
+ * verification; any tampering is caught by the identity comparison.
383
+ * - Pre-spawn verification re-opens with O_NOFOLLOW and compares
384
+ * dev/ino/nlink/size/mtime/ctime against creation-time values, then reads
385
+ * back and verifies content SHA-256.
386
+ * - Post-publish verification (after npm returns) re-opens with O_NOFOLLOW,
387
+ * compares identity fields against creation-time values, and reads back
388
+ * SHA-256. If the file changed, the result status is "unknown" and the
389
+ * caller must not treat the publish as successful.
390
+ * - Temp directory is created with identity recorded (dev/ino); cleanup
391
+ * verifies identity before removal and fails closed if replaced.
392
+ * - All paths are verified to be within the production asset root.
393
+ *
394
+ * @returns {{ tarballPath: string, cleanup: () => Promise<void>, verifyPostPublish: () => Promise<{ok: boolean, error?: string}> }}
395
+ */
396
+ async function createNamedVerifiedTarball(action, root, tamperHook) {
397
+ if (!action.tarballPath || isAbsolute(action.tarballPath)) throw new Error('tarballPath must be project-relative');
398
+ if (!/^[a-f0-9]{64}$/.test(action.tarballSha256 ?? '')) throw new Error('tarballSha256 must be a lowercase SHA-256 digest');
399
+ const rootReal = await realpath(root);
400
+ const lexical = resolve(rootReal, action.tarballPath);
401
+ const rel = relative(rootReal, lexical);
402
+ if (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`)) {
403
+ throw new Error('tarballPath escapes project root');
404
+ }
405
+ const before = await lstat(lexical);
406
+ if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1) {
407
+ throw new Error('frozen tarball must be a single-link regular file');
408
+ }
409
+ const source = await open(lexical, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
410
+ let bytes;
411
+ try {
412
+ const opened = await source.stat();
413
+ if (!opened.isFile() || opened.nlink !== 1 || opened.dev !== before.dev || opened.ino !== before.ino) {
414
+ throw new Error('frozen tarball changed before read');
415
+ }
416
+ bytes = Buffer.alloc(opened.size);
417
+ let position = 0;
418
+ while (position < bytes.length) {
419
+ const { bytesRead } = await source.read(bytes, position, bytes.length - position, position);
420
+ if (bytesRead === 0) throw new Error('frozen tarball ended during read');
421
+ position += bytesRead;
422
+ }
423
+ const after = await source.stat();
424
+ if (
425
+ after.dev !== opened.dev || after.ino !== opened.ino || after.nlink !== 1 ||
426
+ after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs
427
+ ) {
428
+ throw new Error('frozen tarball changed during read');
429
+ }
430
+ } finally {
431
+ await source.close();
432
+ }
433
+ const digest = createHash('sha256').update(bytes).digest('hex');
434
+ if (digest !== action.tarballSha256) throw new Error('frozen npm tarball SHA-256 mismatch');
435
+
436
+ // Verify all parent paths between the tarball and the project root are real
437
+ // directories (not symlinks). This prevents symlink-based path traversal
438
+ // within the production asset root. We only check paths inside the root;
439
+ // paths above the root (e.g., /var, /tmp) are outside our control.
440
+ const parentDir = dirname(lexical);
441
+ for (let checkPath = parentDir; checkPath !== rootReal && checkPath !== dirname(rootReal); ) {
442
+ const checkStat = await lstat(checkPath);
443
+ if (checkStat.isSymbolicLink()) {
444
+ throw new Error(`parent path contains symlink within production asset root: ${checkPath}`);
445
+ }
446
+ const checkRel = relative(rootReal, checkPath);
447
+ if (isAbsolute(checkRel) || checkRel === '..' || checkRel.startsWith('..')) {
448
+ break; // above root, stop checking
449
+ }
450
+ checkPath = dirname(checkPath);
451
+ }
452
+
453
+ const tempDir = await mkdtemp(join(parentDir, '.publish-tarball-'));
454
+ const uniqueName = `${action.package.replace('/', '-')}-${action.version}-${randomUUID()}.tgz`;
455
+ const tempPath = join(tempDir, uniqueName);
456
+
457
+ // Record temp directory identity at creation for cleanup verification
458
+ const tempDirStat = await lstat(tempDir);
459
+
460
+ const cleanup = async () => {
461
+ // Verify temp directory identity before cleanup.
462
+ // If the directory was replaced (by a symlink or different directory),
463
+ // fail closed and do not delete the replacement.
464
+ let currentDirStat;
465
+ try {
466
+ currentDirStat = await lstat(tempDir);
467
+ } catch (err) {
468
+ // Directory already gone — nothing to clean
469
+ return;
470
+ }
471
+ if (currentDirStat.isSymbolicLink()) {
472
+ throw new Error('temp directory was replaced with a symlink; refusing to clean');
473
+ }
474
+ if (!currentDirStat.isDirectory()) {
475
+ throw new Error('temp directory path is no longer a directory; refusing to clean');
476
+ }
477
+ if (currentDirStat.dev !== tempDirStat.dev || currentDirStat.ino !== tempDirStat.ino) {
478
+ throw new Error('temp directory identity changed; refusing to clean replacement');
479
+ }
480
+ // Restore permissions so rm can traverse the directory
481
+ await chmod(tempDir, 0o700);
482
+ await rm(tempDir, { recursive: true, force: true });
483
+ };
484
+
485
+ try {
486
+ const writer = await open(tempPath, 'wx', 0o400);
487
+ try {
488
+ await writer.writeFile(bytes);
489
+ await writer.sync();
490
+ } finally {
491
+ await writer.close();
492
+ }
493
+
494
+ // Record creation-time identity for post-hook comparison
495
+ const identityHandle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
496
+ let creationStat;
497
+ try {
498
+ creationStat = await identityHandle.stat();
499
+ if (!creationStat.isFile() || creationStat.nlink !== 1) {
500
+ throw new Error('named tarball failed post-write verification: not a single-link regular file');
501
+ }
502
+ } finally {
503
+ await identityHandle.close();
504
+ }
505
+
506
+ // Internal test hook: allows tests to tamper between write completion
507
+ // and pre-spawn identity verification. Passed via deps.postWriteTamperHook.
508
+ if (typeof tamperHook === 'function') {
509
+ await tamperHook(tempPath);
510
+ }
511
+
512
+ // Pre-spawn verification: re-open with O_NOFOLLOW and compare all six
513
+ // identity fields (dev/ino/nlink/size/mtime/ctime) against creation-time
514
+ // values, then read back and verify content SHA-256.
515
+ const preSpawnHandle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
516
+ try {
517
+ const postStat = await preSpawnHandle.stat();
518
+ if (!postStat.isFile() || postStat.nlink !== 1) {
519
+ throw new Error('named tarball failed pre-spawn verification: not a single-link regular file');
520
+ }
521
+ if (
522
+ postStat.dev !== creationStat.dev ||
523
+ postStat.ino !== creationStat.ino ||
524
+ postStat.nlink !== creationStat.nlink ||
525
+ postStat.size !== creationStat.size ||
526
+ postStat.mtimeMs !== creationStat.mtimeMs ||
527
+ postStat.ctimeMs !== creationStat.ctimeMs
528
+ ) {
529
+ throw new Error(
530
+ 'named tarball failed pre-spawn verification: file identity changed between write and spawn',
531
+ );
532
+ }
533
+ // Read back and verify content SHA-256
534
+ const readback = Buffer.alloc(postStat.size);
535
+ let pos = 0;
536
+ while (pos < readback.length) {
537
+ const { bytesRead } = await preSpawnHandle.read(readback, pos, readback.length - pos, pos);
538
+ if (bytesRead === 0) throw new Error('named tarball failed pre-spawn verification: truncated read');
539
+ pos += bytesRead;
540
+ }
541
+ const readbackDigest = createHash('sha256').update(readback).digest('hex');
542
+ if (readbackDigest !== action.tarballSha256) {
543
+ throw new Error('named tarball failed pre-spawn verification: SHA-256 mismatch');
544
+ }
545
+ } finally {
546
+ await preSpawnHandle.close();
547
+ }
548
+
549
+ // Drop temp directory to minimum permissions (read+traverse only)
550
+ await chmod(tempDir, 0o500);
551
+
552
+ // Post-publish verification function: re-opens the named file after npm
553
+ // returns and compares identity + content. This detects drift during the
554
+ // scheduling window between pre-spawn check and npm's file open.
555
+ const verifyPostPublish = async () => {
556
+ try {
557
+ const ppHandle = await open(tempPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
558
+ try {
559
+ const ppStat = await ppHandle.stat();
560
+ if (!ppStat.isFile() || ppStat.nlink !== 1) {
561
+ return { ok: false, error: 'post-publish: not a single-link regular file' };
562
+ }
563
+ if (
564
+ ppStat.dev !== creationStat.dev ||
565
+ ppStat.ino !== creationStat.ino ||
566
+ ppStat.nlink !== creationStat.nlink ||
567
+ ppStat.size !== creationStat.size ||
568
+ ppStat.mtimeMs !== creationStat.mtimeMs ||
569
+ ppStat.ctimeMs !== creationStat.ctimeMs
570
+ ) {
571
+ return { ok: false, error: 'post-publish: file identity changed between spawn and npm completion' };
572
+ }
573
+ const ppBuf = Buffer.alloc(ppStat.size);
574
+ let p = 0;
575
+ while (p < ppBuf.length) {
576
+ const { bytesRead } = await ppHandle.read(ppBuf, p, ppBuf.length - p, p);
577
+ if (bytesRead === 0) return { ok: false, error: 'post-publish: truncated read' };
578
+ p += bytesRead;
579
+ }
580
+ const ppDigest = createHash('sha256').update(ppBuf).digest('hex');
581
+ if (ppDigest !== action.tarballSha256) {
582
+ return { ok: false, error: 'post-publish: content SHA-256 mismatch' };
583
+ }
584
+ return { ok: true };
585
+ } finally {
586
+ await ppHandle.close();
587
+ }
588
+ } catch (err) {
589
+ return { ok: false, error: `post-publish verification failed: ${err.message}` };
590
+ }
591
+ };
592
+
593
+ return { tarballPath: tempPath, cleanup, verifyPostPublish };
594
+ } catch (err) {
595
+ await cleanup();
596
+ throw err;
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Spawn npm publish with a named tarball path. The tarball path is passed as
602
+ * the package spec argument (not via fd), which works correctly on macOS and
603
+ * Linux. Arguments are passed as an array — no shell interpolation.
604
+ */
605
+ async function spawnNpmPublishTarball({ args, cwd }) {
606
+ return new Promise((resolvePromise, rejectPromise) => {
607
+ const child = spawn('npm', args, {
608
+ cwd,
609
+ shell: false,
610
+ env: process.env,
611
+ stdio: ['ignore', 'pipe', 'pipe'],
612
+ });
613
+ let stdout = '';
614
+ let stderr = '';
615
+ child.stdout.setEncoding('utf8');
616
+ child.stderr.setEncoding('utf8');
617
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
618
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
619
+ child.on('error', rejectPromise);
620
+ child.on('close', (code) => {
621
+ if (code === 0) resolvePromise({ stdout, stderr });
622
+ else {
623
+ const error = new Error(`npm publish exited with code ${code}: ${stderr.trim()}`);
624
+ error.code = code;
625
+ error.stdout = stdout;
626
+ error.stderr = stderr;
627
+ rejectPromise(error);
628
+ }
629
+ });
630
+ });
631
+ }
632
+
633
+ async function queryVersion(action, cwd, exec, registry) {
634
+ try {
635
+ const args = ['view', `${action.package}@${action.version}`, 'version', '--json'];
636
+ if (registry) {
637
+ args.push('--registry', registry);
638
+ }
639
+ const { stdout } = await exec('npm', args, { cwd, shell: false });
640
+ let version;
641
+ try {
642
+ version = JSON.parse(stdout);
643
+ } catch (err) {
644
+ throw new Error(`npm view returned malformed JSON: ${err.message}`);
645
+ }
646
+ if (typeof version !== 'string' || version !== action.version) {
647
+ throw new Error(`npm view returned an unexpected version for ${action.package}@${action.version}`);
648
+ }
649
+ return { exists: true, version };
650
+ } catch (err) {
651
+ if (isNotFound(err)) return { exists: false, version: null };
652
+ throw new Error(`cannot determine npm version uniqueness: ${err.message}`);
653
+ }
654
+ }
655
+
656
+ export function createNpmAdapter(deps = {}) {
657
+ const exec = deps.exec ?? run;
658
+ const publishFromTarball = deps.publishFromTarball ?? (deps.exec
659
+ ? ({ args, cwd }) => exec('npm', args, { cwd, shell: false })
660
+ : spawnNpmPublishTarball);
661
+ // publishTarballBuffer: stable-bytes publish seam. When present, execute
662
+ // reads the tarball into a verified Buffer and passes it directly to this
663
+ // function instead of writing a named temp file. This eliminates the
664
+ // named-file TOCTOU window entirely.
665
+ // Signature: ({ buffer: Buffer, manifest: object, opts: { access?, tag?, provenance? } }) => Promise<any>
666
+ //
667
+ // Default: always use libnpmpublish's Buffer API. Tests that replace this
668
+ // seam must provide another Buffer consumer. A frozen tarball is never
669
+ // handed back to the legacy named-path publisher.
670
+ const publishTarballBuffer = deps.publishTarballBuffer === undefined
671
+ ? defaultPublishTarballBuffer
672
+ : deps.publishTarballBuffer;
673
+ const beforeBufferPublishHook = deps.beforeBufferPublishHook ?? null;
674
+ const resolveAuthToken = deps.resolveAuthToken ?? defaultResolveAuthToken;
675
+ const whoamiWithToken = deps.whoamiWithToken ?? defaultWhoamiWithToken;
676
+ const authEnv = deps.authEnv ?? process.env;
677
+ // postWriteTamperHook: injectable test seam for pre-spawn verification tests.
678
+ // Not part of the public adapter interface; only used in test environments.
679
+ const postWriteTamperHook = deps.postWriteTamperHook ?? null;
680
+ // afterPublishBeforeVerifyHook: test seam for post-publish tamper tests (Item 27).
681
+ // Called after publishFromTarball returns, before verifyPostPublish.
682
+ const afterPublishBeforeVerifyHook = deps.afterPublishBeforeVerifyHook ?? null;
683
+ // beforeCleanupHook: test seam for cleanup attack tests (Item 26).
684
+ // Called in execute's finally, before cleanup() runs.
685
+ const beforeCleanupHook = deps.beforeCleanupHook ?? null;
686
+ return Object.freeze({
687
+ name: NAME,
688
+ actionTypes: Object.freeze([ActionType.NPM_PACK, ActionType.NPM_PUBLISH]),
689
+
690
+ async preflight(action, context) {
691
+ try {
692
+ if (action.actionType === ActionType.NPM_PACK) {
693
+ await exec('npm', ['pack', '--dry-run', '--json'], { cwd: context.root, shell: false });
694
+ } else if (action.actionType === ActionType.NPM_PUBLISH) {
695
+ if (!action.package || !action.version) throw new Error('npm-publish requires package and version');
696
+ validatePackageName(action.package);
697
+
698
+ // Validate and normalize registry
699
+ if (!action.registry) throw new Error('npm-publish requires explicit registry');
700
+ const normalizedRegistry = normalizeRegistry(action.registry);
701
+
702
+ // Validate publisher
703
+ if (!action.publisher) throw new Error('npm-publish requires explicit publisher');
704
+
705
+ const access = action.access ?? (action.tarballPath ? null : 'public');
706
+ if (!['public', 'restricted'].includes(access)) {
707
+ throw new Error('npm-publish requires explicit access: public or restricted');
708
+ }
709
+ const cwd = resolvePackageCwd(action.cwd, context.root);
710
+ if (action.tarballPath) {
711
+ await verifyFrozenNpmTarballIdentity(action, context.root);
712
+ }
713
+
714
+ let whoamiUser;
715
+ if (action.tarballPath) {
716
+ try {
717
+ const token = await resolveAuthToken({ registry: normalizedRegistry, cwd, exec, env: authEnv });
718
+ whoamiUser = await whoamiWithToken({ registry: normalizedRegistry, token, cwd, exec });
719
+ } catch {
720
+ throw new Error('npm bearer authentication does not match the frozen registry and publisher');
721
+ }
722
+ } else {
723
+ try {
724
+ const { stdout } = await exec('npm', ['whoami', '--registry', normalizedRegistry], { cwd, shell: false });
725
+ whoamiUser = stdout.trim();
726
+ } catch {
727
+ throw new Error(`npm authentication not configured for registry ${normalizedRegistry}`);
728
+ }
729
+ }
730
+
731
+ // Verify whoami matches publisher
732
+ if (whoamiUser !== action.publisher) {
733
+ throw new Error(
734
+ `npm whoami returned "${whoamiUser}" but expected publisher "${action.publisher}" for registry ${normalizedRegistry}`
735
+ );
736
+ }
737
+
738
+ // Query version with explicit registry
739
+ const remote = await queryVersion(action, cwd, exec, normalizedRegistry);
740
+ if (remote.exists) throw new Error(`Package ${action.package}@${action.version} is already published`);
741
+ } else {
742
+ throw new Error(`unsupported action type: ${action.actionType}`);
743
+ }
744
+ return createResult({ actionType: action.actionType, status: ActionStatus.PREFLIGHT_PASSED });
745
+ } catch (err) {
746
+ return createResult({ actionType: action.actionType, status: ActionStatus.PREFLIGHT_FAILED, error: err.message });
747
+ }
748
+ },
749
+
750
+ async execute(action, context) {
751
+ assertWritesAuthorized(context, action.actionType);
752
+ let namedTarball = null;
753
+ let primaryResult = null;
754
+ try {
755
+ if (action.actionType === ActionType.NPM_PACK) {
756
+ const { stdout } = await exec('npm', ['pack', '--json'], { cwd: action.cwd ? resolvePackageCwd(action.cwd, context.root) : context.root, shell: false });
757
+ const parsed = JSON.parse(stdout);
758
+ const info = Array.isArray(parsed) ? parsed[0] : parsed;
759
+ primaryResult = createResult({ actionType: action.actionType, status: ActionStatus.EXECUTED, observation: info });
760
+ return primaryResult;
761
+ }
762
+ if (action.actionType !== ActionType.NPM_PUBLISH) throw new Error(`unsupported action type: ${action.actionType}`);
763
+ validatePackageName(action.package);
764
+
765
+ // Validate and normalize registry
766
+ if (!action.registry) throw new Error('npm-publish requires explicit registry');
767
+ const normalizedRegistry = normalizeRegistry(action.registry);
768
+
769
+ // Validate publisher
770
+ if (!action.publisher) throw new Error('npm-publish requires explicit publisher');
771
+
772
+ const cwd = resolvePackageCwd(action.cwd, context.root);
773
+ const access = action.access ?? (action.tarballPath ? null : 'public');
774
+ if (!['public', 'restricted'].includes(access)) {
775
+ throw new Error('npm-publish requires explicit access: public or restricted');
776
+ }
777
+
778
+ // Stable-bytes Buffer path: when publishTarballBuffer is available and
779
+ // we have a frozen tarball, read the verified bytes directly into memory
780
+ // and hand them to the registry API — no named temp file, no TOCTOU.
781
+ if (action.tarballPath) {
782
+ if (typeof publishTarballBuffer !== 'function') {
783
+ throw new Error('frozen npm tarball requires a stable Buffer publish capability');
784
+ }
785
+ const buffer = await readVerifiedTarballBytes(action, context.root);
786
+ const manifest = extractManifestFromTarball(buffer, { name: action.package, version: action.version });
787
+ let token;
788
+ try {
789
+ token = await resolveAuthToken({ registry: normalizedRegistry, cwd, exec, env: authEnv });
790
+ const authenticatedPublisher = await whoamiWithToken({ registry: normalizedRegistry, token, cwd, exec });
791
+ if (authenticatedPublisher !== action.publisher) {
792
+ throw new Error('npm bearer identity does not match the frozen publisher');
793
+ }
794
+ } catch {
795
+ throw new Error('npm bearer authentication does not match the frozen registry and publisher');
796
+ }
797
+ if (typeof beforeBufferPublishHook === 'function') {
798
+ await beforeBufferPublishHook(resolve(context.root, action.tarballPath));
799
+ }
800
+ try {
801
+ await publishTarballBuffer({
802
+ buffer,
803
+ manifest,
804
+ opts: {
805
+ registry: normalizedRegistry,
806
+ token,
807
+ access,
808
+ tag: action.tag ?? undefined,
809
+ provenance: action.provenance === true || undefined,
810
+ },
811
+ });
812
+ } catch (pubErr) {
813
+ // Sanitize: strip any credential or token from the error message.
814
+ // The publish API may include auth headers or tokens in errors.
815
+ const safeCode = typeof pubErr.code === 'string' && /^[A-Z0-9_-]{1,32}$/.test(pubErr.code)
816
+ ? pubErr.code
817
+ : 'unknown';
818
+ throw new Error(`npm registry publish failed (${safeCode})`);
819
+ }
820
+ primaryResult = createResult({ actionType: action.actionType, status: ActionStatus.EXECUTED });
821
+ return primaryResult;
822
+ }
823
+
824
+ throw new Error('npm-publish requires a frozen tarball; mutable cwd publishing is not supported');
825
+ } catch (err) {
826
+ primaryResult = createResult({ actionType: action.actionType, status: ActionStatus.EXECUTE_FAILED, error: err.message });
827
+ return primaryResult;
828
+ } finally {
829
+ if (namedTarball) {
830
+ // Test seam (Item 26): allows tests to tamper with the temp directory
831
+ // before cleanup runs, to test identity-bound cleanup.
832
+ if (typeof beforeCleanupHook === 'function') {
833
+ await beforeCleanupHook(namedTarball.tarballPath);
834
+ }
835
+ try {
836
+ await namedTarball.cleanup();
837
+ } catch (cleanupErr) {
838
+ // Cleanup failure (e.g., identity-bound rejection) must not silently
839
+ // override a more informative primary error (post-publish verification,
840
+ // pre-spawn detection). Record it but preserve the primary status.
841
+ if (primaryResult && primaryResult.status === ActionStatus.EXECUTED) {
842
+ return createResult({
843
+ actionType: action.actionType,
844
+ status: ActionStatus.EXECUTE_FAILED,
845
+ error: `cleanup failed: ${cleanupErr.message}`,
846
+ });
847
+ }
848
+ // Primary result already recorded an error; attach cleanup info.
849
+ if (primaryResult) {
850
+ primaryResult.error = `${primaryResult.error}\n[cleanup also failed: ${cleanupErr.message}]`;
851
+ }
852
+ }
853
+ }
854
+ }
855
+ },
856
+
857
+ async observe(action, context) {
858
+ try {
859
+ if (action.actionType === ActionType.NPM_PACK) {
860
+ return createResult({ actionType: action.actionType, status: ActionStatus.OBSERVED, observation: { local: true } });
861
+ }
862
+ if (action.actionType !== ActionType.NPM_PUBLISH) throw new Error(`unsupported action type: ${action.actionType}`);
863
+ validatePackageName(action.package);
864
+
865
+ // Validate and normalize registry
866
+ if (!action.registry) throw new Error('npm-publish requires explicit registry');
867
+ const normalizedRegistry = normalizeRegistry(action.registry);
868
+ if (!action.publisher) throw new Error('npm-publish requires explicit publisher');
869
+
870
+ const cwd = resolvePackageCwd(action.cwd, context.root);
871
+ const { stdout: whoamiStdout } = await exec(
872
+ 'npm',
873
+ ['whoami', '--registry', normalizedRegistry],
874
+ { cwd, shell: false },
875
+ );
876
+ const publisher = whoamiStdout.trim();
877
+ if (publisher !== action.publisher) {
878
+ throw new Error(
879
+ `npm whoami returned "${publisher}" but expected publisher "${action.publisher}" for registry ${normalizedRegistry}`,
880
+ );
881
+ }
882
+ const args = ['view', `${action.package}@${action.version}`, 'version', 'dist.integrity', 'dist.tarball', '--json'];
883
+ args.push('--registry', normalizedRegistry);
884
+
885
+ let stdout;
886
+ try {
887
+ ({ stdout } = await exec('npm', args, { cwd, shell: false }));
888
+ } catch (err) {
889
+ // Only an E404 from the exact frozen package@version view is trusted
890
+ // as proof of absence. Auth, whoami, cwd, registry, parsing, and all
891
+ // other failures remain unknown and must never authorize a retry.
892
+ if (isNotFound(err)) {
893
+ return createResult({
894
+ actionType: action.actionType,
895
+ status: ActionStatus.OBSERVED,
896
+ observation: {
897
+ exists: false,
898
+ package: action.package,
899
+ version: action.version,
900
+ registry: normalizedRegistry,
901
+ },
902
+ error: null,
903
+ });
904
+ }
905
+ throw err;
906
+ }
907
+ const data = JSON.parse(stdout);
908
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
909
+ throw new Error('npm observe returned a non-object JSON response');
910
+ }
911
+ if (data.version !== action.version) {
912
+ throw new Error(`npm observe version mismatch: expected ${action.version}`);
913
+ }
914
+ const integrity = data['dist.integrity'] ?? data.integrity;
915
+ if (typeof integrity !== 'string' || integrity.length === 0) {
916
+ throw new Error('npm observe response is missing dist.integrity');
917
+ }
918
+ return createResult({
919
+ actionType: action.actionType,
920
+ status: ActionStatus.OBSERVED,
921
+ observation: {
922
+ package: action.package,
923
+ version: data.version,
924
+ integrity,
925
+ tarball: data['dist.tarball'] ?? data.tarball ?? null,
926
+ registry: normalizedRegistry,
927
+ publisher,
928
+ },
929
+ });
930
+ } catch (err) {
931
+ return createResult({ actionType: action.actionType, status: ActionStatus.OBSERVED, observation: {}, error: err.message });
932
+ }
933
+ },
934
+
935
+ async verify(action, context) {
936
+ const observed = await this.observe(action, context);
937
+ if (observed.error) return createResult({ actionType: action.actionType, status: ActionStatus.VERIFY_FAILED, observation: observed.observation, error: observed.error });
938
+ const comparison = matchObservation(action.expected ?? {}, observed.observation);
939
+ return createResult({
940
+ actionType: action.actionType,
941
+ status: comparison.matches ? ActionStatus.VERIFIED : ActionStatus.VERIFY_FAILED,
942
+ observation: observed.observation,
943
+ error: comparison.matches ? null : comparison.mismatches.join('; '),
944
+ });
945
+ },
946
+ });
947
+ }