@indigoai-us/hq-cli 5.33.0 → 5.34.0

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 (39) hide show
  1. package/dist/commands/__fixtures__/make-tar.d.ts +46 -0
  2. package/dist/commands/__fixtures__/make-tar.js +105 -0
  3. package/dist/commands/master-sync.d.ts +11 -0
  4. package/dist/commands/master-sync.js +15 -0
  5. package/dist/commands/pack-install.d.ts +187 -1
  6. package/dist/commands/pack-install.js +405 -16
  7. package/dist/commands/packs.js +9 -4
  8. package/dist/commands/publish.d.ts +186 -0
  9. package/dist/commands/publish.js +375 -0
  10. package/dist/commands/rescue.d.ts +33 -0
  11. package/dist/commands/rescue.js +161 -0
  12. package/dist/commands/safe-extract.d.ts +154 -0
  13. package/dist/commands/safe-extract.js +347 -0
  14. package/dist/index.js +17 -2
  15. package/dist/lib/local-tree-diff.d.ts +21 -0
  16. package/dist/lib/local-tree-diff.js +18 -3
  17. package/dist/types.d.ts +22 -0
  18. package/dist/utils/vault-api.d.ts +11 -0
  19. package/dist/utils/vault-api.js +39 -2
  20. package/package.json +2 -2
  21. package/src/commands/__fixtures__/make-tar.ts +126 -0
  22. package/src/commands/artifact-verify.test.ts +177 -0
  23. package/src/commands/marketplace-install.test.ts +414 -0
  24. package/src/commands/marketplace-security.test.ts +646 -0
  25. package/src/commands/master-sync.ts +23 -0
  26. package/src/commands/pack-install.test.ts +209 -1
  27. package/src/commands/pack-install.ts +617 -15
  28. package/src/commands/packs.ts +8 -1
  29. package/src/commands/publish.test.ts +538 -0
  30. package/src/commands/publish.ts +517 -0
  31. package/src/commands/rescue.test.ts +39 -0
  32. package/src/commands/rescue.ts +210 -0
  33. package/src/commands/safe-extract.test.ts +459 -0
  34. package/src/commands/safe-extract.ts +444 -0
  35. package/src/index.ts +18 -0
  36. package/src/lib/local-tree-diff.test.ts +19 -0
  37. package/src/lib/local-tree-diff.ts +17 -1
  38. package/src/types.ts +23 -0
  39. package/src/utils/vault-api.ts +41 -0
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * HQ CLI - Module management, package management, and cloud sync for HQ
4
4
  */
5
5
 
6
- !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]="89e1a66a-6a45-56a3-847c-2f0beba1429b")}catch(e){}}();
6
+ !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]="004dc716-c660-5745-94db-988ac49d102e")}catch(e){}}();
7
7
  import { Command } from "commander";
8
8
  import { initSentry, Sentry } from "./sentry.js";
9
9
  import { registerAddCommand } from "./commands/add.js";
@@ -24,6 +24,7 @@ import { registerPackageRemoveCommand } from "./commands/pkg-remove.js";
24
24
  import { registerPackageUpdateCommand } from "./commands/pkg-update.js";
25
25
  import { registerPackageListCommand } from "./commands/pkg-list.js";
26
26
  import { registerPacksCommand } from "./commands/packs.js";
27
+ import { registerPublishCommand } from "./commands/publish.js";
27
28
  import { registerTeamSyncCommand } from "./commands/team-sync.js";
28
29
  import { registerAuthCommands } from "./commands/auth.js";
29
30
  import { registerSecretsCommand } from "./commands/secrets.js";
@@ -38,6 +39,8 @@ import { registerFeedbackCommand } from "./commands/feedback.js";
38
39
  import { registerMeetingsCommand } from "./commands/meetings.js";
39
40
  import { registerSourcesCommand } from "./commands/sources.js";
40
41
  import { registerSignalsCommand } from "./commands/signals.js";
42
+ import { registerMasterSyncCommand } from "./commands/master-sync.js";
43
+ import { registerRescueCommand } from "./commands/rescue.js";
41
44
  import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
42
45
  import { maybeWarnNewVersion, refreshVersionCache, } from "./utils/version-check.js";
43
46
  import { enforceVersionGate, shouldSkipGate, } from "./utils/version-gate.js";
@@ -84,6 +87,10 @@ registerPacksCommand(program);
84
87
  // "hq remove <slug>" = "hq packages remove <slug>"
