iterate-skill-installer 3.5.1 → 3.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -39,8 +39,15 @@ npx iterate-skill-installer --version
39
39
 
40
40
  1. Checks for Python 3 on your system.
41
41
  2. Fetches the latest iterate-skill release from GitHub.
42
- 3. Downloads the release tarball and `SHA256SUMS.txt`.
43
- 4. Verifies the tarball checksum.
42
+ 3. Downloads the release tarball, `SHA256SUMS.txt` and its detached GPG
43
+ signature (`iterate-skill.tar.gz.asc`).
44
+ 4. Verifies BOTH the tarball checksum (integrity) and the signature against the
45
+ pinned iterate signing key (authenticity). **Signing is mandatory**: a
46
+ release without a valid signature is refused, because `SHA256SUMS.txt`
47
+ travels in the same release as the tarball and therefore cannot be the only
48
+ anchor. Install GnuPG (`gpg`) if you do not have it; hosts that cannot run
49
+ gpg may opt out explicitly with `ITERATE_ALLOW_UNSIGNED=1` (a bad signature
50
+ is never bypassed).
44
51
  5. Extracts the release into a temporary directory.
45
52
  6. Runs the bundled Python install script (`scripts/install.py`) which:
46
53
  - Detects installed AI coding assistants on your machine.
package/README.zh-CN.md CHANGED
@@ -39,8 +39,13 @@ npx iterate-skill-installer --version
39
39
 
40
40
  1. 检查你系统上的 Python 3。
41
41
  2. 从 GitHub 获取最新的 iterate-skill 发布版本。
42
- 3. 下载发布 tarball 与 `SHA256SUMS.txt`。
43
- 4. 校验 tarball 的校验和。
42
+ 3. 下载发布 tarball、`SHA256SUMS.txt` 及其分离式 GPG 签名
43
+ (`iterate-skill.tar.gz.asc`)。
44
+ 4. 同时校验 tarball 校验和(完整性)与签名(真实性,对照内嵌的 iterate 签名公钥)。
45
+ **签名为强制**:没有有效签名的发布会直接拒绝安装——因为 `SHA256SUMS.txt` 与
46
+ tarball 出自同一个 release,不能作为唯一信任锚点。没有 `gpg` 请先安装 GnuPG;
47
+ 确实无法运行 gpg 的环境可显式设置 `ITERATE_ALLOW_UNSIGNED=1` 放行(但**坏签名
48
+ 永不放行**)。
44
49
  5. 解压发布内容到临时目录。
45
50
  6. 运行自带的 Python 安装脚本(`scripts/install.py`),它会:
