iterate-skill-installer 3.5.1 → 3.5.2

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/lib/installer.js CHANGED
@@ -424,6 +424,71 @@ function enforceDownloadedSize(destPath, maxBytes) {
424
424
  }
425
425
  }
426
426
 
427
+ // True when the operator explicitly opted out of signature enforcement. Kept
428
+ // as an ENVIRONMENT variable (not a flag) so it cannot be enabled from a
429
+ // project config and shows up in the invoking environment for audit.
430
+ const ALLOW_UNSIGNED_ENV = 'ITERATE_ALLOW_UNSIGNED';
431
+
432
+ function allowUnsigned() {
433
+ return String(process.env[ALLOW_UNSIGNED_ENV] || '').trim() !== '';
434
+ }
435
+
436
+ /**
437
+ * Apply the MANDATORY release-signature policy (mirrors
438
+ * ``iterate_cli/signing.py`` and ``scripts/install.py``).
439
+ *
440
+ * Returns an error MESSAGE when the install must abort, or null when it may
441
+ * proceed. The ``ITERATE_ALLOW_UNSIGNED`` override downgrades "cannot check"
442
+ * (no ``.asc`` asset / no ``gpg`` binary) to a warning, but it NEVER bypasses a
443
+ * BAD signature — that is exactly the tampering/key-substitution case.
444
+ *
445
+ * @param {object} state
446
+ * @param {boolean} state.hasSignatureAsset release published an ``.asc``
447
+ * @param {string} [state.status] 'verified' | 'invalid' | 'unavailable'
448
+ * @param {string} [state.detail] verifier detail for messages
449
+ * @param {string} [state.downloadError] failure to FETCH the ``.asc``
450
+ * @param {boolean} [state.allowUnsigned] override (defaults to the env var)
451
+ */
452
+ function signaturePolicyError({
453
+ hasSignatureAsset,
454
+ status = null,
455
+ detail = '',
456
+ downloadError = null,
457
+ allowUnsigned: override = allowUnsigned(),
458
+ } = {}) {
459
+ if (downloadError) {
460
+ // The release DID publish a signature but we could not fetch it: a
461
+ // transport problem, not a licence to skip authenticity.
462
+ return (
463
+ `Could not download the release signature (${downloadError}). Refusing to install ` +
464
+ `without authenticity verification; retry once the network recovers. Set ` +
465
+ `${ALLOW_UNSIGNED_ENV}=1 only if you accept an unauthenticated install.`
466
+ );
467
+ }
468
+ if (!hasSignatureAsset) {
469
+ if (override) return null;
470
+ return (
471
+ 'Release is UNSIGNED: it publishes no .asc signature, so authenticity cannot be ' +
472
+ `verified (SHA256SUMS travels in the same release and is not an independent ` +
473
+ `anchor). Set ${ALLOW_UNSIGNED_ENV}=1 only if you accept an unauthenticated install.`
474
+ );
475
+ }
476
+ if (status === 'verified') return null;
477
+ if (status === 'invalid') {
478
+ return (
479
+ `GPG signature verification FAILED (${detail}). Refusing to install: the tarball ` +
480
+ `is not signed by the pinned iterate signing key. This is either tampering or a ` +
481
+ `key rotation this installer version does not know about; ${ALLOW_UNSIGNED_ENV} ` +
482
+ 'does NOT bypass a bad signature.'
483
+ );
484
+ }
485
+ if (override) return null;
486
+ return (
487
+ `Cannot verify the release signature (${detail || 'gpg unavailable'}). Install GnuPG ` +
488
+ `(gpg) and retry, or set ${ALLOW_UNSIGNED_ENV}=1 to accept an unauthenticated install.`
489
+ );
490
+ }
491
+
427
492
  function sha256File(filePath) {
428
493
  return new Promise((resolve, reject) => {
429
494
  const hash = crypto.createHash('sha256');
@@ -447,7 +512,11 @@ function sha256File(filePath) {
447
512
  // Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
448
513
  // gpg-agent are never touched or modified.
449
514
  function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor, tmpDir) {
450
- const gpgHome = path.join(tmpDir, 'gnupg');
515
+ // NOTE: gpg derives its agent socket from the homedir
516
+ // (<homedir>/S.gpg-agent) and unix sockets cap out around 104 characters, so
517
+ // keep this path SHORT — a deep tmpdir made gpg fail with "File name too
518
+ // long: can't connect to the gpg-agent".
519
+ const gpgHome = path.join(tmpDir, 'g');
451
520
  fs.mkdirSync(gpgHome, { recursive: true, mode: 0o700 });
452
521
  const keyPath = path.join(tmpDir, 'iterate-skill-pubkey.asc');
453
522
  fs.writeFileSync(keyPath, publicKeyArmor, 'utf8');
@@ -1252,20 +1321,27 @@ async function main(options = {}) {
1252
1321
  hint('Retry, or set GITHUB_TOKEN if this is a rate-limit artefact.');
1253
1322
  return 1;
1254
1323
  }
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.
1324
+ // Announce releases that carry the tarball but not the checksum/signature
1325
+ // pair: they are skipped, which silently installs an OLDER release when the
1326
+ // newest one is mid-upload. Saying so out loud keeps "installed an old
1327
+ // version" visible.
1258
1328
  for (const candidate of releases) {
1259
1329
  if (!candidate || candidate.draft || candidate.prerelease) continue;
1260
1330
  const assets = Array.isArray(candidate.assets) ? candidate.assets : [];
1261
1331
  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) {
1332
+ const missing = [];
1333
+ if (!assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME)) missing.push(CHECKSUMS_ASSET_NAME);
1334
+ if (!assets.some((a) => a && a.name === TARBALL_SIGNATURE_ASSET_NAME)) missing.push(TARBALL_SIGNATURE_ASSET_NAME);
1335
+ if (hasTarball && missing.length > 0) {
1264
1336
  warning(
1265
- `Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${CHECKSUMS_ASSET_NAME}.`,
1337
+ `Skipping release ${candidate.tag_name}: has ${TARBALL_ASSET_NAME} but no ${missing.join(' / ')}.`,
1266
1338
  );
1267
1339
  }
1268
1340
  }
1341
+ // A usable release must carry the tarball, its checksums AND its detached
1342
+ // signature: the signature is mandatory at install time (see
1343
+ // signaturePolicyError), so selecting a release without one would only turn
1344
+ // "found the latest" into "failed to install".
1269
1345
  const release = releases.find(
1270
1346
  (r) =>
1271
1347
  r &&
@@ -1273,12 +1349,14 @@ async function main(options = {}) {
1273
1349
  !r.prerelease &&
1274
1350
  Array.isArray(r.assets) &&
1275
1351
  r.assets.some((a) => a && a.name === TARBALL_ASSET_NAME) &&
1276
- r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME),
1352
+ r.assets.some((a) => a && a.name === CHECKSUMS_ASSET_NAME) &&
1353
+ r.assets.some((a) => a && a.name === TARBALL_SIGNATURE_ASSET_NAME),
1277
1354
  );
1278
1355
 
1279
1356
  if (!release) {
1280
1357
  error(
1281
- `No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ${CHECKSUMS_ASSET_NAME} asset pair.`
1358
+ `No published release in the last 100 carries a ${TARBALL_ASSET_NAME} + ` +
1359
+ `${CHECKSUMS_ASSET_NAME} + ${TARBALL_SIGNATURE_ASSET_NAME} asset triple.`
1282
1360
  );
1283
1361
  hint('This is usually a GitHub API rate limit; retry with GITHUB_TOKEN set.');
1284
1362
  return 1;
@@ -1306,7 +1384,7 @@ async function main(options = {}) {
1306
1384
  return 1;
1307
1385
  }
1308
1386
 
1309
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'iterate-skill-install-'));
1387
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ig-install-'));
1310
1388
  const tarballPath = path.join(tmpDir, TARBALL_ASSET_NAME);
1311
1389
  const checksumPath = path.join(tmpDir, CHECKSUMS_ASSET_NAME);
1312
1390
 
@@ -1358,19 +1436,17 @@ async function main(options = {}) {
1358
1436
  `\x1b[32m✓\x1b[0m SHA256 checksum verified`,
1359
1437
  ]);
1360
1438
 
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).
1439
+ // GPG authenticity check — MANDATORY (see signaturePolicyError).
1368
1440
  //
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.
1441
+ // SHA256SUMS.txt travels in the SAME release as the tarball, so an attacker
1442
+ // who can replace one can replace both; the pinned public key below is the
1443
+ // only anchor they cannot forge. A missing `.asc`, a host without `gpg`, an
1444
+ // unfetchable signature, or a signature that does not verify all ABORT the
1445
+ // install. The single documented escape hatch is ``ITERATE_ALLOW_UNSIGNED``
1446
+ // (hosts without GnuPG) and it never bypasses a BAD signature.
1447
+ let signatureStatus = null;
1448
+ let signatureDetail = '';
1449
+ let signatureDownloadError = null;
1374
1450
  if (signatureAsset) {
1375
1451
  const signaturePath = path.join(tmpDir, TARBALL_SIGNATURE_ASSET_NAME);
1376
1452
  try {
@@ -1378,30 +1454,28 @@ async function main(options = {}) {
1378
1454
  maxBytes: CHECKSUMS_MAX_BYTES,
1379
1455
  });
1380
1456
  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
- }
1457
+ signatureStatus = sig.status;
1458
+ signatureDetail = sig.detail;
1396
1459
  } catch (err) {
1397
- warning(`Could not download/verify the GPG signature (${err.message}); SHA256 integrity still enforced.`);
1460
+ signatureDownloadError = err.message;
1398
1461
  }
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.
1462
+ }
1463
+ const signatureError = signaturePolicyError({
1464
+ hasSignatureAsset: Boolean(signatureAsset),
1465
+ status: signatureStatus,
1466
+ detail: signatureDetail,
1467
+ downloadError: signatureDownloadError,
1468
+ });
1469
+ if (signatureError) {
1470
+ error(signatureError);
1471
+ return 1;
1472
+ }
1473
+ if (signatureStatus === 'verified') {
1474
+ success('GPG signature verified.');
1475
+ } else if (allowUnsigned()) {
1403
1476
  warning(
1404
- 'Release publishes no GPG signature asset; authenticity check skipped (SHA256 integrity still enforced).',
1477
+ `Signature NOT verified (${signatureDetail || 'no signature'}) but accepted because ` +
1478
+ `${ALLOW_UNSIGNED_ENV} is set — SHA256 integrity only.`,
1405
1479
  );
1406
1480
  }
1407
1481
 
@@ -1490,6 +1564,9 @@ module.exports = {
1490
1564
  isGithubApiUrl,
1491
1565
  normalizeToken,
1492
1566
  validateTokenShape,
1567
+ allowUnsigned,
1568
+ signaturePolicyError,
1569
+ ALLOW_UNSIGNED_ENV,
1493
1570
  buildAuthFlags,
1494
1571
  supportsCurlFailWithBody,
1495
1572
  // 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.2",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "files": [