85
88
  registerPackageInstallCommand(program);
86
89
  registerPackageRemoveCommand(program);
90
+ // Marketplace publish (top-level — packer + authenticated upload, US-004)
91
+ // "hq publish <skill-or-worker-path>" packages and submits a pack to the
92
+ // marketplace via POST /v1/listings.
93
+ registerPublishCommand(program);
87
94
  // Cloud sync subcommand group
88
95
  const syncCmd = program
89
96
  .command("sync")
@@ -132,6 +139,14 @@ registerMeetingsCommand(program);
132
139
  registerSourcesCommand(program);
133
140
  // Signals read surface (subcommand group — hq signals list|get|types|entities)
134
141
  registerSignalsCommand(program);
142
+ // Skill/personal-overlay mirroring + workers-registry regen. Internal command
143
+ // invoked by the hq-core master-sync hook shim (formerly the master-sync.sh
144
+ // hook body). Implementation lives in @indigoai-us/hq-cloud.
145
+ registerMasterSyncCommand(program);
146
+ // Drift-preserving HQ-core re-sync (top-level — `hq rescue`). CLI sibling of
147
+ // the HQ Sync app's "Update / Restore" pill; drives the same replace-rescue.sh
148
+ // shipped from @indigoai-us/hq-cloud.
149
+ registerRescueCommand(program);
135
150
  (async () => {
136
151
  try {
137
152
  Sentry.addBreadcrumb({
@@ -158,4 +173,4 @@ registerSignalsCommand(program);
158
173
  }
159
174
  })();
160
175
  //# sourceMappingURL=index.js.map
161
- //# debugId=89e1a66a-6a45-56a3-847c-2f0beba1429b
176
+ //# debugId=004dc716-c660-5745-94db-988ac49d102e
@@ -89,6 +89,27 @@ export interface BuildNarrowPlanInput {
89
89
  * files yet.
90
90
  */
91
91
  export declare function buildNarrowPlan(input: BuildNarrowPlanInput): NarrowPlan;
92
+ /**
93
+ * Recursive readdir walk yielding regular files (and dangling-target
94
+ * symlinks — those are recorded as files for narrow-plan purposes; the
95
+ * delete path uses `fs.unlinkSync` which handles symlinks correctly).
96
+ *
97
+ * Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
98
+ * target chain (matches the share-engine convention).
99
+ */
100
+ /**
101
+ * Normalize OS-native path separators to POSIX "/" for vault keys.
102
+ *
103
+ * `path.relative()` yields "\\"-separated paths on Windows, but vault S3 keys
104
+ * are always "/"-separated — the server splits keys on "/" to rebuild the tree
105
+ * and the journal + grants endpoint use that same namespace. Emit POSIX keys
106
+ * regardless of client OS so a Windows narrow-plan lines up with the
107
+ * forward-slash keys everywhere else. Mirrors the same normalization on
108
+ * @indigoai-us/hq-cloud's upload path. Converting "\\" explicitly (rather than
109
+ * only path.sep) keeps the result correct — and the regression test meaningful
110
+ * — on a POSIX CI too.
111
+ */
112
+ export declare function toPosixKey(p: string): string;
92
113
  /**
93
114
  * Render the dry-run summary as plain text (no chalk — keep it pure). The
94
115
  * CLI wrapper can colorize lines afterwards if desired.
@@ -24,7 +24,7 @@
24
24
  * for the destructive side effects (delete, tombstone, PUT sync-config).
25
25
  */
26
26
 
27
- !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]="20a280f3-0fed-5868-9ad4-fcc562253486")}catch(e){}}();
27
+ !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]="8f0265e5-8320-54bb-b2c9-b03ee9581af8")}catch(e){}}();
28
28
  import * as fs from "node:fs";
29
29
  import * as path from "node:path";
30
30
  import { hashFile, isCoveredByAny, } from "@indigoai-us/hq-cloud";
