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.
- package/lib/installer.js +103 -23
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
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 (
|
|
1416
|
+
if (name === '--help' || name === '-h') {
|
|
1352
1417
|
options.mode = 'help';
|
|
1353
1418
|
break;
|
|
1354
1419
|
}
|
|
1355
|
-
if (
|
|
1420
|
+
if (name === '--version' || name === '-v') {
|
|
1356
1421
|
options.mode = 'version';
|
|
1357
1422
|
break;
|
|
1358
1423
|
}
|
|
1359
1424
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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 =
|
|
1366
|
-
i++;
|
|
1437
|
+
options.ai = value;
|
|
1438
|
+
if (inlineValue === null) i++;
|
|
1367
1439
|
break;
|
|
1368
|
-
|
|
1369
|
-
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 ${
|
|
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.
|
|
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",
|