iterate-skill-installer 3.5.2 → 3.6.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 (3) hide show
  1. package/bin/cli.js +27 -7
  2. package/lib/installer.js +333 -104
  3. package/package.json +2 -2
package/bin/cli.js CHANGED
@@ -42,28 +42,48 @@ Examples:
42
42
  }
43
43
 
44
44
  async function run() {
45
- const options = parseArgs(process.argv.slice(2));
45
+ let options;
46
+ try {
47
+ options = parseArgs(process.argv.slice(2));
48
+ } catch (err) {
49
+ // Hard argument errors are THROWN by parseArgs (library code never calls
50
+ // process.exit); report them cleanly at this boundary.
51
+ console.error(`Error: ${err.message}`);
52
+ return 1;
53
+ }
46
54
 
47
55
  if (options.mode === 'help') {
48
56
  printHelp();
49
- process.exit(0);
57
+ return 0;
50
58
  }
51
59
 
52
60
  if (options.mode === 'version') {
53
61
  console.log(`iterate-skill-installer ${VERSION}`);
54
- process.exit(0);
62
+ return 0;
55
63
  }
56
64
 
57
65
  try {
58
66
  const code = await main(options);
59
67
  // Normalize: a null/non-numeric code (main should never produce one, but a
60
68
  // signal-killed child upstream does) must surface as a failure, never as
61
- // the success exit 0 that `process.exit(null)` would yield.
62
- process.exit(typeof code === 'number' ? code : 1);
69
+ // the success exit 0 that a null exit code would yield.
70
+ return typeof code === 'number' ? code : 1;
63
71
  } catch (err) {
64
72
  console.error(`\nUnexpected error: ${err.message}`);
65
- process.exit(1);
73
+ return 1;
66
74
  }
67
75
  }
68
76
 