@@ -109,6 +109,21 @@ function emptyPlan() {
109
109
  * Uses `lstat` rather than `stat` so a symlink's size doesn't follow the
110
110
  * target chain (matches the share-engine convention).
111
111
  */
112
+ /**
113
+ * Normalize OS-native path separators to POSIX "/" for vault keys.
114
+ *
115
+ * `path.relative()` yields "\\"-separated paths on Windows, but vault S3 keys
116
+ * are always "/"-separated — the server splits keys on "/" to rebuild the tree
117
+ * and the journal + grants endpoint use that same namespace. Emit POSIX keys
118
+ * regardless of client OS so a Windows narrow-plan lines up with the
119
+ * forward-slash keys everywhere else. Mirrors the same normalization on
120
+ * @indigoai-us/hq-cloud's upload path. Converting "\\" explicitly (rather than
121
+ * only path.sep) keeps the result correct — and the regression test meaningful
122
+ * — on a POSIX CI too.
123
+ */
124
+ export function toPosixKey(p) {
125
+ return p.split("\\").join("/");
126
+ }
112
127
  function walkLocal(dir,
113
128
  // Rel-root for `relPath` — the company walk root (`<hqRoot>/companies/<slug>`),
114
129
  // so emitted `relPath`s are company-relative. Fixed across recursion.
@@ -127,7 +142,7 @@ relRoot, emit) {
127
142
  }
128
143
  for (const entry of entries) {
129
144
  const absPath = path.join(dir, entry.name);
130
- const relPath = path.relative(relRoot, absPath);
145
+ const relPath = toPosixKey(path.relative(relRoot, absPath));
131
146
  if (entry.isSymbolicLink()) {
132
147
  // Record the link as a file-like entry. Don't descend — narrow is
133
148
  // about pruning files that the LOCAL tree has materialized here; a
@@ -253,4 +268,4 @@ export function formatBytes(n) {
253
268
  return `${v.toFixed(2)} ${units[i]}`;
254
269
  }
255
270
  //# sourceMappingURL=local-tree-diff.js.map
256
- //# debugId=20a280f3-0fed-5868-9ad4-fcc562253486
271
+ //# debugId=8f0265e5-8320-54bb-b2c9-b03ee9581af8
package/dist/types.d.ts CHANGED
@@ -57,6 +57,17 @@ export interface SyncResult {
57
57
  filesChanged?: number;
58
58
  }
59
59
  export type PackContributeKey = 'workers' | 'knowledge' | 'skills' | 'commands' | 'hooks' | 'policies' | 'scripts';
60
+ /**
61
+ * Pack authorship attribution (US-001). OPTIONAL and backwards-compatible —
62
+ * packs published before this field still validate. When present, install can
63
+ * attribute the pack to a creator: `uid` is the HQ person UID, `handle` the
64
+ * creator's marketplace handle, `displayName` the human-readable name.
65
+ */
66
+ export interface PackAuthor {
67
+ uid: string;
68
+ handle: string;
69
+ displayName: string;
70
+ }
60
71
  export interface PackManifest {
61
72
  name: string;
62
73
  version: string;
@@ -71,5 +82,16 @@ export interface PackManifest {
71
82
  repository?: string;
72
83
  keywords?: string[];
73
84
  conditional?: string;
85
+ /**
86
+ * Pack authorship attribution (US-001). Optional — absent on legacy packs.
87
+ */
88
+ author?: PackAuthor;
89
+ /**
90
+ * Declared capabilities the pack touches (US-001), e.g. hooks/scripts/
91
+ * network/fs/secrets. Optional, free-form string entries — surfaced to the
92
+ * user at install time for an at-a-glance trust signal. Reserved here; not
93
+ * yet enforced.
94
+ */
95
+ capabilities?: string[];
74
96
  }
75
97
  //# sourceMappingURL=types.d.ts.map
@@ -6,6 +6,17 @@ export interface VaultApiOptions {
6
6
  query?: Record<string, string>;
7
7
  }
8
8
  export declare function vaultApiFetch(opts: VaultApiOptions): Promise<Response>;
9
+ /**
10
+ * Public (NONE-auth) GET against the vault API — no bearer token. The
11
+ * marketplace browse endpoints (`GET /v1/listings`, `GET /v1/listings/{id}`)
12
+ * from US-005 are public so a logged-out user can resolve + download an
13
+ * approved pack. Distinct from `vaultApiFetch`, which always attaches a
14
+ * bearer token.
15
+ */
16
+ export declare function vaultApiFetchPublic(opts: {
17
+ path: string;
18
+ query?: Record<string, string>;
19
+ }): Promise<Response>;
9
20
  export declare function getCompanyUid(token: string, companySlug: string | undefined): Promise<string>;
10
21
  export declare function resolveCallerPersonUid(token: string): Promise<string>;
11
22
  export declare function getEntityUid(token: string, opts: {
@@ -1,5 +1,5 @@
1
1
 
2
- !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]="e1ca2b83-1de0-5000-ab66-f573000a579b")}catch(e){}}();
2
+ !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]="bdd04f0a-8d43-5072-916d-a801a2f25731")}catch(e){}}();
3
3
  import { DEFAULT_VAULT_API_URL } from './cognito-session.js';
