@rhize/skill-forge 0.6.0 → 0.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/LICENSE +1 -0
- package/README.md +80 -8
- package/dist/cli.js +563 -269
- package/dist/cli.js.map +1 -1
- package/dist/ingest-prompt.md +14 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,6 @@ import { Command } from "commander";
|
|
|
5
5
|
import { createRequire } from "module";
|
|
6
6
|
|
|
7
7
|
// src/commands/add.ts
|
|
8
|
-
import { createInterface as createInterface2 } from "readline/promises";
|
|
9
8
|
import { join as join15 } from "path";
|
|
10
9
|
|
|
11
10
|
// src/config.ts
|
|
@@ -153,15 +152,24 @@ async function quarantine(source, quarantineDir, deps = {}) {
|
|
|
153
152
|
break;
|
|
154
153
|
}
|
|
155
154
|
case "npm": {
|
|
156
|
-
const packResult = await exec(
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
const packResult = await exec(
|
|
156
|
+
"npm",
|
|
157
|
+
["pack", source.value, "--ignore-scripts", "--pack-destination", quarantinePath],
|
|
158
|
+
{ cwd: quarantinePath }
|
|
159
|
+
);
|
|
159
160
|
if (packResult.code !== 0) {
|
|
160
161
|
throw new UserError(
|
|
161
162
|
`npm pack failed for "${source.value}" (exit ${packResult.code}): ${packResult.stderr || packResult.stdout}`
|
|
162
163
|
);
|
|
163
164
|
}
|
|
164
165
|
const tarballPath = join3(quarantinePath, findTarball(quarantinePath, source.value));
|
|
166
|
+
const listResult = await exec("tar", ["-tzf", tarballPath], { cwd: quarantinePath });
|
|
167
|
+
if (listResult.code !== 0) {
|
|
168
|
+
throw new UserError(
|
|
169
|
+
`could not list tarball contents for "${source.value}" (exit ${listResult.code}): ${listResult.stderr || listResult.stdout}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
assertSafeTarMembers(listResult.stdout, source.value);
|
|
165
173
|
const extractResult = await exec("tar", ["-xzf", tarballPath, "-C", quarantinePath], {
|
|
166
174
|
cwd: quarantinePath
|
|
167
175
|
});
|
|
@@ -190,7 +198,7 @@ function listQuarantineIds(quarantineDir) {
|
|
|
190
198
|
}
|
|
191
199
|
function findSkillDirs(root) {
|
|
192
200
|
const found = [];
|
|
193
|
-
function
|
|
201
|
+
function walk2(dir) {
|
|
194
202
|
let entries;
|
|
195
203
|
try {
|
|
196
204
|
entries = readdirSync(dir, { withFileTypes: true });
|
|
@@ -202,13 +210,24 @@ function findSkillDirs(root) {
|
|
|
202
210
|
}
|
|
203
211
|
for (const entry of entries) {
|
|
204
212
|
if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
|
|
205
|
-
|
|
213
|
+
walk2(join3(dir, entry.name));
|
|
206
214
|
}
|
|
207
215
|
}
|
|
208
216
|
}
|
|
209
|
-
|
|
217
|
+
walk2(root);
|
|
210
218
|
return found;
|
|
211
219
|
}
|
|
220
|
+
function assertSafeTarMembers(tarListOutput, sourceValue) {
|
|
221
|
+
for (const raw of tarListOutput.split("\n")) {
|
|
222
|
+
const member = raw.trim();
|
|
223
|
+
if (!member) continue;
|
|
224
|
+
if (member.startsWith("/") || member.split("/").includes("..")) {
|
|
225
|
+
throw new UserError(
|
|
226
|
+
`refusing to extract tarball for "${sourceValue}": unsafe member path "${member}" (absolute path or ".." segment \u2014 possible path traversal)`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
212
231
|
function findTarball(dir, sourceValue) {
|
|
213
232
|
const tarball = readdirSync(dir).find((name) => name.endsWith(".tgz"));
|
|
214
233
|
if (!tarball) {
|
|
@@ -281,6 +300,7 @@ import { dirname } from "path";
|
|
|
281
300
|
function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
282
301
|
const force = options.force ?? false;
|
|
283
302
|
const serverName = profile.name;
|
|
303
|
+
const { command, args, envVarsToFill } = deriveServerEntry(profile);
|
|
284
304
|
let config = {};
|
|
285
305
|
let rawExisting;
|
|
286
306
|
if (existsSync5(targetConfigPath)) {
|
|
@@ -302,7 +322,6 @@ function promoteMcp(_entry, profile, targetConfigPath, options = {}) {
|
|
|
302
322
|
backupPath = `${targetConfigPath}.bak-${Date.now()}`;
|
|
303
323
|
writeFileSync2(backupPath, rawExisting);
|
|
304
324
|
}
|
|
305
|
-
const { command, args, envVarsToFill } = deriveServerEntry(profile);
|
|
306
325
|
const env = {};
|
|
307
326
|
for (const name of envVarsToFill) env[name] = "";
|
|
308
327
|
servers[serverName] = { command, args, env };
|
|
@@ -324,6 +343,11 @@ function deriveServerEntry(profile) {
|
|
|
324
343
|
envVarsToFill: Array.from(envNames)
|
|
325
344
|
};
|
|
326
345
|
}
|
|
346
|
+
if (!profile.version) {
|
|
347
|
+
throw new UserError(
|
|
348
|
+
`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`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
327
351
|
return {
|
|
328
352
|
command: "npx",
|
|
329
353
|
args: ["-y", `${profile.name}@${profile.version}`],
|
|
@@ -406,7 +430,7 @@ function appendProvenance(entry, skillsRoot) {
|
|
|
406
430
|
}
|
|
407
431
|
|
|
408
432
|
// src/commands/gatePipeline.ts
|
|
409
|
-
import { readdirSync as
|
|
433
|
+
import { readdirSync as readdirSync5 } from "fs";
|
|
410
434
|
import { join as join11 } from "path";
|
|
411
435
|
|
|
412
436
|
// src/gate/profile.ts
|
|
@@ -416,23 +440,46 @@ import {
|
|
|
416
440
|
existsSync as existsSync8,
|
|
417
441
|
mkdtempSync,
|
|
418
442
|
openSync,
|
|
419
|
-
readFileSync as
|
|
443
|
+
readFileSync as readFileSync6,
|
|
420
444
|
readdirSync as readdirSync3,
|
|
421
445
|
readSync,
|
|
422
446
|
rmSync as rmSync2,
|
|
423
447
|
statSync as statSync4
|
|
424
448
|
} from "fs";
|
|
425
449
|
import { tmpdir } from "os";
|
|
426
|
-
import { basename as basename3, dirname as dirname2, extname as
|
|
450
|
+
import { basename as basename3, dirname as dirname2, extname as extname3, join as join8, resolve as resolve2 } from "path";
|
|
427
451
|
|
|
428
452
|
// src/gate/mcpCapabilities.ts
|
|
429
|
-
import { existsSync as existsSync7, readFileSync as
|
|
453
|
+
import { existsSync as existsSync7, readFileSync as readFileSync5 } from "fs";
|
|
454
|
+
import { extname as extname2, join as join7 } from "path";
|
|
455
|
+
|
|
456
|
+
// src/gate/scanShared.ts
|
|
457
|
+
import { readFileSync as readFileSync4, readdirSync as readdirSync2, statSync as statSync3 } from "fs";
|
|
430
458
|
import { extname, join as join6 } from "path";
|
|
431
459
|
var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", "__pycache__", ".venv", "venv"]);
|
|
432
|
-
var
|
|
460
|
+
var BINARY_EXT = /* @__PURE__ */ new Set([
|
|
461
|
+
".png",
|
|
462
|
+
".jpg",
|
|
463
|
+
".jpeg",
|
|
464
|
+
".gif",
|
|
465
|
+
".webp",
|
|
466
|
+
".ico",
|
|
467
|
+
".pdf",
|
|
468
|
+
".zip",
|
|
469
|
+
".tar",
|
|
470
|
+
".gz",
|
|
471
|
+
".woff",
|
|
472
|
+
".woff2",
|
|
473
|
+
".ttf",
|
|
474
|
+
".eot",
|
|
475
|
+
".mp4",
|
|
476
|
+
".mp3",
|
|
477
|
+
".wasm",
|
|
478
|
+
".bin",
|
|
479
|
+
".exe"
|
|
480
|
+
]);
|
|
433
481
|
var MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
434
|
-
|
|
435
|
-
function* walkSourceFiles(dir) {
|
|
482
|
+
function* walk(dir) {
|
|
436
483
|
let entries;
|
|
437
484
|
try {
|
|
438
485
|
entries = readdirSync2(dir, { withFileTypes: true });
|
|
@@ -443,23 +490,44 @@ function* walkSourceFiles(dir) {
|
|
|
443
490
|
const full = join6(dir, entry.name);
|
|
444
491
|
if (entry.isDirectory()) {
|
|
445
492
|
if (SKIP_DIRS2.has(entry.name)) continue;
|
|
446
|
-
yield*
|
|
447
|
-
} else if (entry.isFile()
|
|
493
|
+
yield* walk(full);
|
|
494
|
+
} else if (entry.isFile()) {
|
|
448
495
|
yield full;
|
|
449
496
|
}
|
|
450
497
|
}
|
|
451
498
|
}
|
|
452
|
-
function
|
|
499
|
+
function readTextFile(file) {
|
|
500
|
+
if (BINARY_EXT.has(extname(file).toLowerCase())) return void 0;
|
|
453
501
|
try {
|
|
454
502
|
if (statSync3(file).size > MAX_FILE_BYTES) return void 0;
|
|
455
|
-
|
|
503
|
+
const content = readFileSync4(file, "utf-8");
|
|
504
|
+
if (content.slice(0, 512).includes("\0")) return void 0;
|
|
505
|
+
return content;
|
|
456
506
|
} catch {
|
|
457
507
|
return void 0;
|
|
458
508
|
}
|
|
459
509
|
}
|
|
510
|
+
var BLOCK_SEVERITIES_BY_STRICTNESS = {
|
|
511
|
+
"block-high": /* @__PURE__ */ new Set(["HIGH", "CRITICAL"]),
|
|
512
|
+
"block-medium": /* @__PURE__ */ new Set(["MEDIUM", "HIGH", "CRITICAL"]),
|
|
513
|
+
"warn-only": /* @__PURE__ */ new Set()
|
|
514
|
+
};
|
|
515
|
+
function verdictFor(findings, strictness) {
|
|
516
|
+
const blockSeverities = BLOCK_SEVERITIES_BY_STRICTNESS[strictness];
|
|
517
|
+
if (findings.some((f) => blockSeverities.has(f.severity))) return "block";
|
|
518
|
+
if (findings.length > 0) return "warn";
|
|
519
|
+
return "pass";
|
|
520
|
+
}
|
|
521
|
+
function formatSafetyFinding(f) {
|
|
522
|
+
return `${f.severity} [${f.rule}] ${f.file}: ${f.detail}`;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// src/gate/mcpCapabilities.ts
|
|
526
|
+
var SOURCE_EXT = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
527
|
+
var MAX_ITEMS_PER_CATEGORY = 200;
|
|
460
528
|
function readJsonSafe(file) {
|
|
461
529
|
try {
|
|
462
|
-
return JSON.parse(
|
|
530
|
+
return JSON.parse(readFileSync5(file, "utf-8"));
|
|
463
531
|
} catch {
|
|
464
532
|
return void 0;
|
|
465
533
|
}
|
|
@@ -560,7 +628,7 @@ var MCP_SDK_IMPORT_RE = /@modelcontextprotocol\/sdk/;
|
|
|
560
628
|
function extractMcpCapabilities(packageDir) {
|
|
561
629
|
const buckets = { tools: /* @__PURE__ */ new Set(), resources: /* @__PURE__ */ new Set(), prompts: /* @__PURE__ */ new Set() };
|
|
562
630
|
let hasPackageJsonMarker = false;
|
|
563
|
-
const pkgPath =
|
|
631
|
+
const pkgPath = join7(packageDir, "package.json");
|
|
564
632
|
if (existsSync7(pkgPath)) {
|
|
565
633
|
const pkg2 = readJsonSafe(pkgPath);
|
|
566
634
|
if (pkg2 && typeof pkg2 === "object" && !Array.isArray(pkg2)) {
|
|
@@ -568,7 +636,7 @@ function extractMcpCapabilities(packageDir) {
|
|
|
568
636
|
}
|
|
569
637
|
}
|
|
570
638
|
for (const manifestName of [".mcp.json", "mcp.json"]) {
|
|
571
|
-
const manifestPath =
|
|
639
|
+
const manifestPath = join7(packageDir, manifestName);
|
|
572
640
|
if (!existsSync7(manifestPath)) continue;
|
|
573
641
|
const parsed = readJsonSafe(manifestPath);
|
|
574
642
|
if (parsed !== void 0) {
|
|
@@ -578,8 +646,9 @@ function extractMcpCapabilities(packageDir) {
|
|
|
578
646
|
let sawStdioTransport = false;
|
|
579
647
|
let sawHttpTransport = false;
|
|
580
648
|
let sawMcpSdkImport = false;
|
|
581
|
-
for (const file of
|
|
582
|
-
|
|
649
|
+
for (const file of walk(packageDir)) {
|
|
650
|
+
if (!SOURCE_EXT.has(extname2(file))) continue;
|
|
651
|
+
const text = readTextFile(file);
|
|
583
652
|
if (text === void 0) continue;
|
|
584
653
|
captureRegistrations(text, buckets);
|
|
585
654
|
extractFromListHandlers(text, buckets);
|
|
@@ -658,8 +727,24 @@ function profileSkill(inputPath) {
|
|
|
658
727
|
}
|
|
659
728
|
return profileSkillDir(p);
|
|
660
729
|
}
|
|
730
|
+
function asSkillProfile(profile, context) {
|
|
731
|
+
if (profile.kind !== "skill") {
|
|
732
|
+
throw new Error(`expected a skill candidate ${context}, found a ${profile.kind}`);
|
|
733
|
+
}
|
|
734
|
+
return profile;
|
|
735
|
+
}
|
|
736
|
+
function asProfile(candidatePath, artifact) {
|
|
737
|
+
const profile = profileSkill(candidatePath);
|
|
738
|
+
if (artifact === "mcp") {
|
|
739
|
+
if (profile.kind !== "mcp") {
|
|
740
|
+
throw new Error(`expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}`);
|
|
741
|
+
}
|
|
742
|
+
return profile;
|
|
743
|
+
}
|
|
744
|
+
return asSkillProfile(profile, `at ${candidatePath}`);
|
|
745
|
+
}
|
|
661
746
|
function classifyInput(p) {
|
|
662
|
-
if (existsSync8(
|
|
747
|
+
if (existsSync8(join8(p, ".claude-plugin", "plugin.json"))) return "plugin";
|
|
663
748
|
if (detectArtifactType(p) === "mcp") return "mcp";
|
|
664
749
|
return "skill";
|
|
665
750
|
}
|
|
@@ -667,10 +752,10 @@ var MCP_SDK_DEP2 = "@modelcontextprotocol/sdk";
|
|
|
667
752
|
var MCP_KEYWORD2 = "mcp-server";
|
|
668
753
|
var MCP_BIN_PREFIX2 = /^mcp-/i;
|
|
669
754
|
function readPackageJsonSafe(root) {
|
|
670
|
-
const pkgPath =
|
|
755
|
+
const pkgPath = join8(root, "package.json");
|
|
671
756
|
if (!existsSync8(pkgPath)) return void 0;
|
|
672
757
|
try {
|
|
673
|
-
return JSON.parse(
|
|
758
|
+
return JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
674
759
|
} catch {
|
|
675
760
|
return void 0;
|
|
676
761
|
}
|
|
@@ -691,7 +776,7 @@ function hasMcpPackageMarkers(pkg2) {
|
|
|
691
776
|
return false;
|
|
692
777
|
}
|
|
693
778
|
function detectArtifactType(root) {
|
|
694
|
-
if (existsSync8(
|
|
779
|
+
if (existsSync8(join8(root, ".mcp.json")) || existsSync8(join8(root, "mcp.json"))) return "mcp";
|
|
695
780
|
const pkg2 = readPackageJsonSafe(root);
|
|
696
781
|
if (pkg2 && hasMcpPackageMarkers(pkg2)) return "mcp";
|
|
697
782
|
return "skill";
|
|
@@ -700,8 +785,8 @@ function resolveSkillMd(inputPath) {
|
|
|
700
785
|
let currentPath = inputPath;
|
|
701
786
|
let tmpDir;
|
|
702
787
|
const initialStat = statSync4(currentPath);
|
|
703
|
-
if (
|
|
704
|
-
tmpDir = mkdtempSync(
|
|
788
|
+
if (extname3(currentPath) === ".skill" || initialStat.isFile() && isZipFile(currentPath)) {
|
|
789
|
+
tmpDir = mkdtempSync(join8(tmpdir(), "forge_"));
|
|
705
790
|
extractZip(currentPath, tmpDir);
|
|
706
791
|
currentPath = tmpDir;
|
|
707
792
|
}
|
|
@@ -710,7 +795,7 @@ function resolveSkillMd(inputPath) {
|
|
|
710
795
|
return { skillMd: currentPath, root: dirname2(currentPath), tmpDir };
|
|
711
796
|
}
|
|
712
797
|
if (stat.isDirectory()) {
|
|
713
|
-
const direct =
|
|
798
|
+
const direct = join8(currentPath, "SKILL.md");
|
|
714
799
|
if (existsSync8(direct)) {
|
|
715
800
|
return { skillMd: direct, root: currentPath, tmpDir };
|
|
716
801
|
}
|
|
@@ -736,15 +821,15 @@ function findSkillMdOneLevelDown(dir) {
|
|
|
736
821
|
const matches = /* @__PURE__ */ new Set();
|
|
737
822
|
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
738
823
|
if (entry.isDirectory()) {
|
|
739
|
-
const candidate =
|
|
824
|
+
const candidate = join8(dir, entry.name, "SKILL.md");
|
|
740
825
|
if (existsSync8(candidate)) matches.add(candidate);
|
|
741
826
|
}
|
|
742
827
|
}
|
|
743
|
-
const skillsDir =
|
|
828
|
+
const skillsDir = join8(dir, "skills");
|
|
744
829
|
if (isDir(skillsDir)) {
|
|
745
830
|
for (const entry of readdirSync3(skillsDir, { withFileTypes: true })) {
|
|
746
831
|
if (entry.isDirectory()) {
|
|
747
|
-
const candidate =
|
|
832
|
+
const candidate = join8(skillsDir, entry.name, "SKILL.md");
|
|
748
833
|
if (existsSync8(candidate)) matches.add(candidate);
|
|
749
834
|
}
|
|
750
835
|
}
|
|
@@ -828,9 +913,9 @@ function detectLicense(root, fm) {
|
|
|
828
913
|
if (fm.license) return fm.license;
|
|
829
914
|
const names = ["LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "license"];
|
|
830
915
|
for (const name of names) {
|
|
831
|
-
const p =
|
|
916
|
+
const p = join8(root, name);
|
|
832
917
|
if (existsSync8(p) && statSync4(p).isFile()) {
|
|
833
|
-
const head =
|
|
918
|
+
const head = readFileSync6(p, "utf-8").slice(0, 2e3).toLowerCase();
|
|
834
919
|
for (const [hint, label] of Object.entries(SPDX_HINTS)) {
|
|
835
920
|
if (head.includes(hint)) return label;
|
|
836
921
|
}
|
|
@@ -842,7 +927,7 @@ function detectLicense(root, fm) {
|
|
|
842
927
|
function walkFiles(dir) {
|
|
843
928
|
const out = [];
|
|
844
929
|
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
845
|
-
const full =
|
|
930
|
+
const full = join8(dir, entry.name);
|
|
846
931
|
if (entry.isDirectory()) {
|
|
847
932
|
out.push(...walkFiles(full));
|
|
848
933
|
} else if (entry.isFile()) {
|
|
@@ -854,7 +939,7 @@ function walkFiles(dir) {
|
|
|
854
939
|
function countResources(root) {
|
|
855
940
|
const resources = {};
|
|
856
941
|
for (const d of RESOURCE_DIRS) {
|
|
857
|
-
const dp =
|
|
942
|
+
const dp = join8(root, d);
|
|
858
943
|
if (isDir(dp)) {
|
|
859
944
|
resources[d] = walkFiles(dp).length;
|
|
860
945
|
}
|
|
@@ -865,11 +950,11 @@ function scanMcpAndImports(text, root) {
|
|
|
865
950
|
const mcpRefRe = /mcp__[a-zA-Z0-9_]+/g;
|
|
866
951
|
const mcpRefs = new Set(text.match(mcpRefRe) ?? []);
|
|
867
952
|
const imports = /* @__PURE__ */ new Set();
|
|
868
|
-
const scriptsDir =
|
|
953
|
+
const scriptsDir = join8(root, "scripts");
|
|
869
954
|
if (isDir(scriptsDir)) {
|
|
870
955
|
for (const f of walkFiles(scriptsDir)) {
|
|
871
956
|
if (!f.endsWith(".py")) continue;
|
|
872
|
-
const ft =
|
|
957
|
+
const ft = readFileSync6(f, "utf-8");
|
|
873
958
|
for (const m2 of ft.match(mcpRefRe) ?? []) mcpRefs.add(m2);
|
|
874
959
|
const importRe = /^\s*(?:import|from)\s+([a-zA-Z0-9_]+)/gm;
|
|
875
960
|
let m;
|
|
@@ -886,7 +971,7 @@ function scanMcpAndImports(text, root) {
|
|
|
886
971
|
function profileSkillDir(inputPath) {
|
|
887
972
|
const { skillMd, root, tmpDir } = resolveSkillMd(inputPath);
|
|
888
973
|
try {
|
|
889
|
-
const text =
|
|
974
|
+
const text = readFileSync6(skillMd, "utf-8");
|
|
890
975
|
const fm = parseFrontmatter(text);
|
|
891
976
|
const bodyLines = (text.match(/\n/g)?.length ?? 0) + 1;
|
|
892
977
|
const headers = Array.from(text.matchAll(/^#{1,3}\s+(.+)$/gm)).map((m) => m[1]);
|
|
@@ -914,19 +999,19 @@ function profileSkillDir(inputPath) {
|
|
|
914
999
|
}
|
|
915
1000
|
}
|
|
916
1001
|
function profilePlugin(p) {
|
|
917
|
-
const manifestPath =
|
|
1002
|
+
const manifestPath = join8(p, ".claude-plugin", "plugin.json");
|
|
918
1003
|
let manifest;
|
|
919
1004
|
try {
|
|
920
|
-
manifest = JSON.parse(
|
|
1005
|
+
manifest = JSON.parse(readFileSync6(manifestPath, "utf-8"));
|
|
921
1006
|
} catch (err) {
|
|
922
1007
|
throw new Error(`could not parse plugin.json under ${p}: ${err.message}`);
|
|
923
1008
|
}
|
|
924
|
-
const skillsDir =
|
|
1009
|
+
const skillsDir = join8(p, "skills");
|
|
925
1010
|
let skills = [];
|
|
926
1011
|
if (isDir(skillsDir)) {
|
|
927
|
-
skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(
|
|
1012
|
+
skills = readdirSync3(skillsDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync8(join8(skillsDir, d.name, "SKILL.md"))).map((d) => d.name).sort();
|
|
928
1013
|
}
|
|
929
|
-
const commandsDir =
|
|
1014
|
+
const commandsDir = join8(p, "commands");
|
|
930
1015
|
const commands = isDir(commandsDir) ? readdirSync3(commandsDir).filter((f) => f.endsWith(".md")).length : 0;
|
|
931
1016
|
const mc = manifest.mcpServers ?? manifest.mcp_servers ?? {};
|
|
932
1017
|
const mcpServers = typeof mc === "object" && mc !== null ? Object.keys(mc).sort() : [];
|
|
@@ -944,11 +1029,11 @@ function profilePlugin(p) {
|
|
|
944
1029
|
};
|
|
945
1030
|
}
|
|
946
1031
|
function profileMcp(p) {
|
|
947
|
-
const explicitConfig = existsSync8(
|
|
1032
|
+
const explicitConfig = existsSync8(join8(p, ".mcp.json")) ? join8(p, ".mcp.json") : existsSync8(join8(p, "mcp.json")) ? join8(p, "mcp.json") : void 0;
|
|
948
1033
|
if (explicitConfig) {
|
|
949
1034
|
let cfg;
|
|
950
1035
|
try {
|
|
951
|
-
cfg = JSON.parse(
|
|
1036
|
+
cfg = JSON.parse(readFileSync6(explicitConfig, "utf-8"));
|
|
952
1037
|
} catch (err) {
|
|
953
1038
|
throw new Error(`could not parse MCP config ${explicitConfig}: ${err.message}`);
|
|
954
1039
|
}
|
|
@@ -964,10 +1049,10 @@ function profileMcp(p) {
|
|
|
964
1049
|
capabilities: extractMcpCapabilities(p)
|
|
965
1050
|
};
|
|
966
1051
|
}
|
|
967
|
-
const pkgPath =
|
|
1052
|
+
const pkgPath = join8(p, "package.json");
|
|
968
1053
|
let pkg2;
|
|
969
1054
|
try {
|
|
970
|
-
pkg2 = JSON.parse(
|
|
1055
|
+
pkg2 = JSON.parse(readFileSync6(pkgPath, "utf-8"));
|
|
971
1056
|
} catch (err) {
|
|
972
1057
|
throw new Error(`could not parse package.json ${pkgPath}: ${err.message}`);
|
|
973
1058
|
}
|
|
@@ -984,16 +1069,11 @@ function profileMcp(p) {
|
|
|
984
1069
|
}
|
|
985
1070
|
|
|
986
1071
|
// src/gate/safety.ts
|
|
987
|
-
import {
|
|
1072
|
+
import { statSync as statSync5 } from "fs";
|
|
988
1073
|
import { execFile } from "child_process";
|
|
989
|
-
import { delimiter,
|
|
1074
|
+
import { delimiter, join as join9 } from "path";
|
|
990
1075
|
import { promisify } from "util";
|
|
991
1076
|
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
1077
|
var DENY_RULES = [
|
|
998
1078
|
{
|
|
999
1079
|
rule: "curl-pipe-shell",
|
|
@@ -1056,57 +1136,6 @@ var DENY_RULES = [
|
|
|
1056
1136
|
message: "invokes sudo \u2014 review for unnecessary privilege escalation"
|
|
1057
1137
|
}
|
|
1058
1138
|
];
|
|
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
1139
|
function scanStaticRules(targetPath) {
|
|
1111
1140
|
const findings = [];
|
|
1112
1141
|
let root;
|
|
@@ -1141,7 +1170,7 @@ function which(cmd) {
|
|
|
1141
1170
|
const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
|
|
1142
1171
|
for (const dir of dirs) {
|
|
1143
1172
|
for (const ext of exts) {
|
|
1144
|
-
const candidate =
|
|
1173
|
+
const candidate = join9(dir, cmd + ext);
|
|
1145
1174
|
try {
|
|
1146
1175
|
if (statSync5(candidate).isFile()) return candidate;
|
|
1147
1176
|
} catch {
|
|
@@ -1175,20 +1204,14 @@ async function scanWithSkillSpector(targetPath) {
|
|
|
1175
1204
|
return [];
|
|
1176
1205
|
}
|
|
1177
1206
|
}
|
|
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
1207
|
async function scanSafety(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
1185
1208
|
const findings = [...scanStaticRules(targetPath), ...await scanWithSkillSpector(targetPath)];
|
|
1186
1209
|
return { findings, verdict: verdictFor(findings, strictness) };
|
|
1187
1210
|
}
|
|
1188
1211
|
|
|
1189
1212
|
// src/gate/overlap.ts
|
|
1190
|
-
import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as
|
|
1191
|
-
import { basename as basename4, dirname as dirname3, join as
|
|
1213
|
+
import { existsSync as existsSync9, readFileSync as readFileSync7, readdirSync as readdirSync4 } from "fs";
|
|
1214
|
+
import { basename as basename4, dirname as dirname3, join as join10, resolve as resolve3 } from "path";
|
|
1192
1215
|
var STOPWORDS = new Set(
|
|
1193
1216
|
`
|
|
1194
1217
|
a an the and or of to for in on with use used using when whether this that these those is are be
|
|
@@ -1196,14 +1219,14 @@ your you it its as at by from into via per also any all not no than then so if u
|
|
|
1196
1219
|
skill skills user claude code based comprehensive enhanced practical including include includes
|
|
1197
1220
|
`.split(/\s+/).filter(Boolean)
|
|
1198
1221
|
);
|
|
1199
|
-
function splitLimited(text,
|
|
1222
|
+
function splitLimited(text, sep2, maxSplit) {
|
|
1200
1223
|
const parts = [];
|
|
1201
1224
|
let rest = text;
|
|
1202
1225
|
for (let i = 0; i < maxSplit; i++) {
|
|
1203
|
-
const idx = rest.indexOf(
|
|
1226
|
+
const idx = rest.indexOf(sep2);
|
|
1204
1227
|
if (idx === -1) break;
|
|
1205
1228
|
parts.push(rest.slice(0, idx));
|
|
1206
|
-
rest = rest.slice(idx +
|
|
1229
|
+
rest = rest.slice(idx + sep2.length);
|
|
1207
1230
|
}
|
|
1208
1231
|
parts.push(rest);
|
|
1209
1232
|
return parts;
|
|
@@ -1307,25 +1330,25 @@ function listSubdirs(dir) {
|
|
|
1307
1330
|
if (!existsSync9(dir)) return [];
|
|
1308
1331
|
let entries;
|
|
1309
1332
|
try {
|
|
1310
|
-
entries =
|
|
1333
|
+
entries = readdirSync4(dir, { withFileTypes: true });
|
|
1311
1334
|
} catch {
|
|
1312
1335
|
return [];
|
|
1313
1336
|
}
|
|
1314
|
-
return entries.filter((e) => e.isDirectory()).map((e) =>
|
|
1337
|
+
return entries.filter((e) => e.isDirectory()).map((e) => join10(dir, e.name));
|
|
1315
1338
|
}
|
|
1316
1339
|
function findSkillMdFiles(root) {
|
|
1317
1340
|
const found = /* @__PURE__ */ new Set();
|
|
1318
1341
|
for (const dir of listSubdirs(root)) {
|
|
1319
|
-
const candidate =
|
|
1342
|
+
const candidate = join10(dir, "SKILL.md");
|
|
1320
1343
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1321
1344
|
}
|
|
1322
|
-
for (const dir of listSubdirs(
|
|
1323
|
-
const candidate =
|
|
1345
|
+
for (const dir of listSubdirs(join10(root, "skills"))) {
|
|
1346
|
+
const candidate = join10(dir, "SKILL.md");
|
|
1324
1347
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1325
1348
|
}
|
|
1326
1349
|
for (const topDir of listSubdirs(root)) {
|
|
1327
|
-
for (const dir of listSubdirs(
|
|
1328
|
-
const candidate =
|
|
1350
|
+
for (const dir of listSubdirs(join10(topDir, "skills"))) {
|
|
1351
|
+
const candidate = join10(dir, "SKILL.md");
|
|
1329
1352
|
if (existsSync9(candidate)) found.add(resolve3(candidate));
|
|
1330
1353
|
}
|
|
1331
1354
|
}
|
|
@@ -1338,7 +1361,7 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
1338
1361
|
}
|
|
1339
1362
|
let cpath = resolve3(expandHome(candidatePath));
|
|
1340
1363
|
if (isDir(cpath)) {
|
|
1341
|
-
cpath =
|
|
1364
|
+
cpath = join10(cpath, "SKILL.md");
|
|
1342
1365
|
}
|
|
1343
1366
|
if (!existsSync9(cpath)) {
|
|
1344
1367
|
throw new Error(`candidate SKILL.md not found: ${cpath}`);
|
|
@@ -1364,59 +1387,8 @@ function scanOverlap(candidatePath, skillsRoot) {
|
|
|
1364
1387
|
}
|
|
1365
1388
|
|
|
1366
1389
|
// src/gate/mcpSafety.ts
|
|
1367
|
-
import {
|
|
1368
|
-
import { basename as basename5, extname as extname4
|
|
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
|
-
}
|
|
1390
|
+
import { statSync as statSync6 } from "fs";
|
|
1391
|
+
import { basename as basename5, extname as extname4 } from "path";
|
|
1420
1392
|
function isConfigOrExampleFile(file) {
|
|
1421
1393
|
const base = basename5(file).toLowerCase();
|
|
1422
1394
|
if (base.startsWith(".env")) return true;
|
|
@@ -1446,7 +1418,18 @@ function looksHighEntropy(value) {
|
|
|
1446
1418
|
}
|
|
1447
1419
|
function isPinnedPackageSpec(spec) {
|
|
1448
1420
|
const withoutScope = spec.startsWith("@") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
1449
|
-
|
|
1421
|
+
const atIdx = withoutScope.lastIndexOf("@");
|
|
1422
|
+
if (atIdx <= 0) return false;
|
|
1423
|
+
return /^\d/.test(withoutScope.slice(atIdx + 1));
|
|
1424
|
+
}
|
|
1425
|
+
function isNpxCommand(command) {
|
|
1426
|
+
const base = command.split(/[\\/]/).pop() ?? command;
|
|
1427
|
+
return base.replace(/\.(cmd|exe)$/i, "").toLowerCase() === "npx";
|
|
1428
|
+
}
|
|
1429
|
+
function packageSpecBase(spec) {
|
|
1430
|
+
const scopeEnd = spec.startsWith("@") ? spec.indexOf("/") + 1 : 0;
|
|
1431
|
+
const atIdx = spec.lastIndexOf("@");
|
|
1432
|
+
return atIdx > scopeEnd ? spec.slice(0, atIdx) : spec;
|
|
1450
1433
|
}
|
|
1451
1434
|
function collectServerNodes(node, out) {
|
|
1452
1435
|
if (Array.isArray(node)) {
|
|
@@ -1475,7 +1458,7 @@ function scanJsonConfig(file, parsed) {
|
|
|
1475
1458
|
for (const node of nodes) {
|
|
1476
1459
|
const command = typeof node.command === "string" ? node.command : void 0;
|
|
1477
1460
|
const args = Array.isArray(node.args) ? node.args.filter((a) => typeof a === "string") : [];
|
|
1478
|
-
if (command
|
|
1461
|
+
if (command && isNpxCommand(command)) {
|
|
1479
1462
|
const flagIdx = args.findIndex((a) => a === "-y" || a === "--yes");
|
|
1480
1463
|
if (flagIdx !== -1) {
|
|
1481
1464
|
const pkgSpec = args.slice(flagIdx + 1).find((a) => !a.startsWith("-"));
|
|
@@ -1484,7 +1467,7 @@ function scanJsonConfig(file, parsed) {
|
|
|
1484
1467
|
severity: "MEDIUM",
|
|
1485
1468
|
rule: "mcp-unpinned-npx",
|
|
1486
1469
|
file,
|
|
1487
|
-
detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${pkgSpec}@<version>) so the resolved server code can't change silently`
|
|
1470
|
+
detail: `npx -y ${pkgSpec} is unpinned \u2014 pin an exact version (${packageSpecBase(pkgSpec)}@<version>) so the resolved server code can't change silently`
|
|
1488
1471
|
});
|
|
1489
1472
|
}
|
|
1490
1473
|
}
|
|
@@ -1523,9 +1506,22 @@ function scanJsonConfig(file, parsed) {
|
|
|
1523
1506
|
}
|
|
1524
1507
|
return findings;
|
|
1525
1508
|
}
|
|
1526
|
-
var NPX_SHELL_RE = /\bnpx
|
|
1509
|
+
var NPX_SHELL_RE = /\bnpx(?:\.cmd|\.exe)?\s+(?:-y|--yes)\s+((?:@[\w.-]+\/)?[\w.-]+)(@[\w.-]+)?/gi;
|
|
1527
1510
|
var DANGEROUS_FLAG_RE = /--dangerously-[\w-]+|--no-sandbox\b/g;
|
|
1528
1511
|
var CREDENTIAL_ASSIGNMENT_RE = /["']?([A-Za-z][A-Za-z0-9_]*)["']?\s*[:=]\s*["']([^"']{6,})["']/g;
|
|
1512
|
+
var CREDENTIAL_ASSIGNMENT_UNQUOTED_RE = /^\s*(?:export\s+|-\s+)?([A-Za-z][A-Za-z0-9_]*)\s*[:=]\s*(\S+)/;
|
|
1513
|
+
var LEADING_QUOTE_RE = /^["']/;
|
|
1514
|
+
function pushCredentialFinding(findings, file, lineNo, varName, value) {
|
|
1515
|
+
if (isPlaceholderValue(value)) return;
|
|
1516
|
+
if (looksCredentialKey(varName) || looksHighEntropy(value)) {
|
|
1517
|
+
findings.push({
|
|
1518
|
+
severity: "HIGH",
|
|
1519
|
+
rule: "mcp-inline-credential-value",
|
|
1520
|
+
file,
|
|
1521
|
+
detail: `line ${lineNo}: inline credential value assigned to ${varName}`
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1529
1525
|
function scanTextFile(file, content, allowCredentialCheck) {
|
|
1530
1526
|
const findings = [];
|
|
1531
1527
|
const lines = content.split("\n");
|
|
@@ -1536,8 +1532,8 @@ function scanTextFile(file, content, allowCredentialCheck) {
|
|
|
1536
1532
|
let m;
|
|
1537
1533
|
while ((m = NPX_SHELL_RE.exec(line)) !== null) {
|
|
1538
1534
|
const pkgSpec = m[1];
|
|
1539
|
-
const
|
|
1540
|
-
if (!
|
|
1535
|
+
const versionSuffix = m[2] ?? "";
|
|
1536
|
+
if (!isPinnedPackageSpec(pkgSpec + versionSuffix)) {
|
|
1541
1537
|
findings.push({
|
|
1542
1538
|
severity: "MEDIUM",
|
|
1543
1539
|
rule: "mcp-unpinned-npx",
|
|
@@ -1558,24 +1554,18 @@ function scanTextFile(file, content, allowCredentialCheck) {
|
|
|
1558
1554
|
if (allowCredentialCheck) {
|
|
1559
1555
|
CREDENTIAL_ASSIGNMENT_RE.lastIndex = 0;
|
|
1560
1556
|
while ((m = CREDENTIAL_ASSIGNMENT_RE.exec(line)) !== null) {
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
severity: "HIGH",
|
|
1567
|
-
rule: "mcp-inline-credential-value",
|
|
1568
|
-
file,
|
|
1569
|
-
detail: `line ${lineNo}: inline credential value assigned to ${varName}`
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
1557
|
+
pushCredentialFinding(findings, file, lineNo, m[1], m[2]);
|
|
1558
|
+
}
|
|
1559
|
+
const unquoted = CREDENTIAL_ASSIGNMENT_UNQUOTED_RE.exec(line);
|
|
1560
|
+
if (unquoted && !LEADING_QUOTE_RE.test(unquoted[2])) {
|
|
1561
|
+
pushCredentialFinding(findings, file, lineNo, unquoted[1], unquoted[2]);
|
|
1572
1562
|
}
|
|
1573
1563
|
}
|
|
1574
1564
|
}
|
|
1575
1565
|
return findings;
|
|
1576
1566
|
}
|
|
1577
1567
|
function scanFileForMcpFindings(file) {
|
|
1578
|
-
const content =
|
|
1568
|
+
const content = readTextFile(file);
|
|
1579
1569
|
if (content === void 0) return [];
|
|
1580
1570
|
if (extname4(file).toLowerCase() === ".json") {
|
|
1581
1571
|
const parsed = tryParseJson(content);
|
|
@@ -1590,40 +1580,29 @@ function scanMcpFindings(targetPath) {
|
|
|
1590
1580
|
} catch {
|
|
1591
1581
|
return [];
|
|
1592
1582
|
}
|
|
1593
|
-
const files = root.isDirectory() ? Array.from(
|
|
1583
|
+
const files = root.isDirectory() ? Array.from(walk(targetPath)) : [targetPath];
|
|
1594
1584
|
const findings = [];
|
|
1595
1585
|
for (const file of files) {
|
|
1596
1586
|
findings.push(...scanFileForMcpFindings(file));
|
|
1597
1587
|
}
|
|
1598
1588
|
return findings;
|
|
1599
1589
|
}
|
|
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
1590
|
async function mcpScan(targetPath, strictness = DEFAULT_STRICTNESS) {
|
|
1612
1591
|
const base = await scanSafety(targetPath, strictness);
|
|
1613
1592
|
const mcpFindings = scanMcpFindings(targetPath);
|
|
1614
1593
|
const findings = [...base.findings, ...mcpFindings];
|
|
1615
|
-
return { findings, verdict:
|
|
1594
|
+
return { findings, verdict: verdictFor(findings, strictness) };
|
|
1616
1595
|
}
|
|
1617
1596
|
|
|
1618
1597
|
// src/gate/mcpOverlap.ts
|
|
1619
|
-
import { existsSync as existsSync10, readFileSync as
|
|
1598
|
+
import { existsSync as existsSync10, readFileSync as readFileSync8 } from "fs";
|
|
1620
1599
|
import { resolve as resolve4 } from "path";
|
|
1621
1600
|
function readTargetServers(targetPath) {
|
|
1622
1601
|
const path = resolve4(expandHome(targetPath));
|
|
1623
1602
|
if (!existsSync10(path)) return [];
|
|
1624
1603
|
let cfg;
|
|
1625
1604
|
try {
|
|
1626
|
-
cfg = JSON.parse(
|
|
1605
|
+
cfg = JSON.parse(readFileSync8(path, "utf-8"));
|
|
1627
1606
|
} catch {
|
|
1628
1607
|
return [];
|
|
1629
1608
|
}
|
|
@@ -1782,6 +1761,14 @@ function proFeatureStatus(feature, betaFree = BETA_FREE) {
|
|
|
1782
1761
|
}
|
|
1783
1762
|
|
|
1784
1763
|
// src/report/render.ts
|
|
1764
|
+
var SAFETY_VERDICT_MAP = {
|
|
1765
|
+
pass: "ALLOW",
|
|
1766
|
+
warn: "CAUTION",
|
|
1767
|
+
block: "BLOCK"
|
|
1768
|
+
};
|
|
1769
|
+
function mapSafetyFindings(findings) {
|
|
1770
|
+
return findings.map((f) => ({ severity: f.severity, rule: f.rule, message: `${f.file}: ${f.detail}` }));
|
|
1771
|
+
}
|
|
1785
1772
|
var BORDER = "\u2501".repeat(55);
|
|
1786
1773
|
var LABEL_WIDTH = 20;
|
|
1787
1774
|
function padLabel(text) {
|
|
@@ -1856,29 +1843,14 @@ function renderReport(result) {
|
|
|
1856
1843
|
function renderJson(result) {
|
|
1857
1844
|
return JSON.stringify(result, null, 2);
|
|
1858
1845
|
}
|
|
1859
|
-
|
|
1860
|
-
// src/commands/gatePipeline.ts
|
|
1861
|
-
var SAFETY_VERDICT_MAP = {
|
|
1862
|
-
pass: "ALLOW",
|
|
1863
|
-
warn: "CAUTION",
|
|
1864
|
-
block: "BLOCK"
|
|
1865
|
-
};
|
|
1866
1846
|
function printGateOutput(gateResult, json) {
|
|
1867
1847
|
if (!json) {
|
|
1868
1848
|
for (const notice of gateResult.notices ?? []) console.log(`[skill-forge] ${notice}`);
|
|
1869
1849
|
}
|
|
1870
1850
|
console.log(json ? renderJson(gateResult) : renderReport(gateResult));
|
|
1871
1851
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
const expectedKind = artifact === "mcp" ? "mcp" : "skill";
|
|
1875
|
-
if (profile.kind !== expectedKind) {
|
|
1876
|
-
throw new Error(
|
|
1877
|
-
artifact === "mcp" ? `expected an MCP candidate at ${candidatePath} (--artifact mcp), found a ${profile.kind}` : `expected a skill candidate at ${candidatePath}, found a ${profile.kind}`
|
|
1878
|
-
);
|
|
1879
|
-
}
|
|
1880
|
-
return profile;
|
|
1881
|
-
}
|
|
1852
|
+
|
|
1853
|
+
// src/commands/gatePipeline.ts
|
|
1882
1854
|
function tryOverlap(candidatePath, skillsRoot) {
|
|
1883
1855
|
try {
|
|
1884
1856
|
return scanOverlap(candidatePath, skillsRoot);
|
|
@@ -1908,7 +1880,7 @@ function resolveMcpCandidatePath(entry) {
|
|
|
1908
1880
|
if (entry.packageDir) return entry.packageDir;
|
|
1909
1881
|
let children = [];
|
|
1910
1882
|
try {
|
|
1911
|
-
children =
|
|
1883
|
+
children = readdirSync5(entry.quarantinePath, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
1912
1884
|
} catch {
|
|
1913
1885
|
}
|
|
1914
1886
|
return children.length === 1 ? join11(entry.quarantinePath, children[0]) : entry.quarantinePath;
|
|
@@ -1930,11 +1902,7 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1930
1902
|
notices: proStatus.notice ? [proStatus.notice] : void 0,
|
|
1931
1903
|
safety: {
|
|
1932
1904
|
verdict: SAFETY_VERDICT_MAP[safety.verdict],
|
|
1933
|
-
findings: safety.findings
|
|
1934
|
-
severity: f.severity,
|
|
1935
|
-
rule: f.rule,
|
|
1936
|
-
message: `${f.file}: ${f.detail}`
|
|
1937
|
-
}))
|
|
1905
|
+
findings: mapSafetyFindings(safety.findings)
|
|
1938
1906
|
}
|
|
1939
1907
|
};
|
|
1940
1908
|
return { safety, gateResult, proEnabled: proStatus.enabled };
|
|
@@ -1942,7 +1910,7 @@ async function runGate(candidatePath, skillsRoot, {
|
|
|
1942
1910
|
|
|
1943
1911
|
// src/queue.ts
|
|
1944
1912
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
1945
|
-
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as
|
|
1913
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "fs";
|
|
1946
1914
|
import { join as join12 } from "path";
|
|
1947
1915
|
function getQueuePath() {
|
|
1948
1916
|
return join12(getHomeDir(), "queue.json");
|
|
@@ -1952,7 +1920,7 @@ function readQueue() {
|
|
|
1952
1920
|
if (!existsSync11(path)) {
|
|
1953
1921
|
return { version: 1, entries: [] };
|
|
1954
1922
|
}
|
|
1955
|
-
const raw =
|
|
1923
|
+
const raw = readFileSync9(path, "utf-8");
|
|
1956
1924
|
const parsed = JSON.parse(raw);
|
|
1957
1925
|
return { version: 1, entries: parsed.entries ?? [] };
|
|
1958
1926
|
}
|
|
@@ -2468,13 +2436,38 @@ async function maybeOfferInit(options = {}) {
|
|
|
2468
2436
|
}
|
|
2469
2437
|
}
|
|
2470
2438
|
|
|
2471
|
-
// src/
|
|
2472
|
-
|
|
2473
|
-
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
2439
|
+
// src/decision.ts
|
|
2440
|
+
import { createInterface as createInterface2 } from "readline/promises";
|
|
2474
2441
|
function logOutcome(message, json) {
|
|
2475
2442
|
if (json) console.error(message);
|
|
2476
2443
|
else console.log(message);
|
|
2477
2444
|
}
|
|
2445
|
+
function yesDecision(verdict) {
|
|
2446
|
+
return verdict === "block" ? "reject" : "promote";
|
|
2447
|
+
}
|
|
2448
|
+
async function promptDecision(verdict, subject) {
|
|
2449
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2450
|
+
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2451
|
+
const prompt = `Promote, hold, or reject ${subject}? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2452
|
+
try {
|
|
2453
|
+
process.stdout.write(prompt);
|
|
2454
|
+
for await (const raw of rl) {
|
|
2455
|
+
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2456
|
+
if (answer === "p" || answer === "promote") return "promote";
|
|
2457
|
+
if (answer === "h" || answer === "hold") return "hold";
|
|
2458
|
+
if (answer === "r" || answer === "reject") return "reject";
|
|
2459
|
+
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2460
|
+
${prompt}`);
|
|
2461
|
+
}
|
|
2462
|
+
return defaultChoice;
|
|
2463
|
+
} finally {
|
|
2464
|
+
rl.close();
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// src/commands/add.ts
|
|
2469
|
+
var PRO_FEATURE_LABEL = "overlap analysis, the provenance ledger, and the pending-ingestion queue";
|
|
2470
|
+
var MCP_PRO_FEATURE_LABEL = "overlap analysis and the pending-ingestion queue";
|
|
2478
2471
|
async function add(source, options = {}) {
|
|
2479
2472
|
await maybeOfferInit({ json: options.json, yes: options.yes });
|
|
2480
2473
|
const config = loadConfig();
|
|
@@ -2511,7 +2504,7 @@ async function add(source, options = {}) {
|
|
|
2511
2504
|
throw new Error(`expected a skill candidate, found a ${profile.kind}`);
|
|
2512
2505
|
}
|
|
2513
2506
|
printGateOutput(gateResult, options.json);
|
|
2514
|
-
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2507
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
|
|
2515
2508
|
let installedPath = null;
|
|
2516
2509
|
if (decision === "promote") {
|
|
2517
2510
|
const result = promote(entry, targetRoot);
|
|
@@ -2551,7 +2544,7 @@ async function add(source, options = {}) {
|
|
|
2551
2544
|
gate: {
|
|
2552
2545
|
license: profile.license ?? null,
|
|
2553
2546
|
safetyVerdict: safety.verdict,
|
|
2554
|
-
safetyFindings: safety.findings.map(
|
|
2547
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2555
2548
|
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2556
2549
|
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2557
2550
|
},
|
|
@@ -2583,13 +2576,15 @@ async function addMcp(source, config, options) {
|
|
|
2583
2576
|
);
|
|
2584
2577
|
}
|
|
2585
2578
|
printGateOutput(gateResult, options.json);
|
|
2586
|
-
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict);
|
|
2579
|
+
const decision = options.yes || options.json ? yesDecision(safety.verdict) : await promptDecision(safety.verdict, "this skill");
|
|
2587
2580
|
let installedPath = null;
|
|
2588
2581
|
if (decision === "promote") {
|
|
2589
2582
|
const detail = resolveMcpCandidateDetail(profile);
|
|
2590
2583
|
const serverProfile = {
|
|
2591
2584
|
name: detail.name,
|
|
2592
|
-
|
|
2585
|
+
// version may be absent — promoteMcp refuses the unpinnable npx fallback
|
|
2586
|
+
// rather than writing "npx pkg@unknown"; see deriveServerEntry in promoteMcp.ts.
|
|
2587
|
+
version: detail.version,
|
|
2593
2588
|
envVars: detail.env ? Object.keys(detail.env) : void 0,
|
|
2594
2589
|
candidateEntry: detail.command ? { command: detail.command, args: detail.args, env: detail.env } : void 0
|
|
2595
2590
|
};
|
|
@@ -2622,7 +2617,7 @@ async function addMcp(source, config, options) {
|
|
|
2622
2617
|
gate: {
|
|
2623
2618
|
license: null,
|
|
2624
2619
|
safetyVerdict: safety.verdict,
|
|
2625
|
-
safetyFindings: safety.findings.map(
|
|
2620
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2626
2621
|
overlapTop: overlap ? overlap.ranking.slice(0, 3).map((r) => ({ skill: r.skill, score: r.score })) : [],
|
|
2627
2622
|
suggestedVerb: overlap ? suggestVerbCode(overlap.nearestScore) : null
|
|
2628
2623
|
},
|
|
@@ -2635,28 +2630,6 @@ async function addMcp(source, config, options) {
|
|
|
2635
2630
|
}
|
|
2636
2631
|
}
|
|
2637
2632
|
}
|
|
2638
|
-
function yesDecision(verdict) {
|
|
2639
|
-
return verdict === "block" ? "reject" : "promote";
|
|
2640
|
-
}
|
|
2641
|
-
async function promptDecision(verdict) {
|
|
2642
|
-
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
2643
|
-
const defaultChoice = verdict === "block" ? "reject" : verdict === "warn" ? "hold" : "promote";
|
|
2644
|
-
const prompt = `Promote, hold, or reject this skill? [promote/hold/reject] (default: ${defaultChoice}) `;
|
|
2645
|
-
try {
|
|
2646
|
-
process.stdout.write(prompt);
|
|
2647
|
-
for await (const raw of rl) {
|
|
2648
|
-
const answer = raw.trim().toLowerCase() || defaultChoice;
|
|
2649
|
-
if (answer === "p" || answer === "promote") return "promote";
|
|
2650
|
-
if (answer === "h" || answer === "hold") return "hold";
|
|
2651
|
-
if (answer === "r" || answer === "reject") return "reject";
|
|
2652
|
-
process.stdout.write(`Please answer promote, hold, or reject (or p/h/r).
|
|
2653
|
-
${prompt}`);
|
|
2654
|
-
}
|
|
2655
|
-
return defaultChoice;
|
|
2656
|
-
} finally {
|
|
2657
|
-
rl.close();
|
|
2658
|
-
}
|
|
2659
|
-
}
|
|
2660
2633
|
|
|
2661
2634
|
// src/commands/scan.ts
|
|
2662
2635
|
async function scan(source, options = {}) {
|
|
@@ -2748,6 +2721,319 @@ async function status() {
|
|
|
2748
2721
|
console.log(` held in quarantine: ${heldCount}`);
|
|
2749
2722
|
}
|
|
2750
2723
|
|
|
2724
|
+
// src/evolve.ts
|
|
2725
|
+
import { createInterface as createInterface3 } from "readline/promises";
|
|
2726
|
+
import { createHash } from "crypto";
|
|
2727
|
+
import { cpSync as cpSync3, existsSync as existsSync14, lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync10, rmSync as rmSync3 } from "fs";
|
|
2728
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
2729
|
+
import { basename as basename6, dirname as dirname5, join as join17, resolve as resolvePath2, sep } from "path";
|
|
2730
|
+
var SKILLOPT_BIN = "skillopt-sleep";
|
|
2731
|
+
var PRO_FEATURE_LABEL2 = "self-evolution via SkillOpt-Sleep (evolve) \u2014 re-gating, provenance, and the pending-ingestion queue";
|
|
2732
|
+
async function evolve(targetSkillDir, options = {}, deps = {}) {
|
|
2733
|
+
const exec = deps.exec ?? defaultExec;
|
|
2734
|
+
const json = options.json;
|
|
2735
|
+
const resolvedTargetDir = resolvePath2(targetSkillDir);
|
|
2736
|
+
const skillMdPath = join17(resolvedTargetDir, "SKILL.md");
|
|
2737
|
+
if (!existsSync14(skillMdPath)) {
|
|
2738
|
+
throw new UserError(`no SKILL.md found at ${skillMdPath} \u2014 evolve requires an existing, installed skill directory`);
|
|
2739
|
+
}
|
|
2740
|
+
const proStatus = proFeatureStatus(PRO_FEATURE_LABEL2);
|
|
2741
|
+
if (!proStatus.enabled) {
|
|
2742
|
+
throw new UserError(proStatus.notice ?? "evolve is a Pro feature \u2014 set SKILL_FORGE_LICENSE or config.licenseKey to a valid license to unlock.");
|
|
2743
|
+
}
|
|
2744
|
+
const notices = proStatus.notice ? [proStatus.notice] : [];
|
|
2745
|
+
const backend = options.backend ?? "mock";
|
|
2746
|
+
if (backend !== "mock") {
|
|
2747
|
+
const disclosure = `data boundary: this run uses the "${backend}" backend. Per SkillOpt-Sleep's own docs, harvesting itself is local and read-only, but a real backend sends truncated excerpts from harvested sessions and derived tasks to the provider you selected \u2014 this is not currently guaranteed to be secret-free.`;
|
|
2748
|
+
if (options.yes) {
|
|
2749
|
+
notices.push(`${disclosure} Proceeding (--yes).`);
|
|
2750
|
+
} else if (json) {
|
|
2751
|
+
throw new UserError(`${disclosure} Pass --yes to proceed \u2014 --json is non-interactive and cannot prompt for confirmation.`);
|
|
2752
|
+
} else {
|
|
2753
|
+
console.log(`[skill-forge] ${disclosure}`);
|
|
2754
|
+
const confirmFn = deps.confirm ?? defaultConfirm;
|
|
2755
|
+
const confirmed = await confirmFn("Proceed and send session data to this backend? [y/N] ");
|
|
2756
|
+
if (!confirmed) {
|
|
2757
|
+
throw new UserError("evolve cancelled \u2014 data-boundary disclosure was not accepted.");
|
|
2758
|
+
}
|
|
2759
|
+
notices.push(`${disclosure} Confirmed interactively.`);
|
|
2760
|
+
}
|
|
2761
|
+
} else {
|
|
2762
|
+
notices.push('using the offline "mock" backend \u2014 deterministic, no network calls, no session data leaves this machine.');
|
|
2763
|
+
}
|
|
2764
|
+
const project = resolvePath2(options.project ?? process.cwd());
|
|
2765
|
+
const present = await detectSkillopt(exec, project);
|
|
2766
|
+
if (!present) {
|
|
2767
|
+
throw new UserError(
|
|
2768
|
+
"`skillopt-sleep` not found on PATH. Install it with `pip install skillopt` (https://github.com/microsoft/SkillOpt) and re-run `skill-forge evolve`."
|
|
2769
|
+
);
|
|
2770
|
+
}
|
|
2771
|
+
const subcommand = options.dryRun ? "dry-run" : "run";
|
|
2772
|
+
const runArgs = [subcommand, "--project", project, "--target-skill-path", skillMdPath, "--json", "--backend", backend];
|
|
2773
|
+
if (options.lookbackHours !== void 0) {
|
|
2774
|
+
runArgs.push("--lookback-hours", String(options.lookbackHours));
|
|
2775
|
+
}
|
|
2776
|
+
const runResult = await exec(SKILLOPT_BIN, runArgs, { cwd: project });
|
|
2777
|
+
if (runResult.code !== 0) {
|
|
2778
|
+
throw new UserError(`skillopt-sleep ${subcommand} failed (exit ${runResult.code}): ${tail(runResult.stderr || runResult.stdout)}`);
|
|
2779
|
+
}
|
|
2780
|
+
let rawOutput;
|
|
2781
|
+
try {
|
|
2782
|
+
rawOutput = JSON.parse(runResult.stdout);
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
throw new UserError(`could not parse skillopt-sleep JSON output: ${err.message}`);
|
|
2785
|
+
}
|
|
2786
|
+
const parsed = validateRunOutput(rawOutput);
|
|
2787
|
+
if (!parsed.accepted || !parsed.staging_dir) {
|
|
2788
|
+
if (json) {
|
|
2789
|
+
console.log(JSON.stringify({ ok: true, accepted: parsed.accepted, stagingDir: parsed.staging_dir }, null, 2));
|
|
2790
|
+
} else {
|
|
2791
|
+
console.log("[skill-forge] skillopt-sleep produced no accepted proposal \u2014 nothing to re-gate or adopt.");
|
|
2792
|
+
}
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
const stagingRoot = join17(project, ".skillopt-sleep", "staging");
|
|
2796
|
+
const stagingDir = resolvePath2(parsed.staging_dir);
|
|
2797
|
+
if (stagingDir !== stagingRoot && !stagingDir.startsWith(stagingRoot + sep)) {
|
|
2798
|
+
throw new UserError(
|
|
2799
|
+
`skillopt-sleep reported a staging_dir outside the expected sandbox (${stagingRoot}): ${stagingDir} \u2014 refusing to use it`
|
|
2800
|
+
);
|
|
2801
|
+
}
|
|
2802
|
+
const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
|
|
2803
|
+
const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
|
|
2804
|
+
const reportMdPath = join17(stagingDir, "report.md");
|
|
2805
|
+
if (!existsSync14(proposedSkillMd)) {
|
|
2806
|
+
throw new UserError(`skillopt-sleep reported an accepted proposal but ${proposedSkillMd} is missing`);
|
|
2807
|
+
}
|
|
2808
|
+
if (existsSync14(reportMdPath)) {
|
|
2809
|
+
notices.push(`SkillOpt-Sleep's full report: ${reportMdPath}`);
|
|
2810
|
+
}
|
|
2811
|
+
assertNotSymlink(proposedSkillMd, "proposed_SKILL.md");
|
|
2812
|
+
assertNotSymlink(proposedClaudeMd, "proposed_CLAUDE.md");
|
|
2813
|
+
const scannedSkillHash = sha256File(proposedSkillMd);
|
|
2814
|
+
const scannedClaudeHash = sha256File(proposedClaudeMd);
|
|
2815
|
+
const tempDir = mkdtempSync2(join17(tmpdir2(), "skill-forge-evolve-"));
|
|
2816
|
+
let profile;
|
|
2817
|
+
let safety;
|
|
2818
|
+
try {
|
|
2819
|
+
cpSync3(proposedSkillMd, join17(tempDir, "SKILL.md"));
|
|
2820
|
+
if (existsSync14(proposedClaudeMd)) {
|
|
2821
|
+
cpSync3(proposedClaudeMd, join17(tempDir, "CLAUDE.md"));
|
|
2822
|
+
}
|
|
2823
|
+
profile = asSkillProfile(profileSkill(tempDir), "for the evolved proposal");
|
|
2824
|
+
const strictness = loadConfig().strictness;
|
|
2825
|
+
safety = await scanSafety(tempDir, strictness);
|
|
2826
|
+
} finally {
|
|
2827
|
+
try {
|
|
2828
|
+
rmSync3(tempDir, { recursive: true, force: true });
|
|
2829
|
+
} catch (err) {
|
|
2830
|
+
console.error(`[skill-forge] failed to clean up temp re-gate dir ${tempDir}: ${err.message}`);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
const safetyVerdict = safety.verdict;
|
|
2834
|
+
const gateResult = {
|
|
2835
|
+
profile,
|
|
2836
|
+
safety: {
|
|
2837
|
+
verdict: SAFETY_VERDICT_MAP[safetyVerdict],
|
|
2838
|
+
findings: mapSafetyFindings(safety.findings)
|
|
2839
|
+
},
|
|
2840
|
+
notices
|
|
2841
|
+
};
|
|
2842
|
+
printGateOutput(gateResult, json);
|
|
2843
|
+
if (options.dryRun) {
|
|
2844
|
+
logOutcome("[skill-forge] --dry-run: report only, nothing staged or adopted.", json);
|
|
2845
|
+
return;
|
|
2846
|
+
}
|
|
2847
|
+
const decision = options.yes || json ? yesDecision(safetyVerdict) : await promptDecision(safetyVerdict, "this evolution proposal");
|
|
2848
|
+
if (decision === "promote") {
|
|
2849
|
+
const manifest = readManifest(stagingDir);
|
|
2850
|
+
if (isAlreadyAdopted(stagingDir, manifest) && !options.force) {
|
|
2851
|
+
throw new UserError(
|
|
2852
|
+
`staging dir ${stagingDir} looks already adopted \u2014 its proposed content is byte-identical to the live skill (and memory, if present). Pass --force to re-adopt anyway.`
|
|
2853
|
+
);
|
|
2854
|
+
}
|
|
2855
|
+
if (sha256File(proposedSkillMd) !== scannedSkillHash || sha256File(proposedClaudeMd) !== scannedClaudeHash) {
|
|
2856
|
+
throw new UserError(`proposal changed after it was re-gated (staging dir: ${stagingDir}) \u2014 refusing to adopt`);
|
|
2857
|
+
}
|
|
2858
|
+
const adoptResult = await exec(SKILLOPT_BIN, ["adopt", "--staging", stagingDir, "--project", project], { cwd: project });
|
|
2859
|
+
if (adoptResult.code !== 0) {
|
|
2860
|
+
throw new UserError(`skillopt-sleep adopt failed (exit ${adoptResult.code}): ${tail(adoptResult.stderr || adoptResult.stdout)}`);
|
|
2861
|
+
}
|
|
2862
|
+
logOutcome(`[skill-forge] adopted -> ${resolvedTargetDir}`, json);
|
|
2863
|
+
const skillsRootForProvenance = dirname5(resolvedTargetDir);
|
|
2864
|
+
let ledgerPath;
|
|
2865
|
+
try {
|
|
2866
|
+
ledgerPath = appendProvenance(
|
|
2867
|
+
{
|
|
2868
|
+
name: profile.name ?? basename6(resolvedTargetDir),
|
|
2869
|
+
source: resolvedTargetDir,
|
|
2870
|
+
version: profile.version,
|
|
2871
|
+
license: profile.license,
|
|
2872
|
+
verb: "DEFER",
|
|
2873
|
+
target: resolvedTargetDir,
|
|
2874
|
+
took: `self-evolved via SkillOpt-Sleep (staging: ${stagingDir})`,
|
|
2875
|
+
notes: `self-evolved via SkillOpt-Sleep; re-gated (verdict: ${safetyVerdict})`
|
|
2876
|
+
},
|
|
2877
|
+
skillsRootForProvenance
|
|
2878
|
+
);
|
|
2879
|
+
} catch (err) {
|
|
2880
|
+
throw new UserError(
|
|
2881
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed, but recording the provenance entry failed: ${err.message}`
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
logOutcome(`[skill-forge] provenance recorded -> ${ledgerPath}`, json);
|
|
2885
|
+
try {
|
|
2886
|
+
appendQueueEntry({
|
|
2887
|
+
source: resolvedTargetDir,
|
|
2888
|
+
sourceType: "local",
|
|
2889
|
+
installedPath: resolvedTargetDir,
|
|
2890
|
+
quarantinePath: stagingDir,
|
|
2891
|
+
gate: {
|
|
2892
|
+
license: profile.license ?? null,
|
|
2893
|
+
safetyVerdict,
|
|
2894
|
+
safetyFindings: safety.findings.map(formatSafetyFinding),
|
|
2895
|
+
overlapTop: [],
|
|
2896
|
+
suggestedVerb: null
|
|
2897
|
+
},
|
|
2898
|
+
status: "pending",
|
|
2899
|
+
artifactType: "skill",
|
|
2900
|
+
origin: "evolve"
|
|
2901
|
+
});
|
|
2902
|
+
} catch (err) {
|
|
2903
|
+
throw new UserError(
|
|
2904
|
+
`adopt succeeded and the live skill at ${resolvedTargetDir} has already changed (provenance was recorded), but appending the pending-ingestion queue entry failed: ${err.message}`
|
|
2905
|
+
);
|
|
2906
|
+
}
|
|
2907
|
+
logOutcome("[skill-forge] queued for ingestion review", json);
|
|
2908
|
+
} else if (decision === "reject") {
|
|
2909
|
+
try {
|
|
2910
|
+
rmSync3(stagingDir, { recursive: true, force: true });
|
|
2911
|
+
} catch (err) {
|
|
2912
|
+
throw new UserError(
|
|
2913
|
+
`rejected the proposal, but removing the staging dir ${stagingDir} failed: ${err.message} \u2014 remove it manually.`
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2916
|
+
logOutcome("[skill-forge] rejected \u2014 staging dir removed", json);
|
|
2917
|
+
if (safetyVerdict === "block") process.exitCode = 1;
|
|
2918
|
+
} else {
|
|
2919
|
+
logOutcome(`[skill-forge] held \u2014 staging dir left in place -> ${stagingDir}`, json);
|
|
2920
|
+
if (existsSync14(reportMdPath)) {
|
|
2921
|
+
logOutcome(`[skill-forge] see ${reportMdPath} for the full evidence report`, json);
|
|
2922
|
+
}
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
async function defaultConfirm(question) {
|
|
2926
|
+
const rl = createInterface3({ input: process.stdin, output: process.stdout });
|
|
2927
|
+
try {
|
|
2928
|
+
process.stdout.write(question);
|
|
2929
|
+
for await (const raw of rl) {
|
|
2930
|
+
return /^y(es)?$/i.test(raw.trim());
|
|
2931
|
+
}
|
|
2932
|
+
return false;
|
|
2933
|
+
} finally {
|
|
2934
|
+
rl.close();
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
async function detectSkillopt(exec, cwd) {
|
|
2938
|
+
try {
|
|
2939
|
+
const result = await exec(SKILLOPT_BIN, ["--help"], { cwd });
|
|
2940
|
+
return result.code === 0;
|
|
2941
|
+
} catch {
|
|
2942
|
+
return false;
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
function tail(text, maxLines = 20) {
|
|
2946
|
+
return text.trim().split("\n").slice(-maxLines).join("\n");
|
|
2947
|
+
}
|
|
2948
|
+
function validateRunOutput(raw) {
|
|
2949
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2950
|
+
throw new UserError(`skillopt-sleep produced unexpected JSON output (expected an object): ${JSON.stringify(raw)}`);
|
|
2951
|
+
}
|
|
2952
|
+
const obj = raw;
|
|
2953
|
+
const stagingDirRaw = obj.staging_dir;
|
|
2954
|
+
if (stagingDirRaw !== null && stagingDirRaw !== void 0 && typeof stagingDirRaw !== "string") {
|
|
2955
|
+
throw new UserError(
|
|
2956
|
+
`skillopt-sleep produced an unexpected "staging_dir" (expected a string or null): ${JSON.stringify(stagingDirRaw)}`
|
|
2957
|
+
);
|
|
2958
|
+
}
|
|
2959
|
+
return { accepted: Boolean(obj.accepted), staging_dir: stagingDirRaw ?? null };
|
|
2960
|
+
}
|
|
2961
|
+
function readManifest(stagingDir) {
|
|
2962
|
+
const manifestPath = join17(stagingDir, "manifest.json");
|
|
2963
|
+
let raw;
|
|
2964
|
+
try {
|
|
2965
|
+
raw = readFileSync10(manifestPath, "utf-8");
|
|
2966
|
+
} catch (err) {
|
|
2967
|
+
throw new UserError(`could not read staging manifest ${manifestPath}: ${err.message}`);
|
|
2968
|
+
}
|
|
2969
|
+
let parsed;
|
|
2970
|
+
try {
|
|
2971
|
+
parsed = JSON.parse(raw);
|
|
2972
|
+
} catch (err) {
|
|
2973
|
+
throw new UserError(`could not parse staging manifest ${manifestPath}: ${err.message}`);
|
|
2974
|
+
}
|
|
2975
|
+
return validateManifest(parsed, manifestPath);
|
|
2976
|
+
}
|
|
2977
|
+
function validateManifest(raw, manifestPath) {
|
|
2978
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
2979
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected shape (expected an object)`);
|
|
2980
|
+
}
|
|
2981
|
+
const obj = raw;
|
|
2982
|
+
if (typeof obj.live_skill_path !== "string") {
|
|
2983
|
+
throw new UserError(`staging manifest ${manifestPath} is missing a valid "live_skill_path" string`);
|
|
2984
|
+
}
|
|
2985
|
+
if (obj.live_memory_path !== void 0 && obj.live_memory_path !== null && typeof obj.live_memory_path !== "string") {
|
|
2986
|
+
throw new UserError(`staging manifest ${manifestPath} has an unexpected "live_memory_path" (expected a string or null)`);
|
|
2987
|
+
}
|
|
2988
|
+
if (typeof obj.has_skill !== "boolean" || typeof obj.has_memory !== "boolean") {
|
|
2989
|
+
throw new UserError(`staging manifest ${manifestPath} is missing valid "has_skill"/"has_memory" booleans`);
|
|
2990
|
+
}
|
|
2991
|
+
return {
|
|
2992
|
+
live_skill_path: obj.live_skill_path,
|
|
2993
|
+
live_memory_path: obj.live_memory_path ?? null,
|
|
2994
|
+
has_skill: obj.has_skill,
|
|
2995
|
+
has_memory: obj.has_memory
|
|
2996
|
+
};
|
|
2997
|
+
}
|
|
2998
|
+
function filesIdentical(a, b) {
|
|
2999
|
+
if (!existsSync14(a) || !existsSync14(b)) return false;
|
|
3000
|
+
return readFileSync10(a).equals(readFileSync10(b));
|
|
3001
|
+
}
|
|
3002
|
+
function isAlreadyAdopted(stagingDir, manifest) {
|
|
3003
|
+
const proposedSkillMd = join17(stagingDir, "proposed_SKILL.md");
|
|
3004
|
+
const proposedClaudeMd = join17(stagingDir, "proposed_CLAUDE.md");
|
|
3005
|
+
let sawComparison = false;
|
|
3006
|
+
if (existsSync14(proposedSkillMd)) {
|
|
3007
|
+
sawComparison = true;
|
|
3008
|
+
if (!filesIdentical(proposedSkillMd, manifest.live_skill_path)) return false;
|
|
3009
|
+
}
|
|
3010
|
+
if (existsSync14(proposedClaudeMd) && manifest.live_memory_path) {
|
|
3011
|
+
sawComparison = true;
|
|
3012
|
+
if (!filesIdentical(proposedClaudeMd, manifest.live_memory_path)) return false;
|
|
3013
|
+
}
|
|
3014
|
+
return sawComparison;
|
|
3015
|
+
}
|
|
3016
|
+
function assertNotSymlink(path, label) {
|
|
3017
|
+
let stat;
|
|
3018
|
+
try {
|
|
3019
|
+
stat = lstatSync(path);
|
|
3020
|
+
} catch {
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (stat.isSymbolicLink()) {
|
|
3024
|
+
throw new UserError(`refusing to re-gate ${label}: ${path} is a symlink (a staged proposal must be a plain file)`);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
function sha256File(path) {
|
|
3028
|
+
if (!existsSync14(path)) return null;
|
|
3029
|
+
return createHash("sha256").update(readFileSync10(path)).digest("hex");
|
|
3030
|
+
}
|
|
3031
|
+
|
|
3032
|
+
// src/commands/evolve.ts
|
|
3033
|
+
async function evolveCommand(targetSkillDir, options = {}) {
|
|
3034
|
+
await evolve(targetSkillDir, options);
|
|
3035
|
+
}
|
|
3036
|
+
|
|
2751
3037
|
// src/cli.ts
|
|
2752
3038
|
var require2 = createRequire(import.meta.url);
|
|
2753
3039
|
var pkg = require2("../package.json");
|
|
@@ -2757,6 +3043,13 @@ function parseArtifact(value) {
|
|
|
2757
3043
|
}
|
|
2758
3044
|
return value;
|
|
2759
3045
|
}
|
|
3046
|
+
function parseLookbackHours(value) {
|
|
3047
|
+
const n = Number(value);
|
|
3048
|
+
if (value.trim() === "" || !Number.isFinite(n) || n < 0) {
|
|
3049
|
+
throw new UserError(`--lookback-hours must be a non-negative number (got "${value}")`);
|
|
3050
|
+
}
|
|
3051
|
+
return n;
|
|
3052
|
+
}
|
|
2760
3053
|
var program = new Command();
|
|
2761
3054
|
program.name("skill-forge").description("The supply-chain gate for agent skills").version(pkg.version);
|
|
2762
3055
|
program.command("add <source>").description("Quarantine-install a skill and run it through the safety gate").option("-y, --yes", "skip the interactive prompt; honor the gate verdict automatically").option("-t, --target <dir>", "skills root to promote into (defaults to config defaultTarget, then skillsRoots[0])").option("--json", "print the gate result as JSON instead of the terminal report").option("--ingest", "after a successful promote, hand off the bundled ingest prompt to a coding agent").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).option(
|
|
@@ -2766,6 +3059,7 @@ program.command("add <source>").description("Quarantine-install a skill and run
|
|
|
2766
3059
|
program.command("scan <source>").description("Run the safety gate against a skill without installing it").option("--json", "print the gate result as JSON instead of the terminal report").option("--artifact <type>", 'gate as a "skill" or an "mcp" server (default: skill)', parseArtifact).action(scan);
|
|
2767
3060
|
program.command("list").description("List skills currently in quarantine").action(list);
|
|
2768
3061
|
program.command("status").description("Show skill-forge configuration and quarantine status").action(status);
|
|
3062
|
+
program.command("evolve <target-skill-dir>").description("Self-evolve a skill via SkillOpt-Sleep, re-gate the proposal, then promote/hold/reject (Pro)").option("--project <dir>", "project dir passed to SkillOpt-Sleep as --project (defaults to cwd)").option("--dry-run", "SkillOpt-Sleep dry-run: report only, nothing staged").option("--backend <name>", "SkillOpt-Sleep backend (default: mock, offline \u2014 a real backend requires --yes or interactive confirmation)").option("--lookback-hours <n>", "hours of session history for SkillOpt-Sleep to harvest", parseLookbackHours).option("-y, --yes", "skip interactive prompts; honor the re-gate verdict automatically").option("--json", "print the re-gate result as JSON instead of the terminal report").option("--force", "allow re-adopting a staging dir whose proposed content already matches the live skill").action(evolveCommand);
|
|
2769
3063
|
program.command("init").description("Detect installed agents and set gate targets, default promotion target, and handoff agent").option("--defaults", "non-interactive: use all detected agents as gate targets, the first as the default target").option("--list", "print detected agent skill roots and exit \u2014 no writes").action(init);
|
|
2770
3064
|
try {
|
|
2771
3065
|
await program.parseAsync();
|