iterate-skill-installer 3.5.3 → 3.7.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 (2) hide show
  1. package/lib/installer.js +103 -23
  2. package/package.json +2 -2
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
@@ -554,7 +566,14 @@ function constantTimeHashEqual(actualHash, expectedHash) {
554
566
  // defense-in-depth layer, not the only gate.
555
567
  // Runs inside an isolated temporary GNUPGHOME so the end user's own keyring and
556
568
  // gpg-agent are never touched or modified.
557
- 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;
558
577
  // NOTE: gpg derives its agent socket from the homedir
559
578
  // (<homedir>/S.gpg-agent) and unix sockets cap out around 104 characters, so
560
579
  // keep this path SHORT — a deep tmpdir made gpg fail with "File name too
@@ -567,22 +586,37 @@ function verifyGpgDetachedSignature(artifactPath, signaturePath, publicKeyArmor,
567
586
  const importResult = spawnSync(
568
587
  'gpg',
569
588
  ['--homedir', gpgHome, '--batch', '--yes', '--quiet', '--no-autostart', '--import', keyPath],
570
- { encoding: 'utf8' }
589
+ { encoding: 'utf8', timeout, killSignal: 'SIGKILL' }
571
590
  );
572
591
  if (importResult.error) {
573
- const detail = importResult.error.code === 'ENOENT' ? 'gpg binary not found' : importResult.error.message;
574
- 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 };
575
599
  }
576
600
  if (importResult.status !== 0) {
577
- 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
+ };
578
609
  }
579
610
 
580
611
  const verifyResult = spawnSync(
581
612
  'gpg',
582
613
  ['--homedir', gpgHome, '--batch', '--quiet', '--no-autostart', '--verify', signaturePath, artifactPath],
583
- { encoding: 'utf8' }
614
+ { encoding: 'utf8', timeout, killSignal: 'SIGKILL' }
584
615
  );
585
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
+ }
586
620
  return { status: 'unavailable', detail: `gpg launch failed: ${verifyResult.error.message}` };
587
621
  }
588
622
  // gpg exit 0 = good signature; 1 = bad signature; 2 = fatal (bad key usage).
@@ -883,6 +917,23 @@ function sumDeclaredSizes(verboseListing) {
883
917
  return { total, parsed, entries };
884
918
  }
885
919
 
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
+
886
937
  /**
887
938
  * Validate every archive member NAME before extraction.
888
939
  *
@@ -1014,6 +1065,7 @@ async function extractTarball(
1014
1065
  // the listing we already parsed. This fails before a single byte is
1015
1066
  // written, complementing the post-extraction re-measure in step 5.
1016
1067
  const declared = sumDeclaredSizes(verbose);
1068
+ assertDeclaredSizesKnown(declared);
1017
1069
  if (declared.total > declaredSizeCap) {
1018
1070
  throw new InstallerError(
1019
1071
  `Refusing to extract tarball: declared uncompressed size ${declared.total} bytes ` +
@@ -1345,38 +1397,61 @@ function parseArgs(argv) {
1345
1397
  const arg = argv[i];
1346
1398
  const next = argv[i + 1];
1347
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
+
1348
1413
  // -h/--help and -v/--version short-circuit parsing: later arguments (even
1349
1414
  // malformed or value-consuming flags) must not turn `--help`/`--version`
1350
1415
  // into an error exit. bin/cli.js exits 0 on these modes before installing.
1351
- if (arg === '--help' || arg === '-h') {
1416
+ if (name === '--help' || name === '-h') {
1352
1417
  options.mode = 'help';
1353
1418
  break;
1354
1419
  }
1355
- if (arg === '--version' || arg === '-v') {
1420
+ if (name === '--version' || name === '-v') {
1356
1421
  options.mode = 'version';
1357
1422
  break;
1358
1423
  }
1359
1424
 
1360
- switch (arg) {
1361
- case '--ai':
1362
- if (!next || next.startsWith('-')) {
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('-')) {
1363
1435
  throw new InstallerError('--ai requires a value (a flag was found instead)');
1364
1436
  }
1365
- options.ai = next;
1366
- i++;
1437
+ options.ai = value;
1438
+ if (inlineValue === null) i++;
1367
1439
  break;
1368
- case '--target':
1369
- if (!next || next.startsWith('-')) {
1440
+ }
1441
+ case '--target': {
1442
+ const value = inlineValue !== null ? inlineValue : next;
1443
+ if (!value || value.startsWith('-')) {
1370
1444
  throw new InstallerError('--target requires a value (a flag was found instead)');
1371
1445
  }
1372
1446
  if (options.globalExplicit) {
1373
1447
  warning('--target overrides --global: installing into the project directory instead.');
1374
1448
  }
1375
- options.target = next;
1449
+ options.target = value;
1376
1450
  options.global = false;
1377
1451
  options.targetExplicit = true;
1378
- i++;
1452
+ if (inlineValue === null) i++;
1379
1453
  break;
1454
+ }
1380
1455
  case '--global':
1381
1456
  if (options.targetExplicit) {
1382
1457
  warning('--global overrides --target: installing into the user home directory instead.');
@@ -1393,16 +1468,18 @@ function parseArgs(argv) {
1393
1468
  // user who only wants the skill is not surprised by a global install.
1394
1469
  options.noCli = true;
1395
1470
  break;
1396
- case '--token':
1397
- if (!next || next.startsWith('-')) {
1471
+ case '--token': {
1472
+ const value = inlineValue !== null ? inlineValue : next;
1473
+ if (!value || value.startsWith('-')) {
1398
1474
  throw new InstallerError('--token requires a value (a flag was found instead)');
1399
1475
  }
1400
- options.token = normalizeToken(next);
1401
- i++;
1476
+ options.token = normalizeToken(value);
1477
+ if (inlineValue === null) i++;
1402
1478
  break;
1479
+ }
1403
1480
  default:
1404
1481
  if (arg.startsWith('-')) {
1405
- throw new InstallerError(`unknown option ${arg}`);
1482
+ throw new InstallerError(`unknown option ${name}`);
1406
1483
  }
1407
1484
  // A bare positional argument is not a valid installer flag; warn so a
1408
1485
  // typo like "npx iterate-skill-installer trae" is not silently ignored.
@@ -1708,6 +1785,7 @@ module.exports = {
1708
1785
  extractTarball,
1709
1786
  rejectLinkEntries,
1710
1787
  sumDeclaredSizes,
1788
+ assertDeclaredSizesKnown,
1711
1789
  declaredEntrySize,
1712
1790
  validateEntryNames,
1713
1791
  isGithubApiUrl,
@@ -1726,6 +1804,8 @@ module.exports = {
1726
1804
  FullBuffer,
1727
1805
  measureTree,
1728
1806
  enforceExtractedSize,
1807
+ verifyGpgDetachedSignature,
1808
+ GPG_TIMEOUT_MS,
1729
1809
  MAX_LISTING_CHARS,
1730
1810
  MAX_EXTRACTED_BYTES,
1731
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.3",
3
+ "version": "3.7.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",