@indigoai-us/hq-cli 5.32.0 → 5.33.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.
- package/dist/commands/__fixtures__/make-tar.d.ts +46 -0
- package/dist/commands/__fixtures__/make-tar.js +105 -0
- package/dist/commands/pack-install.d.ts +187 -1
- package/dist/commands/pack-install.js +405 -16
- package/dist/commands/packs.js +9 -4
- package/dist/commands/publish.d.ts +186 -0
- package/dist/commands/publish.js +375 -0
- package/dist/commands/safe-extract.d.ts +154 -0
- package/dist/commands/safe-extract.js +347 -0
- package/dist/index.js +7 -2
- package/dist/lib/local-tree-diff.d.ts +21 -0
- package/dist/lib/local-tree-diff.js +18 -3
- package/dist/types.d.ts +22 -0
- package/dist/utils/vault-api.d.ts +11 -0
- package/dist/utils/vault-api.js +39 -2
- package/package.json +2 -2
- package/src/commands/__fixtures__/make-tar.ts +126 -0
- package/src/commands/artifact-verify.test.ts +177 -0
- package/src/commands/marketplace-install.test.ts +414 -0
- package/src/commands/marketplace-security.test.ts +646 -0
- package/src/commands/pack-install.test.ts +209 -1
- package/src/commands/pack-install.ts +617 -15
- package/src/commands/packs.ts +8 -1
- package/src/commands/publish.test.ts +538 -0
- package/src/commands/publish.ts +517 -0
- package/src/commands/safe-extract.test.ts +459 -0
- package/src/commands/safe-extract.ts +444 -0
- package/src/index.ts +6 -0
- package/src/lib/local-tree-diff.test.ts +19 -0
- package/src/lib/local-tree-diff.ts +17 -1
- package/src/types.ts +23 -0
- package/src/utils/vault-api.ts +41 -0
|
@@ -34,12 +34,13 @@
|
|
|
34
34
|
* from each pack's package.yaml; rationale lives in the layout-fix PR.)
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
37
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="dafe45c8-d9bb-5082-b5b7-aaf1a2ee5c7e")}catch(e){}}();
|
|
38
38
|
import * as fs from 'fs';
|
|
39
39
|
import * as os from 'os';
|
|
40
40
|
import * as path from 'path';
|
|
41
41
|
import * as readline from 'readline';
|
|
42
42
|
import * as yaml from 'js-yaml';
|
|
43
|
+
import { createHash, createPublicKey, verify as cryptoVerify, } from 'node:crypto';
|
|
43
44
|
import { execFileSync, spawnSync } from 'child_process';
|
|
44
45
|
import chalk from 'chalk';
|
|
45
46
|
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
@@ -47,7 +48,18 @@ import semverValid from 'semver/functions/valid.js';
|
|
|
47
48
|
import semverValidRange from 'semver/ranges/valid.js';
|
|
48
49
|
import semverGt from 'semver/functions/gt.js';
|
|
49
50
|
import { findHqRoot } from '../utils/manifest.js';
|
|
51
|
+
import { safeExtractTarball } from './safe-extract.js';
|
|
52
|
+
import { vaultApiFetchPublic } from '../utils/vault-api.js';
|
|
53
|
+
/** Prefix that routes a source through the HQ marketplace transport (US-006). */
|
|
54
|
+
export const MARKETPLACE_PREFIX = 'marketplace:';
|
|
50
55
|
export function classify(source) {
|
|
56
|
+
// US-006: marketplace:<slug>[@version] is recognized BEFORE the legacy
|
|
57
|
+
// registry fallback and ahead of every other transport. The slug is resolved
|
|
58
|
+
// against the public listings API, the presigned tarball is downloaded,
|
|
59
|
+
// verified (US-021), and safe-extracted (US-020) into the existing local
|
|
60
|
+
// install + symlink-wiring path.
|
|
61
|
+
if (source.startsWith(MARKETPLACE_PREFIX))
|
|
62
|
+
return 'marketplace';
|
|
51
63
|
if (source.startsWith('@'))
|
|
52
64
|
return 'npm';
|
|
53
65
|
if (source.startsWith('http://') ||
|
|
@@ -124,7 +136,7 @@ export function sourceMatchesPackPattern(source) {
|
|
|
124
136
|
return false;
|
|
125
137
|
}
|
|
126
138
|
}
|
|
127
|
-
function fetchNpm(source, tmpDir) {
|
|
139
|
+
function fetchNpm(source, tmpDir, integrity) {
|
|
128
140
|
// Use `npm pack` to grab the tarball without actually installing anything.
|
|
129
141
|
// Capture output to get the produced filename. Arguments are passed as an
|
|
130
142
|
// argv array — never interpolated into a shell string — so `source` cannot
|
|
@@ -136,10 +148,28 @@ function fetchNpm(source, tmpDir) {
|
|
|
136
148
|
}
|
|
137
149
|
const tarballPath = path.join(tmpDir, tarball);
|
|
138
150
|
const extractDir = path.join(tmpDir, 'extracted');
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
151
|
+
// SECURITY (US-021): verify integrity + authenticity over the EXACT
|
|
152
|
+
// downloaded bytes BEFORE we extract. A hash/signature mismatch throws
|
|
153
|
+
// `ArtifactVerificationError` here, so a tampered artifact is never unpacked
|
|
154
|
+
// or wired. This MUST precede `safeExtractTarball` (US-020). Only runs when
|
|
155
|
+
// the caller supplied a pinned hash (i.e. a marketplace listing did); the
|
|
156
|
+
// plain `npm pack` path with no listing metadata is unaffected.
|
|
157
|
+
if (integrity) {
|
|
158
|
+
const tarballBytes = fs.readFileSync(tarballPath);
|
|
159
|
+
verifyArtifact({
|
|
160
|
+
tarballBytes,
|
|
161
|
+
expectedHash: integrity.expectedHash,
|
|
162
|
+
signature: integrity.signature,
|
|
163
|
+
publicKey: integrity.publicKey,
|
|
164
|
+
requireSignature: integrity.requireSignature,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// SECURITY (US-020): the tarball is untrusted. Extract through the hardened
|
|
168
|
+
// path — zip-slip / link-escape containment + decompression-bomb caps +
|
|
169
|
+
// staged-then-atomic commit with full rollback — instead of a blind
|
|
170
|
+
// `tar -xzf`. safeExtractTarball requires the destination not to pre-exist,
|
|
171
|
+
// so we do NOT mkdir extractDir first; it is created atomically on success.
|
|
172
|
+
safeExtractTarball(tarballPath, extractDir);
|
|
143
173
|
// npm tarballs unpack into ./package/
|
|
144
174
|
const payloadDir = path.join(extractDir, 'package');
|
|
145
175
|
if (!fs.existsSync(payloadDir)) {
|
|
@@ -159,6 +189,169 @@ function stripVersion(src) {
|
|
|
159
189
|
return src.slice(0, at);
|
|
160
190
|
return src;
|
|
161
191
|
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Marketplace transport (US-006)
|
|
194
|
+
//
|
|
195
|
+
// `marketplace:<slug>[@version]` resolves a slug against the PUBLIC listings
|
|
196
|
+
// API (US-005, NONE-auth), downloads the presigned tarball, verifies it
|
|
197
|
+
// (US-021) BEFORE extraction, and feeds it into the existing safe-extract +
|
|
198
|
+
// validate + symlink-wire path. The legacy registry/npm/git/local transports
|
|
199
|
+
// are untouched.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
/** Parsed `marketplace:<slug>[@version]` source. */
|
|
202
|
+
export function parseMarketplaceSource(source) {
|
|
203
|
+
if (!source.startsWith(MARKETPLACE_PREFIX)) {
|
|
204
|
+
throw new Error(`Not a marketplace source: "${source}"`);
|
|
205
|
+
}
|
|
206
|
+
const rest = source.slice(MARKETPLACE_PREFIX.length).trim();
|
|
207
|
+
if (!rest) {
|
|
208
|
+
throw new Error('marketplace: source requires a slug (marketplace:<slug>[@version]).');
|
|
209
|
+
}
|
|
210
|
+
// Split on the LAST '@' so a slug never legitimately contains one, but be
|
|
211
|
+
// defensive: only treat the suffix as a version if there's a non-empty slug.
|
|
212
|
+
const at = rest.lastIndexOf('@');
|
|
213
|
+
if (at > 0) {
|
|
214
|
+
return { slug: rest.slice(0, at), version: rest.slice(at + 1) || undefined };
|
|
215
|
+
}
|
|
216
|
+
return { slug: rest };
|
|
217
|
+
}
|
|
218
|
+
/** True when a download result signals an expired/forbidden presigned URL. */
|
|
219
|
+
function isExpired(r) {
|
|
220
|
+
return !(r instanceof Uint8Array) && r.expired === true;
|
|
221
|
+
}
|
|
222
|
+
/** Map a raw `GET /v1/listings/{id}` body into a MarketplaceListing. */
|
|
223
|
+
export function toMarketplaceListing(raw) {
|
|
224
|
+
// Unwrap the `{ listing: {...} }` envelope when present; fall back to the
|
|
225
|
+
// top level for the legacy flat shape (backwards-compat).
|
|
226
|
+
const detail = raw.listing && typeof raw.listing === 'object' ? raw.listing : raw;
|
|
227
|
+
// The detail uses `id`; the legacy flat shape may use `listingId`.
|
|
228
|
+
const listingId = detail.id ?? detail.listingId;
|
|
229
|
+
const downloadUrl = detail.downloadUrl ?? detail.url;
|
|
230
|
+
const contentHash = detail.contentHash ?? detail.sha256;
|
|
231
|
+
if (!listingId)
|
|
232
|
+
throw new Error('Listing detail missing an id.');
|
|
233
|
+
if (!downloadUrl) {
|
|
234
|
+
throw new Error(`Listing ${listingId} has no download URL — it may not be approved yet.`);
|
|
235
|
+
}
|
|
236
|
+
if (!contentHash) {
|
|
237
|
+
throw new Error(`Listing ${listingId} has no content hash — refusing to install an unverifiable artifact.`);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
listingId,
|
|
241
|
+
slug: detail.slug ?? '',
|
|
242
|
+
version: detail.version ?? detail.latestVersion,
|
|
243
|
+
downloadUrl,
|
|
244
|
+
contentHash,
|
|
245
|
+
contentHashAlg: detail.contentHashAlg,
|
|
246
|
+
signature: detail.signature,
|
|
247
|
+
signingKeyId: detail.signingKeyId,
|
|
248
|
+
publicKey: detail.publicKey,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** Default production marketplace deps — public listings API + presigned S3. */
|
|
252
|
+
export function defaultMarketplaceDeps() {
|
|
253
|
+
const fetchDetail = async (listingId) => {
|
|
254
|
+
const res = await vaultApiFetchPublic({
|
|
255
|
+
path: `/v1/listings/${encodeURIComponent(listingId)}`,
|
|
256
|
+
});
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
throw new Error(`Failed to resolve listing ${listingId} (HTTP ${res.status}). The pack may be unavailable or unapproved.`);
|
|
259
|
+
}
|
|
260
|
+
const body = (await res.json().catch(() => ({})));
|
|
261
|
+
return toMarketplaceListing(body);
|
|
262
|
+
};
|
|
263
|
+
return {
|
|
264
|
+
resolveListing: async (slug, version) => {
|
|
265
|
+
// Search by slug (public). The API returns approved listings only.
|
|
266
|
+
const res = await vaultApiFetchPublic({
|
|
267
|
+
path: '/v1/listings',
|
|
268
|
+
query: version ? { slug, version } : { slug },
|
|
269
|
+
});
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
throw new Error(`Failed to search marketplace for "${slug}" (HTTP ${res.status}).`);
|
|
272
|
+
}
|
|
273
|
+
const body = (await res.json().catch(() => ({})));
|
|
274
|
+
const listings = body.listings ?? [];
|
|
275
|
+
const match = version
|
|
276
|
+
? listings.find((l) => (l.version ?? l.latestVersion) === version)
|
|
277
|
+
: listings[0];
|
|
278
|
+
if (!match) {
|
|
279
|
+
throw new Error(`No approved marketplace listing found for "${slug}"${version ? `@${version}` : ''}.`);
|
|
280
|
+
}
|
|
281
|
+
const id = match.listingId ?? match.id;
|
|
282
|
+
if (!id)
|
|
283
|
+
throw new Error(`Listing for "${slug}" has no id.`);
|
|
284
|
+
// The summary may omit the presigned URL; always fetch detail to mint it.
|
|
285
|
+
return fetchDetail(id);
|
|
286
|
+
},
|
|
287
|
+
refreshListing: fetchDetail,
|
|
288
|
+
download: async (url) => {
|
|
289
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
|
|
290
|
+
if (response.status === 403)
|
|
291
|
+
return { expired: true };
|
|
292
|
+
if (!response.ok) {
|
|
293
|
+
throw new Error(`Tarball download failed (HTTP ${response.status}).`);
|
|
294
|
+
}
|
|
295
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Fetch + verify + safe-extract a marketplace pack. Mirrors `fetchNpm`'s
|
|
301
|
+
* contract (returns a payloadDir the caller mvs into core/packages/), but
|
|
302
|
+
* resolves the tarball from the marketplace and ALWAYS verifies it against the
|
|
303
|
+
* listing's approved hash/signature BEFORE extraction (US-021 → US-020).
|
|
304
|
+
*
|
|
305
|
+
* @param requireSignature production posture once signing keys exist; during
|
|
306
|
+
* the key-deferral window callers pass false (hash still always checked).
|
|
307
|
+
*/
|
|
308
|
+
export async function fetchMarketplace(source, tmpDir, deps, opts = {}) {
|
|
309
|
+
const { slug, version } = parseMarketplaceSource(source);
|
|
310
|
+
let listing = await deps.resolveListing(slug, version);
|
|
311
|
+
// Download with a single expired-URL retry: a presigned URL can expire
|
|
312
|
+
// between resolve and download, surfacing as a raw S3 403. We re-resolve the
|
|
313
|
+
// detail by id to mint a fresh URL and retry ONCE rather than leaking the 403.
|
|
314
|
+
let bytes = await deps.download(listing.downloadUrl);
|
|
315
|
+
if (isExpired(bytes)) {
|
|
316
|
+
listing = await deps.refreshListing(listing.listingId);
|
|
317
|
+
bytes = await deps.download(listing.downloadUrl);
|
|
318
|
+
if (isExpired(bytes)) {
|
|
319
|
+
throw new Error(`Marketplace download URL for "${slug}" expired and re-resolving did not help. ` +
|
|
320
|
+
`Try again in a moment.`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const tarballBytes = bytes;
|
|
324
|
+
// SECURITY (US-021): verify integrity (+ authenticity) over the EXACT
|
|
325
|
+
// downloaded bytes BEFORE extraction. A hash/signature mismatch throws
|
|
326
|
+
// `ArtifactVerificationError` and nothing is unpacked or wired. The hash is
|
|
327
|
+
// ALWAYS verified when present; signature is enforced when requireSignature.
|
|
328
|
+
verifyArtifact({
|
|
329
|
+
tarballBytes,
|
|
330
|
+
expectedHash: listing.contentHash,
|
|
331
|
+
signature: listing.signature,
|
|
332
|
+
publicKey: listing.publicKey,
|
|
333
|
+
requireSignature: opts.requireSignature ?? false,
|
|
334
|
+
});
|
|
335
|
+
// Write to a tmp tarball and run it through the SAME hardened extractor
|
|
336
|
+
// (US-020) as every other tarball transport.
|
|
337
|
+
const tarballPath = path.join(tmpDir, 'marketplace.tar.gz');
|
|
338
|
+
fs.writeFileSync(tarballPath, tarballBytes);
|
|
339
|
+
const extractDir = path.join(tmpDir, 'extracted');
|
|
340
|
+
safeExtractTarball(tarballPath, extractDir);
|
|
341
|
+
// Marketplace tarballs are packed with `tar -C payloadDir .` (publish.ts), so
|
|
342
|
+
// the manifest + contributes dirs are at the archive root — no `package/`
|
|
343
|
+
// wrapper to descend into.
|
|
344
|
+
const payloadDir = extractDir;
|
|
345
|
+
if (!fs.existsSync(path.join(payloadDir, 'package.yaml'))) {
|
|
346
|
+
throw new Error(`Marketplace tarball for "${slug}" has no package.yaml at its root.`);
|
|
347
|
+
}
|
|
348
|
+
// Record the marketplace source for re-install + `hq packs update`. We stamp
|
|
349
|
+
// the resolved version so a later `update` can detect a newer listing.
|
|
350
|
+
const resolvedSource = listing.version
|
|
351
|
+
? `${MARKETPLACE_PREFIX}${slug}@${listing.version}`
|
|
352
|
+
: `${MARKETPLACE_PREFIX}${slug}`;
|
|
353
|
+
return { payloadDir, resolvedSource };
|
|
354
|
+
}
|
|
162
355
|
function fetchGit(source, tmpDir, followBranch) {
|
|
163
356
|
const parsed = parseGitFragment(source);
|
|
164
357
|
const url = expandGithubShorthand(parsed.url);
|
|
@@ -302,6 +495,20 @@ export function resolveLatest(source, installedVersion) {
|
|
|
302
495
|
if (transport === 'local') {
|
|
303
496
|
return { transport, updateAvailable: null, error: 'local source — re-run to re-sync' };
|
|
304
497
|
}
|
|
498
|
+
if (transport === 'marketplace') {
|
|
499
|
+
// The marketplace probe needs an async network call (listings API), which
|
|
500
|
+
// this synchronous helper cannot make. Callers that want the marketplace
|
|
501
|
+
// update check use the async `resolveLatestMarketplace`; here we report the
|
|
502
|
+
// installed version but leave availability undeterminable (null) so the
|
|
503
|
+
// sync menubar `--check-updates` path is not regressed.
|
|
504
|
+
const { version } = parseMarketplaceSource(source);
|
|
505
|
+
return {
|
|
506
|
+
transport,
|
|
507
|
+
current: installedVersion ?? version,
|
|
508
|
+
updateAvailable: null,
|
|
509
|
+
error: 'marketplace source — use async update check',
|
|
510
|
+
};
|
|
511
|
+
}
|
|
305
512
|
if (transport === 'npm') {
|
|
306
513
|
const pkg = stripVersion(source);
|
|
307
514
|
const current = installedVersion ?? (source.lastIndexOf('@') > 0 ? source.slice(source.lastIndexOf('@') + 1) : undefined);
|
|
@@ -343,10 +550,145 @@ export function resolveLatest(source, installedVersion) {
|
|
|
343
550
|
return { transport, current, updateAvailable: null, error: `git ls-remote failed: ${e.message}` };
|
|
344
551
|
}
|
|
345
552
|
}
|
|
553
|
+
/**
|
|
554
|
+
* Async marketplace update probe (US-006): resolve the slug's latest approved
|
|
555
|
+
* listing version and compare it to the installed version. Reuses the same
|
|
556
|
+
* `MarketplaceDeps.resolveListing` seam as the install path (resolving with NO
|
|
557
|
+
* version pin returns the latest listing). Never throws — network/parse
|
|
558
|
+
* failures return `{ updateAvailable: null, error }` so `hq packs update`
|
|
559
|
+
* stays resilient.
|
|
560
|
+
*
|
|
561
|
+
* @param source the stamped `marketplace:<slug>[@version]` source
|
|
562
|
+
* @param installedVersion the installed pack's manifest version (semver compare)
|
|
563
|
+
* @param deps injected for tests; defaults to the public API
|
|
564
|
+
*/
|
|
565
|
+
export async function resolveLatestMarketplace(source, installedVersion, deps) {
|
|
566
|
+
let slug;
|
|
567
|
+
let pinned;
|
|
568
|
+
try {
|
|
569
|
+
({ slug, version: pinned } = parseMarketplaceSource(source));
|
|
570
|
+
}
|
|
571
|
+
catch (e) {
|
|
572
|
+
return { transport: 'marketplace', updateAvailable: null, error: e.message };
|
|
573
|
+
}
|
|
574
|
+
const current = installedVersion ?? pinned;
|
|
575
|
+
try {
|
|
576
|
+
const d = deps ?? defaultMarketplaceDeps();
|
|
577
|
+
// No version pin → latest approved listing.
|
|
578
|
+
const listing = await d.resolveListing(slug);
|
|
579
|
+
const latest = listing.version;
|
|
580
|
+
let updateAvailable = null;
|
|
581
|
+
if (current && latest) {
|
|
582
|
+
// Prefer semver compare; fall back to inequality if either side isn't
|
|
583
|
+
// valid semver (so a non-semver listing version still flags a change).
|
|
584
|
+
updateAvailable =
|
|
585
|
+
semverValid(latest) && semverValid(current)
|
|
586
|
+
? semverGt(latest, current)
|
|
587
|
+
: latest !== current;
|
|
588
|
+
}
|
|
589
|
+
return { transport: 'marketplace', current, latest, updateAvailable };
|
|
590
|
+
}
|
|
591
|
+
catch (e) {
|
|
592
|
+
return {
|
|
593
|
+
transport: 'marketplace',
|
|
594
|
+
current,
|
|
595
|
+
updateAvailable: null,
|
|
596
|
+
error: `marketplace check failed: ${e.message}`,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// ---------------------------------------------------------------------------
|
|
601
|
+
// Marketplace artifact verification (US-021, INSTALL side)
|
|
602
|
+
//
|
|
603
|
+
// Guarantees the bytes we install are EXACTLY the bytes a moderator approved:
|
|
604
|
+
// 1. recompute sha256 over the downloaded tarball bytes and compare it to the
|
|
605
|
+
// hash the listing pinned at publish (and approval bound to);
|
|
606
|
+
// 2. verify the platform Ed25519 signature over that hash against a
|
|
607
|
+
// distributable PUBLIC key.
|
|
608
|
+
//
|
|
609
|
+
// SHARED CONTRACT (must match hq-pro `src/listings/signing.ts`):
|
|
610
|
+
// • CANONICAL BYTES = the gzipped tarball object EXACTLY as uploaded to S3
|
|
611
|
+
// (i.e. exactly the bytes we downloaded — no transform).
|
|
612
|
+
// • contentHash = sha256(canonical bytes), lowercase hex.
|
|
613
|
+
// • signature = Ed25519 over the UTF-8 bytes of the lowercase-hex
|
|
614
|
+
// contentHash, base64-encoded.
|
|
615
|
+
//
|
|
616
|
+
// This runs BEFORE safe-extract (US-020): a mismatch must abort the install
|
|
617
|
+
// before any bytes are unpacked or wired. US-006 wires the marketplace
|
|
618
|
+
// transport that surfaces the listing's {hash, signature, publicKey}; this
|
|
619
|
+
// function is the reusable verifier it calls.
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
/** Thrown when a downloaded artifact fails hash or signature verification. */
|
|
622
|
+
export class ArtifactVerificationError extends Error {
|
|
623
|
+
constructor(message) {
|
|
624
|
+
super(message);
|
|
625
|
+
this.name = 'ArtifactVerificationError';
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** Hash algorithm for the content hash — pinned to sha256 (matches publish). */
|
|
629
|
+
export const ARTIFACT_HASH_ALG = 'sha256';
|
|
630
|
+
/** Lowercase-hex sha256 of the given bytes — the install-side content hash. */
|
|
631
|
+
export function computeArtifactHash(tarballBytes) {
|
|
632
|
+
return createHash(ARTIFACT_HASH_ALG).update(tarballBytes).digest('hex');
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Verify a downloaded marketplace artifact's integrity (hash) and authenticity
|
|
636
|
+
* (signature). Throws `ArtifactVerificationError` on ANY mismatch — the caller
|
|
637
|
+
* MUST let it propagate so the install aborts before extraction.
|
|
638
|
+
*
|
|
639
|
+
* Constant-time-ish hash comparison: hashes are fixed-length lowercase hex, and
|
|
640
|
+
* the early-exit risk is negligible (the hash is public), but we still compare
|
|
641
|
+
* full strings rather than prefixes.
|
|
642
|
+
*/
|
|
643
|
+
export function verifyArtifact(input) {
|
|
644
|
+
const { tarballBytes, expectedHash, signature, publicKey, requireSignature = false, } = input;
|
|
645
|
+
if (!expectedHash || !/^[0-9a-f]{64}$/.test(expectedHash)) {
|
|
646
|
+
throw new ArtifactVerificationError(`Refusing to install: listing did not provide a valid ${ARTIFACT_HASH_ALG} content hash.`);
|
|
647
|
+
}
|
|
648
|
+
// 1. Integrity — recompute the hash over the EXACT downloaded bytes.
|
|
649
|
+
const actualHash = computeArtifactHash(tarballBytes);
|
|
650
|
+
if (actualHash !== expectedHash) {
|
|
651
|
+
throw new ArtifactVerificationError(`Artifact hash mismatch — refusing to install. ` +
|
|
652
|
+
`expected ${ARTIFACT_HASH_ALG}=${expectedHash}, got ${actualHash}. ` +
|
|
653
|
+
`The downloaded bytes are NOT the bytes that were approved.`);
|
|
654
|
+
}
|
|
655
|
+
// 2. Authenticity — verify the Ed25519 signature over the hash.
|
|
656
|
+
if (!signature || !publicKey) {
|
|
657
|
+
if (requireSignature) {
|
|
658
|
+
throw new ArtifactVerificationError(signature
|
|
659
|
+
? 'Artifact signature present but no public key available to verify it — refusing to install.'
|
|
660
|
+
: 'Artifact is unsigned and a signature is required — refusing to install.');
|
|
661
|
+
}
|
|
662
|
+
// Deferred-key window: hash verified, no signature to check. Caller opted to
|
|
663
|
+
// allow unsigned (requireSignature=false).
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
let key;
|
|
667
|
+
try {
|
|
668
|
+
key = typeof publicKey === 'string' ? createPublicKey(publicKey) : publicKey;
|
|
669
|
+
}
|
|
670
|
+
catch (e) {
|
|
671
|
+
throw new ArtifactVerificationError(`Invalid artifact public key — refusing to install: ${e.message}`);
|
|
672
|
+
}
|
|
673
|
+
let ok = false;
|
|
674
|
+
try {
|
|
675
|
+
// Ed25519: digest algorithm is null (the scheme hashes internally). The
|
|
676
|
+
// signed payload is the UTF-8 bytes of the lowercase-hex hash — identical to
|
|
677
|
+
// what hq-pro signed at publish.
|
|
678
|
+
ok = cryptoVerify(null, Buffer.from(expectedHash, 'utf-8'), key, Buffer.from(signature, 'base64'));
|
|
679
|
+
}
|
|
680
|
+
catch (e) {
|
|
681
|
+
throw new ArtifactVerificationError(`Artifact signature verification errored — refusing to install: ${e.message}`);
|
|
682
|
+
}
|
|
683
|
+
if (!ok) {
|
|
684
|
+
throw new ArtifactVerificationError('Artifact signature is invalid — refusing to install. ' +
|
|
685
|
+
'The artifact was not signed by the platform key (possible tampering).');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
346
688
|
// ---------------------------------------------------------------------------
|
|
347
689
|
// Manifest validation (spec §Validation, 10 checks)
|
|
348
690
|
// ---------------------------------------------------------------------------
|
|
349
|
-
function validateManifest(payloadDir, hqVersion) {
|
|
691
|
+
export function validateManifest(payloadDir, hqVersion) {
|
|
350
692
|
// 1. parse
|
|
351
693
|
const manifestPath = path.join(payloadDir, 'package.yaml');
|
|
352
694
|
if (!fs.existsSync(manifestPath)) {
|
|
@@ -414,6 +756,26 @@ function validateManifest(payloadDir, hqVersion) {
|
|
|
414
756
|
}
|
|
415
757
|
}
|
|
416
758
|
}
|
|
759
|
+
// author + capabilities (US-001) — both OPTIONAL and backwards-compatible.
|
|
760
|
+
// Absent → fine (legacy packs). Present → must be well-shaped so a malformed
|
|
761
|
+
// attribution can't masquerade as a valid one.
|
|
762
|
+
if (m.author !== undefined) {
|
|
763
|
+
const a = m.author;
|
|
764
|
+
if (!a || typeof a !== 'object' || Array.isArray(a)) {
|
|
765
|
+
throw new Error('author must be a mapping with uid, handle, displayName');
|
|
766
|
+
}
|
|
767
|
+
for (const field of ['uid', 'handle', 'displayName']) {
|
|
768
|
+
if (typeof a[field] !== 'string' || a[field].trim() === '') {
|
|
769
|
+
throw new Error(`author.${field} must be a non-empty string`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
if (m.capabilities !== undefined) {
|
|
774
|
+
if (!Array.isArray(m.capabilities) ||
|
|
775
|
+
!m.capabilities.every((c) => typeof c === 'string')) {
|
|
776
|
+
throw new Error('capabilities must be a list of strings');
|
|
777
|
+
}
|
|
778
|
+
}
|
|
417
779
|
return m;
|
|
418
780
|
}
|
|
419
781
|
function readHqVersion(hqRoot) {
|
|
@@ -507,13 +869,31 @@ export function installToPackages(payloadDir, pkg, hqRoot) {
|
|
|
507
869
|
const packagesDir = path.join(hqRoot, 'core', 'packages');
|
|
508
870
|
fs.mkdirSync(packagesDir, { recursive: true });
|
|
509
871
|
const destDir = path.join(packagesDir, pkg.name);
|
|
510
|
-
|
|
511
|
-
|
|
872
|
+
// SECURITY/ATOMICITY (US-020): wire atomically. rsync into a sibling staging
|
|
873
|
+
// dir on the SAME filesystem first; only swap it into the final destination
|
|
874
|
+
// once the copy fully succeeds. A failed/partial rsync therefore never
|
|
875
|
+
// leaves a half-wired pack dir in place (which scan-packages.sh would then
|
|
876
|
+
// wire into host paths). On any error the staging dir is rolled back.
|
|
877
|
+
const stagingDir = fs.mkdtempSync(path.join(packagesDir, `.${pkg.name}.staging-`));
|
|
878
|
+
try {
|
|
879
|
+
// rsync preserves modes/symlinks; argv form — no shell.
|
|
880
|
+
const srcSlashed = payloadDir.endsWith('/') ? payloadDir : `${payloadDir}/`;
|
|
881
|
+
const stagingSlashed = stagingDir.endsWith('/') ? stagingDir : `${stagingDir}/`;
|
|
882
|
+
execFileSync('rsync', ['-a', srcSlashed, stagingSlashed], { stdio: 'inherit' });
|
|
883
|
+
// Swap into place: remove any prior install, then atomic rename. The brief
|
|
884
|
+
// window between rm and rename is unavoidable with a same-name dir, but the
|
|
885
|
+
// staged copy guarantees the NEW contents are fully present before we touch
|
|
886
|
+
// the live location.
|
|
887
|
+
if (fs.existsSync(destDir)) {
|
|
888
|
+
fs.rmSync(destDir, { recursive: true, force: true });
|
|
889
|
+
}
|
|
890
|
+
fs.renameSync(stagingDir, destDir);
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
if (fs.existsSync(stagingDir)) {
|
|
894
|
+
fs.rmSync(stagingDir, { recursive: true, force: true });
|
|
895
|
+
}
|
|
512
896
|
}
|
|
513
|
-
// rsync preserves modes/symlinks; tar also works. argv form — no shell.
|
|
514
|
-
const srcSlashed = payloadDir.endsWith('/') ? payloadDir : `${payloadDir}/`;
|
|
515
|
-
const destSlashed = destDir.endsWith('/') ? destDir : `${destDir}/`;
|
|
516
|
-
execFileSync('rsync', ['-a', srcSlashed, destSlashed], { stdio: 'inherit' });
|
|
517
897
|
return destDir;
|
|
518
898
|
}
|
|
519
899
|
/**
|
|
@@ -615,7 +995,7 @@ export async function installPack(source, opts = {}) {
|
|
|
615
995
|
let fetched;
|
|
616
996
|
switch (transport) {
|
|
617
997
|
case 'npm':
|
|
618
|
-
fetched = fetchNpm(source, tmpDir);
|
|
998
|
+
fetched = fetchNpm(source, tmpDir, opts.integrity);
|
|
619
999
|
break;
|
|
620
1000
|
case 'git':
|
|
621
1001
|
fetched = fetchGit(source, tmpDir, opts.followBranch ?? false);
|
|
@@ -623,6 +1003,9 @@ export async function installPack(source, opts = {}) {
|
|
|
623
1003
|
case 'local':
|
|
624
1004
|
fetched = fetchLocal(source, tmpDir);
|
|
625
1005
|
break;
|
|
1006
|
+
case 'marketplace':
|
|
1007
|
+
fetched = await fetchMarketplace(source, tmpDir, opts.marketplaceDeps ?? defaultMarketplaceDeps(), { requireSignature: opts.requireSignature });
|
|
1008
|
+
break;
|
|
626
1009
|
}
|
|
627
1010
|
const pkg = validateManifest(fetched.payloadDir, hqVersion);
|
|
628
1011
|
if (pkg.conditional) {
|
|
@@ -651,7 +1034,13 @@ export async function installPack(source, opts = {}) {
|
|
|
651
1034
|
// setup.sh can dedup against `core/core.yaml:recommended_packages` on
|
|
652
1035
|
// re-runs. Stamping the literal input (not the resolved SHA/version)
|
|
653
1036
|
// matches the verbatim equality check in setup.sh.
|
|
654
|
-
|
|
1037
|
+
//
|
|
1038
|
+
// US-006 EXCEPTION: for the marketplace transport we stamp the RESOLVED
|
|
1039
|
+
// source (`marketplace:<slug>@<version>`) rather than the literal input.
|
|
1040
|
+
// The version is what lets `hq packs update` detect a newer listing — the
|
|
1041
|
+
// bare `marketplace:<slug>` input carries no version to compare against.
|
|
1042
|
+
const stampedSource = transport === 'marketplace' ? fetched.resolvedSource : source;
|
|
1043
|
+
stampInstallSource(destDir, stampedSource);
|
|
655
1044
|
runScanPackages(hqRoot, { quiet: opts.quiet });
|
|
656
1045
|
say(chalk.green(`\nOK Installed ${pkg.name}@${pkg.version} -> ${path.relative(hqRoot, destDir)}/`));
|
|
657
1046
|
say(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
|
|
@@ -662,4 +1051,4 @@ export async function installPack(source, opts = {}) {
|
|
|
662
1051
|
}
|
|
663
1052
|
}
|
|
664
1053
|
//# sourceMappingURL=pack-install.js.map
|
|
665
|
-
//# debugId=
|
|
1054
|
+
//# debugId=dafe45c8-d9bb-5082-b5b7-aaf1a2ee5c7e
|
package/dist/commands/packs.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Spec: knowledge/public/hq-core/package-yaml-spec.md.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
21
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="fa6cf3bf-90fa-5261-a15f-c8810e4b5615")}catch(e){}}();
|
|
22
22
|
import * as fs from 'fs';
|
|
23
23
|
import * as path from 'path';
|
|
24
24
|
import * as readline from 'readline';
|
|
@@ -26,7 +26,7 @@ import { spawnSync } from 'child_process';
|
|
|
26
26
|
import chalk from 'chalk';
|
|
27
27
|
import semverSatisfies from 'semver/functions/satisfies.js';
|
|
28
28
|
import { findHqRoot } from '../utils/manifest.js';
|
|
29
|
-
import { classify, resolveLatest, runScanPackages, installPack, } from './pack-install.js';
|
|
29
|
+
import { classify, resolveLatest, resolveLatestMarketplace, runScanPackages, installPack, } from './pack-install.js';
|
|
30
30
|
import { contributionLinks, linkStatus, listInstalledPacks, readPackManifest, unwirePack, readHqVersion, readRecommendedPackages, packagesDir, } from '../utils/pack-contributions.js';
|
|
31
31
|
function resolveRoot(opts) {
|
|
32
32
|
return opts.hqRoot ? path.resolve(opts.hqRoot) : findHqRoot();
|
|
@@ -195,7 +195,12 @@ async function runUpdate(name, opts) {
|
|
|
195
195
|
results.push({ name: pname, transport: 'unknown', updateAvailable: null, applied: false, error: 'no stamped source -- cannot update' });
|
|
196
196
|
continue;
|
|
197
197
|
}
|
|
198
|
-
|
|
198
|
+
// US-006: marketplace sources need the async listings probe (network) —
|
|
199
|
+
// the sync `resolveLatest` cannot make that call and returns null. Every
|
|
200
|
+
// other transport keeps the existing synchronous probe unchanged.
|
|
201
|
+
const probe = safeClassify(source) === 'marketplace'
|
|
202
|
+
? await resolveLatestMarketplace(source, m.version)
|
|
203
|
+
: resolveLatest(source, m.version);
|
|
199
204
|
const base = {
|
|
200
205
|
name: pname,
|
|
201
206
|
transport: probe.transport,
|
|
@@ -403,4 +408,4 @@ export function registerPacksCommand(parent) {
|
|
|
403
408
|
});
|
|
404
409
|
}
|
|
405
410
|
//# sourceMappingURL=packs.js.map
|
|
406
|
-
//# debugId=
|
|
411
|
+
//# debugId=fa6cf3bf-90fa-5261-a15f-c8810e4b5615
|