@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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * safe-extract — hardened tarball extraction for the content-pack installer.
3
+ *
4
+ * THREAT MODEL (US-020). A pack tarball is UNTRUSTED input fetched from npm,
5
+ * a git remote, or a local path the user merely pointed at. Naively shelling
6
+ * out to `tar -xzf <tarball> -C <dir>` trusts the archive to behave; a hostile
7
+ * archive can:
8
+ *
9
+ * 1. Path traversal / "zip slip": an entry named `../../etc/cron.d/evil` or
10
+ * an absolute `/etc/passwd` escapes the intended target dir and clobbers
11
+ * arbitrary files (RCE if it lands a hook / cron / shell-rc).
12
+ * 2. Symlink/hardlink escape: an entry that is a symlink whose target points
13
+ * OUTSIDE the pack dir (e.g. `link -> /etc`), optionally followed by a
14
+ * second entry that writes "through" the link — the classic two-step
15
+ * tar symlink attack.
16
+ * 3. Decompression bomb: a tiny `.tgz` that inflates to terabytes (many
17
+ * files, or one enormous sparse/zero file), exhausting disk or memory.
18
+ *
19
+ * DEFENSE (defense-in-depth, default-deny):
20
+ * - PRE-FLIGHT: enumerate every entry's header (type, size, name, link
21
+ * target) via `tar -tv` WITHOUT extracting. Reject the whole archive if
22
+ * ANY entry fails a containment or cap check. Containment uses normalized
23
+ * path-prefix logic on the *resolved* path, never naive string matching.
24
+ * - CAPS: enforced against the header-reported uncompressed sizes during the
25
+ * pre-flight scan, so we abort BEFORE writing a single byte of a bomb.
26
+ * - STAGED + ATOMIC: extraction lands in a sibling staging dir on the SAME
27
+ * filesystem as the final destination; only after a successful, fully
28
+ * validated extract do we `fs.rename` it into place (atomic). Any failure
29
+ * mid-extract rolls the staging dir back entirely (`rm -rf` in finally) —
30
+ * no partial/half-wired pack is ever left behind.
31
+ * - POST-EXTRACT: an independent realpath containment sweep over everything
32
+ * actually written (we do NOT rely solely on the pre-flight scan or on the
33
+ * tar binary's own protections).
34
+ */
35
+ export interface ExtractCaps {
36
+ /** Max total uncompressed bytes across all entries. Default 256 MiB. */
37
+ maxUncompressedBytes: number;
38
+ /** Max number of entries in the archive. Default 20,000. */
39
+ maxFileCount: number;
40
+ /** Max uncompressed bytes of any single entry. Default 64 MiB. */
41
+ maxSingleFileBytes: number;
42
+ }
43
+ export declare const DEFAULT_CAPS: ExtractCaps;
44
+ export declare class UnsafeArchiveError extends Error {
45
+ constructor(message: string);
46
+ }
47
+ export type EntryType = 'file' | 'dir' | 'symlink' | 'hardlink' | 'other';
48
+ export interface TarEntry {
49
+ type: EntryType;
50
+ /** Entry path as recorded in the archive (may contain `..`, be absolute…). */
51
+ name: string;
52
+ /** Uncompressed size in bytes from the header (0 for dirs/links). */
53
+ size: number;
54
+ /** For sym/hardlinks: the link target as recorded in the archive. */
55
+ linkTarget?: string;
56
+ }
57
+ /**
58
+ * Parse the verbose listing produced by `tar -tv`. We use the verbose form so
59
+ * we get the type flag, size, and (for links) the `name -> target` /
60
+ * `name link to target` suffix without extracting anything.
61
+ *
62
+ * SECURITY-CRITICAL CROSS-PLATFORM NOTE. bsdtar (macOS/BSD) and GNU tar
63
+ * (Linux / prod Lambdas) format the verbose listing DIFFERENTLY, and an
64
+ * implementation that only understands one of them silently parses ZERO
65
+ * entries against the other — which means the pre-flight containment scan
66
+ * sees nothing to reject and every malicious archive sails through. The two
67
+ * shapes we must both handle:
68
+ *
69
+ * bsdtar: <mode> <links> <owner> <group> <size> <Mon> <DD> <HH:MM|YYYY> <name...>
70
+ * -rw-r--r-- 0 user group 1234 Jan 1 00:00 path/file
71
+ * lrwxr-xr-x 0 user group 0 Jan 1 00:00 link -> target
72
+ *
73
+ * GNU: <mode> <owner>/<group> <size> <YYYY-MM-DD> <HH:MM[:SS]> <name...>
74
+ * -rw-r--r-- user/group 1234 2026-01-01 00:00 path/file
75
+ * lrwxrwxrwx user/group 0 2026-01-01 00:00 link -> target
76
+ * hrw-r--r-- user/group 0 2026-01-01 00:00 hard link to target
77
+ *
78
+ * GNU has NO link-count column, joins owner/group with `/`, and renders the
79
+ * date ISO-style (`YYYY-MM-DD HH:MM`) instead of `Mon DD HH:MM`. The previous
80
+ * parser anchored on a `[A-Za-z]{3}` month token, so it matched NOTHING under
81
+ * GNU tar and returned [] — the cross-platform hole this fix closes.
82
+ *
83
+ * Robust strategy: do NOT anchor on the (format-divergent) date column. Anchor
84
+ * on the leading mode string (always present, position 0, stable type char),
85
+ * then take the SIZE as the last integer that appears before the date/time
86
+ * column, accepting BOTH date formats. The leading mode char is the type flag:
87
+ * '-' file, 'd' dir, 'l' symlink, 'h' hardlink (GNU prints 'h'); bsdtar
88
+ * prints hardlinks as files with a "link to" suffix, which we also detect.
89
+ */
90
+ export declare function parseTarListing(verbose: string): TarEntry[];
91
+ /**
92
+ * True iff `candidate` is `base` itself or strictly nested under it, judged on
93
+ * normalized absolute paths with a trailing-separator guard so that a sibling
94
+ * like `/a/baseEVIL` is NOT considered "under" `/a/base`.
95
+ */
96
+ export declare function isContained(base: string, candidate: string): boolean;
97
+ /**
98
+ * Resolve where an entry NAME would land under `targetDir` and assert it stays
99
+ * inside. Rejects absolute names and any `..` that escapes. Returns the
100
+ * resolved absolute path on success.
101
+ */
102
+ export declare function resolveContainedPath(targetDir: string, entryName: string): string;
103
+ /**
104
+ * Validate a sym/hardlink's target stays inside `packDir`. The link is created
105
+ * AT `entryAbs` (already known-contained), so a relative target is resolved
106
+ * against the link's parent dir; an absolute target is checked as-is. Either
107
+ * way the final location must be contained in the pack dir.
108
+ */
109
+ export declare function assertLinkTargetContained(packDir: string, entryAbs: string, linkTarget: string): void;
110
+ /**
111
+ * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
112
+ * argv form — never a shell string — so a hostile filename can't break out.
113
+ */
114
+ export declare function listTarball(tarballPath: string): TarEntry[];
115
+ /**
116
+ * Pre-flight validation against the entry headers. Enforces (a) containment of
117
+ * every entry name under `targetDir`, (b) containment of every link target
118
+ * under `targetDir`, and (c) the three decompression-bomb caps. Throws
119
+ * UnsafeArchiveError on the first violation — default-deny.
120
+ */
121
+ export declare function validateEntries(entries: TarEntry[], targetDir: string, caps?: ExtractCaps): void;
122
+ /**
123
+ * Walk everything actually written under `stagingDir` and assert that no entry
124
+ * — and, for symlinks, no link target — escapes. This is the defense-in-depth
125
+ * backstop: it does not trust the pre-flight scan OR the tar binary. Uses
126
+ * realpath on the link's PARENT (which exists) plus lstat to inspect links
127
+ * without following them off-tree.
128
+ */
129
+ export declare function assertExtractContained(stagingDir: string): void;
130
+ export interface SafeExtractOptions {
131
+ caps?: ExtractCaps;
132
+ /**
133
+ * Test seam: invoked AFTER `tar` extracts into the staging dir but BEFORE
134
+ * the post-extract sweep / atomic rename. Throwing here simulates a failure
135
+ * "mid-extract" and must leave NO staging or final dir behind.
136
+ */
137
+ afterExtractHook?: (stagingDir: string) => void;
138
+ }
139
+ /**
140
+ * Safely extract `tarballPath` so its contents land atomically at `finalDir`.
141
+ *
142
+ * Sequence:
143
+ * 1. Pre-flight list + validate (containment + caps) — abort before writing.
144
+ * 2. Extract into a sibling `*.staging-<rand>` dir on the SAME filesystem.
145
+ * 3. Post-extract realpath containment sweep (independent backstop).
146
+ * 4. `afterExtractHook` (test failure-injection seam).
147
+ * 5. Atomic `fs.rename(staging -> finalDir)`.
148
+ * 6. finally: `rm -rf` the staging dir if it still exists (rollback).
149
+ *
150
+ * On ANY failure the staging dir is removed and `finalDir` is never created,
151
+ * so a half-extracted pack can never be wired.
152
+ */
153
+ export declare function safeExtractTarball(tarballPath: string, finalDir: string, opts?: SafeExtractOptions): void;
154
+ //# sourceMappingURL=safe-extract.d.ts.map
@@ -0,0 +1,347 @@
1
+ /**
2
+ * safe-extract — hardened tarball extraction for the content-pack installer.
3
+ *
4
+ * THREAT MODEL (US-020). A pack tarball is UNTRUSTED input fetched from npm,
5
+ * a git remote, or a local path the user merely pointed at. Naively shelling
6
+ * out to `tar -xzf <tarball> -C <dir>` trusts the archive to behave; a hostile
7
+ * archive can:
8
+ *
9
+ * 1. Path traversal / "zip slip": an entry named `../../etc/cron.d/evil` or
10
+ * an absolute `/etc/passwd` escapes the intended target dir and clobbers
11
+ * arbitrary files (RCE if it lands a hook / cron / shell-rc).
12
+ * 2. Symlink/hardlink escape: an entry that is a symlink whose target points
13
+ * OUTSIDE the pack dir (e.g. `link -> /etc`), optionally followed by a
14
+ * second entry that writes "through" the link — the classic two-step
15
+ * tar symlink attack.
16
+ * 3. Decompression bomb: a tiny `.tgz` that inflates to terabytes (many
17
+ * files, or one enormous sparse/zero file), exhausting disk or memory.
18
+ *
19
+ * DEFENSE (defense-in-depth, default-deny):
20
+ * - PRE-FLIGHT: enumerate every entry's header (type, size, name, link
21
+ * target) via `tar -tv` WITHOUT extracting. Reject the whole archive if
22
+ * ANY entry fails a containment or cap check. Containment uses normalized
23
+ * path-prefix logic on the *resolved* path, never naive string matching.
24
+ * - CAPS: enforced against the header-reported uncompressed sizes during the
25
+ * pre-flight scan, so we abort BEFORE writing a single byte of a bomb.
26
+ * - STAGED + ATOMIC: extraction lands in a sibling staging dir on the SAME
27
+ * filesystem as the final destination; only after a successful, fully
28
+ * validated extract do we `fs.rename` it into place (atomic). Any failure
29
+ * mid-extract rolls the staging dir back entirely (`rm -rf` in finally) —
30
+ * no partial/half-wired pack is ever left behind.
31
+ * - POST-EXTRACT: an independent realpath containment sweep over everything
32
+ * actually written (we do NOT rely solely on the pre-flight scan or on the
33
+ * tar binary's own protections).
34
+ */
35
+
36
+ !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]="372e4f76-03b2-5287-ac66-249543f88937")}catch(e){}}();
37
+ import { execFileSync } from 'child_process';
38
+ import * as fs from 'fs';
39
+ import * as path from 'path';
40
+ export const DEFAULT_CAPS = {
41
+ // A content pack is docs/skills/policies/small scripts. 256 MiB is already
42
+ // generous; anything larger is almost certainly a bomb or a mistake.
43
+ maxUncompressedBytes: 256 * 1024 * 1024,
44
+ maxFileCount: 20_000,
45
+ maxSingleFileBytes: 64 * 1024 * 1024,
46
+ };
47
+ export class UnsafeArchiveError extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = 'UnsafeArchiveError';
51
+ }
52
+ }
53
+ /**
54
+ * Parse the verbose listing produced by `tar -tv`. We use the verbose form so
55
+ * we get the type flag, size, and (for links) the `name -> target` /
56
+ * `name link to target` suffix without extracting anything.
57
+ *
58
+ * SECURITY-CRITICAL CROSS-PLATFORM NOTE. bsdtar (macOS/BSD) and GNU tar
59
+ * (Linux / prod Lambdas) format the verbose listing DIFFERENTLY, and an
60
+ * implementation that only understands one of them silently parses ZERO
61
+ * entries against the other — which means the pre-flight containment scan
62
+ * sees nothing to reject and every malicious archive sails through. The two
63
+ * shapes we must both handle:
64
+ *
65
+ * bsdtar: <mode> <links> <owner> <group> <size> <Mon> <DD> <HH:MM|YYYY> <name...>
66
+ * -rw-r--r-- 0 user group 1234 Jan 1 00:00 path/file
67
+ * lrwxr-xr-x 0 user group 0 Jan 1 00:00 link -> target
68
+ *
69
+ * GNU: <mode> <owner>/<group> <size> <YYYY-MM-DD> <HH:MM[:SS]> <name...>
70
+ * -rw-r--r-- user/group 1234 2026-01-01 00:00 path/file
71
+ * lrwxrwxrwx user/group 0 2026-01-01 00:00 link -> target
72
+ * hrw-r--r-- user/group 0 2026-01-01 00:00 hard link to target
73
+ *
74
+ * GNU has NO link-count column, joins owner/group with `/`, and renders the
75
+ * date ISO-style (`YYYY-MM-DD HH:MM`) instead of `Mon DD HH:MM`. The previous
76
+ * parser anchored on a `[A-Za-z]{3}` month token, so it matched NOTHING under
77
+ * GNU tar and returned [] — the cross-platform hole this fix closes.
78
+ *
79
+ * Robust strategy: do NOT anchor on the (format-divergent) date column. Anchor
80
+ * on the leading mode string (always present, position 0, stable type char),
81
+ * then take the SIZE as the last integer that appears before the date/time
82
+ * column, accepting BOTH date formats. The leading mode char is the type flag:
83
+ * '-' file, 'd' dir, 'l' symlink, 'h' hardlink (GNU prints 'h'); bsdtar
84
+ * prints hardlinks as files with a "link to" suffix, which we also detect.
85
+ */
86
+ export function parseTarListing(verbose) {
87
+ const entries = [];
88
+ for (const raw of verbose.split('\n')) {
89
+ const line = raw.replace(/\r$/, '');
90
+ if (line.trim() === '')
91
+ continue;
92
+ const modeChar = line[0];
93
+ // Only accept lines that actually begin with a tar mode string (10 chars:
94
+ // a type flag followed by 9 permission chars, possibly with a trailing
95
+ // ACL/xattr '+'/'@'/'.'). This skips non-entry noise without depending on
96
+ // the date column at all.
97
+ if (!/^[-dlhpscbD][-rwxXsStT]{9}[.+@]?\s/.test(line))
98
+ continue;
99
+ // The size is the last run of digits that sits immediately before the
100
+ // date/time column. We accept BOTH date renderings:
101
+ // bsdtar: `Mon DD HH:MM` / `Mon DD YYYY`
102
+ // GNU: `YYYY-MM-DD HH:MM[:SS]`
103
+ // Anchoring the size on "the integer right before a recognized date" works
104
+ // identically whether or not a link-count column is present (bsdtar) and
105
+ // whether owner/group is one slash-joined token (GNU) or two columns
106
+ // (bsdtar), because we only ever look at the integer adjacent to the date.
107
+ const dateRe = /\s(\d+)\s+(?:[A-Za-z]{3}\s+\d{1,2}\s+(?:\d{1,2}:\d{2}|\d{4})|\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}(?::\d{2})?)\s+/;
108
+ const m = dateRe.exec(line);
109
+ if (!m)
110
+ continue;
111
+ const size = Number.parseInt(m[1], 10);
112
+ const nameAndLink = line.slice(m.index + m[0].length).trim();
113
+ let type;
114
+ let name = nameAndLink;
115
+ let linkTarget;
116
+ if (modeChar === 'd') {
117
+ type = 'dir';
118
+ name = name.replace(/\/+$/, '');
119
+ }
120
+ else if (modeChar === 'l') {
121
+ type = 'symlink';
122
+ const arrow = nameAndLink.indexOf(' -> ');
123
+ if (arrow >= 0) {
124
+ name = nameAndLink.slice(0, arrow);
125
+ linkTarget = nameAndLink.slice(arrow + 4);
126
+ }
127
+ }
128
+ else if (modeChar === 'h') {
129
+ type = 'hardlink';
130
+ // GNU: "name link to target"
131
+ const m = / link to /.exec(nameAndLink);
132
+ if (m) {
133
+ name = nameAndLink.slice(0, m.index);
134
+ linkTarget = nameAndLink.slice(m.index + m[0].length);
135
+ }
136
+ }
137
+ else if (modeChar === '-') {
138
+ // bsdtar renders hardlinks as a file entry with a trailing "link to".
139
+ const m = / link to /.exec(nameAndLink);
140
+ if (m) {
141
+ type = 'hardlink';
142
+ name = nameAndLink.slice(0, m.index);
143
+ linkTarget = nameAndLink.slice(m.index + m[0].length);
144
+ }
145
+ else {
146
+ type = 'file';
147
+ }
148
+ }
149
+ else {
150
+ type = 'other';
151
+ }
152
+ entries.push({
153
+ type,
154
+ name,
155
+ size: Number.isFinite(size) ? size : 0,
156
+ linkTarget,
157
+ });
158
+ }
159
+ return entries;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Containment — normalized prefix on the RESOLVED path (not string matching)
163
+ // ---------------------------------------------------------------------------
164
+ /**
165
+ * True iff `candidate` is `base` itself or strictly nested under it, judged on
166
+ * normalized absolute paths with a trailing-separator guard so that a sibling
167
+ * like `/a/baseEVIL` is NOT considered "under" `/a/base`.
168
+ */
169
+ export function isContained(base, candidate) {
170
+ const b = path.resolve(base);
171
+ const c = path.resolve(candidate);
172
+ if (c === b)
173
+ return true;
174
+ const withSep = b.endsWith(path.sep) ? b : b + path.sep;
175
+ return c.startsWith(withSep);
176
+ }
177
+ /**
178
+ * Resolve where an entry NAME would land under `targetDir` and assert it stays
179
+ * inside. Rejects absolute names and any `..` that escapes. Returns the
180
+ * resolved absolute path on success.
181
+ */
182
+ export function resolveContainedPath(targetDir, entryName) {
183
+ // path.resolve collapses `..` segments and makes absolute names absolute to
184
+ // the FS root (so `/etc/passwd` resolves to itself, which then fails
185
+ // containment) — exactly what we want for the check.
186
+ const resolved = path.resolve(targetDir, entryName);
187
+ if (!isContained(targetDir, resolved)) {
188
+ throw new UnsafeArchiveError(`Path traversal blocked: entry "${entryName}" resolves to "${resolved}", ` +
189
+ `outside extraction dir "${path.resolve(targetDir)}".`);
190
+ }
191
+ return resolved;
192
+ }
193
+ /**
194
+ * Validate a sym/hardlink's target stays inside `packDir`. The link is created
195
+ * AT `entryAbs` (already known-contained), so a relative target is resolved
196
+ * against the link's parent dir; an absolute target is checked as-is. Either
197
+ * way the final location must be contained in the pack dir.
198
+ */
199
+ export function assertLinkTargetContained(packDir, entryAbs, linkTarget) {
200
+ const resolvedTarget = path.isAbsolute(linkTarget)
201
+ ? path.resolve(linkTarget)
202
+ : path.resolve(path.dirname(entryAbs), linkTarget);
203
+ if (!isContained(packDir, resolvedTarget)) {
204
+ throw new UnsafeArchiveError(`Link escape blocked: entry "${path.relative(packDir, entryAbs)}" points to ` +
205
+ `"${linkTarget}" -> "${resolvedTarget}", outside pack dir "${path.resolve(packDir)}".`);
206
+ }
207
+ }
208
+ // ---------------------------------------------------------------------------
209
+ // Pre-flight: list entries, enforce containment + caps WITHOUT extracting
210
+ // ---------------------------------------------------------------------------
211
+ /**
212
+ * List a gzip-compressed tarball's entries via `tar -tvf` (no extraction).
213
+ * argv form — never a shell string — so a hostile filename can't break out.
214
+ */
215
+ export function listTarball(tarballPath) {
216
+ const out = execFileSync('tar', ['-tvf', tarballPath], {
217
+ encoding: 'utf-8',
218
+ maxBuffer: 64 * 1024 * 1024,
219
+ stdio: ['ignore', 'pipe', 'pipe'],
220
+ });
221
+ return parseTarListing(out);
222
+ }
223
+ /**
224
+ * Pre-flight validation against the entry headers. Enforces (a) containment of
225
+ * every entry name under `targetDir`, (b) containment of every link target
226
+ * under `targetDir`, and (c) the three decompression-bomb caps. Throws
227
+ * UnsafeArchiveError on the first violation — default-deny.
228
+ */
229
+ export function validateEntries(entries, targetDir, caps = DEFAULT_CAPS) {
230
+ // Count real members (dirs included — they still occupy inodes; cheap to
231
+ // count and keeps "many empty dirs" bombs in scope).
232
+ if (entries.length > caps.maxFileCount) {
233
+ throw new UnsafeArchiveError(`Decompression-bomb guard: archive has ${entries.length} entries, ` +
234
+ `exceeding the limit of ${caps.maxFileCount}.`);
235
+ }
236
+ let total = 0;
237
+ for (const e of entries) {
238
+ if (e.type === 'other') {
239
+ throw new UnsafeArchiveError(`Unsupported entry type for "${e.name}" — only files, dirs, and ` +
240
+ `contained links are allowed.`);
241
+ }
242
+ // (a) name containment — rejects `..` traversal and absolute paths.
243
+ const entryAbs = resolveContainedPath(targetDir, e.name);
244
+ // (b) link-target containment.
245
+ if ((e.type === 'symlink' || e.type === 'hardlink') && e.linkTarget) {
246
+ assertLinkTargetContained(targetDir, entryAbs, e.linkTarget);
247
+ }
248
+ // (c) per-file + cumulative caps.
249
+ if (e.size > caps.maxSingleFileBytes) {
250
+ throw new UnsafeArchiveError(`Decompression-bomb guard: entry "${e.name}" is ${e.size} bytes, ` +
251
+ `exceeding the per-file limit of ${caps.maxSingleFileBytes}.`);
252
+ }
253
+ total += e.size;
254
+ if (total > caps.maxUncompressedBytes) {
255
+ throw new UnsafeArchiveError(`Decompression-bomb guard: cumulative uncompressed size exceeded ` +
256
+ `${caps.maxUncompressedBytes} bytes (reached ${total} before finishing the scan).`);
257
+ }
258
+ }
259
+ }
260
+ // ---------------------------------------------------------------------------
261
+ // Post-extract: independent realpath containment sweep
262
+ // ---------------------------------------------------------------------------
263
+ /**
264
+ * Walk everything actually written under `stagingDir` and assert that no entry
265
+ * — and, for symlinks, no link target — escapes. This is the defense-in-depth
266
+ * backstop: it does not trust the pre-flight scan OR the tar binary. Uses
267
+ * realpath on the link's PARENT (which exists) plus lstat to inspect links
268
+ * without following them off-tree.
269
+ */
270
+ export function assertExtractContained(stagingDir) {
271
+ const realRoot = fs.realpathSync(stagingDir);
272
+ const walk = (dir) => {
273
+ for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) {
274
+ const abs = path.join(dir, dirent.name);
275
+ if (dirent.isSymbolicLink()) {
276
+ const target = fs.readlinkSync(abs);
277
+ const resolved = path.isAbsolute(target)
278
+ ? path.resolve(target)
279
+ : path.resolve(path.dirname(abs), target);
280
+ if (!isContained(realRoot, resolved)) {
281
+ throw new UnsafeArchiveError(`Post-extract link escape: "${path.relative(realRoot, abs)}" -> "${target}".`);
282
+ }
283
+ continue; // do not descend through links
284
+ }
285
+ // Real path of the entry's parent must stay inside the root (catches a
286
+ // dir that is itself a link off-tree, belt-and-suspenders).
287
+ const realParent = fs.realpathSync(path.dirname(abs));
288
+ if (!isContained(realRoot, path.join(realParent, dirent.name))) {
289
+ throw new UnsafeArchiveError(`Post-extract path escape: "${abs}" resolves outside "${realRoot}".`);
290
+ }
291
+ if (dirent.isDirectory())
292
+ walk(abs);
293
+ }
294
+ };
295
+ walk(realRoot);
296
+ }
297
+ /**
298
+ * Safely extract `tarballPath` so its contents land atomically at `finalDir`.
299
+ *
300
+ * Sequence:
301
+ * 1. Pre-flight list + validate (containment + caps) — abort before writing.
302
+ * 2. Extract into a sibling `*.staging-<rand>` dir on the SAME filesystem.
303
+ * 3. Post-extract realpath containment sweep (independent backstop).
304
+ * 4. `afterExtractHook` (test failure-injection seam).
305
+ * 5. Atomic `fs.rename(staging -> finalDir)`.
306
+ * 6. finally: `rm -rf` the staging dir if it still exists (rollback).
307
+ *
308
+ * On ANY failure the staging dir is removed and `finalDir` is never created,
309
+ * so a half-extracted pack can never be wired.
310
+ */
311
+ export function safeExtractTarball(tarballPath, finalDir, opts = {}) {
312
+ const caps = opts.caps ?? DEFAULT_CAPS;
313
+ if (fs.existsSync(finalDir)) {
314
+ throw new Error(`safeExtractTarball: refusing to extract over existing dir "${finalDir}".`);
315
+ }
316
+ // 1. Pre-flight against headers (no bytes written yet).
317
+ const entries = listTarball(tarballPath);
318
+ validateEntries(entries, finalDir, caps);
319
+ // 2. Sibling staging dir on the same filesystem so the rename is atomic.
320
+ const parent = path.dirname(path.resolve(finalDir));
321
+ fs.mkdirSync(parent, { recursive: true });
322
+ const stagingDir = fs.mkdtempSync(path.join(parent, `.${path.basename(finalDir)}.staging-`));
323
+ try {
324
+ // `--no-same-owner` avoids chown surprises when run as root; argv form, no
325
+ // shell. We deliberately re-extract here (not the pre-flight listing) and
326
+ // re-validate after, rather than trusting tar to honor any single flag.
327
+ execFileSync('tar', ['-xzf', tarballPath, '-C', stagingDir, '--no-same-owner'], { stdio: 'inherit' });
328
+ // 3. Independent backstop sweep over what actually landed.
329
+ assertExtractContained(stagingDir);
330
+ // 4. Failure-injection seam (tests).
331
+ opts.afterExtractHook?.(stagingDir);
332
+ // 5. Atomic commit. `fs.renameSync` is atomic within a filesystem; the
333
+ // staged contents are fully present and validated before this point, so
334
+ // the live location only ever sees a complete pack — never a partial.
335
+ fs.renameSync(stagingDir, finalDir);
336
+ }
337
+ finally {
338
+ // Rollback: if anything before the rename threw, the staging dir still
339
+ // exists and is removed here — no partial/half-wired pack survives. After a
340
+ // successful rename the staging dir is gone, so this is a no-op.
341
+ if (fs.existsSync(stagingDir)) {
342
+ fs.rmSync(stagingDir, { recursive: true, force: true });
343
+ }
344
+ }
345
+ }
346
+ //# sourceMappingURL=safe-extract.js.map
347
+ //# debugId=372e4f76-03b2-5287-ac66-249543f88937
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]="83d8729b-5b5c-50b8-8785-372d61feb425")}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";
@@ -84,6 +85,10 @@ registerPacksCommand(program);
84
85
  // "hq remove <slug>" = "hq packages remove <slug>"
85
86
  registerPackageInstallCommand(program);
86
87
  registerPackageRemoveCommand(program);
88
+ // Marketplace publish (top-level — packer + authenticated upload, US-004)
89
+ // "hq publish <skill-or-worker-path>" packages and submits a pack to the
90
+ // marketplace via POST /v1/listings.
91
+ registerPublishCommand(program);
87
92
  // Cloud sync subcommand group
88
93
  const syncCmd = program
89
94
  .command("sync")
@@ -158,4 +163,4 @@ registerSignalsCommand(program);
158
163
  }
159
164
  })();
160
165
  //# sourceMappingURL=index.js.map
161
- //# debugId=89e1a66a-6a45-56a3-847c-2f0beba1429b
166
+ //# debugId=83d8729b-5b5c-50b8-8785-372d61feb425
@@ -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: {