4
4
  import { Sentry } from '../sentry.js';
5
5
  export async function vaultApiFetch(opts) {
@@ -35,6 +35,43 @@ export async function vaultApiFetch(opts) {
35
35
  }
36
36
  return response;
37
37
  }
38
+ /**
39
+ * Public (NONE-auth) GET against the vault API — no bearer token. The
40
+ * marketplace browse endpoints (`GET /v1/listings`, `GET /v1/listings/{id}`)
41
+ * from US-005 are public so a logged-out user can resolve + download an
42
+ * approved pack. Distinct from `vaultApiFetch`, which always attaches a
43
+ * bearer token.
44
+ */
45
+ export async function vaultApiFetchPublic(opts) {
46
+ const url = new URL(opts.path, DEFAULT_VAULT_API_URL);
47
+ if (opts.query) {
48
+ for (const [k, v] of Object.entries(opts.query)) {
49
+ url.searchParams.set(k, v);
50
+ }
51
+ }
52
+ const safeUrl = url.search
53
+ ? `${url.origin}${url.pathname}?<redacted>`
54
+ : `${url.origin}${url.pathname}`;
55
+ Sentry.addBreadcrumb({
56
+ category: 'http',
57
+ message: `GET ${opts.path}`,
58
+ level: 'info',
59
+ data: { url: safeUrl, method: 'GET' },
60
+ });
61
+ const response = await fetch(url.toString(), {
62
+ method: 'GET',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ });
65
+ if (!response.ok) {
66
+ Sentry.addBreadcrumb({
67
+ category: 'http',
68
+ message: `GET ${opts.path} → ${response.status}`,
69
+ level: 'warning',
70
+ data: { url: safeUrl, status: response.status },
71
+ });
72
+ }
73
+ return response;
74
+ }
38
75
  async function resolveCompanyUid(token, slug) {
39
76
  const res = await vaultApiFetch({
40
77
  token,
@@ -106,4 +143,4 @@ export async function getEntityUid(token, opts) {
106
143
  return getCompanyUid(token, opts.companySlug);
107
144
  }
108
145
  //# sourceMappingURL=vault-api.js.map
109
- //# debugId=e1ca2b83-1de0-5000-ab66-f573000a579b
146
+ //# debugId=bdd04f0a-8d43-5072-916d-a801a2f25731
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.33.0",
3
+ "version": "5.34.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -15,7 +15,7 @@
15
15
  "clean": "rm -rf dist"
16
16
  },
17
17
  "dependencies": {
18
- "@indigoai-us/hq-cloud": "~5.47.0",
18
+ "@indigoai-us/hq-cloud": "~5.48.0",
19
19
  "@indigoai-us/hq-onboarding": "^0.1.0",
20
20
  "@sentry/node": "^10.49.0",
21
21
  "chalk": "^5.3.0",
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Deterministic, platform-independent malicious-tar fixture builder.
3
+ *
4
+ * WHY THIS EXISTS (cross-platform zip-slip coverage). The adversarial
5
+ * safe-extract / marketplace-security suites need archives that GENUINELY carry
6
+ * hostile entries — a `../` traversal name, an absolute path, an escaping
7
+ * symlink/hardlink. The obvious approach (shell out to the system `tar` to
8
+ * CREATE the archive) is NOT portable: GNU tar (Linux / CI / prod Lambdas)
9
+ * STRIPS the leading `../` and absolute `/` when it WRITES an archive, so the
10
+ * resulting "malicious" fixture contains a benign `evil-escape` entry and the
11
+ * guard has nothing to reject — the test passes on macOS bsdtar (which
12
+ * preserves them) but fails on Linux. The guard itself is correct; only the
13
+ * fixture construction was platform-dependent.
14
+ *
15
+ * Fix: WRITE THE TAR BYTES DIRECTLY here. A tar archive is just a sequence of
16
+ * 512-byte ustar headers, each optionally followed by the file's content padded
17
+ * to a 512-byte boundary, terminated by two zero blocks. By emitting the bytes
18
+ * ourselves we control the recorded entry names verbatim — `../evil-escape`,
19
+ * `/tmp/evil`, a symlink with linkname `../../etc/passwd` — identically on every
20
+ * platform, with no create-time stripping. The reading side (`tar -tvf`, which
21
+ * safeExtractTarball's pre-flight uses) does NOT strip anything; it faithfully
22
+ * lists whatever bytes we wrote, so the guard sees the real attack everywhere.
23
+ */
24
+
25
+ import { gzipSync } from 'node:zlib';
26
+ import * as fs from 'node:fs';
27
+
28
+ const BLOCK = 512;
29
+
30
+ /** ustar typeflag values we use. */
31
+ export type TarType = 'file' | 'symlink' | 'hardlink';
32
+
33
+ const TYPEFLAG: Record<TarType, string> = {
34
+ file: '0', // regular file
35
+ symlink: '2', // symbolic link
36
+ hardlink: '1', // hard link
37
+ };
38
+
39
+ export interface TarEntrySpec {
40
+ /** Recorded entry name — written VERBATIM (may contain `..`, be absolute…). */
41
+ name: string;
42
+ type?: TarType; // default 'file'
43
+ /** For sym/hardlinks: the recorded link target (also written verbatim). */
44
+ linkname?: string;
45
+ /** File content (regular files only). */
46
+ content?: string | Buffer;
47
+ }
48
+
49
+ /** Write an ASCII string into a fixed-width field at `offset`, NUL-padded. */
50
+ function writeField(buf: Buffer, offset: number, width: number, value: string): void {
51
+ // Truncate defensively; ustar fields are fixed-width.
52
+ const s = value.slice(0, width);
53
+ buf.write(s, offset, 'ascii');
54
+ // Remaining bytes are already 0 from Buffer.alloc.
55
+ }
56
+
57
+ /** Write an octal numeric field: `width-1` octal digits, space-or-NUL terminated. */
58
+ function writeOctal(buf: Buffer, offset: number, width: number, value: number): void {
59
+ // Classic ustar numeric field: zero-padded octal in (width-1) chars + NUL.
60
+ const digits = width - 1;
61
+ const oct = value.toString(8).padStart(digits, '0').slice(-digits);
62
+ buf.write(oct, offset, 'ascii');
63
+ buf[offset + digits] = 0; // NUL terminator
64
+ }
65
+
66
+ /**
67
+ * Build one 512-byte ustar header (plus content blocks for regular files) for a
68
+ * single entry. The header checksum is computed exactly per spec: sum every
69
+ * header byte treating the 8 checksum bytes themselves as ASCII spaces, then
70
+ * write that sum as 6 octal digits + NUL + space.
71
+ */
72
+ export function makeTarEntry(spec: TarEntrySpec): Buffer {
73
+ const type = spec.type ?? 'file';
74
+ const content =
75
+ type === 'file'
76
+ ? Buffer.isBuffer(spec.content)
77
+ ? spec.content
78
+ : Buffer.from(spec.content ?? '', 'utf-8')
79
+ : Buffer.alloc(0); // links carry no content payload
80
+
81
+ const header = Buffer.alloc(BLOCK); // zero-filled
82
+
83
+ writeField(header, 0, 100, spec.name); // name
84
+ writeOctal(header, 100, 8, 0o644); // mode
85
+ writeOctal(header, 108, 8, 0); // uid
86
+ writeOctal(header, 116, 8, 0); // gid
87
+ writeOctal(header, 124, 12, content.length); // size (0 for links)
88
+ writeOctal(header, 136, 12, 0); // mtime (deterministic: epoch)
89
+ // checksum field (148, 8) — filled below; start as spaces for the sum.
90
+ header.fill(' '.charCodeAt(0), 148, 156);
91
+ writeField(header, 156, 1, TYPEFLAG[type]); // typeflag
92
+ if (spec.linkname !== undefined) {
93
+ writeField(header, 157, 100, spec.linkname); // linkname (verbatim)
94
+ }
95
+ writeField(header, 257, 6, 'ustar'); // magic "ustar\0"
96
+ writeField(header, 263, 2, '00'); // version "00"
97
+ // uname/gname left empty; devmajor/devminor zero (already NUL).
98
+
99
+ // Header checksum: unsigned sum of all 512 bytes (with the checksum field as
100
+ // spaces, which we set above). Written as 6 octal digits, NUL, space.
101
+ let sum = 0;
102
+ for (let i = 0; i < BLOCK; i++) sum += header[i];
103
+ const cksum = sum.toString(8).padStart(6, '0').slice(-6);
104
+ header.write(cksum, 148, 'ascii');
105
+ header[154] = 0; // NUL
106
+ header[155] = ' '.charCodeAt(0); // space
107
+
108
+ if (type !== 'file' || content.length === 0) return header;
109
+
110
+ // Content padded up to a 512-byte boundary.
111
+ const pad = (BLOCK - (content.length % BLOCK)) % BLOCK;
112
+ return Buffer.concat([header, content, Buffer.alloc(pad)]);
113
+ }
114
+
115
+ /** Concatenate entries and append the two zero blocks that end every tar. */
116
+ export function makeTar(entries: TarEntrySpec[]): Buffer {
117
+ const blocks = entries.map(makeTarEntry);
118
+ blocks.push(Buffer.alloc(BLOCK * 2)); // end-of-archive marker
119
+ return Buffer.concat(blocks);
120
+ }
121
+
122
+ /** Build the tar, gzip it, and write it to `outPath`. Returns `outPath`. */
123
+ export function writeMaliciousTarGz(outPath: string, entries: TarEntrySpec[]): string {
124
+ fs.writeFileSync(outPath, gzipSync(makeTar(entries)));
125
+ return outPath;
126
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Marketplace artifact verification (US-021, INSTALL side) tests.
3
+ *
4
+ * Proves the install-time guarantee: the bytes we install are EXACTLY the bytes
5
+ * a moderator approved. Covers the two E2E behaviors required by the story:
6
+ * - an approved artifact whose S3 object is mutated → hash mismatch → REFUSE.
7
+ * - signature verification against the platform Ed25519 PUBLIC key.
8
+ *
9
+ * SHARED CONTRACT mirror — we reproduce the publish-side signing here with an
10
+ * ephemeral in-test keypair (no key material in the repo) to prove both sides
11
+ * agree byte-for-byte.
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import {
16
+ generateKeyPairSync,
17
+ createHash,
18
+ sign as cryptoSign,
19
+ } from 'node:crypto';
20
+ import {
21
+ verifyArtifact,
22
+ computeArtifactHash,
23
+ ArtifactVerificationError,
24
+ ARTIFACT_HASH_ALG,
25
+ } from './pack-install.js';
26
+
27
+ // Ephemeral platform keypair (regenerated each run — never committed).
28
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
29
+ const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
30
+
31
+ /** Reproduce the hq-pro publish-side: sha256 hex hash + Ed25519 over the hash. */
32
+ function publishSide(bytes: Buffer): { contentHash: string; signature: string } {
33
+ const contentHash = createHash('sha256').update(bytes).digest('hex');
34
+ const signature = cryptoSign(
35
+ null,
36
+ Buffer.from(contentHash, 'utf-8'),
37
+ privateKey,
38
+ ).toString('base64');
39
+ return { contentHash, signature };
40
+ }
41
+
42
+ const APPROVED_BYTES = Buffer.from('the-approved-pack.tar.gz-bytes');
43
+
44
+ describe('computeArtifactHash', () => {
45
+ it('matches a lowercase-hex sha256 (shared contract with publish)', () => {
46
+ const expected = createHash('sha256').update(APPROVED_BYTES).digest('hex');
47
+ expect(computeArtifactHash(APPROVED_BYTES)).toBe(expected);
48
+ expect(ARTIFACT_HASH_ALG).toBe('sha256');
49
+ });
50
+ });
51
+
52
+ describe('verifyArtifact — integrity (hash)', () => {
53
+ it('passes when the downloaded bytes match the pinned hash + valid signature', () => {
54
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
55
+ expect(() =>
56
+ verifyArtifact({
57
+ tarballBytes: APPROVED_BYTES,
58
+ expectedHash: contentHash,
59
+ signature,
60
+ publicKey: publicKeyPem,
61
+ requireSignature: true,
62
+ }),
63
+ ).not.toThrow();
64
+ });
65
+
66
+ it('E2E: a MUTATED S3 object → hash mismatch → REFUSES', () => {
67
+ // Publish/approval pinned the hash of APPROVED_BYTES…
68
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
69
+ // …but the bytes we actually downloaded were swapped by an attacker.
70
+ const mutatedBytes = Buffer.from('EVIL-swapped-pack.tar.gz-bytes');
71
+
72
+ expect(() =>
73
+ verifyArtifact({
74
+ tarballBytes: mutatedBytes,
75
+ expectedHash: contentHash,
76
+ signature,
77
+ publicKey: publicKeyPem,
78
+ }),
79
+ ).toThrow(ArtifactVerificationError);
80
+
81
+ try {
82
+ verifyArtifact({
83
+ tarballBytes: mutatedBytes,
84
+ expectedHash: contentHash,
85
+ });
86
+ } catch (e) {
87
+ expect((e as Error).message).toMatch(/hash mismatch/i);
88
+ }
89
+ });
90
+
91
+ it('refuses when the listing provides no valid hash', () => {
92
+ expect(() =>
93
+ verifyArtifact({ tarballBytes: APPROVED_BYTES, expectedHash: '' }),
94
+ ).toThrow(/valid sha256 content hash/i);
95
+ expect(() =>
96
+ verifyArtifact({ tarballBytes: APPROVED_BYTES, expectedHash: 'not-a-hash' }),
97
+ ).toThrow(ArtifactVerificationError);
98
+ });
99
+ });
100
+
101
+ describe('verifyArtifact — authenticity (signature)', () => {
102
+ it('refuses a hash-valid artifact whose signature was forged by a different key', () => {
103
+ const { contentHash } = publishSide(APPROVED_BYTES);
104
+ // Attacker signs the (correct) hash with their OWN key.
105
+ const { privateKey: evilKey } = generateKeyPairSync('ed25519');
106
+ const forged = cryptoSign(
107
+ null,
108
+ Buffer.from(contentHash, 'utf-8'),
109
+ evilKey,
110
+ ).toString('base64');
111
+
112
+ expect(() =>
113
+ verifyArtifact({
114
+ tarballBytes: APPROVED_BYTES,
115
+ expectedHash: contentHash,
116
+ signature: forged,
117
+ publicKey: publicKeyPem, // platform key — won't validate the forgery
118
+ }),
119
+ ).toThrow(/signature is invalid/i);
120
+ });
121
+
122
+ it('refuses on a corrupt/garbage signature', () => {
123
+ const { contentHash } = publishSide(APPROVED_BYTES);
124
+ expect(() =>
125
+ verifyArtifact({
126
+ tarballBytes: APPROVED_BYTES,
127
+ expectedHash: contentHash,
128
+ signature: 'not-base64-or-a-real-sig!!!',
129
+ publicKey: publicKeyPem,
130
+ }),
131
+ ).toThrow(ArtifactVerificationError);
132
+ });
133
+
134
+ it('refuses on an invalid public key', () => {
135
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
136
+ expect(() =>
137
+ verifyArtifact({
138
+ tarballBytes: APPROVED_BYTES,
139
+ expectedHash: contentHash,
140
+ signature,
141
+ publicKey: 'not a real pem',
142
+ }),
143
+ ).toThrow(/public key/i);
144
+ });
145
+ });
146
+
147
+ describe('verifyArtifact — deferred-key window', () => {
148
+ it('hash-verifies but skips signature when none provided (requireSignature=false)', () => {
149
+ const { contentHash } = publishSide(APPROVED_BYTES);
150
+ expect(() =>
151
+ verifyArtifact({ tarballBytes: APPROVED_BYTES, expectedHash: contentHash }),
152
+ ).not.toThrow();
153
+ });
154
+
155
+ it('refuses an unsigned artifact when requireSignature=true', () => {
156
+ const { contentHash } = publishSide(APPROVED_BYTES);
157
+ expect(() =>
158
+ verifyArtifact({
159
+ tarballBytes: APPROVED_BYTES,
160
+ expectedHash: contentHash,
161
+ requireSignature: true,
162
+ }),
163
+ ).toThrow(/unsigned/i);
164
+ });
165
+
166
+ it('refuses when a signature is present but no public key is available (requireSignature=true)', () => {
167
+ const { contentHash, signature } = publishSide(APPROVED_BYTES);
168
+ expect(() =>
169
+ verifyArtifact({
170
+ tarballBytes: APPROVED_BYTES,
171
+ expectedHash: contentHash,
172
+ signature,
173
+ requireSignature: true,
174
+ }),
175
+ ).toThrow(/public key/i);
176
+ });
177
+ });