46
51
  - 检测你机器上已安装的 AI 编程助手。
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
@@ -339,9 +339,14 @@ function validateTokenShape(token) {
339
339
  * loudly instead of leaking the PAT to a foreign host.
340
340
  */
341
341
  function buildAuthFlags(url, token) {
342
- if (!token) return [];
342
+ // Re-normalize here as well as at the CLI boundary: this is the exact point
343
+ // the token is interpolated into a header, so a raw CR/LF (e.g. a token read
344
+ // from a Windows-created file) must never reach curl — an embedded newline
345
+ // would split the header (CRLF/header injection) or silently break auth.
346
+ const clean = normalizeToken(token);
347
+ if (!clean) return [];
343
348
  if (!isGithubApiUrl(url)) return [];
344
- return ['-H', `Authorization: Bearer ${token}`, '--max-redirs', '0'];
349
+ return ['-H', `Authorization: Bearer ${clean}`, '--max-redirs', '0'];
345
350
  }
346
351
 
347
352
  async function fetchJson(url, token) {
@@ -349,7 +354,10 @@ async function fetchJson(url, token) {
349
354
  // and avoids Node-specific certificate issues in some environments.
350
355
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
351
356
  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);
357
+ // `--` ends curl's option parsing so a URL that begins with `-` (e.g. a
358
+ // hostile/odd `browser_download_url` served by the API) is treated as a URL,
359
+ // never smuggled in as a curl flag.
360
+ args.push('--', url);
353
361
  // stdoutMustBeComplete with an explicit cap: the default 1 MiB TailBuffer
354
362
  // would truncate a large /releases payload and JSON.parse then failed with a
355
363
  // baffling "Unexpected end of JSON input". The cap now matches the
@@ -381,7 +389,9 @@ async function downloadFile(url, destPath, token, { progress = false, maxBytes =
381
389
  const failFlag = (await supportsCurlFailWithBody()) ? '--fail-with-body' : '--fail';
382
390
  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
391
  if (progress) args.push('--progress-bar');
384
- args.push(url);
392
+ // `--` ends curl's option parsing so a `-`-leading URL can never be read as
393
+ // a curl flag (option injection); it is always the final positional URL.
394
+ args.push('--', url);
385
395
  if (progress) {
386
396
  // Let curl write its progress bar directly to the terminal.
387
397
  await runCommand('curl', args, { stdio: ['ignore', 'ignore', 'inherit'] });
@@ -424,6 +434,79 @@ function enforceDownloadedSize(destPath, maxBytes) {
424
434
  }
425
435
  }
426
436
 
437
+ // True when the operator explicitly opted out of signature enforcement. Kept
438
+ // as an ENVIRONMENT variable (not a flag) so it cannot be enabled from a
439
+ // project config and shows up in the invoking environment for audit.
440
+ const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
441
+
442
+ // Only these (case-insensitive) values enable the override. A bare "any
443
+ // non-empty string" check meant `ITERATE_ALLOW_UNSIGNED=0` (or "false", or a
444
+ // stray "no") ENABLED unsigned installs — the exact opposite of what the
445
+ // operator intended, and a silent downgrade of the authenticity gate.
446
+ const ALLOW_UNSIGNED_TRUTHY = new Set(['1', 'true', 'yes']);
447
+
448
+ function allowUnsigned() {
449
+ const raw = process.env[ALLOW_UNSIGNED_ENV];
450
+ if (raw == null) return false;
451
+ return ALLOW_UNSIGNED_TRUTHY.has(String(raw).trim().toLowerCase());
452
+ }
453
+
454
+ /**
455
+ * Apply the MANDATORY release-signature policy (mirrors
456
+ * ``iterate_cli/signing.py`` and ``scripts/install.py``).
457
+ *
458
+ * Returns an error MESSAGE when the install must abort, or null when it may
459
+ * proceed. The ``ITERATE_ALLOW_UNSIGNED`` override downgrades "cannot check"
460
+ * (no ``.asc`` asset / no ``gpg`` binary) to a warning, but it NEVER bypasses a
461
+ * BAD signature — that is exactly the tampering/key-substitution case.
462
+ *
463
+ * @param {object} state
464
+ * @param {boolean} state.hasSignatureAsset release published an ``.asc``
465
+ * @param {string} [state.status] 'verified' | 'invalid' | 'unavailable'
466
+ * @param {string} [state.detail] verifier detail for messages
467
+ * @param {string} [state.downloadError] failure to FETCH the ``.asc``
468
+ * @param {boolean} [state.allowUnsigned] override (defaults to the env var)
469
+ */
470
+ function signaturePolicyError({
471
+ hasSignatureAsset,
472
+ status = null,
473
+ detail = '',
474
+ downloadError = null,
475
+ allowUnsigned: override = allowUnsigned(),
476
+ } = {}) {
477
+ if (downloadError) {
478
+ // The release DID publish a signature but we could not fetch it: a
479
+ // transport problem, not a licence to skip authenticity.
480
+ return (
481
+ `Could not download the release signature (${downloadError}). Refusing to install ` +
482
+ `without authenticity verification; retry once the network recovers. Set ` +
483
+ `${ALLOW_UNSIGNED_ENV}=1 only if you accept an unauthenticated install.`
484
+ );
485
+ }
486
+ if (!hasSignatureAsset) {
487
+ if (override) return null;
488
+ return (
489
+ 'Release is UNSIGNED: it publishes no .asc signature, so authenticity cannot be ' +
490
+ `verified (SHA256SUMS travels in the same release and is not an independent ` +
491
+ `anchor). Set ${ALLOW_UNSIGNED_ENV}=1 only if you accept an unauthenticated install.`
492
+ );
493
+ }
494
+ if (status === 'verified') return null;
495
+ if (status === 'invalid') {
496
+ return (
497
+ `GPG signature verification FAILED (${detail}). Refusing to install: the tarball ` +
498
+ `is not signed by the pinned iterate signing key. This is either tampering or a ` +
499
+ `key rotation this installer version does not know about; ${ALLOW_UNSIGNED_ENV} ` +
500
+ 'does NOT bypass a bad signature.'
501
+ );
502
+ }
503
+ if (override) return null;
504
+ return (
505
+ `Cannot verify the release signature (${detail || 'gpg unavailable'}). Install GnuPG ` +
506
+ `(gpg) and retry, or set ${ALLOW_UNSIGNED_ENV}=1 to accept an unauthenticated install.`
507
+ );
508
+ }
509
+
427
510
  function sha256File(filePath) {
428
511
  return new Promise((resolve, reject) => {
429
512
  const hash = crypto.createHash('sha256');
@@ -434,6 +517,31 @@ function sha256File(filePath) {
434
517
  });
435
518
  }
436
519
 
520
+ /**
521
+ * Constant-time, case-insensitive comparison of two SHA256 hex digests.
522
+ *
523
+ * Casing must never decide the outcome: `sha256sum`/other tools may emit
524
+ * upper-case hex, and a case-sensitive `===` would reject a perfectly good
525
+ * release. Both sides are lower-cased and compared with `timingSafeEqual` —
526
+ * the expected hash travels over the network beside the tarball, so an
527
+ * early-exit string inequality would leak prefix information usable to forge a
528
+ * checksum. The length guard keeps `timingSafeEqual` from throwing on
529
+ * mismatched lengths (an empty/short value is refused outright).
530
+ *
531
+ * Exported so the comparison itself is unit-testable (the inline version it
532
+ * replaces could only be exercised by a full network install).
533
+ */
534
+ function constantTimeHashEqual(actualHash, expectedHash) {
535
+ if (typeof actualHash !== 'string' || typeof expectedHash !== 'string') return false;
536
+ const actualBuf = Buffer.from(actualHash.trim().toLowerCase(), 'utf8');
537
+ const expectedBuf = Buffer.from(expectedHash.trim().toLowerCase(), 'utf8');
538
+ return (
539
+ actualBuf.length === expectedBuf.length &&
540
+ actualBuf.length > 0 &&
541
+ crypto.timingSafeEqual(actualBuf, expectedBuf)
542
+ );
543
+ }
544
+
437
545
  // Best-effort GPG authenticity verification of a downloaded artifact against
438
546
  // the embedded signing key. Returns one of:
439
547
  // 'verified' — `gpg` present and the detached signature is valid.
@@ -447,7 +555,11 @@ function sha256File(filePath) {
447
555
  // Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
448
556
  // gpg-agent are never touched or modified.
449
557
  function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir) {
450
- const gpgHome = path.join(tmpDir, 'gnupg');
558
+ // NOTE: gpg derives its agent socket from the homedir
559
+ // (<homedir>/S.gpg-agent) and unix sockets cap out around 104 characters, so
560
+ // keep this path SHORT — a deep tmpdir made gpg fail with "File name too
561
+ // long: can't connect to the gpg-agent".
562
+ const gpgHome = path.join(tmpDir, 'g');
451
563
  fs.mkdirSync(gpgHome, { recursive: true, mode: 0o700 });
452
564
  const keyPath = path.join(tmpDir, 'iterate-skill-pubkey.asc');
453
565
  fs.writeFileSync(keyPath, publicKeyArmor, 'utf8');
@@ -678,77 +790,118 @@ function runCommand(bin, args, options = {}) {
678
790
 
679
791
  async function rejectLinkEntries(tarballPath, { listingCap = MAX_LISTING_CHARS } = {}) {
680
792
  // Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
681
- // link entry. We deliberately reject *all* links rather than validating
682
- // their targets: the release tarball contains none, so any link is a sign
683
- // of a tampered archive and refusing it outright is the safest response to
684
- // a symlink-escape attempt (see extractTarball above).
793
+ // link entry — and any device ("b" block / "c" character), FIFO ("p") or
794
+ // socket special node. We deliberately reject *all* links rather than
795
+ // validating their targets: the release tarball contains none, so any link is
796
+ // a sign of a tampered archive and refusing it outright is the safest
797
+ // response to a symlink-escape attempt (see extractTarball above). Special
798
+ // nodes are rejected for the same reason: neither the release nor its
799
+ // installer ever ships one, and a FIFO/device could wedge or bypass a later
800
+ // read of the extracted tree.
685
801
  //
686
802
  // stdoutMustBeComplete: this listing is a SECURITY input. The old 1 MiB
687
803
  // tail buffer silently dropped the OLDEST entries, so an archive with enough
688
804
  // filler members to push its listing past the cap hid a symlink/hardlink
689
805
  // entry from this scan entirely (while `tar -xzf` still extracted it).
690
806
  // A listing over the cap now fails closed instead.
807
+ //
808
+ // Returns the COMPLETE verbose listing so callers (extractTarball) can reuse
809
+ // it for the pre-extraction declared-size check without a second tar run.
691
810
  const verbose = await runCommand('tar', ['-tvzf', tarballPath], {
692
811
  stdoutCap: listingCap,
693
812
  stdoutMustBeComplete: true,
694
813
  });
814
+ const TYPE_LABELS = {
815
+ l: 'symbolic link',
816
+ h: 'hard link',
817
+ b: 'block device node',
818
+ c: 'character device node',
819
+ p: 'FIFO',
820
+ s: 'socket',
821
+ };
695
822
  for (const line of verbose.split(/\r?\n/)) {
696
823
  if (!line) {
697
824
  continue;
698
825
  }
699
826
  const type = line[0];
700
- if (type === 'l') {
701
- throw new InstallerError(
702
- 'Refusing to extract tarball: archive contains a symbolic link, which the release never ships.',
703
- );
704
- }
705
- if (type === 'h') {
827
+ const label = TYPE_LABELS[type];
828
+ if (label) {
706
829
  throw new InstallerError(
707
- 'Refusing to extract tarball: archive contains a hard link, which the release never ships.',
830
+ `Refusing to extract tarball: archive contains a ${label}, which the release never ships.`,
708
831
  );
709
832
  }
710
833
  }
834
+ return verbose;
711
835
  }
712
836
 
713
- async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_CHARS } = {}) {
714
- // Use the system tar command (available on macOS, Linux, and Windows 10+),
715
- // but defend against path-traversal tarballs: before extracting we list the
716
- // archive contents and refuse to extract any entry whose resolved path
717
- // escapes destDir (e.g. a "../" prefix). This keeps the installer
718
- // dependency-free while closing the theoretical escape vector.
719
- //
720
- // Extraction uses --strip-components=1, which relies on the archive having
721
- // exactly one top-level directory (see README "Release tarball structure").
722
- // Archives that violate that contract — multiple top-level directories, or
723
- // entries with no path left after stripping — are rejected outright.
724
- //
725
- // A name-based `../` check alone is not enough: a tarball can smuggle a
726
- // symlink whose in-archive name looks safe but whose target points outside
727
- // destDir, then place files "through" that symlink (e.g. `link/passwd` where
728
- // `link -> /etc`). Because extraction happens with a system `tar`, the write
729
- // would follow the link before we could validate — so any symbolic or hard
730
- // link is rejected *before* extraction. The release tarball never uses links.
731
- const absoluteDest = path.resolve(destDir);
732
- fs.mkdirSync(absoluteDest, { recursive: true });
837
+ // A month abbreviation ("Jan".."Dec") appears in the timestamp column of the
838
+ // bsdtar verbose listing; GNU tar prints an ISO date ("2023-01-01"). Both mark
839
+ // the end of the size/user fields, so the numeric token immediately before the
840
+ // first such token is the entry's declared uncompressed size in EITHER format.
841
+ const MONTH_ABBR_RE = /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/;
842
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
733
843
 
734
- // 1. List archive entries (no extraction yet). Must be complete — see
735
- // rejectLinkEntries: every guard below reads THIS listing, and a tail
736
- // buffer would let a big archive hide entries from all of them.
737
- const listing = await runCommand('tar', ['-tzf', tarballPath], {
738
- stdoutCap: listingCap,
739
- stdoutMustBeComplete: true,
740
- });
741
- const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
742
- if (entries.length === 0) {
743
- throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
844
+ /**
845
+ * Parse one `tar -tvzf` line's declared uncompressed size, or null when the
846
+ * line cannot be parsed. `size` is the numeric field immediately preceding the
847
+ * timestamp column (see MONTH_ABBR_RE / ISO_DATE_RE).
848
+ */
849
+ function declaredEntrySize(line) {
850
+ const tokens = line.trim().split(/\s+/);
851
+ for (let i = 1; i < tokens.length; i += 1) {
852
+ if (ISO_DATE_RE.test(tokens[i]) || MONTH_ABBR_RE.test(tokens[i])) {
853
+ const candidate = tokens[i - 1];
854
+ return /^\d+$/.test(candidate) ? Number(candidate) : null;
855
+ }
744
856
  }
857
+ return null;
858
+ }
745
859
 
746
- // 1a. Reject absolute-path entries outright before any other accounting.
747
- // A member named ``/etc/cron.d/x`` or ``C:\\...`` would resolve through the
748
- // top-level-strip logic into destDir, but accepting its name as a "safe"
749
- // component invites ambiguity between tar implementations about what the
750
- // first component really is — never claim a release archive can contain
751
- // absolute paths, so fail closed instead.
860
+ /**
861
+ * Sum the declared uncompressed sizes in a `tar -tvzf` listing.
862
+ *
863
+ * This is the PRE-extraction half of the decompression-bomb guard: `tar -xzf`
864
+ * has no size bound and the download cap only limits the COMPRESSED size, so a
865
+ * small highly-compressible archive can declare (and would expand to) many GiB.
866
+ * Reading the declared sizes out of the listing lets us refuse BEFORE writing
867
+ * anything to disk. Returns the total plus how many entries were parsed so a
868
+ * caller can distinguish "empty" from "unparseable format".
869
+ */
870
+ function sumDeclaredSizes(verboseListing) {
871
+ let total = 0;
872
+ let parsed = 0;
873
+ let entries = 0;
874
+ for (const line of verboseListing.split(/\r?\n/)) {
875
+ if (!line.trim()) continue;
876
+ entries += 1;
877
+ const size = declaredEntrySize(line);
878
+ if (size !== null) {
879
+ total += size;
880
+ parsed += 1;
881
+ }
882
+ }
883
+ return { total, parsed, entries };
884
+ }
885
+
886
+ /**
887
+ * Validate every archive member NAME before extraction.
888
+ *
889
+ * Rejects, in order:
890
+ * - absolute paths (`/etc/...`, `C:\...`), which invite ambiguity between
891
+ * tar implementations about what the "first component" is;
892
+ * - any `..` path component (path traversal), which — even when the
893
+ * top-level-strip logic happens to keep it inside destDir — violates the
894
+ * release's "single top-level directory" contract and must never appear;
895
+ * - archives without exactly one top-level directory (required by
896
+ * --strip-components=1);
897
+ * - entries that still escape destDir after the top-level component is
898
+ * stripped.
899
+ *
900
+ * Pure and exported so the guards are unit-testable without crafting hostile
901
+ * tarballs. Returns the resolved top-level directory name.
902
+ */
903
+ function validateEntryNames(entries, absoluteDest) {
904
+ // Reject absolute-path entries outright before any other accounting.
752
905
  for (const entry of entries) {
753
906
  if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) {
754
907
  throw new InstallerError(
@@ -757,15 +910,20 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
757
910
  }
758
911
  }
759
912
 
760
- // 1b. Reject any symbolic or hard link entry before touching the tree.
761
- // The verbose listing's first character is the entry type for both GNU tar
762
- // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
763
- // hard link. We only need to detect links, so a cheap first-char scan is
764
- // sufficient and avoids relying on the rest of the format.
765
- await rejectLinkEntries(tarballPath, { listingCap });
913
+ // Reject any ``..`` component. A name-based resolve() check below already
914
+ // catches escapes, but an explicit rejection is unambiguous and also refuses
915
+ // the "stripped away" cases (e.g. a lone `..` top-level) that resolve back
916
+ // inside destDir while still violating the archive contract.
917
+ for (const entry of entries) {
918
+ if (entry.split('/').includes('..')) {
919
+ throw new InstallerError(
920
+ `Refusing to extract tarball: entry "${entry}" contains a ".." path component.`,
921
+ );
922
+ }
923
+ }
766
924
 
767
- // 2. Enforce the single-top-level-directory contract required by
768
- // --strip-components=1.
925
+ // Enforce the single-top-level-directory contract required by
926
+ // --strip-components=1.
769
927
  const topLevelComponents = new Set(entries.map((entry) => entry.split('/')[0]));
770
928
  if (topLevelComponents.size !== 1) {
771
929
  const tops = [...topLevelComponents].join(', ');
@@ -775,8 +933,8 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
775
933
  }
776
934
  const topLevel = entries[0].split('/')[0];
777
935
 
778
- // 3. Validate every entry stays inside destDir after stripping the single
779
- // top-level directory component (--strip-components=1).
936
+ // Validate every entry stays inside destDir after stripping the single
937
+ // top-level directory component (--strip-components=1).
780
938
  for (const entry of entries) {
781
939
  const stripped = entry.split('/').slice(1).join('/');
782
940
  if (stripped === '') {
@@ -798,7 +956,73 @@ async function extractTarball(tarballPath, destDir, { listingCap = MAX_LISTING_C
798
956
  }
799
957
  }
800
958
 
959
+ return topLevel;
960
+ }
961
+
962
+ async function extractTarball(
963
+ tarballPath,
964
+ destDir,
965
+ { listingCap = MAX_LISTING_CHARS, declaredSizeCap = MAX_EXTRACTED_BYTES } = {},
966
+ ) {
967
+ // Use the system tar command (available on macOS, Linux, and Windows 10+),
968
+ // but defend against path-traversal tarballs: before extracting we list the
969
+ // archive contents and refuse to extract any entry whose resolved path
970
+ // escapes destDir (e.g. a "../" prefix). This keeps the installer
971
+ // dependency-free while closing the theoretical escape vector.
972
+ //
973
+ // Extraction uses --strip-components=1, which relies on the archive having
974
+ // exactly one top-level directory (see README "Release tarball structure").
975
+ // Archives that violate that contract — multiple top-level directories, or
976
+ // entries with no path left after stripping — are rejected outright.
977
+ //
978
+ // A name-based `../` check alone is not enough: a tarball can smuggle a
979
+ // symlink whose in-archive name looks safe but whose target points outside
980
+ // destDir, then place files "through" that symlink (e.g. `link/passwd` where
981
+ // `link -> /etc`). Because extraction happens with a system `tar`, the write
982
+ // would follow the link before we could validate — so any symbolic or hard
983
+ // link is rejected *before* extraction. The release tarball never uses links.
984
+ // Note: dest is created AFTER every guard below (not before), so a refused
985
+ // archive never leaves a directory behind. `tar -xzf` requires the target to
986
+ // exist, so mkdir stays inside this function rather than with the caller.
987
+ const absoluteDest = path.resolve(destDir);
988
+
989
+ // 1. List archive entries (no extraction yet). Must be complete — see
990
+ // rejectLinkEntries: every guard below reads THIS listing, and a tail
991
+ // buffer would let a big archive hide entries from all of them.
992
+ const listing = await runCommand('tar', ['-tzf', tarballPath], {
993
+ stdoutCap: listingCap,
994
+ stdoutMustBeComplete: true,
995
+ });
996
+ const entries = listing.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
997
+ if (entries.length === 0) {
998
+ throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
999
+ }
1000
+
1001
+ // 1a/2/3. Reject absolute paths and `..` components, enforce the single
1002
+ // top-level directory, and prove every entry stays inside destDir.
1003
+ validateEntryNames(entries, absoluteDest);
1004
+
1005
+ // 1b. Reject any symbolic/hard link — and any device/FIFO/socket node —
1006
+ // before touching the tree. The verbose listing's first character is the
1007
+ // entry type for both GNU tar and bsdtar (macOS): '-' regular, 'd' directory,
1008
+ // 'l' symbolic link, 'h' hard link, 'b'/'c' device, 'p' FIFO. The COMPLETE
1009
+ // listing is returned so the declared-size check below can reuse it.
1010
+ const verbose = await rejectLinkEntries(tarballPath, { listingCap });
1011
+
1012
+ // 1c. PRE-extraction decompression-bomb check: refuse an archive whose
1013
+ // declared uncompressed total exceeds the budget, using the sizes from
1014
+ // the listing we already parsed. This fails before a single byte is
1015
+ // written, complementing the post-extraction re-measure in step 5.
1016
+ const declared = sumDeclaredSizes(verbose);
1017
+ if (declared.total > declaredSizeCap) {
1018
+ throw new InstallerError(
1019
+ `Refusing to extract tarball: declared uncompressed size ${declared.total} bytes ` +
1020
+ `exceeds the safety budget (${declaredSizeCap} bytes).`,
1021
+ );
1022
+ }
1023
+
801
1024
  // 4. Now extract safely — the content has been validated above.
1025
+ fs.mkdirSync(absoluteDest, { recursive: true });
802
1026
  await runCommand('tar', ['-xzf', tarballPath, '-C', absoluteDest, '--strip-components=1']);
803
1027
 
804
1028
  // 5. Decompression-bomb guard (mirrors scripts/install.py). ``tar`` itself
@@ -1095,8 +1319,10 @@ function buildPythonInstallArgs(options) {
1095
1319
  *
1096
1320
  * Kept as a pure function (exported) so the flag surface — including the
1097
1321
  * skill-only ``--no-cli`` switch — can be unit-tested without spawning the
1098
- * full download-and-install pipeline. ``process.exit`` is only reached for
1099
- * hard argument errors (e.g. a value-consuming flag missing its value).
1322
+ * full download-and-install pipeline. Hard argument errors (a value-consuming
1323
+ * flag missing its value, an unknown option) THROW an ``InstallerError`` for
1324
+ * the CLI boundary to report; library code must never call ``process.exit``
1325
+ * (which would skip the caller's cleanup/flush and is untestable in-process).
1100
1326
  */
1101
1327
  function parseArgs(argv) {
1102
1328
  const options = {
@@ -1134,16 +1360,14 @@ function parseArgs(argv) {
1134
1360
  switch (arg) {
1135
1361
  case '--ai':
1136
1362
  if (!next || next.startsWith('-')) {
1137
- console.error('Error: --ai requires a value (a flag was found instead)');
1138
- process.exit(1);
1363
+ throw new InstallerError('--ai requires a value (a flag was found instead)');
1139
1364
  }
1140
1365
  options.ai = next;
1141
1366
  i++;
1142
1367
  break;
1143
1368
  case '--target':
1144
1369
  if (!next || next.startsWith('-')) {
1145
- console.error('Error: --target requires a value (a flag was found instead)');
1146
- process.exit(1);
1370
+ throw new InstallerError('--target requires a value (a flag was found instead)');
1147
1371
  }
1148
1372
  if (options.globalExplicit) {
1149
1373
  warning('--target overrides --global: installing into the project directory instead.');
@@ -1171,16 +1395,14 @@ function parseArgs(argv) {
1171
1395
  break;
1172
1396
  case '--token':
1173
1397
  if (!next || next.startsWith('-')) {
1174
- console.error('Error: --token requires a value (a flag was found instead)');
1175
- process.exit(1);
1398
+ throw new InstallerError('--token requires a value (a flag was found instead)');
1176
1399
  }
1177
1400
  options.token = normalizeToken(next);
1178
1401
  i++;
1179
1402
  break;
1180
1403
  default:
1181
1404
  if (arg.startsWith('-')) {
1182
- console.error(`Error: unknown option ${arg}`);
1183
- process.exit(1);
1405
+ throw new InstallerError(`unknown option ${arg}`);
1184
1406
  }
1185
1407
  // A bare positional argument is not a valid installer flag; warn so a
1186
1408
  // typo like "npx iterate-skill-installer trae" is not silently ignored.
@@ -1252,20 +1474,27 @@ async function main(options = {}) {
1252
1474
  hint('Retry, or set GITHUB_TOKEN if this is a rate-limit artefact.');
1253
1475
  return 1;
1254
1476
  }
1255
- // Announce releases that carry the tarball but not the checksum pair: they
1256
- // are skipped, which silently installs an OLDER release when the newest one
1257
- // is mid-upload. Saying so out loud keeps "installed an old version" visible.
1477
+ // Announce releases that carry the tarball but not the checksum/signature
1478
+ // pair: they are skipped, which silently installs an OLDER release when the
1479
+ // newest one is mid-upload. Saying so out loud keeps "installed an old
1480
+ // version" visible.
1258
1481
  for (const candidate of releases) {
1259
1482
  if (!candidate || candidate.draft || candidate.prerelease) continue;
1260
1483
  const assets = Array.isArray(candidate.assets) ? candidate.assets : [];
1261
1484
  const hasTarball = assets.some((a) => a && a.name === TARBALL_ASSET_NAME);
1262
- const hasChecksums = assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME);
1263
- if (hasTarball && !hasChecksums) {
1485
+ const missing = [];
1486
+ if (!assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)) missing.push(CHECKSUMS_ASSET_NAME);
1487
+ if (!assets.some((a) => a && a.name === TARBALL_SIGNATURE_ASSET_NAME)) missing.push(TARBALL_SIGNATURE_ASSET_NAME);
1488
+ if (hasTarball && missing.length > 0) {
1264
1489
  warning(
1265
- `Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${CHECKSUMS_ASSET_NAME}.`,
1490
+ `Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${missing.join(' / ')}.`,
1266
1491
  );
1267
1492
  }
1268
1493
  }
1494
+ // A usable release must carry the tarball, its checksums AND its detached
1495
+ // signature: the signature is mandatory at install time (see
1496
+ // signaturePolicyError), so selecting a release without one would only turn
1497
+ // "found the latest" into "failed to install".
1269
1498
  const release = releases.find(
1270
1499
  (r) =>
1271
1500
  r &&
@@ -1273,12 +1502,14 @@ async function main(options = {}) {
1273
1502
  !r.prerelease &&
1274
1503
  Array.isArray(r.assets) &&
1275
1504
  r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
1276
- r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME),
1505
+ r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME) &&
1506
+ r.assets.some((a) => a && a.name === TARBALL_SIGNATURE_ASSET_NAME),
1277
1507
  );
1278
1508
 
1279
1509
  if (!release) {
1280
1510
  error(
1281
- `No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ${CHECKSUMS_ASSET_NAME} asset pair.`
1511
+ `No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ` +
1512
+ `${CHECKSUMS_ASSET_NAME} + ${TARBALL_SIGNATURE_ASSET_NAME} asset triple.`
1282
1513
  );
1283
1514
  hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
1284
1515
  return 1;
@@ -1306,7 +1537,7 @@ async function main(options = {}) {
1306
1537
  return 1;
1307
1538
  }
1308
1539
 
1309
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iterate-skill-install-'));
1540
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ig-install-'));
1310
1541
  const tarballPath = path.join(tmpDir, TARBALL_ASSET_NAME);
1311
1542
  const checksumPath = path.join(tmpDir, CHECKSUMS_ASSET_NAME);
1312
1543
 
@@ -1338,16 +1569,8 @@ async function main(options = {}) {
1338
1569
  return 1;
1339
1570
  }
1340
1571
  const actualHash = await sha256File(tarballPath);
1341
- // Constant-time compare (timingSafeEqual): the expected hash travels
1342
- // over the network with the tarball, so an early-exit string inequality
1343
- // would leak prefix information usable to forge a checksum. The length
1344
- // guard keeps timingSafeEqual from throwing on mismatched lengths.
1345
- const actualBuf = Buffer.from(actualHash.toLowerCase(), 'utf8');
1346
- const expectedBuf = Buffer.from(expectedHash.toLowerCase(), 'utf8');
1347
- const hashMatches =
1348
- actualBuf.length === expectedBuf.length &&
1349
- actualBuf.length > 0 &&
1350
- crypto.timingSafeEqual(actualBuf, expectedBuf);
1572
+ // Constant-time, case-insensitive compare (see constantTimeHashEqual).
1573
+ const hashMatches = constantTimeHashEqual(actualHash, expectedHash);
1351
1574
  if (!hashMatches) {
1352
1575
  error('Checksum mismatch — refusing to install.');
1353
1576
  return 1;
@@ -1358,19 +1581,17 @@ async function main(options = {}) {
1358
1581
  `\x1b[32m✓\x1b[0m SHA256 checksum verified`,
1359
1582
  ]);
1360
1583
 
1361
- // GPG authenticity check (defense-in-depth, fail-closed when it runs). The
1362
- // release also publishes a detached ASCII-armored signature over the
1363
- // tarball. When a `gpg` binary is present we verify it against the embedded
1364
- // signing key: a *valid* signature upgrades the release from
1365
- // "integrity-guaranteed" to "authenticity-guaranteed". If gpg is
1366
- // unavailable we degrade to a warning (SHA256 already guards integrity —
1367
- // that check still stands on its own).
1584
+ // GPG authenticity check — MANDATORY (see signaturePolicyError).
1368
1585
  //
1369
- // If gpg IS present and the signature is present but does NOT verify, the
1370
- // install now FAILS. SHA256SUMS travels in the same release as the tarball,
1371
- // so an attacker who can replace one can replace both; the pinned public
1372
- // key is the single anchor they cannot forge. Reporting "signature
1373
- // invalid" and installing anyway made that anchor decorative.
1586
+ // SHA256SUMS.txt travels in the SAME release as the tarball, so an attacker
1587
+ // who can replace one can replace both; the pinned public key below is the
1588
+ // only anchor they cannot forge. A missing `.asc`, a host without `gpg`, an
1589
+ // unfetchable signature, or a signature that does not verify all ABORT the
1590
+ // install. The single documented escape hatch is ``ITERATE_ALLOW_UNSIGNED``
1591
+ // (hosts without GnuPG) and it never bypasses a BAD signature.
1592
+ let signatureStatus = null;
1593
+ let signatureDetail = '';
1594
+ let signatureDownloadError = null;
1374
1595
  if (signatureAsset) {
1375
1596
  const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
1376
1597
  try {
@@ -1378,30 +1599,28 @@ async function main(options = {}) {
1378
1599
  maxBytes: CHECKSUMS_MAX_BYTES,
1379
1600
  });
1380
1601
  const sig = verifyGpgDetachedSignature(tarballPath, signaturePath, ITERATE_SIGNING_PUBLIC_KEY, tmpDir);
1381
- if (sig.status === 'verified') {
1382
- success('GPG signature verified.');
1383
- } else if (sig.status === 'invalid') {
1384
- error(
1385
- `GPG signature verification FAILED (${sig.detail}) — refusing to install.\n` +
1386
- ' The tarball was signed with a key that does not match the pinned ' +
1387
- 'iterate-skill signing key: this is either tampering or a key rotation ' +
1388
- 'that this installer version does not know about. Report it before retrying.',
1389
- );
1390
- return 1;
1391
- } else {
1392
- warning(
1393
- `gpg not available (${sig.detail}); skipping signature check — SHA256 integrity still enforced.`
1394
- );
1395
- }
1602
+ signatureStatus = sig.status;
1603
+ signatureDetail = sig.detail;
1396
1604
  } catch (err) {
1397
- warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
1605
+ signatureDownloadError = err.message;
1398
1606
  }
1399
- } else {
1400
- // Say it out loud: a silent skip meant an operator could believe the
1401
- // signature layer ran on every install. SHA256 still guards integrity,
1402
- // but authenticity was NOT verified for this release.
1607
+ }
1608
+ const signatureError = signaturePolicyError({
1609
+ hasSignatureAsset: Boolean(signatureAsset),
1610
+ status: signatureStatus,
1611
+ detail: signatureDetail,
1612
+ downloadError: signatureDownloadError,
1613
+ });
1614
+ if (signatureError) {
1615
+ error(signatureError);
1616
+ return 1;
1617
+ }
1618
+ if (signatureStatus === 'verified') {
1619
+ success('GPG signature verified.');
1620
+ } else if (allowUnsigned()) {
1403
1621
  warning(
1404
- 'Release publishes no GPG signature asset; authenticity check skipped (SHA256 integrity still enforced).',
1622
+ `Signature NOT verified (${signatureDetail || 'no signature'}) but accepted because ` +
1623
+ `${ALLOW_UNSIGNED_ENV} is set — SHA256 integrity only.`,
1405
1624
  );
1406
1625
  }
1407
1626
 
@@ -1484,12 +1703,19 @@ module.exports = {
1484
1703
  resolveInstallMode,
1485
1704
  askYesNo,
1486
1705
  parseChecksums,
1706
+ constantTimeHashEqual,
1487
1707
  enforceDownloadedSize,
1488
1708
  extractTarball,
1489
1709
  rejectLinkEntries,
1710
+ sumDeclaredSizes,
1711
+ declaredEntrySize,
1712
+ validateEntryNames,
1490
1713
  isGithubApiUrl,
1491
1714
  normalizeToken,
1492
1715
  validateTokenShape,
1716
+ allowUnsigned,
1717
+ signaturePolicyError,
1718
+ ALLOW_UNSIGNED_ENV,
1493
1719
  buildAuthFlags,
1494
1720
  supportsCurlFailWithBody,
1495
1721
  // Executed under test for the timeout/null-exit-code hardening; retaining
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "3.5.1",
3
+ "version": "3.5.3",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [