@rhize/skill-forge 0.6.0 → 0.6.1

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/dist/cli.js CHANGED
@@ -153,15 +153,24 @@ async function quarantine(source, quarantineDir, deps = {}) {
153
153
  break;
154
154
  }
155
155
  case "npm": {
156
- const packResult = await exec("npm", ["pack", source.value, "--pack-destination", quarantinePath], {
157
- cwd: quarantinePath
158
- });
156
+ const packResult = await exec(
157
+ "npm",
158
+ ["pack", source.value, "--ignore-scripts", "--pack-destination", quarantinePath],
159
+ { cwd: quarantinePath }
160
+ );
159
161
  if (packResult.code !== 0) {
160
162
  throw new UserError(
161
163
  `npm pack failed for "${source.value}" (exit ${packResult.code}): ${packResult.stderr || packResult.stdout}`
162
164
  );
163
165
  }
164
166
  const tarballPath = join3(quarantinePath, findTarball(quarantinePath, source.value));
167
+ const listResult = await exec("tar", ["-tzf", tarballPath], { cwd: quarantinePath });
168
+ if (listResult.code !== 0) {
169
+ throw new UserError(
170
+ `could not list tarball contents for "${source.value}" (exit ${listResult.code}): ${listResult.stderr || listResult.stdout}`
171
+ );
172
+ }
173
+ assertSafeTarMembers(listResult.stdout, source.value);
165
174
  const extractResult = await exec("tar", ["-xzf", tarballPath, "-C", quarantinePath], {
166
175
  cwd: quarantinePath
167
176
  });
@@ -190,7 +199,7 @@ function listQuarantineIds(quarantineDir) {
190
199
  }
191
200
  function findSkillDirs(root) {
192
201
  const found = [];
193
- function walk3(dir) {
202
+ function walk2(dir) {
194
203
  let entries;
195
204
  try {
196
205
  entries = readdirSync(dir, { withFileTypes: true });
@@ -202,13 +211,24 @@ function findSkillDirs(root) {
202
211
  }
203
212
  for (const entry of entries) {
204
213
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
205
- walk3(join3(dir, entry.name));
214
+ walk2(join3(dir, entry.name));
206
215
  }
207
216
  }
208
217
  }
209
- walk3(root);
218
+ walk2(root);
210
219
  return found;
211
220
  }
221
+ function assertSafeTarMembers(tarListOutput, sourceValue) {
222
+ for (const raw of tarListOutput.split("\n")) {
223
+ const member = raw.trim();
224
+ if (!member) continue;
225
+ if (member.startsWith("/") || member.split("/").includes("..")) {
226
+ throw new UserError(
227
+ `refusing to extract tarball for "${sourceValue}": unsafe member path "${member}" (absolute path or ".." segment \u2014 possible path traversal)`
228
+ );
229
+ }
230
+ }
231
+ }
212
232
  function findTarball(dir, sourceValue) {
213
233
  const tarball = readdirSync(dir).find((name) => name.endsWith(".tgz"));
214
234
  if (!tarball) {
@@ -281,6 +301,7 @@ import { dirname } from "path";
281
301
  function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
282
302
  const force = options.force ?? false;
283
303
  const serverName = profile.name;
304
+ const { command, args, envVarsToFill } = deriveServerEntry(profile);
284
305
  let config = {};
285
306
  let rawExisting;
286
307
  if (existsSync5(targetConfigPath)) {
@@ -302,7 +323,6 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
302
323
  backupPath = `${targetConfigPath}.bak-${Date.now()}`;
303
324
  writeFileSync2(backupPath, rawExisting);
304
325
  }
305
- const { command, args, envVarsToFill } = deriveServerEntry(profile);
306
326
  const env = {};
307
327
  for (const name of envVarsToFill) env[name] = "";
308
328
  servers[serverName] = { command, args, env };
@@ -324,6 +344,11 @@ function deriveServerEntry(profile) {
324
344
  envVarsToFill: Array.from(envNames)
325
345
  };
326
346
  }
347
+ if (!profile.version) {
348
+ throw new UserError(
349
+ `cannot promote MCP server "${profile.name}": no version was found in its package.json, and there is no documented launch command to fall back to \u2014 refusing to write an unpinnable "npx ${profile.name}@unknown" command`
350
+ );
351
+ }
327
352
  return {
328
353
  command: "npx",
329
354
  args: ["-y", `${profile.name}@${profile.version}`],
@@ -406,7 +431,7 @@ function appendProvenance(entry, skillsRoot) {
406
431
  }
407
432
 
408
433
  // src/commands/gatePipeline.ts
409
- import { readdirSync as readdirSync7 } from "fs";
434
+ import { readdirSync as readdirSync5 } from "fs";
410
435
  import { join as join11 } from "path";
411
436
 
412
437
  // src/gate/profile.ts
@@ -416,23 +441,46 @@ import {
416
441
  existsSync as existsSync8,
417
442
  mkdtempSync,
418
443
  openSync,
419
- readFileSync as readFileSync5,
444
+ readFileSync as readFileSync6,
420
445
  readdirSync as readdirSync3,
421
446
  readSync,
422
447
  rmSync as rmSync2,
423
448
  statSync as statSync4
424
449
  } from "fs";
425
450
  import { tmpdir } from "os";
426
- import { basename as basename3, dirname as dirname2, extname as extname2, join as join7, resolve as resolve2 } from "path";
451
+ import { basename as basename3, dirname as dirname2, extname as extname3, join as join8, resolve as resolve2 } from "path";
427
452
 
428
453
  // src/gate/mcpCapabilities.ts
429
- import { existsSync as existsSync7, readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
454
+ import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
455
+ import { extname as extname2, join as join7 } from "path";
456
+
457
+ // src/gate/scanShared.ts
458
+ import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
430
459
  import { extname, join as join6 } from "path";
431
460
  var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
432
- var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
461
+ var BINARY_EXT = /* @__PURE__ */ new Set([
462
+ ".png",
463
+ ".jpg",
464
+ ".jpeg",
465
+ ".gif",
466
+ ".webp",
467
+ ".ico",
468
+ ".pdf",
469
+ ".zip",
470
+ ".tar",
471
+ ".gz",
472
+ ".woff",
473
+ ".woff2",
474
+ ".ttf",
475
+ ".eot",
476
+ ".mp4",
477
+ ".mp3",
478
+ ".wasm",
479
+ ".bin",
480
+ ".exe"
481
+ ]);
433
482
  var MAX_FILE_BYTES = 2 * 1024 * 1024;
434
- var MAX_ITEMS_PER_CATEGORY = 200;
435
- function* walkSourceFiles(dir) {
483
+ function* walk(dir) {
436
484
  let entries;
437
485
  try {
438
486
  entries = readdirSync2(dir, { withFileTypes: true });
@@ -443,23 +491,41 @@ function* walkSourceFiles(dir) {
443
491
  const full = join6(dir, entry.name);
444
492
  if (entry.isDirectory()) {
445
493
  if (SKIP_DIRS2.has(entry.name)) continue;
446
- yield* walkSourceFiles(full);
447
- } else if (entry.isFile() && SOURCE_EXT.has(extname(entry.name))) {
494
+ yield* walk(full);
495
+ } else if (entry.isFile()) {
448
496
  yield full;
449
497
  }
450
498
  }
451
499
  }
452
- function readTextFileSafe(file) {
500
+ function readTextFile(file) {
501
+ if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
453
502
  try {
454
503
  if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
455
- return readFileSync4(file, "utf-8");
504
+ const content = readFileSync4(file, "utf-8");
505
+ if (content.slice(0, 512).includes("\0")) return void 0;
506
+ return content;
456
507
  } catch {
457
508
  return void 0;
458
509
  }
459
510
  }
511
+ var BLOCK_SEVERITIES_BY_STRICTNESS = {
512
+ "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
513
+ "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
514
+ "warn-only": /* @__PURE__ */ new Set()
515
+ };
516
+ function verdictFor(findings, strictness) {
517
+ const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
518
+ if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
519
+ if (findings.length > 0) return "warn";
520
+ return "pass";
521
+ }
522
+
523
+ // src/gate/mcpCapabilities.ts
524
+ var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
525
+ var MAX_ITEMS_PER_CATEGORY = 200;
460
526
  function readJsonSafe(file) {
461
527
  try {
462
- return JSON.parse(readFileSync4(file, "utf-8"));
528
+ return JSON.parse(readFileSync5(file, "utf-8"));
463
529
  } catch {
464
530
  return void 0;
465
531
  }
@@ -560,7 +626,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
560
626
  function extractMcpCapabilities(packageDir) {
561
627
  const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
562
628
  let hasPackageJsonMarker = false;
563
- const pkgPath = join6(packageDir, "package.json");
629
+ const pkgPath = join7(packageDir, "package.json");
564
630
  if (existsSync7(pkgPath)) {
565
631
  const pkg2 = readJsonSafe(pkgPath);
566
632
  if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
@@ -568,7 +634,7 @@ function extractMcpCapabilities(packageDir) {
568
634
  }
569
635
  }
570
636
  for (const manifestName of [".mcp.json", "mcp.json"]) {
571
- const manifestPath = join6(packageDir, manifestName);
637
+ const manifestPath = join7(packageDir, manifestName);
572
638
  if (!existsSync7(manifestPath)) continue;
573
639
  const parsed = readJsonSafe(manifestPath);
574
640
  if (parsed !== void 0) {
@@ -578,8 +644,9 @@ function extractMcpCapabilities(packageDir) {
578
644
  let sawStdioTransport = false;
579
645
  let sawHttpTransport = false;
580
646
  let sawMcpSdkImport = false;
581
- for (const file of walkSourceFiles(packageDir)) {
582
- const text = readTextFileSafe(file);
647
+ for (const file of walk(packageDir)) {
648
+ if (!SOURCE_EXT.has(extname2(file))) continue;
649
+ const text = readTextFile(file);
583
650
  if (text === void 0) continue;
584
651
  captureRegistrations(text, buckets);
585
652
  extractFromListHandlers(text, buckets);
@@ -659,7 +726,7 @@ function profileSkill(inputPath) {
659
726
  return profileSkillDir(p);
660
727
  }
661
728
  function classifyInput(p) {
662
- if (existsSync8(join7(p, ".claude-plugin", "plugin.json"))) return "plugin";
729
+ if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
663
730
  if (detectArtifactType(p) === "mcp") return "mcp";
664
731
  return "skill";
665
732
  }
@@ -667,10 +734,10 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
667
734
  var MCP_KEYWORD2 = "mcp-server";
668
735
  var MCP_BIN_PREFIX2 = /^mcp-/i;
669
736
  function readPackageJsonSafe(root) {
670
- const pkgPath = join7(root, "package.json");
737
+ const pkgPath = join8(root, "package.json");
671
738
  if (!existsSync8(pkgPath)) return void 0;
672
739
  try {
673
- return JSON.parse(readFileSync5(pkgPath, "utf-8"));
740
+ return JSON.parse(readFileSync6(pkgPath, "utf-8"));
674
741
  } catch {
675
742
  return void 0;
676
743
  }
@@ -691,7 +758,7 @@ function hasMcpPackageMarkers(pkg2) {
691
758
  return false;
692
759
  }
693
760
  function detectArtifactType(root) {
694
- if (existsSync8(join7(root, ".mcp.json")) || existsSync8(join7(root, "mcp.json"))) return "mcp";
761
+ if (existsSync8(join8(root, ".mcp.json")) || existsSync8(join8(root, "mcp.json"))) return "mcp";
695
762
  const pkg2 = readPackageJsonSafe(root);
696
763
  if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
697
764
  return "skill";
@@ -700,8 +767,8 @@ function resolveSkillMd(inputPath) {
700
767
  let currentPath = inputPath;
701
768
  let tmpDir;
702
769
  const initialStat = statSync4(currentPath);
703
- if (extname2(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
704
- tmpDir = mkdtempSync(join7(tmpdir(), "forge_"));
770
+ if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
771
+ tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
705
772
  extractZip(currentPath, tmpDir);
706
773
  currentPath = tmpDir;
707
774
  }
@@ -710,7 +777,7 @@ function resolveSkillMd(inputPath) {
710
777
  return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
711
778
  }
712
779
  if (stat.isDirectory()) {
713
- const direct = join7(currentPath, "SKILL.md");
780
+ const direct = join8(currentPath, "SKILL.md");
714
781
  if (existsSync8(direct)) {
715
782
  return { skillMd: direct, root: currentPath, tmpDir };
716
783
  }
@@ -736,15 +803,15 @@ function findSkillMdOneLevelDown(dir) {
736
803
  const matches = /* @__PURE__ */ new Set();
737
804
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
738
805
  if (entry.isDirectory()) {
739
- const candidate = join7(dir, entry.name, "SKILL.md");
806
+ const candidate = join8(dir, entry.name, "SKILL.md");
740
807
  if (existsSync8(candidate)) matches.add(candidate);
741
808
  }
742
809
  }
743
- const skillsDir = join7(dir, "skills");
810
+ const skillsDir = join8(dir, "skills");
744
811
  if (isDir(skillsDir)) {
745
812
  for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
746
813
  if (entry.isDirectory()) {
747
- const candidate = join7(skillsDir, entry.name, "SKILL.md");
814
+ const candidate = join8(skillsDir, entry.name, "SKILL.md");
748
815
  if (existsSync8(candidate)) matches.add(candidate);
749
816
  }
750
817
  }
@@ -828,9 +895,9 @@ function detectLicense(root, fm) {
828
895
  if (fm.license) return fm.license;
829
896
  const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
830
897
  for (const name of names) {
831
- const p = join7(root, name);
898
+ const p = join8(root, name);
832
899
  if (existsSync8(p) && statSync4(p).isFile()) {
833
- const head = readFileSync5(p, "utf-8").slice(0, 2e3).toLowerCase();
900
+ const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
834
901
  for (const [hint, label] of Object.entries(SPDX_HINTS)) {
835
902
  if (head.includes(hint)) return label;
836
903
  }
@@ -842,7 +909,7 @@ function detectLicense(root, fm) {
842
909
  function walkFiles(dir) {
843
910
  const out = [];
844
911
  for (const entry of readdirSync3(dir, { withFileTypes: true })) {
845
- const full = join7(dir, entry.name);
912
+ const full = join8(dir, entry.name);
846
913
  if (entry.isDirectory()) {
847
914
  out.push(...walkFiles(full));
848
915
  } else if (entry.isFile()) {
@@ -854,7 +921,7 @@ function walkFiles(dir) {
854
921
  function countResources(root) {
855
922
  const resources = {};
856
923
  for (const d of RESOURCE_DIRS) {
857
- const dp = join7(root, d);
924
+ const dp = join8(root, d);
858
925
  if (isDir(dp)) {
859
926
  resources[d] = walkFiles(dp).length;
860
927
  }
@@ -865,11 +932,11 @@ function scanMcpAndImports(text, root) {
865
932
  const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
866
933
  const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
867
934
  const imports = /* @__PURE__ */ new Set();
868
- const scriptsDir = join7(root, "scripts");
935
+ const scriptsDir = join8(root, "scripts");
869
936
  if (isDir(scriptsDir)) {
870
937
  for (const f of walkFiles(scriptsDir)) {
871
938
  if (!f.endsWith(".py")) continue;
872
- const ft = readFileSync5(f, "utf-8");
939
+ const ft = readFileSync6(f, "utf-8");
873
940
  for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
874
941
  const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
875
942
  let m;
@@ -886,7 +953,7 @@ function scanMcpAndImports(text, root) {
886
953
  function profileSkillDir(inputPath) {
887
954
  const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
888
955
  try {
889
- const text = readFileSync5(skillMd, "utf-8");
956
+ const text = readFileSync6(skillMd, "utf-8");
890
957
  const fm = parseFrontmatter(text);
891
958
  const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
892
959
  const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
@@ -914,19 +981,19 @@ function profileSkillDir(inputPath) {
914
981
  }
915
982
  }
916
983
  function profilePlugin(p) {
917
- const manifestPath = join7(p, ".claude-plugin", "plugin.json");
984
+ const manifestPath = join8(p, ".claude-plugin", "plugin.json");
918
985
  let manifest;
919
986
  try {
920
- manifest = JSON.parse(readFileSync5(manifestPath, "utf-8"));
987
+ manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
921
988
  } catch (err) {
922
989
  throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
923
990
  }
924
- const skillsDir = join7(p, "skills");
991
+ const skillsDir = join8(p, "skills");
925
992
  let skills = [];
926
993
  if (isDir(skillsDir)) {
927
- skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join7(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
994
+ skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join8(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
928
995
  }
929
- const commandsDir = join7(p, "commands");
996
+ const commandsDir = join8(p, "commands");
930
997
  const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
931
998
  const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
932
999
  const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
@@ -944,11 +1011,11 @@ function profilePlugin(p) {
944
1011
  };
945
1012
  }
946
1013
  function profileMcp(p) {
947
- const explicitConfig = existsSync8(join7(p, ".mcp.json")) ? join7(p, ".mcp.json") : existsSync8(join7(p, "mcp.json")) ? join7(p, "mcp.json") : void 0;
1014
+ const explicitConfig = existsSync8(join8(p, ".mcp.json")) ? join8(p, ".mcp.json") : existsSync8(join8(p, "mcp.json")) ? join8(p, "mcp.json") : void 0;
948
1015
  if (explicitConfig) {
949
1016
  let cfg;
950
1017
  try {
951
- cfg = JSON.parse(readFileSync5(explicitConfig, "utf-8"));
1018
+ cfg = JSON.parse(readFileSync6(explicitConfig, "utf-8"));
952
1019
  } catch (err) {
953
1020
  throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
954
1021
  }
@@ -964,10 +1031,10 @@ function profileMcp(p) {
964
1031
  capabilities: extractMcpCapabilities(p)
965
1032
  };
966
1033
  }
967
- const pkgPath = join7(p, "package.json");
1034
+ const pkgPath = join8(p, "package.json");
968
1035
  let pkg2;
969
1036
  try {
970
- pkg2 = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1037
+ pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
971
1038
  } catch (err) {
972
1039
  throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
973
1040
  }
@@ -984,16 +1051,11 @@ function profileMcp(p) {
984
1051
  }
985
1052
 
986
1053
  // src/gate/safety.ts
987
- import { readFileSync as readFileSync6, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
1054
+ import { statSync as statSync5 } from "fs";
988
1055
  import { execFile } from "child_process";
989
- import { delimiter, extname as extname3, join as join8 } from "path";
1056
+ import { delimiter, join as join9 } from "path";
990
1057
  import { promisify } from "util";
991
1058
  var execFileAsync = promisify(execFile);
992
- var BLOCK_SEVERITIES_BY_STRICTNESS = {
993
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
994
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
995
- "warn-only": /* @__PURE__ */ new Set()
996
- };
997
1059
  var DENY_RULES = [
998
1060
  {
999
1061
  rule: "curl-pipe-shell",
@@ -1056,57 +1118,6 @@ var DENY_RULES = [
1056
1118
  message: "invokes sudo \u2014 review for unnecessary privilege escalation"
1057
1119
  }
1058
1120
  ];
1059
- var SKIP_DIRS3 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1060
- var BINARY_EXT = /* @__PURE__ */ new Set([
1061
- ".png",
1062
- ".jpg",
1063
- ".jpeg",
1064
- ".gif",
1065
- ".webp",
1066
- ".ico",
1067
- ".pdf",
1068
- ".zip",
1069
- ".tar",
1070
- ".gz",
1071
- ".woff",
1072
- ".woff2",
1073
- ".ttf",
1074
- ".eot",
1075
- ".mp4",
1076
- ".mp3",
1077
- ".wasm",
1078
- ".bin",
1079
- ".exe"
1080
- ]);
1081
- var MAX_FILE_BYTES2 = 2 * 1024 * 1024;
1082
- function* walk(dir) {
1083
- let entries;
1084
- try {
1085
- entries = readdirSync4(dir, { withFileTypes: true });
1086
- } catch {
1087
- return;
1088
- }
1089
- for (const entry of entries) {
1090
- const full = join8(dir, entry.name);
1091
- if (entry.isDirectory()) {
1092
- if (SKIP_DIRS3.has(entry.name)) continue;
1093
- yield* walk(full);
1094
- } else if (entry.isFile()) {
1095
- yield full;
1096
- }
1097
- }
1098
- }
1099
- function readTextFile(file) {
1100
- if (BINARY_EXT.has(extname3(file).toLowerCase())) return void 0;
1101
- try {
1102
- if (statSync5(file).size > MAX_FILE_BYTES2) return void 0;
1103
- const content = readFileSync6(file, "utf-8");
1104
- if (content.slice(0, 512).includes("\0")) return void 0;
1105
- return content;
1106
- } catch {
1107
- return void 0;
1108
- }
1109
- }
1110
1121
  function scanStaticRules(targetPath) {
1111
1122
  const findings = [];
1112
1123
  let root;
@@ -1141,7 +1152,7 @@ function which(cmd) {
1141
1152
  const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1142
1153
  for (const dir of dirs) {
1143
1154
  for (const ext of exts) {
1144
- const candidate = join8(dir, cmd + ext);
1155
+ const candidate = join9(dir, cmd + ext);
1145
1156
  try {
1146
1157
  if (statSync5(candidate).isFile()) return candidate;
1147
1158
  } catch {
@@ -1175,20 +1186,14 @@ async function scanWithSkillSpector(targetPath) {
1175
1186
  return [];
1176
1187
  }
1177
1188
  }
1178
- function verdictFor(findings, strictness) {
1179
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
1180
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
1181
- if (findings.length > 0) return "warn";
1182
- return "pass";
1183
- }
1184
1189
  async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
1185
1190
  const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
1186
1191
  return { findings, verdict: verdictFor(findings, strictness) };
1187
1192
  }
1188
1193
 
1189
1194
  // src/gate/overlap.ts
1190
- import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync5 } from "fs";
1191
- import { basename as basename4, dirname as dirname3, join as join9, resolve as resolve3 } from "path";
1195
+ import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
1196
+ import { basename as basename4, dirname as dirname3, join as join10, resolve as resolve3 } from "path";
1192
1197
  var STOPWORDS = new Set(
1193
1198
  `
1194
1199
  a an the and or of to for in on with use used using when whether this that these those is are be
@@ -1307,25 +1312,25 @@ function listSubdirs(dir) {
1307
1312
  if (!existsSync9(dir)) return [];
1308
1313
  let entries;
1309
1314
  try {
1310
- entries = readdirSync5(dir, { withFileTypes: true });
1315
+ entries = readdirSync4(dir, { withFileTypes: true });
1311
1316
  } catch {
1312
1317
  return [];
1313
1318
  }
1314
- return entries.filter((e) => e.isDirectory()).map((e) => join9(dir, e.name));
1319
+ return entries.filter((e) => e.isDirectory()).map((e) => join10(dir, e.name));
1315
1320
  }
1316
1321
  function findSkillMdFiles(root) {
1317
1322
  const found = /* @__PURE__ */ new Set();
1318
1323
  for (const dir of listSubdirs(root)) {
1319
- const candidate = join9(dir, "SKILL.md");
1324
+ const candidate = join10(dir, "SKILL.md");
1320
1325
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1321
1326
  }
1322
- for (const dir of listSubdirs(join9(root, "skills"))) {
1323
- const candidate = join9(dir, "SKILL.md");
1327
+ for (const dir of listSubdirs(join10(root, "skills"))) {
1328
+ const candidate = join10(dir, "SKILL.md");
1324
1329
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1325
1330
  }
1326
1331
  for (const topDir of listSubdirs(root)) {
1327
- for (const dir of listSubdirs(join9(topDir, "skills"))) {
1328
- const candidate = join9(dir, "SKILL.md");
1332
+ for (const dir of listSubdirs(join10(topDir, "skills"))) {
1333
+ const candidate = join10(dir, "SKILL.md");
1329
1334
  if (existsSync9(candidate)) found.add(resolve3(candidate));
1330
1335
  }
1331
1336
  }
@@ -1338,7 +1343,7 @@ function scanOverlap(candidatePath, skillsRoot) {
1338
1343
  }
1339
1344
  let cpath = resolve3(expandHome(candidatePath));
1340
1345
  if (isDir(cpath)) {
1341
- cpath = join9(cpath, "SKILL.md");
1346
+ cpath = join10(cpath, "SKILL.md");
1342
1347
  }
1343
1348
  if (!existsSync9(cpath)) {
1344
1349
  throw new Error(`candidate SKILL.md not found: ${cpath}`);
@@ -1364,59 +1369,8 @@ function scanOverlap(candidatePath, skillsRoot) {
1364
1369
  }
1365
1370
 
1366
1371
  // src/gate/mcpSafety.ts
1367
- import { readFileSync as readFileSync8, readdirSync as readdirSync6, statSync as statSync6 } from "fs";
1368
- import { basename as basename5, extname as extname4, join as join10 } from "path";
1369
- var SKIP_DIRS4 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
1370
- var BINARY_EXT2 = /* @__PURE__ */ new Set([
1371
- ".png",
1372
- ".jpg",
1373
- ".jpeg",
1374
- ".gif",
1375
- ".webp",
1376
- ".ico",
1377
- ".pdf",
1378
- ".zip",
1379
- ".tar",
1380
- ".gz",
1381
- ".woff",
1382
- ".woff2",
1383
- ".ttf",
1384
- ".eot",
1385
- ".mp4",
1386
- ".mp3",
1387
- ".wasm",
1388
- ".bin",
1389
- ".exe"
1390
- ]);
1391
- var MAX_FILE_BYTES3 = 2 * 1024 * 1024;
1392
- function* walk2(dir) {
1393
- let entries;
1394
- try {
1395
- entries = readdirSync6(dir, { withFileTypes: true });
1396
- } catch {
1397
- return;
1398
- }
1399
- for (const entry of entries) {
1400
- const full = join10(dir, entry.name);
1401
- if (entry.isDirectory()) {
1402
- if (SKIP_DIRS4.has(entry.name)) continue;
1403
- yield* walk2(full);
1404
- } else if (entry.isFile()) {
1405
- yield full;
1406
- }
1407
- }
1408
- }
1409
- function readTextFile2(file) {
1410
- if (BINARY_EXT2.has(extname4(file).toLowerCase())) return void 0;
1411
- try {
1412
- if (statSync6(file).size > MAX_FILE_BYTES3) return void 0;
1413
- const content = readFileSync8(file, "utf-8");
1414
- if (content.slice(0, 512).includes("\0")) return void 0;
1415
- return content;
1416
- } catch {
1417
- return void 0;
1418
- }
1419
- }
1372
+ import { statSync as statSync6 } from "fs";
1373
+ import { basename as basename5, extname as extname4 } from "path";
1420
1374
  function isConfigOrExampleFile(file) {
1421
1375
  const base = basename5(file).toLowerCase();
1422
1376
  if (base.startsWith(".env")) return true;
@@ -1446,7 +1400,18 @@ function looksHighEntropy(value) {
1446
1400
  }
1447
1401
  function isPinnedPackageSpec(spec) {
1448
1402
  const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
1449
- return withoutScope.includes("@");
1403
+ const atIdx = withoutScope.lastIndexOf("@");
1404
+ if (atIdx <= 0) return false;
1405
+ return /^\d/.test(withoutScope.slice(atIdx + 1));
1406
+ }
1407
+ function isNpxCommand(command) {
1408
+ const base = command.split(/[\\/]/).pop() ?? command;
1409
+ return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
1410
+ }
1411
+ function packageSpecBase(spec) {
1412
+ const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
1413
+ const atIdx = spec.lastIndexOf("@");
1414
+ return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
1450
1415
  }
1451
1416
  function collectServerNodes(node, out) {
1452
1417
  if (Array.isArray(node)) {
@@ -1475,7 +1440,7 @@ function scanJsonConfig(file, parsed) {
1475
1440
  for (const node of nodes) {
1476
1441
  const command = typeof node.command === "string" ? node.command : void 0;
1477
1442
  const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
1478
- if (command === "npx") {
1443
+ if (command && isNpxCommand(command)) {
1479
1444
  const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
1480
1445
  if (flagIdx !== -1) {
1481
1446
  const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
@@ -1484,7 +1449,7 @@ function scanJsonConfig(file, parsed) {
1484
1449
  severity: "MEDIUM",
1485
1450
  rule: "mcp-unpinned-npx",
1486
1451
  file,
1487
- detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
1452
+ detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
1488
1453
  });
1489
1454
  }
1490
1455
  }
@@ -1523,9 +1488,22 @@ function scanJsonConfig(file, parsed) {
1523
1488
  }
1524
1489
  return findings;
1525
1490
  }
1526
- var NPX_SHELL_RE = /\bnpx\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/g;
1491
+ var NPX_SHELL_RE = /\bnpx(?:\.cmd|\.exe)?\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/gi;
1527
1492
  var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
1528
1493
  var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
1494
+ var CREDENTIAL_ASSIGNMENT_UNQUOTED_RE = /^\s*(?:export\s+|-\s+)?([A-Za-z][A-Za-z0-9_]*)\s*[:=]\s*(\S+)/;
1495
+ var LEADING_QUOTE_RE = /^["']/;
1496
+ function pushCredentialFinding(findings, file, lineNo, varName, value) {
1497
+ if (isPlaceholderValue(value)) return;
1498
+ if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1499
+ findings.push({
1500
+ severity: "HIGH",
1501
+ rule: "mcp-inline-credential-value",
1502
+ file,
1503
+ detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1504
+ });
1505
+ }
1506
+ }
1529
1507
  function scanTextFile(file, content, allowCredentialCheck) {
1530
1508
  const findings = [];
1531
1509
  const lines = content.split("\n");
@@ -1536,8 +1514,8 @@ function scanTextFile(file, content, allowCredentialCheck) {
1536
1514
  let m;
1537
1515
  while ((m = NPX_SHELL_RE.exec(line)) !== null) {
1538
1516
  const pkgSpec = m[1];
1539
- const pinned = m[2] !== void 0;
1540
- if (!pinned) {
1517
+ const versionSuffix = m[2] ?? "";
1518
+ if (!isPinnedPackageSpec(pkgSpec + versionSuffix)) {
1541
1519
  findings.push({
1542
1520
  severity: "MEDIUM",
1543
1521
  rule: "mcp-unpinned-npx",
@@ -1558,24 +1536,18 @@ function scanTextFile(file, content, allowCredentialCheck) {
1558
1536
  if (allowCredentialCheck) {
1559
1537
  CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
1560
1538
  while ((m = CREDENTIAL_ASSIGNMENT_RE.exec(line)) !== null) {
1561
- const varName = m[1];
1562
- const value = m[2];
1563
- if (isPlaceholderValue(value)) continue;
1564
- if (looksCredentialKey(varName) || looksHighEntropy(value)) {
1565
- findings.push({
1566
- severity: "HIGH",
1567
- rule: "mcp-inline-credential-value",
1568
- file,
1569
- detail: `line ${lineNo}: inline credential value assigned to ${varName}`
1570
- });
1571
- }
1539
+ pushCredentialFinding(findings, file, lineNo, m[1], m[2]);
1540
+ }
1541
+ const unquoted = CREDENTIAL_ASSIGNMENT_UNQUOTED_RE.exec(line);
1542
+ if (unquoted && !LEADING_QUOTE_RE.test(unquoted[2])) {
1543
+ pushCredentialFinding(findings, file, lineNo, unquoted[1], unquoted[2]);
1572
1544
  }
1573
1545
  }
1574
1546
  }
1575
1547
  return findings;
1576
1548
  }
1577
1549
  function scanFileForMcpFindings(file) {
1578
- const content = readTextFile2(file);
1550
+ const content = readTextFile(file);
1579
1551
  if (content === void 0) return [];
1580
1552
  if (extname4(file).toLowerCase() === ".json") {
1581
1553
  const parsed = tryParseJson(content);
@@ -1590,40 +1562,29 @@ function scanMcpFindings(targetPath) {
1590
1562
  } catch {
1591
1563
  return [];
1592
1564
  }
1593
- const files = root.isDirectory() ? Array.from(walk2(targetPath)) : [targetPath];
1565
+ const files = root.isDirectory() ? Array.from(walk(targetPath)) : [targetPath];
1594
1566
  const findings = [];
1595
1567
  for (const file of files) {
1596
1568
  findings.push(...scanFileForMcpFindings(file));
1597
1569
  }
1598
1570
  return findings;
1599
1571
  }
1600
- var BLOCK_SEVERITIES_BY_STRICTNESS2 = {
1601
- "block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
1602
- "block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
1603
- "warn-only": /* @__PURE__ */ new Set()
1604
- };
1605
- function verdictFor2(findings, strictness) {
1606
- const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS2[strictness];
1607
- if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
1608
- if (findings.length > 0) return "warn";
1609
- return "pass";
1610
- }
1611
1572
  async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
1612
1573
  const base = await scanSafety(targetPath, strictness);
1613
1574
  const mcpFindings = scanMcpFindings(targetPath);
1614
1575
  const findings = [...base.findings, ...mcpFindings];
1615
- return { findings, verdict: verdictFor2(findings, strictness) };
1576
+ return { findings, verdict: verdictFor(findings, strictness) };
1616
1577
  }
1617
1578
 
1618
1579
  // src/gate/mcpOverlap.ts
1619
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
1580
+ import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
1620
1581
  import { resolve as resolve4 } from "path";
1621
1582
  function readTargetServers(targetPath) {
1622
1583
  const path = resolve4(expandHome(targetPath));
1623
1584
  if (!existsSync10(path)) return [];
1624
1585
  let cfg;
1625
1586
  try {
1626
- cfg = JSON.parse(readFileSync9(path, "utf-8"));
1587
+ cfg = JSON.parse(readFileSync8(path, "utf-8"));
1627
1588
  } catch {
1628
1589
  return [];
1629
1590
  }
@@ -1908,7 +1869,7 @@ function resolveMcpCandidatePath(entry) {
1908
1869
  if (entry.packageDir) return entry.packageDir;
1909
1870
  let children = [];
1910
1871
  try {
1911
- children = readdirSync7(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1872
+ children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
1912
1873
  } catch {
1913
1874
  }
1914
1875
  return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
@@ -1942,7 +1903,7 @@ async function runGate(candidatePath, skillsRoot, {
1942
1903
 
1943
1904
  // src/queue.ts
1944
1905
  import { randomUUID as randomUUID2 } from "crypto";
1945
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync10, writeFileSync as writeFileSync4 } from "fs";
1906
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
1946
1907
  import { join as join12 } from "path";
1947
1908
  function getQueuePath() {
1948
1909
  return join12(getHomeDir(), "queue.json");
@@ -1952,7 +1913,7 @@ function readQueue() {
1952
1913
  if (!existsSync11(path)) {
1953
1914
  return { version: 1, entries: [] };
1954
1915
  }
1955
- const raw = readFileSync10(path, "utf-8");
1916
+ const raw = readFileSync9(path, "utf-8");
1956
1917
  const parsed = JSON.parse(raw);
1957
1918
  return { version: 1, entries: parsed.entries ?? [] };
1958
1919
  }
@@ -2589,7 +2550,9 @@ async function addMcp(source, config, options) {
2589
2550
  const detail = resolveMcpCandidateDetail(profile);
2590
2551
  const serverProfile = {
2591
2552
  name: detail.name,
2592
- version: detail.version ?? "unknown",
2553
+ // version may be absent — promoteMcp refuses the unpinnable npx fallback
2554
+ // rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
2555
+ version: detail.version,
2593
2556
  envVars: detail.env ? Object.keys(detail.env) : void 0,
2594
2557
  candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
2595
2558
  };