69
- run();
77
+ // CLI boundary: set process.exitCode rather than calling process.exit(), so
78
+ // buffered stdout/stderr still flush and no caller cleanup is skipped. The
79
+ // trailing catch is the top-level net: an unexpected rejection (e.g. a throw
80
+ // before run()'s inner try) becomes a clean message + non-zero code, never an
81
+ // unhandled-rejection stack trace.
82
+ run()
83
+ .then((code) => {
84
+ process.exitCode = typeof code === 'number' ? code : 1;
85
+ })
86
+ .catch((err) => {
87
+ console.error(`\nUnexpected error: ${err && err.message ? err.message : err}`);
88
+ process.exitCode = 1;
89
+ });
package/lib/installer.js CHANGED
@@ -298,7 +298,19 @@ function isGithubApiUrl(url) {
298
298
  function normalizeToken(raw) {
299
299
  if (raw == null) return null;
300
300
  const trimmed = String(raw).trim();
301
- return trimmed === '' ? null : trimmed;
301
+ if (trimmed === '') return null;
302
+ // Trim only removes the ends. A PAT never contains interior whitespace or
303
+ // control characters; one that does (a CR/LF smuggled in from a file, a
304
+ // tab-pasted secret) would either split the Authorization header
305
+ // (CRLF/header injection) or fail as a baffling 401 that looks like an
306
+ // expired token. Reject it here, at the single boundary both the CLI and
307
+ // buildAuthFlags normalize through.
308
+ if (/[\s\x00-\x1f\x7f]/.test(trimmed)) {
309
+ throw new InstallerError(
310
+ 'GitHub token contains whitespace or control characters; re-copy it (check GITHUB_TOKEN / --token).',
311
+ );
312
+ }
313
+ return trimmed;
302
314
  }
303
315
 
304
316
  // GitHub PAT prefixes accepted by scripts/install.py (kept in sync so the two
@@ -339,9 +351,14 @@ function validateTokenShape(token) {
339
351
  * loudly instead of leaking the PAT to a foreign host.
340
352
  */
341
353
  function buildAuthFlags(url, token) {
342
- if (!token) return [];
354
+ // Re-normalize here as well as at the CLI boundary: this is the exact point
355
+ // the token is interpolated into a header, so a raw CR/LF (e.g. a token read
356
+ // from a Windows-created file) must never reach curl — an embedded newline
357
+ // would split the header (CRLF/header injection) or silently break auth.
358
+ const clean = normalizeToken(token);
359
+ if (!clean) return [];
343
360
  if (!isGithubApiUrl(url)) return [];
344
- return ['-H', `Authorization: Bearer ${token}`, '--max-redirs', '0'];
361
+ return ['-H', `Authorization: Bearer ${clean}`, '--max-redirs', '0'];
345
362
  }
346
363
 
347
364
  async function fetchJson(url, token) {
@@ -349,7 +366,10 @@ async function fetchJson(url, token) {
349
366
  // and avoids Node-specific certificate issues in some environments.
350
367
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
351
368
  const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(JSON_MAX_BYTES), '-H', 'Accept: application/vnd.github+json', '-H', 'X-GitHub-Api-Version: 2022-11-28', '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
352
- args.push(url);
369
+ // `--` ends curl's option parsing so a URL that begins with `-` (e.g. a
370
+ // hostile/odd `browser_download_url` served by the API) is treated as a URL,
371
+ // never smuggled in as a curl flag.
372
+ args.push('--', url);
353
373
  // stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
354
374
  // would truncate a large /releases payload and JSON.parse then failed with a
355
375
  // baffling "Unexpected end of JSON input". The cap now matches the
@@ -381,7 +401,9 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
381
401
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
382
402
  const args = ['-sSL', failFlag, '--max-time', CURL_MAX_TIME_SECONDS, '--max-filesize', String(maxBytes), '-o', destPath, '-H', 'User-Agent: iterate-skill-installer', ...buildAuthFlags(url, token)];
383
403
  if (progress) args.push('--progress-bar');
384
- args.push(url);
404
+ // `--` ends curl's option parsing so a `-`-leading URL can never be read as
405
+ // a curl flag (option injection); it is always the final positional URL.
406
+ args.push('--', url);
385
407
  if (progress) {
386
408
  // Let curl write its progress bar directly to the terminal.
387
409
  await runCommand('curl', args, { stdio: ['ignore', 'ignore', 'inherit'] });
@@ -429,8 +451,16 @@ function enforceDownloadedSize(destPath, maxBytes) {
429
451
  // project config and shows up in the invoking environment for audit.
430
452
  const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
431
453
 
454
+ // Only these (case-insensitive) values enable the override. A bare "any
455
+ // non-empty string" check meant `ITERATE_ALLOW_UNSIGNED=0` (or "false", or a
456
+ // stray "no") ENABLED unsigned installs — the exact opposite of what the
457
+ // operator intended, and a silent downgrade of the authenticity gate.
458
+ const ALLOW_UNSIGNED_TRUTHY = new Set(['1', 'true', 'yes']);
459
+
432
460
  function allowUnsigned() {
433
- return String(process.env[ALLOW_UNSIGNED_ENV] || '').trim() !== '';
461
+ const raw = process.env[ALLOW_UNSIGNED_ENV];
462
+ if (raw == null) return false;
463
+ return ALLOW_UNSIGNED_TRUTHY.has(String(raw).trim().toLowerCase());
434
464
  }
435
465
 
436
466
  /**
@@ -499,6 +529,31 @@ function sha256File(filePath) {
499
529
  });
500
530
  }
501
531
 
532
+ /**
533
+ * Constant-time, case-insensitive comparison of two SHA256 hex digests.
534
+ *
535
+ * Casing must never decide the outcome: `sha256sum`/other tools may emit
536
+ * upper-case hex, and a case-sensitive `===` would reject a perfectly good
537
+ * release. Both sides are lower-cased and compared with `timingSafeEqual` —
538
+ * the expected hash travels over the network beside the tarball, so an
539
+ * early-exit string inequality would leak prefix information usable to forge a
540
+ * checksum. The length guard keeps `timingSafeEqual` from throwing on
541
+ * mismatched lengths (an empty/short value is refused outright).
542
+ *
543
+ * Exported so the comparison itself is unit-testable (the inline version it
544
+ * replaces could only be exercised by a full network install).
545
+ */
546
+ function constantTimeHashEqual(actualHash, expectedHash) {
547
+ if (typeof actualHash !== 'string' || typeof expectedHash !== 'string') return false;
548
+ const actualBuf = Buffer.from(actualHash.trim().toLowerCase(), 'utf8');
549
+ const expectedBuf = Buffer.from(expectedHash.trim().toLowerCase(), 'utf8');
550
+ return (
551
+ actualBuf.length === expectedBuf.length &&
552
+ actualBuf.length > 0 &&
553
+ crypto.timingSafeEqual(actualBuf, expectedBuf)
554
+ );
555
+ }
556
+
502
557
  // Best-effort GPG authenticity verification of a downloaded artifact against
503
558
  // the embedded signing key. Returns one of:
504
559
  // 'verified' — `gpg` present and the detached signature is valid.
@@ -511,7 +566,14 @@ function sha256File(filePath) {
511
566
  // defense-in-depth layer, not the only gate.
512
567
  // Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
513
568
  // gpg-agent are never touched or modified.
514
- function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir) {
569
+ // gpg calls are local and short. Without a timeout a wedged gpg-agent (or a
570
+ // pinentry dialog nobody can see) hangs the installer forever; SHA256 already
571
+ // gates integrity, so treating a stuck authenticity check as 'unavailable' is
572
+ // both honest and non-blocking.
573
+ const GPG_TIMEOUT_MS = 30 * 1000;
574
+
575
+ function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir, options = {}) {
576
+ const timeout = options.timeoutMs ?? GPG_TIMEOUT_MS;
515
577
  // NOTE: gpg derives its agent socket from the homedir
516
578
  // (<homedir>/S.gpg-agent) and unix sockets cap out around 104 characters, so
517
579
  // keep this path SHORT — a deep tmpdir made gpg fail with "File name too
@@ -524,22 +586,37 @@ function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor,
524
586
  const importResult = spawnSync(
525
587
  'gpg',
526
588
  ['--homedir', gpgHome, '--batch', '--yes', '--quiet', '--no-autostart', '--import', keyPath],
527
- { encoding: 'utf8' }
589
+ { encoding: 'utf8', timeout, killSignal: 'SIGKILL' }
528
590
  );
529
591
  if (importResult.error) {
530
- const detail = importResult.error.code === 'ENOENT' ? 'gpg binary not found' : importResult.error.message;
531
- return { status: 'unavailable', detail };
592
+ if (importResult.error.code === 'ENOENT') {
593
+ return { status: 'unavailable', detail: 'gpg binary not found' };
594
+ }
595
+ if (importResult.error.code === 'ETIMEDOUT' || importResult.signal === 'SIGKILL') {
596
+ return { status: 'unavailable', detail: `gpg import timed out after ${timeout} ms` };
597
+ }
598
+ return { status: 'unavailable', detail: importResult.error.message };
532
599
  }
533
600
  if (importResult.status !== 0) {
534
- return { status: 'invalid', detail: (importResult.stderr || '').trim() || 'failed to import the signing key' };
601
+ // Failure to import the release key means we cannot establish authenticity
602
+ // — not that the artifact is forged. Classifying it 'invalid' claimed
603
+ // evidence of tampering that this run never had, and only 'unavailable'
604
+ // matches the documented non-blocking policy.
605
+ return {
606
+ status: 'unavailable',
607
+ detail: (importResult.stderr || '').trim() || 'failed to import the signing key',
608
+ };
535
609
  }
536
610
 
537
611
  const verifyResult = spawnSync(
538
612
  'gpg',
539
613
  ['--homedir', gpgHome, '--batch', '--quiet', '--no-autostart', '--verify', signaturePath, artifactPath],
540
- { encoding: 'utf8' }
614
+ { encoding: 'utf8', timeout, killSignal: 'SIGKILL' }
541
615
  );
542
616
  if (verifyResult.error) {
617
+ if (verifyResult.error.code === 'ETIMEDOUT' || verifyResult.signal === 'SIGKILL') {
618
+ return { status: 'unavailable', detail: `gpg verify timed out after ${timeout} ms` };
619
+ }
543
620
  return { status: 'unavailable', detail: `gpg launch failed: ${verifyResult.error.message}` };
544
621
  }
545
622
  // gpg exit 0 = good signature; 1 = bad signature; 2 = fatal (bad key usage).
@@ -747,77 +824,135 @@ function runCommand(bin, args, options = {}) {
747
824
 
748
825
  async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
749
826
  // Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
750
- // link entry. We deliberately reject *all* links rather than validating
751
- // their targets: the release tarball contains none, so any link is a sign
752
- // of a tampered archive and refusing it outright is the safest response to
753
- // a symlink-escape attempt (see extractTarball above).
827
+ // link entry — and any device ("b" block / "c" character), FIFO ("p") or
828
+ // socket special node. We deliberately reject *all* links rather than
829
+ // validating their targets: the release tarball contains none, so any link is
830
+ // a sign of a tampered archive and refusing it outright is the safest
831
+ // response to a symlink-escape attempt (see extractTarball above). Special
832
+ // nodes are rejected for the same reason: neither the release nor its
833
+ // installer ever ships one, and a FIFO/device could wedge or bypass a later
834
+ // read of the extracted tree.
754
835
  //
755
836
  // stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
756
837
  // tail buffer silently dropped the OLDEST entries, so an archive with enough
757
838
  // filler members to push its listing past the cap hid a symlink/hardlink
758
839
  // entry from this scan entirely (while `tar -xzf` still extracted it).
759
840
  // A listing over the cap now fails closed instead.
841
+ //
842
+ // Returns the COMPLETE verbose listing so callers (extractTarball) can reuse
843
+ // it for the pre-extraction declared-size check without a second tar run.
760
844
  const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
761
845
  stdoutCap: listingCap,
762
846
  stdoutMustBeComplete: true,
763
847
  });
848
+ const TYPE_LABELS = {
849
+ l: 'symbolic link',
850
+ h: 'hard link',
851
+ b: 'block device node',
852
+ c: 'character device node',
853
+ p: 'FIFO',
854
+ s: 'socket',
855
+ };
764
856
  for (const line of verbose.split(/\r?\n/)) {
765
857
  if (!line) {
766
858
  continue;
767
859
  }
768
860
  const type = line[0];
769
- if (type === 'l') {
770
- throw new InstallerError(
771
- 'Refusing to extract tarball: archive contains a symbolic link, which the release never ships.',
772
- );
773
- }
774
- if (type === 'h') {
861
+ const label = TYPE_LABELS[type];
862
+ if (label) {
775
863
  throw new InstallerError(
776
- 'Refusing to extract tarball: archive contains a hard link, which the release never ships.',
864
+ `Refusing to extract tarball: archive contains a ${label}, which the release never ships.`,
777
865
  );
778
866
  }
779
867
  }
868
+ return verbose;
780
869
  }
781
870
 
782
- async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_CHARS } = {}) {
783
- // Use the system tar command (available on macOS, Linux, and Windows 10+),
784
- // but defend against path-traversal tarballs: before extracting we list the
785
- // archive contents and refuse to extract any entry whose resolved path
786
- // escapes destDir (e.g. a "../" prefix). This keeps the installer
787
- // dependency-free while closing the theoretical escape vector.
788
- //
789
- // Extraction uses --strip-components=1, which relies on the archive having
790
- // exactly one top-level directory (see README "Release tarball structure").
791
- // Archives that violate that contract — multiple top-level directories, or
792
- // entries with no path left after stripping — are rejected outright.
793
- //
794
- // A name-based `../` check alone is not enough: a tarball can smuggle a
795
- // symlink whose in-archive name looks safe but whose target points outside
796
- // destDir, then place files "through" that symlink (e.g. `link/passwd` where
797
- // `link -> /etc`). Because extraction happens with a system `tar`, the write
798
- // would follow the link before we could validate — so any symbolic or hard
799
- // link is rejected *before* extraction. The release tarball never uses links.
800
- const absoluteDest = path.resolve(destDir);
801
- fs.mkdirSync(absoluteDest, { recursive: true });
871
+ // A month abbreviation ("Jan".."Dec") appears in the timestamp column of the
872
+ // bsdtar verbose listing; GNU tar prints an ISO date ("2023-01-01"). Both mark
873
+ // the end of the size/user fields, so the numeric token immediately before the
874
+ // first such token is the entry's declared uncompressed size in EITHER format.
875
+ const MONTH_ABBR_RE = /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/;
876
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
802
877
 
803
- // 1. List archive entries (no extraction yet). Must be complete — see
804
- // rejectLinkEntries: every guard below reads THIS listing, and a tail
805
- // buffer would let a big archive hide entries from all of them.
806
- const listing = await runCommand('tar', ['-tzf', tarballPath], {
807
- stdoutCap: listingCap,
808
- stdoutMustBeComplete: true,
809
- });
810
- const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
811
- if (entries.length === 0) {
812
- throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
878
+ /**
879
+ * Parse one `tar -tvzf` line's declared uncompressed size, or null when the
880
+ * line cannot be parsed. `size` is the numeric field immediately preceding the
881
+ * timestamp column (see MONTH_ABBR_RE / ISO_DATE_RE).
882
+ */
883
+ function declaredEntrySize(line) {
884
+ const tokens = line.trim().split(/\s+/);
885
+ for (let i = 1; i < tokens.length; i += 1) {
886
+ if (ISO_DATE_RE.test(tokens[i]) || MONTH_ABBR_RE.test(tokens[i])) {
887
+ const candidate = tokens[i - 1];
888
+ return /^\d+$/.test(candidate) ? Number(candidate) : null;
889
+ }
813
890
  }
891
+ return null;
892
+ }
893
+
894
+ /**
895
+ * Sum the declared uncompressed sizes in a `tar -tvzf` listing.
896
+ *
897
+ * This is the PRE-extraction half of the decompression-bomb guard: `tar -xzf`
898
+ * has no size bound and the download cap only limits the COMPRESSED size, so a
899
+ * small highly-compressible archive can declare (and would expand to) many GiB.
900
+ * Reading the declared sizes out of the listing lets us refuse BEFORE writing
901
+ * anything to disk. Returns the total plus how many entries were parsed so a
902
+ * caller can distinguish "empty" from "unparseable format".
903
+ */
904
+ function sumDeclaredSizes(verboseListing) {
905
+ let total = 0;
906
+ let parsed = 0;
907
+ let entries = 0;
908
+ for (const line of verboseListing.split(/\r?\n/)) {
909
+ if (!line.trim()) continue;
910
+ entries += 1;
911
+ const size = declaredEntrySize(line);
912
+ if (size !== null) {
913
+ total += size;
914
+ parsed += 1;
915
+ }
916
+ }
917
+ return { total, parsed, entries };
918
+ }
814
919
 
815
- // 1a. Reject absolute-path entries outright before any other accounting.
816
- // A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
817
- // top-level-strip logic into destDir, but accepting its name as a "safe"
818
- // component invites ambiguity between tar implementations about what the
819
- // first component really is — never claim a release archive can contain
820
- // absolute paths, so fail closed instead.
920
+ /**
921
+ * Refuse a listing whose sizes could not all be read.
922
+ *
923
+ * A partially parsed listing used to sum only the entries it understood, so a
924
+ * localized tar (e.g. `oct.` instead of `Oct`, or a numeric date format) made
925
+ * the bomb guard compare a bogus small total and let the archive through. All
926
+ * entries must be understood before the total may be trusted.
927
+ */
928
+ function assertDeclaredSizesKnown(declared) {
929
+ if (declared.parsed === declared.entries) return;
930
+ const unknown = declared.entries - declared.parsed;
931
+ throw new InstallerError(
932
+ `Refusing to extract tarball: could not read the declared size of ${unknown} of ` +
933
+ `${declared.entries} entries. This is commonly a localized tar listing; rerun with LC_ALL=C.`,
934
+ );
935
+ }
936
+
937
+ /**
938
+ * Validate every archive member NAME before extraction.
939
+ *
940
+ * Rejects, in order:
941
+ * - absolute paths (`/etc/...`, `C:\...`), which invite ambiguity between
942
+ * tar implementations about what the "first component" is;
943
+ * - any `..` path component (path traversal), which — even when the
944
+ * top-level-strip logic happens to keep it inside destDir — violates the
945
+ * release's "single top-level directory" contract and must never appear;
946
+ * - archives without exactly one top-level directory (required by
947
+ * --strip-components=1);
948
+ * - entries that still escape destDir after the top-level component is
949
+ * stripped.
950
+ *
951
+ * Pure and exported so the guards are unit-testable without crafting hostile
952
+ * tarballs. Returns the resolved top-level directory name.
953
+ */
954
+ function validateEntryNames(entries, absoluteDest) {
955
+ // Reject absolute-path entries outright before any other accounting.
821
956
  for (const entry of entries) {
822
957
  if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
823
958
  throw new InstallerError(
@@ -826,15 +961,20 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
826
961
  }
827
962
  }
828
963
 
829
- // 1b. Reject any symbolic or hard link entry before touching the tree.
830
- // The verbose listing's first character is the entry type for both GNU tar
831
- // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
832
- // hard link. We only need to detect links, so a cheap first-char scan is
833
- // sufficient and avoids relying on the rest of the format.
834
- await rejectLinkEntries(tarballPath, { listingCap });
964
+ // Reject any ``..`` component. A name-based resolve() check below already
965
+ // catches escapes, but an explicit rejection is unambiguous and also refuses
966
+ // the "stripped away" cases (e.g. a lone `..` top-level) that resolve back
967
+ // inside destDir while still violating the archive contract.
968
+ for (const entry of entries) {
969
+ if (entry.split('/').includes('..')) {
970
+ throw new InstallerError(
971
+ `Refusing to extract tarball: entry "${entry}" contains a ".." path component.`,
972
+ );
973
+ }
974
+ }
835
975
 
836
- // 2. Enforce the single-top-level-directory contract required by
837
- // --strip-components=1.
976
+ // Enforce the single-top-level-directory contract required by
977
+ // --strip-components=1.
838
978
  const topLevelComponents = new Set(entries.map((entry) => entry.split('/')[0]));
839
979
  if (topLevelComponents.size !== 1) {
840
980
  const tops = [...topLevelComponents].join(', ');
@@ -844,8 +984,8 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
844
984
  }
845
985
  const topLevel = entries[0].split('/')[0];
846
986
 
847
- // 3. Validate every entry stays inside destDir after stripping the single
848
- // top-level directory component (--strip-components=1).
987
+ // Validate every entry stays inside destDir after stripping the single
988
+ // top-level directory component (--strip-components=1).
849
989
  for (const entry of entries) {
850
990
  const stripped = entry.split('/').slice(1).join('/');
851
991
  if (stripped === '') {
@@ -867,7 +1007,74 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
867
1007
  }
868
1008
  }
869
1009
 
1010
+ return topLevel;
1011
+ }
1012
+
1013
+ async function extractTarball(
1014
+ tarballPath,
1015
+ destDir,
1016
+ { listingCap = MAX_LISTING_CHARS, declaredSizeCap = MAX_EXTRACTED_BYTES } = {},
1017
+ ) {
1018
+ // Use the system tar command (available on macOS, Linux, and Windows 10+),
1019
+ // but defend against path-traversal tarballs: before extracting we list the
1020
+ // archive contents and refuse to extract any entry whose resolved path
1021
+ // escapes destDir (e.g. a "../" prefix). This keeps the installer
1022
+ // dependency-free while closing the theoretical escape vector.
1023
+ //
1024
+ // Extraction uses --strip-components=1, which relies on the archive having
1025
+ // exactly one top-level directory (see README "Release tarball structure").
1026
+ // Archives that violate that contract — multiple top-level directories, or
1027
+ // entries with no path left after stripping — are rejected outright.
1028
+ //
1029
+ // A name-based `../` check alone is not enough: a tarball can smuggle a
1030
+ // symlink whose in-archive name looks safe but whose target points outside
1031
+ // destDir, then place files "through" that symlink (e.g. `link/passwd` where
1032
+ // `link -> /etc`). Because extraction happens with a system `tar`, the write
1033
+ // would follow the link before we could validate — so any symbolic or hard
1034
+ // link is rejected *before* extraction. The release tarball never uses links.
1035
+ // Note: dest is created AFTER every guard below (not before), so a refused
1036
+ // archive never leaves a directory behind. `tar -xzf` requires the target to
1037
+ // exist, so mkdir stays inside this function rather than with the caller.
1038
+ const absoluteDest = path.resolve(destDir);
1039
+
1040
+ // 1. List archive entries (no extraction yet). Must be complete — see
1041
+ // rejectLinkEntries: every guard below reads THIS listing, and a tail
1042
+ // buffer would let a big archive hide entries from all of them.
1043
+ const listing = await runCommand('tar', ['-tzf', tarballPath], {
1044
+ stdoutCap: listingCap,
1045
+ stdoutMustBeComplete: true,
1046
+ });
1047
+ const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
1048
+ if (entries.length === 0) {
1049
+ throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
1050
+ }
1051
+
1052
+ // 1a/2/3. Reject absolute paths and `..` components, enforce the single
1053
+ // top-level directory, and prove every entry stays inside destDir.
1054
+ validateEntryNames(entries, absoluteDest);
1055
+
1056
+ // 1b. Reject any symbolic/hard link — and any device/FIFO/socket node —
1057
+ // before touching the tree. The verbose listing's first character is the
1058
+ // entry type for both GNU tar and bsdtar (macOS): '-' regular, 'd' directory,
1059
+ // 'l' symbolic link, 'h' hard link, 'b'/'c' device, 'p' FIFO. The COMPLETE
1060
+ // listing is returned so the declared-size check below can reuse it.
1061
+ const verbose = await rejectLinkEntries(tarballPath, { listingCap });
1062
+
1063
+ // 1c. PRE-extraction decompression-bomb check: refuse an archive whose
1064
+ // declared uncompressed total exceeds the budget, using the sizes from
1065
+ // the listing we already parsed. This fails before a single byte is
1066
+ // written, complementing the post-extraction re-measure in step 5.
1067
+ const declared = sumDeclaredSizes(verbose);
1068
+ assertDeclaredSizesKnown(declared);
1069
+ if (declared.total > declaredSizeCap) {
1070
+ throw new InstallerError(
1071
+ `Refusing to extract tarball: declared uncompressed size ${declared.total} bytes ` +
1072
+ `exceeds the safety budget (${declaredSizeCap} bytes).`,
1073
+ );
1074
+ }
1075
+
870
1076
  // 4. Now extract safely — the content has been validated above.
1077
+ fs.mkdirSync(absoluteDest, { recursive: true });
871
1078
  await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
872
1079
 
873
1080
  // 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
@@ -1164,8 +1371,10 @@ function buildPythonInstallArgs(options) {
1164
1371
  *
1165
1372
  * Kept as a pure function (exported) so the flag surface — including the
1166
1373
  * skill-only ``--no-cli`` switch — can be unit-tested without spawning the
1167
- * full download-and-install pipeline. ``process.exit`` is only reached for
1168
- * hard argument errors (e.g. a value-consuming flag missing its value).
1374
+ * full download-and-install pipeline. Hard argument errors (a value-consuming
1375
+ * flag missing its value, an unknown option) THROW an ``InstallerError`` for
1376
+ * the CLI boundary to report; library code must never call ``process.exit``
1377
+ * (which would skip the caller's cleanup/flush and is untestable in-process).
1169
1378
  */
1170
1379
  function parseArgs(argv) {
1171
1380
  const options = {
@@ -1188,40 +1397,61 @@ function parseArgs(argv) {
1188
1397
  const arg = argv[i];
1189
1398
  const next = argv[i + 1];
1190
1399
 
1400
+ // Accept `--flag=value` as well as `--flag value`: `--ai=trae` is the
1401
+ // spelling users copy from other CLIs, and it used to die as an unknown
1402
+ // option. Split it here so every value-taking switch shares one path.
1403
+ let name = arg;
1404
+ let inlineValue = null;
1405
+ if (arg.startsWith('--')) {
1406
+ const eq = arg.indexOf('=');
1407
+ if (eq !== -1) {
1408
+ name = arg.slice(0, eq);
1409
+ inlineValue = arg.slice(eq + 1);
1410
+ }
1411
+ }
1412
+
1191
1413
  // -h/--help and -v/--version short-circuit parsing: later arguments (even
1192
1414
  // malformed or value-consuming flags) must not turn `--help`/`--version`
1193
1415
  // into an error exit. bin/cli.js exits 0 on these modes before installing.
1194
- if (arg === '--help' || arg === '-h') {
1416
+ if (name === '--help' || name === '-h') {
1195
1417
  options.mode = 'help';
1196
1418
  break;
1197
1419
  }
1198
- if (arg === '--version' || arg === '-v') {
1420
+ if (name === '--version' || name === '-v') {
1199
1421
  options.mode = 'version';
1200
1422
  break;
1201
1423
  }
1202
1424
 
1203
- switch (arg) {
1204
- case '--ai':
1205
- if (!next || next.startsWith('-')) {
1206
- console.error('Error: --ai requires a value (a flag was found instead)');
1207
- process.exit(1);
1425
+ // Boolean flags never take a value; `--force=1` is a typo worth naming
1426
+ // rather than silently treating as --force.
1427
+ if (inlineValue !== null && ['--global', '--force', '--no-cli'].includes(name)) {
1428
+ throw new InstallerError(`${name} does not take a value`);
1429
+ }
1430
+
1431
+ switch (name) {
1432
+ case '--ai': {
1433
+ const value = inlineValue !== null ? inlineValue : next;
1434
+ if (!value || value.startsWith('-')) {
1435
+ throw new InstallerError('--ai requires a value (a flag was found instead)');
1208
1436
  }
1209
- options.ai = next;
1210
- i++;
1437
+ options.ai = value;
1438
+ if (inlineValue === null) i++;
1211
1439
  break;
1212
- case '--target':
1213
- if (!next || next.startsWith('-')) {
1214
- console.error('Error: --target requires a value (a flag was found instead)');
1215
- process.exit(1);
1440
+ }
1441
+ case '--target': {
1442
+ const value = inlineValue !== null ? inlineValue : next;
1443
+ if (!value || value.startsWith('-')) {
1444
+ throw new InstallerError('--target requires a value (a flag was found instead)');
1216
1445
  }
1217
1446
  if (options.globalExplicit) {
1218
1447
  warning('--target overrides --global: installing into the project directory instead.');
1219
1448
  }
1220
- options.target = next;
1449
+ options.target = value;
1221
1450
  options.global = false;
1222
1451
  options.targetExplicit = true;
1223
- i++;
1452
+ if (inlineValue === null) i++;
1224
1453
  break;
1454
+ }
1225
1455
  case '--global':
1226
1456
  if (options.targetExplicit) {
1227
1457
  warning('--global overrides --target: installing into the user home directory instead.');
@@ -1238,18 +1468,18 @@ function parseArgs(argv) {
1238
1468
  // user who only wants the skill is not surprised by a global install.
1239
1469
  options.noCli = true;
1240
1470
  break;
1241
- case '--token':
1242
- if (!next || next.startsWith('-')) {
1243
- console.error('Error: --token requires a value (a flag was found instead)');
1244
- process.exit(1);
1471
+ case '--token': {
1472
+ const value = inlineValue !== null ? inlineValue : next;
1473
+ if (!value || value.startsWith('-')) {
1474
+ throw new InstallerError('--token requires a value (a flag was found instead)');
1245
1475
  }
1246
- options.token = normalizeToken(next);
1247
- i++;
1476
+ options.token = normalizeToken(value);
1477
+ if (inlineValue === null) i++;
1248
1478
  break;
1479
+ }
1249
1480
  default:
1250
1481
  if (arg.startsWith('-')) {
1251
- console.error(`Error: unknown option ${arg}`);
1252
- process.exit(1);
1482
+ throw new InstallerError(`unknown option ${name}`);
1253
1483
  }
1254
1484
  // A bare positional argument is not a valid installer flag; warn so a
1255
1485
  // typo like "npx iterate-skill-installer trae" is not silently ignored.
@@ -1416,16 +1646,8 @@ async function main(options = {}) {
1416
1646
  return 1;
1417
1647
  }
1418
1648
  const actualHash = await sha256File(tarballPath);
1419
- // Constant-time compare (timingSafeEqual): the expected hash travels
1420
- // over the network with the tarball, so an early-exit string inequality
1421
- // would leak prefix information usable to forge a checksum. The length
1422
- // guard keeps timingSafeEqual from throwing on mismatched lengths.
1423
- const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
1424
- const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
1425
- const hashMatches =
1426
- actualBuf.length === expectedBuf.length &&
1427
- actualBuf.length > 0 &&
1428
- crypto.timingSafeEqual(actualBuf, expectedBuf);
1649
+ // Constant-time, case-insensitive compare (see constantTimeHashEqual).
1650
+ const hashMatches = constantTimeHashEqual(actualHash, expectedHash);
1429
1651
  if (!hashMatches) {
1430
1652
  error('Checksum mismatch — refusing to install.');
1431
1653
  return 1;
@@ -1558,9 +1780,14 @@ module.exports = {
1558
1780
  resolveInstallMode,
1559
1781
  askYesNo,
1560
1782
  parseChecksums,
1783
+ constantTimeHashEqual,
1561
1784
  enforceDownloadedSize,
1562
1785
  extractTarball,
1563
1786
  rejectLinkEntries,
1787
+ sumDeclaredSizes,
1788
+ assertDeclaredSizesKnown,
1789
+ declaredEntrySize,
1790
+ validateEntryNames,
1564
1791
  isGithubApiUrl,
1565
1792
  normalizeToken,
1566
1793
  validateTokenShape,
@@ -1577,6 +1804,8 @@ module.exports = {
1577
1804
  FullBuffer,
1578
1805
  measureTree,
1579
1806
  enforceExtractedSize,
1807
+ verifyGpgDetachedSignature,
1808
+ GPG_TIMEOUT_MS,
1580
1809
  MAX_LISTING_CHARS,
1581
1810
  MAX_EXTRACTED_BYTES,
1582
1811
  MAX_EXTRACTED_MEMBER_BYTES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [
@@ -14,7 +14,7 @@
14
14
  "iterate-skill-installer": "bin/cli.js"
15
15
  },
16
16
  "scripts": {
17
- "test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js && node test/tarball.test.js"
17
+ "test": "node --check bin/cli.js && node --check lib/installer.js && node test/mode.test.js && node test/tarball.test.js && node test/security.test.js"
18
18
  },
19
19
  "keywords": [
20
20
  "iterate-skill",