guardskills 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +125 -69
  2. package/dist/cli.cjs +1368 -190
  3. package/dist/cli.js +1368 -190
  4. package/package.json +3 -1
package/dist/cli.cjs CHANGED
@@ -200,9 +200,13 @@ function enforceSourcePolicy(repoInput, policy) {
200
200
 
201
201
  // src/install/skills.ts
202
202
  var import_execa = require("execa");
203
- async function runSkillsInstall(repo, skill) {
203
+ async function runProviderInstall(provider, repo, skill) {
204
+ if (provider === "playbooks" && !skill) {
205
+ return 30;
206
+ }
207
+ const args = provider === "skills" ? skill ? ["skills", "add", repo, "--skill", skill] : ["skills", "add", repo] : provider === "playbooks" ? ["playbooks", "add", "skill", repo, "--skill", skill] : provider === "skillkit" ? skill ? ["skillkit", "install", repo, "--skill", skill] : ["skillkit", "install", repo] : skill ? ["openskills", "install", repo, skill] : ["openskills", "install", repo];
204
208
  try {
205
- await (0, import_execa.execa)("npx", ["skills", "add", repo, "--skill", skill], {
209
+ await (0, import_execa.execa)("npx", args, {
206
210
  stdio: "inherit"
207
211
  });
208
212
  return 0;
@@ -308,6 +312,73 @@ function printHumanLocalReport(report) {
308
312
  function printJsonLocalReport(report) {
309
313
  console.log(JSON.stringify(report, null, 2));
310
314
  }
315
+ function printHumanClawHubReport(report) {
316
+ console.log(`Command: ${report.command}`);
317
+ console.log(`Identifier: ${report.identifier}`);
318
+ console.log(`Registry: ${report.registry}`);
319
+ console.log(`Mapped Repo: ${report.repo}`);
320
+ console.log(`Skill: ${report.skill}`);
321
+ if (report.version) {
322
+ console.log(`Version: ${report.version}`);
323
+ }
324
+ console.log(`Mode: ${report.strict ? "strict" : "standard"}`);
325
+ if (report.configPath) {
326
+ console.log(`Config: ${report.configPath}`);
327
+ }
328
+ if (report.skillDir) {
329
+ console.log(`Skill Dir: ${report.skillDir}`);
330
+ }
331
+ if (report.commitSha) {
332
+ console.log(`Commit: ${report.commitSha}`);
333
+ }
334
+ if (report.moderation) {
335
+ const parts = [];
336
+ if (report.moderation.isSuspicious) {
337
+ parts.push("suspicious");
338
+ }
339
+ if (report.moderation.isMalwareBlocked) {
340
+ parts.push("malware-blocked");
341
+ }
342
+ if (report.moderation.isRemoved) {
343
+ parts.push("removed");
344
+ }
345
+ if (parts.length > 0) {
346
+ console.log(`ClawHub Moderation: ${parts.join(", ")}`);
347
+ }
348
+ }
349
+ console.log(`Files Scanned: ${report.scanFiles.length}`);
350
+ if (report.decision.riskScore === null) {
351
+ console.log("Result: UNVERIFIABLE");
352
+ } else {
353
+ console.log(`Risk Score: ${report.decision.riskScore.toFixed(1)}/100`);
354
+ console.log(`Decision: ${report.decision.level}`);
355
+ }
356
+ if (report.unverifiableReasons && report.unverifiableReasons.length > 0) {
357
+ console.log("Unverifiable Reasons:");
358
+ for (const reason of report.unverifiableReasons) {
359
+ console.log(`- ${reason}`);
360
+ }
361
+ }
362
+ if (report.decision.chainMatches.length > 0) {
363
+ console.log("Attack Chains:");
364
+ for (const chain of report.decision.chainMatches) {
365
+ console.log(`- ${chain.id} (+${chain.bonus}): ${chain.description}`);
366
+ }
367
+ }
368
+ if (report.decision.findings.length > 0) {
369
+ console.log("Findings:");
370
+ for (const finding of report.decision.findings.slice(0, 10)) {
371
+ const fileText = finding.file ? ` (${finding.file})` : "";
372
+ console.log(`- [${finding.severity}/${finding.confidence}] ${finding.title}${fileText}`);
373
+ }
374
+ } else {
375
+ console.log("Findings: none");
376
+ }
377
+ console.log(`Note: ${report.note}`);
378
+ }
379
+ function printJsonClawHubReport(report) {
380
+ console.log(JSON.stringify(report, null, 2));
381
+ }
311
382
 
312
383
  // src/resolver/github.ts
313
384
  var import_node_path2 = __toESM(require("path"), 1);
@@ -1000,6 +1071,22 @@ var cliAddOptionsSchema = import_zod2.z.object({
1000
1071
  maxAuxFiles: import_zod2.z.coerce.number().int().min(1).max(200).optional(),
1001
1072
  maxTotalFiles: import_zod2.z.coerce.number().int().min(1).max(400).optional()
1002
1073
  });
1074
+ var cliBulkAddOptionsSchema = import_zod2.z.object({
1075
+ config: import_zod2.z.string().optional(),
1076
+ strict: import_zod2.z.boolean().optional(),
1077
+ ci: import_zod2.z.boolean().optional(),
1078
+ json: import_zod2.z.boolean().optional(),
1079
+ yes: import_zod2.z.boolean().optional(),
1080
+ dryRun: import_zod2.z.boolean().optional(),
1081
+ force: import_zod2.z.boolean().optional(),
1082
+ allowUnverifiable: import_zod2.z.boolean().optional(),
1083
+ githubTimeoutMs: import_zod2.z.coerce.number().int().min(1e3).max(12e4).optional(),
1084
+ githubRetries: import_zod2.z.coerce.number().int().min(0).max(6).optional(),
1085
+ githubRetryBaseMs: import_zod2.z.coerce.number().int().min(50).max(5e3).optional(),
1086
+ maxFileBytes: import_zod2.z.coerce.number().int().min(4096).max(5e6).optional(),
1087
+ maxAuxFiles: import_zod2.z.coerce.number().int().min(1).max(200).optional(),
1088
+ maxTotalFiles: import_zod2.z.coerce.number().int().min(1).max(400).optional()
1089
+ });
1003
1090
  var effectiveAddOptionsSchema = import_zod2.z.object({
1004
1091
  skill: import_zod2.z.string().min(1),
1005
1092
  strict: import_zod2.z.boolean(),
@@ -1119,9 +1206,11 @@ function evaluateGate(level, options) {
1119
1206
  gateNote: level === "WARNING" ? "WARNING accepted via --yes." : "SAFE to proceed."
1120
1207
  };
1121
1208
  }
1122
- async function runAddCommand(repo, rawOptions) {
1209
+ async function runAddCommand(repo, rawOptions, context = {}) {
1123
1210
  const cliOptions = cliAddOptionsSchema.parse(rawOptions);
1124
1211
  const loadedConfig = loadGuardSkillsConfig(cliOptions.config);
1212
+ const provider = context.provider ?? "skills";
1213
+ const commandName = context.commandName ?? "guardskills add";
1125
1214
  const options = resolveEffectiveAddOptions(cliOptions, loadedConfig.config);
1126
1215
  enforceSourcePolicy(repo, loadedConfig.config.policy);
1127
1216
  enforceOptionPolicy(options, loadedConfig.config);
@@ -1142,7 +1231,7 @@ async function runAddCommand(repo, rawOptions) {
1142
1231
  const gate = evaluateGate(decision.level, options);
1143
1232
  const configNote = loadedConfig.path ? ` Config: ${loadedConfig.path}` : "";
1144
1233
  const report = {
1145
- command: "guardskills add",
1234
+ command: commandName,
1146
1235
  repo,
1147
1236
  skill: options.skill,
1148
1237
  strict: options.strict,
@@ -1167,12 +1256,14 @@ async function runAddCommand(repo, rawOptions) {
1167
1256
  if (options.dryRun || options.ci) {
1168
1257
  return 0;
1169
1258
  }
1170
- return runSkillsInstall(repo, options.skill);
1259
+ return runProviderInstall(provider, repo, options.skill);
1171
1260
  }
1172
1261
 
1173
- // src/commands/scan-local.ts
1262
+ // src/commands/openskills-install.ts
1174
1263
  var import_node_fs2 = __toESM(require("fs"), 1);
1264
+ var import_node_os = __toESM(require("os"), 1);
1175
1265
  var import_node_path4 = __toESM(require("path"), 1);
1266
+ var import_execa2 = require("execa");
1176
1267
  var import_zod3 = require("zod");
1177
1268
  var ALLOWED_TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
1178
1269
  ".md",
@@ -1194,68 +1285,82 @@ var ALLOWED_TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
1194
1285
  ".cfg"
1195
1286
  ]);
1196
1287
  var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".turbo"]);
1197
- var cliScanLocalOptionsSchema = import_zod3.z.object({
1288
+ var cliOpenSkillsOptionsSchema = import_zod3.z.object({
1198
1289
  config: import_zod3.z.string().optional(),
1199
1290
  strict: import_zod3.z.boolean().optional(),
1291
+ ci: import_zod3.z.boolean().optional(),
1200
1292
  json: import_zod3.z.boolean().optional(),
1201
- skill: import_zod3.z.string().min(1).optional(),
1293
+ yes: import_zod3.z.boolean().optional(),
1294
+ dryRun: import_zod3.z.boolean().optional(),
1295
+ force: import_zod3.z.boolean().optional(),
1296
+ allowUnverifiable: import_zod3.z.boolean().optional(),
1297
+ githubTimeoutMs: import_zod3.z.coerce.number().int().min(1e3).max(12e4).optional(),
1298
+ githubRetries: import_zod3.z.coerce.number().int().min(0).max(6).optional(),
1299
+ githubRetryBaseMs: import_zod3.z.coerce.number().int().min(50).max(5e3).optional(),
1202
1300
  maxFileBytes: import_zod3.z.coerce.number().int().min(4096).max(5e6).optional(),
1301
+ maxAuxFiles: import_zod3.z.coerce.number().int().min(1).max(200).optional(),
1203
1302
  maxTotalFiles: import_zod3.z.coerce.number().int().min(1).max(400).optional()
1204
1303
  });
1205
- var effectiveScanLocalOptionsSchema = import_zod3.z.object({
1206
- strict: import_zod3.z.boolean(),
1207
- json: import_zod3.z.boolean(),
1208
- skill: import_zod3.z.string().min(1).optional(),
1209
- maxFileBytes: import_zod3.z.number().int().min(4096).max(5e6),
1210
- maxTotalFiles: import_zod3.z.number().int().min(1).max(400)
1211
- });
1212
1304
  var DEFAULT_OPTIONS2 = {
1213
1305
  strict: false,
1306
+ ci: false,
1214
1307
  json: false,
1215
- skill: void 0,
1308
+ yes: false,
1309
+ dryRun: false,
1310
+ force: false,
1311
+ allowUnverifiable: false,
1312
+ githubTimeoutMs: 15e3,
1313
+ githubRetries: 2,
1314
+ githubRetryBaseMs: 300,
1216
1315
  maxFileBytes: 25e4,
1316
+ maxAuxFiles: 40,
1217
1317
  maxTotalFiles: 120
1218
1318
  };
1219
- function toPosixPath(filePath) {
1220
- return filePath.replace(/\\/g, "/");
1319
+ function resolveEffectiveOptions(cliOptions, config) {
1320
+ const defaults = config.defaults ?? {};
1321
+ const resolver = config.resolver ?? {};
1322
+ return {
1323
+ strict: cliOptions.strict ?? defaults.strict ?? DEFAULT_OPTIONS2.strict,
1324
+ ci: cliOptions.ci ?? defaults.ci ?? DEFAULT_OPTIONS2.ci,
1325
+ json: cliOptions.json ?? defaults.json ?? DEFAULT_OPTIONS2.json,
1326
+ yes: cliOptions.yes ?? defaults.yes ?? DEFAULT_OPTIONS2.yes,
1327
+ dryRun: cliOptions.dryRun ?? defaults.dryRun ?? DEFAULT_OPTIONS2.dryRun,
1328
+ force: cliOptions.force ?? defaults.force ?? DEFAULT_OPTIONS2.force,
1329
+ allowUnverifiable: cliOptions.allowUnverifiable ?? defaults.allowUnverifiable ?? DEFAULT_OPTIONS2.allowUnverifiable,
1330
+ githubTimeoutMs: cliOptions.githubTimeoutMs ?? resolver.githubTimeoutMs ?? DEFAULT_OPTIONS2.githubTimeoutMs,
1331
+ githubRetries: cliOptions.githubRetries ?? resolver.githubRetries ?? DEFAULT_OPTIONS2.githubRetries,
1332
+ githubRetryBaseMs: cliOptions.githubRetryBaseMs ?? resolver.githubRetryBaseMs ?? DEFAULT_OPTIONS2.githubRetryBaseMs,
1333
+ maxFileBytes: cliOptions.maxFileBytes ?? resolver.maxFileBytes ?? DEFAULT_OPTIONS2.maxFileBytes,
1334
+ maxAuxFiles: cliOptions.maxAuxFiles ?? resolver.maxAuxFiles ?? DEFAULT_OPTIONS2.maxAuxFiles,
1335
+ maxTotalFiles: cliOptions.maxTotalFiles ?? resolver.maxTotalFiles ?? DEFAULT_OPTIONS2.maxTotalFiles
1336
+ };
1221
1337
  }
1222
- function getNearbyPathSuggestions(targetPath) {
1223
- const parent = import_node_path4.default.dirname(targetPath);
1224
- if (!import_node_fs2.default.existsSync(parent)) {
1225
- return [];
1338
+ function enforceOptionPolicy2(options, config) {
1339
+ const policy = config.policy;
1340
+ if (!policy) {
1341
+ return;
1226
1342
  }
1227
- const needle = import_node_path4.default.basename(targetPath).toLowerCase();
1228
- const suggestions = [];
1229
- for (const entry of import_node_fs2.default.readdirSync(parent, { withFileTypes: true })) {
1230
- if (entry.name.toLowerCase().includes(needle)) {
1231
- suggestions.push(import_node_path4.default.join(parent, entry.name));
1232
- }
1343
+ if (options.force && policy.allowForce === false) {
1344
+ throw new GuardSkillsError(
1345
+ "POLICY_VIOLATION",
1346
+ "Policy blocks --force overrides (allowForce=false)."
1347
+ );
1233
1348
  }
1234
- return suggestions.slice(0, 5);
1235
- }
1236
- function isSkillFile(filePath) {
1237
- return import_node_path4.default.basename(filePath).toLowerCase() === "skill.md";
1238
- }
1239
- function isScannableTextFile(filePath) {
1240
- if (isSkillFile(filePath)) {
1241
- return true;
1349
+ if (options.allowUnverifiable && policy.allowUnverifiableOverride === false) {
1350
+ throw new GuardSkillsError(
1351
+ "POLICY_VIOLATION",
1352
+ "Policy blocks --allow-unverifiable overrides (allowUnverifiableOverride=false)."
1353
+ );
1242
1354
  }
1243
- const ext = import_node_path4.default.extname(filePath).toLowerCase();
1244
- return ALLOWED_TEXT_EXTENSIONS2.has(ext);
1245
1355
  }
1246
1356
  function findSkillDirs(rootDir) {
1247
1357
  const found = /* @__PURE__ */ new Set();
1248
1358
  const stack = [{ dir: rootDir, depth: 0 }];
1249
- let seen = 0;
1250
1359
  while (stack.length > 0) {
1251
1360
  const current = stack.pop();
1252
1361
  if (!current) {
1253
1362
  continue;
1254
1363
  }
1255
- seen += 1;
1256
- if (seen > 5e3) {
1257
- break;
1258
- }
1259
1364
  const skillFile = import_node_path4.default.join(current.dir, "SKILL.md");
1260
1365
  if (import_node_fs2.default.existsSync(skillFile) && import_node_fs2.default.statSync(skillFile).isFile()) {
1261
1366
  found.add(current.dir);
@@ -1282,82 +1387,6 @@ function findSkillDirs(rootDir) {
1282
1387
  }
1283
1388
  return [...found].sort();
1284
1389
  }
1285
- function formatCandidates(candidates) {
1286
- return candidates.map((candidate) => `- ${toPosixPath(candidate)}`).join("\n");
1287
- }
1288
- function resolveSkillDirectory(inputPath, preferredSkillName) {
1289
- const absoluteInput = import_node_path4.default.resolve(inputPath);
1290
- if (!import_node_fs2.default.existsSync(absoluteInput)) {
1291
- const suggestions = getNearbyPathSuggestions(absoluteInput);
1292
- const suggestionText = suggestions.length > 0 ? `
1293
- Nearby paths:
1294
- ${formatCandidates(suggestions)}` : "";
1295
- throw new GuardSkillsError(
1296
- "INVALID_LOCAL_PATH",
1297
- `Local path not found: ${toPosixPath(absoluteInput)}${suggestionText}`
1298
- );
1299
- }
1300
- const stat = import_node_fs2.default.statSync(absoluteInput);
1301
- if (stat.isFile()) {
1302
- if (!isSkillFile(absoluteInput)) {
1303
- throw new GuardSkillsError(
1304
- "INVALID_LOCAL_PATH",
1305
- "Local scan expects a directory or a SKILL.md file path."
1306
- );
1307
- }
1308
- return {
1309
- skillDir: import_node_path4.default.dirname(absoluteInput),
1310
- note: "Using parent directory of provided SKILL.md file."
1311
- };
1312
- }
1313
- const directSkillFile = import_node_path4.default.join(absoluteInput, "SKILL.md");
1314
- if (import_node_fs2.default.existsSync(directSkillFile) && import_node_fs2.default.statSync(directSkillFile).isFile()) {
1315
- return { skillDir: absoluteInput };
1316
- }
1317
- const discovered = findSkillDirs(absoluteInput);
1318
- if (discovered.length === 0) {
1319
- throw new GuardSkillsError(
1320
- "INVALID_LOCAL_PATH",
1321
- `No SKILL.md found under: ${toPosixPath(absoluteInput)}`
1322
- );
1323
- }
1324
- if (preferredSkillName) {
1325
- const matches = discovered.filter(
1326
- (directory) => import_node_path4.default.basename(directory).toLowerCase() === preferredSkillName.toLowerCase()
1327
- );
1328
- if (matches.length === 1) {
1329
- const selected = matches[0];
1330
- if (!selected) {
1331
- throw new GuardSkillsError("INVALID_LOCAL_PATH", "Unable to resolve selected local skill.");
1332
- }
1333
- return {
1334
- skillDir: selected,
1335
- note: `Auto-selected skill '${preferredSkillName}' under the provided path.`
1336
- };
1337
- }
1338
- const available = discovered.map((directory) => import_node_path4.default.basename(directory));
1339
- throw new GuardSkillsError(
1340
- "INVALID_LOCAL_PATH",
1341
- `Requested --skill '${preferredSkillName}' was not found.
1342
- Available skills: ${available.join(", ")}`
1343
- );
1344
- }
1345
- if (discovered.length === 1) {
1346
- const selected = discovered[0];
1347
- if (!selected) {
1348
- throw new GuardSkillsError("INVALID_LOCAL_PATH", "Unable to resolve discovered local skill.");
1349
- }
1350
- return {
1351
- skillDir: selected,
1352
- note: "Auto-selected the only SKILL.md found under the provided path."
1353
- };
1354
- }
1355
- throw new GuardSkillsError(
1356
- "INVALID_LOCAL_PATH",
1357
- `Multiple skills found under path. Provide --skill <name>.
1358
- ${formatCandidates(discovered)}`
1359
- );
1360
- }
1361
1390
  function collectLocalFiles(skillDir, options) {
1362
1391
  const files = [];
1363
1392
  const unverifiableReasons = [];
@@ -1371,7 +1400,7 @@ function collectLocalFiles(skillDir, options) {
1371
1400
  try {
1372
1401
  entries = import_node_fs2.default.readdirSync(currentDir, { withFileTypes: true });
1373
1402
  } catch {
1374
- unverifiableReasons.push(`Cannot read directory: ${toPosixPath(currentDir)}`);
1403
+ unverifiableReasons.push(`Cannot read directory: ${currentDir}`);
1375
1404
  continue;
1376
1405
  }
1377
1406
  for (const entry of entries) {
@@ -1385,8 +1414,10 @@ function collectLocalFiles(skillDir, options) {
1385
1414
  if (!entry.isFile()) {
1386
1415
  continue;
1387
1416
  }
1388
- const relativePath = toPosixPath(import_node_path4.default.relative(skillDir, fullPath));
1389
- if (!isScannableTextFile(relativePath)) {
1417
+ const relativePath = import_node_path4.default.relative(skillDir, fullPath).replace(/\\/g, "/");
1418
+ const ext = import_node_path4.default.extname(relativePath).toLowerCase();
1419
+ const isSkillFile2 = import_node_path4.default.basename(relativePath).toLowerCase() === "skill.md";
1420
+ if (!isSkillFile2 && !ALLOWED_TEXT_EXTENSIONS2.has(ext)) {
1390
1421
  continue;
1391
1422
  }
1392
1423
  if (files.length >= options.maxTotalFiles) {
@@ -1420,86 +1451,1233 @@ function collectLocalFiles(skillDir, options) {
1420
1451
  }
1421
1452
  return { files, unverifiableReasons };
1422
1453
  }
1423
- function resolveEffectiveScanLocalOptions(cliOptions, config) {
1424
- const defaults = config.defaults ?? {};
1425
- const resolver = config.resolver ?? {};
1426
- return effectiveScanLocalOptionsSchema.parse({
1427
- strict: cliOptions.strict ?? defaults.strict ?? DEFAULT_OPTIONS2.strict,
1428
- json: cliOptions.json ?? defaults.json ?? DEFAULT_OPTIONS2.json,
1429
- skill: cliOptions.skill ?? DEFAULT_OPTIONS2.skill,
1430
- maxFileBytes: cliOptions.maxFileBytes ?? resolver.maxFileBytes ?? DEFAULT_OPTIONS2.maxFileBytes,
1431
- maxTotalFiles: cliOptions.maxTotalFiles ?? resolver.maxTotalFiles ?? DEFAULT_OPTIONS2.maxTotalFiles
1432
- });
1433
- }
1434
- async function runScanLocalCommand(inputPath, rawOptions) {
1435
- const cliOptions = cliScanLocalOptionsSchema.parse(rawOptions);
1436
- const loadedConfig = loadGuardSkillsConfig(cliOptions.config);
1437
- const options = resolveEffectiveScanLocalOptions(cliOptions, loadedConfig.config);
1438
- const target = resolveSkillDirectory(inputPath, options.skill);
1439
- const { files, unverifiableReasons } = collectLocalFiles(target.skillDir, options);
1440
- if (files.length === 0) {
1441
- throw new GuardSkillsError(
1442
- "INVALID_LOCAL_PATH",
1443
- `No scannable text files found in: ${toPosixPath(target.skillDir)}`
1444
- );
1454
+ function resolveSource(source) {
1455
+ const maybePath = import_node_path4.default.resolve(source);
1456
+ if (import_node_fs2.default.existsSync(maybePath)) {
1457
+ return { kind: "local", path: maybePath };
1445
1458
  }
1446
- const resolvedSkill = {
1447
- source: `local:${toPosixPath(target.skillDir)}`,
1448
- owner: "local",
1449
- repo: "local",
1450
- defaultBranch: "local",
1451
- commitSha: "local",
1452
- skillName: options.skill ?? import_node_path4.default.basename(target.skillDir),
1453
- skillDir: toPosixPath(target.skillDir),
1454
- skillFilePath: "SKILL.md",
1455
- files,
1456
- unverifiableReasons
1457
- };
1458
- const scan = scanResolvedSkill(resolvedSkill);
1459
- const decision = calculateRiskScore(scan.findings, {
1460
- strict: options.strict,
1461
- trustCredits: 0,
1462
- hasUnverifiableContent: scan.hasUnverifiableContent
1463
- });
1464
- const noteParts = ["Local scan complete."];
1465
- if (target.note) {
1466
- noteParts.push(target.note);
1459
+ const shorthand = source.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
1460
+ if (shorthand) {
1461
+ return { kind: "git", cloneUrl: `https://github.com/${shorthand[1]}/${shorthand[2]}.git` };
1467
1462
  }
1468
- if (decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE") {
1469
- noteParts.push("Blocked-level risk detected.");
1463
+ try {
1464
+ const parsed = new URL(source);
1465
+ if (parsed.hostname === "github.com" || parsed.hostname === "www.github.com") {
1466
+ return { kind: "git", cloneUrl: source.endsWith(".git") ? source : `${source}.git` };
1467
+ }
1468
+ } catch {
1470
1469
  }
1471
- if (loadedConfig.path) {
1472
- noteParts.push(`Config: ${loadedConfig.path}`);
1470
+ return { kind: "git", cloneUrl: source };
1471
+ }
1472
+ function aggregateLevel(levels) {
1473
+ if (levels.includes("UNVERIFIABLE")) {
1474
+ return "UNVERIFIABLE";
1473
1475
  }
1474
- const report = {
1475
- command: "guardskills scan-local",
1476
- inputPath,
1477
- strict: options.strict,
1478
- configPath: loadedConfig.path ?? void 0,
1479
- decision,
1480
- scanFiles: resolvedSkill.files.map((file) => file.path),
1481
- skillDir: resolvedSkill.skillDir,
1482
- unverifiableReasons: scan.unverifiableReasons,
1483
- note: noteParts.join(" ")
1484
- };
1485
- if (options.json) {
1486
- printJsonLocalReport(report);
1487
- } else {
1488
- printHumanLocalReport(report);
1476
+ if (levels.includes("CRITICAL")) {
1477
+ return "CRITICAL";
1489
1478
  }
1490
- return decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE" ? 20 : 0;
1479
+ if (levels.includes("UNSAFE")) {
1480
+ return "UNSAFE";
1481
+ }
1482
+ if (levels.includes("WARNING")) {
1483
+ return "WARNING";
1484
+ }
1485
+ return "SAFE";
1486
+ }
1487
+ async function runInteractiveInstallCommand(provider, source, skillName, rawOptions) {
1488
+ if (provider !== "openskills" && provider !== "skills" && provider !== "skillkit") {
1489
+ throw new GuardSkillsError(
1490
+ "INVALID_OPTIONS",
1491
+ `Interactive install flow is not supported for provider '${provider}'.`
1492
+ );
1493
+ }
1494
+ const cliOptions = cliOpenSkillsOptionsSchema.parse(rawOptions);
1495
+ const loadedConfig = loadGuardSkillsConfig(cliOptions.config);
1496
+ const options = resolveEffectiveOptions(cliOptions, loadedConfig.config);
1497
+ enforceSourcePolicy(source, loadedConfig.config.policy);
1498
+ enforceOptionPolicy2(options, loadedConfig.config);
1499
+ const resolvedSource = resolveSource(source);
1500
+ const tempDir = resolvedSource.kind === "git" ? import_node_fs2.default.mkdtempSync(import_node_path4.default.join(import_node_os.default.tmpdir(), `guardskills-${provider}-`)) : null;
1501
+ const scanRoot = resolvedSource.kind === "git" ? tempDir ?? "" : resolvedSource.path;
1502
+ try {
1503
+ if (resolvedSource.kind === "git") {
1504
+ try {
1505
+ await (0, import_execa2.execa)("git", ["clone", "--depth", "1", resolvedSource.cloneUrl, scanRoot], {
1506
+ timeout: options.githubTimeoutMs
1507
+ });
1508
+ } catch (error) {
1509
+ const message = error instanceof Error ? error.message : String(error);
1510
+ throw new GuardSkillsError(
1511
+ "GITHUB_UNKNOWN",
1512
+ `Failed to clone source '${source}' for ${provider} scan: ${message}`,
1513
+ { cause: error }
1514
+ );
1515
+ }
1516
+ }
1517
+ const discovered = findSkillDirs(scanRoot);
1518
+ if (discovered.length === 0) {
1519
+ throw new GuardSkillsError("SKILL_NOT_FOUND", "No SKILL.md files were found in the source.");
1520
+ }
1521
+ const selectedDirs = skillName ? discovered.filter((dir) => import_node_path4.default.basename(dir).toLowerCase() === skillName.toLowerCase()) : discovered;
1522
+ if (selectedDirs.length === 0) {
1523
+ throw new GuardSkillsError(
1524
+ "SKILL_NOT_FOUND",
1525
+ `Skill '${skillName}' was not found. Available: ${discovered.map((dir) => import_node_path4.default.basename(dir)).join(", ")}`
1526
+ );
1527
+ }
1528
+ const levels = [];
1529
+ const summaries = [];
1530
+ for (const skillDir of selectedDirs) {
1531
+ const skill = import_node_path4.default.basename(skillDir);
1532
+ const { files, unverifiableReasons } = collectLocalFiles(skillDir, options);
1533
+ const resolvedSkill = {
1534
+ source: `local:${skillDir}`,
1535
+ owner: "local",
1536
+ repo: "local",
1537
+ defaultBranch: "local",
1538
+ commitSha: "local",
1539
+ skillName: skill,
1540
+ skillDir: skillDir.replace(/\\/g, "/"),
1541
+ skillFilePath: "SKILL.md",
1542
+ files,
1543
+ unverifiableReasons
1544
+ };
1545
+ const scan = scanResolvedSkill(resolvedSkill);
1546
+ const decision = calculateRiskScore(scan.findings, {
1547
+ strict: options.strict,
1548
+ trustCredits: 0,
1549
+ hasUnverifiableContent: scan.hasUnverifiableContent
1550
+ });
1551
+ levels.push(decision.level);
1552
+ summaries.push({ skill, level: decision.level, riskScore: decision.riskScore });
1553
+ }
1554
+ const overall = aggregateLevel(levels);
1555
+ const gate = evaluateGate(overall, { ...options, skill: skillName ?? "ALL_SKILLS" });
1556
+ const label = provider === "openskills" ? "OpenSkills" : provider === "skillkit" ? "skillkit" : "skills.sh";
1557
+ const note = `${gate.gateNote} ${label} flow: ${skillName ? "single skill" : "interactive skill selection"}.`;
1558
+ if (options.json) {
1559
+ console.log(
1560
+ JSON.stringify(
1561
+ {
1562
+ command: `guardskills ${provider} ${provider === "skills" ? "add" : "install"}`,
1563
+ source,
1564
+ skill: skillName ?? null,
1565
+ scannedSkills: summaries.length,
1566
+ overallLevel: overall,
1567
+ skills: summaries,
1568
+ note
1569
+ },
1570
+ null,
1571
+ 2
1572
+ )
1573
+ );
1574
+ } else {
1575
+ console.log(`Command: guardskills ${provider} ${provider === "skills" ? "add" : "install"}`);
1576
+ console.log(`Source: ${source}`);
1577
+ console.log(`Selection: ${skillName ?? "interactive (all scanned)"}`);
1578
+ console.log(`Scanned Skills: ${summaries.length}`);
1579
+ console.log(`Decision: ${overall}`);
1580
+ console.log("Per-skill results:");
1581
+ for (const summary of summaries) {
1582
+ const score = summary.riskScore === null ? "n/a" : summary.riskScore.toFixed(1);
1583
+ console.log(`- ${summary.skill}: ${summary.level} (risk ${score})`);
1584
+ }
1585
+ console.log(`Note: ${note}`);
1586
+ }
1587
+ if (!gate.canInstall) {
1588
+ return gate.exitCode;
1589
+ }
1590
+ if (options.dryRun || options.ci) {
1591
+ return 0;
1592
+ }
1593
+ return runProviderInstall(provider, source, skillName);
1594
+ } finally {
1595
+ if (tempDir) {
1596
+ import_node_fs2.default.rmSync(tempDir, { recursive: true, force: true });
1597
+ }
1598
+ }
1599
+ }
1600
+
1601
+ // src/commands/scan-clawhub.ts
1602
+ var import_zod4 = require("zod");
1603
+
1604
+ // src/resolver/clawhub.ts
1605
+ var import_node_path5 = __toESM(require("path"), 1);
1606
+ var import_jszip = __toESM(require("jszip"), 1);
1607
+ var DEFAULT_REGISTRY_BASE_URL = "https://clawhub.ai";
1608
+ var DEFAULT_ARCHIVE_BASE_URL = "https://auth.clawdhub.com";
1609
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 15e3;
1610
+ var DEFAULT_MAX_FILE_SIZE_BYTES = 25e4;
1611
+ var DEFAULT_MAX_TOTAL_FILES = 120;
1612
+ var RETRYABLE_STATUS2 = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
1613
+ var ALLOWED_TEXT_EXTENSIONS3 = /* @__PURE__ */ new Set([
1614
+ ".md",
1615
+ ".txt",
1616
+ ".sh",
1617
+ ".bash",
1618
+ ".zsh",
1619
+ ".ps1",
1620
+ ".py",
1621
+ ".js",
1622
+ ".ts",
1623
+ ".mjs",
1624
+ ".cjs",
1625
+ ".json",
1626
+ ".yaml",
1627
+ ".yml",
1628
+ ".toml",
1629
+ ".ini",
1630
+ ".cfg"
1631
+ ]);
1632
+ function normalizeRegistryBaseUrl(input) {
1633
+ const raw = input?.trim() || DEFAULT_REGISTRY_BASE_URL;
1634
+ const withProtocol = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
1635
+ const url = new URL(withProtocol);
1636
+ const pathname = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname;
1637
+ return `${url.origin}${pathname}`;
1638
+ }
1639
+ function toApiCandidates(registryBaseUrl, identifier) {
1640
+ const encodedIdentifier = encodeURIComponent(identifier);
1641
+ const slugOnly = identifier.split("/").filter(Boolean).at(-1) ?? identifier;
1642
+ const encodedSlug = encodeURIComponent(slugOnly);
1643
+ return [
1644
+ `${registryBaseUrl}/api/v1/package/${encodedIdentifier}`,
1645
+ `${registryBaseUrl}/api/package/${encodedIdentifier}`,
1646
+ `${registryBaseUrl}/api/v1/skills/${encodedSlug}`
1647
+ ];
1648
+ }
1649
+ function tryParseClawHubUrl(input) {
1650
+ try {
1651
+ const parsed = new URL(input.trim());
1652
+ const host = parsed.hostname.toLowerCase();
1653
+ if (host !== "clawhub.ai" && host !== "www.clawhub.ai") {
1654
+ return null;
1655
+ }
1656
+ return parsed;
1657
+ } catch {
1658
+ return null;
1659
+ }
1660
+ }
1661
+ function extractIdentifierCandidates(input) {
1662
+ const trimmed = input.trim();
1663
+ if (!trimmed) {
1664
+ return [];
1665
+ }
1666
+ const candidates = [];
1667
+ const pushUnique = (value) => {
1668
+ if (!value) {
1669
+ return;
1670
+ }
1671
+ const normalized = value.trim();
1672
+ if (!normalized) {
1673
+ return;
1674
+ }
1675
+ if (!candidates.includes(normalized)) {
1676
+ candidates.push(normalized);
1677
+ }
1678
+ };
1679
+ try {
1680
+ const parsed = new URL(trimmed);
1681
+ const host = parsed.hostname.toLowerCase();
1682
+ if (host !== "clawhub.ai" && host !== "www.clawhub.ai") {
1683
+ return [trimmed];
1684
+ }
1685
+ const segments = parsed.pathname.split("/").filter(Boolean);
1686
+ if (segments.length >= 2 && segments[0] && segments[1]) {
1687
+ pushUnique(`${segments[0]}/${segments[1]}`);
1688
+ }
1689
+ if (segments.length >= 3 && segments[0]?.toLowerCase() === "skills" && segments[1] && segments[2]) {
1690
+ pushUnique(`${segments[1]}/${segments[2]}`);
1691
+ }
1692
+ if (segments.length > 0) {
1693
+ pushUnique(segments.join("/"));
1694
+ const last = segments.at(-1);
1695
+ pushUnique(last);
1696
+ }
1697
+ } catch {
1698
+ pushUnique(trimmed);
1699
+ }
1700
+ return candidates;
1701
+ }
1702
+ function mapClawHubError(error, operation) {
1703
+ if (error instanceof GuardSkillsError) {
1704
+ return error;
1705
+ }
1706
+ const status = typeof error === "object" && error !== null && "status" in error ? error.status : void 0;
1707
+ const message = typeof error === "object" && error !== null && "message" in error ? String(error.message) : String(error);
1708
+ const lowerMessage = message.toLowerCase();
1709
+ if (status === 401 || status === 403) {
1710
+ return new GuardSkillsError(
1711
+ "CLAWHUB_AUTH",
1712
+ `${operation} failed: authentication/authorization error from ClawHub.`,
1713
+ { status, cause: error }
1714
+ );
1715
+ }
1716
+ if (status === 404) {
1717
+ return new GuardSkillsError("CLAWHUB_NOT_FOUND", `${operation} failed: resource not found.`, {
1718
+ status,
1719
+ cause: error
1720
+ });
1721
+ }
1722
+ if (status !== void 0 && RETRYABLE_STATUS2.has(status)) {
1723
+ return new GuardSkillsError(
1724
+ status === 429 ? "CLAWHUB_RATE_LIMIT" : "CLAWHUB_TRANSIENT",
1725
+ `${operation} failed with retryable ClawHub status ${status}.`,
1726
+ { status, retryable: true, cause: error }
1727
+ );
1728
+ }
1729
+ if (lowerMessage.includes("timeout") || lowerMessage.includes("timed out") || lowerMessage.includes("abort")) {
1730
+ return new GuardSkillsError("CLAWHUB_TIMEOUT", `${operation} timed out while calling ClawHub API.`, {
1731
+ retryable: true,
1732
+ cause: error
1733
+ });
1734
+ }
1735
+ return new GuardSkillsError("CLAWHUB_UNKNOWN", `${operation} failed: ${message}`, {
1736
+ status,
1737
+ cause: error
1738
+ });
1739
+ }
1740
+ function unwrapResponsePayload(payload) {
1741
+ if (!payload || typeof payload !== "object") {
1742
+ throw new GuardSkillsError("CLAWHUB_UNKNOWN", "ClawHub returned a non-object JSON payload.");
1743
+ }
1744
+ const objectPayload = payload;
1745
+ if (objectPayload.data && typeof objectPayload.data === "object") {
1746
+ return objectPayload.data;
1747
+ }
1748
+ return objectPayload;
1749
+ }
1750
+ async function fetchJson(url, timeoutMs) {
1751
+ const controller = new AbortController();
1752
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1753
+ try {
1754
+ const response = await fetch(url, {
1755
+ method: "GET",
1756
+ headers: { accept: "application/json" },
1757
+ signal: controller.signal
1758
+ });
1759
+ if (!response.ok) {
1760
+ throw { status: response.status, message: `${response.status} ${response.statusText}` };
1761
+ }
1762
+ const payload = await response.json();
1763
+ return unwrapResponsePayload(payload);
1764
+ } catch (error) {
1765
+ throw mapClawHubError(error, `ClawHub metadata fetch (${url})`);
1766
+ } finally {
1767
+ clearTimeout(timeout);
1768
+ }
1769
+ }
1770
+ async function fetchText(url, timeoutMs) {
1771
+ const controller = new AbortController();
1772
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1773
+ try {
1774
+ const response = await fetch(url, {
1775
+ method: "GET",
1776
+ headers: { accept: "text/html,application/xhtml+xml" },
1777
+ signal: controller.signal
1778
+ });
1779
+ if (!response.ok) {
1780
+ throw { status: response.status, message: `${response.status} ${response.statusText}` };
1781
+ }
1782
+ return await response.text();
1783
+ } catch (error) {
1784
+ throw mapClawHubError(error, `ClawHub page fetch (${url})`);
1785
+ } finally {
1786
+ clearTimeout(timeout);
1787
+ }
1788
+ }
1789
+ async function fetchArrayBuffer(url, timeoutMs) {
1790
+ const controller = new AbortController();
1791
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
1792
+ try {
1793
+ const response = await fetch(url, {
1794
+ method: "GET",
1795
+ signal: controller.signal
1796
+ });
1797
+ if (!response.ok) {
1798
+ throw { status: response.status, message: `${response.status} ${response.statusText}` };
1799
+ }
1800
+ return await response.arrayBuffer();
1801
+ } catch (error) {
1802
+ throw mapClawHubError(error, `ClawHub archive fetch (${url})`);
1803
+ } finally {
1804
+ clearTimeout(timeout);
1805
+ }
1806
+ }
1807
+ function normalizeRepoRef(candidate) {
1808
+ const trimmed = candidate.trim();
1809
+ if (!trimmed) {
1810
+ return null;
1811
+ }
1812
+ const shorthand = trimmed.match(/^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/);
1813
+ if (shorthand && shorthand[1] && shorthand[2]) {
1814
+ return `${shorthand[1]}/${shorthand[2].replace(/\.git$/i, "")}`;
1815
+ }
1816
+ try {
1817
+ const parsed = new URL(trimmed);
1818
+ if (!(parsed.hostname === "github.com" || parsed.hostname === "www.github.com")) {
1819
+ return null;
1820
+ }
1821
+ const parts = parsed.pathname.split("/").filter(Boolean);
1822
+ if (parts.length < 2 || !parts[0] || !parts[1]) {
1823
+ return null;
1824
+ }
1825
+ return `${parts[0]}/${parts[1].replace(/\.git$/i, "")}`;
1826
+ } catch {
1827
+ return null;
1828
+ }
1829
+ }
1830
+ function getNestedString(obj, ...keys) {
1831
+ let cursor = obj;
1832
+ for (const key of keys) {
1833
+ if (!cursor || typeof cursor !== "object" || !(key in cursor)) {
1834
+ return void 0;
1835
+ }
1836
+ cursor = cursor[key];
1837
+ }
1838
+ return typeof cursor === "string" && cursor.trim() ? cursor.trim() : void 0;
1839
+ }
1840
+ function getNestedBoolean(obj, ...keys) {
1841
+ let cursor = obj;
1842
+ for (const key of keys) {
1843
+ if (!cursor || typeof cursor !== "object" || !(key in cursor)) {
1844
+ return void 0;
1845
+ }
1846
+ cursor = cursor[key];
1847
+ }
1848
+ return typeof cursor === "boolean" ? cursor : void 0;
1849
+ }
1850
+ function isLikelyTextFile2(filePath) {
1851
+ const lower = filePath.toLowerCase();
1852
+ if (lower.endsWith("/skill.md") || lower === "skill.md") {
1853
+ return true;
1854
+ }
1855
+ const ext = import_node_path5.default.posix.extname(lower);
1856
+ return ALLOWED_TEXT_EXTENSIONS3.has(ext);
1857
+ }
1858
+ function isBinaryContent2(content) {
1859
+ return content.includes("\0");
1860
+ }
1861
+ function collectGitHubRepos(value, collected, seen) {
1862
+ if (value === null || value === void 0) {
1863
+ return;
1864
+ }
1865
+ if (typeof value === "string") {
1866
+ const repo = normalizeRepoRef(value);
1867
+ if (repo) {
1868
+ collected.add(repo);
1869
+ }
1870
+ return;
1871
+ }
1872
+ if (typeof value !== "object") {
1873
+ return;
1874
+ }
1875
+ if (seen.has(value)) {
1876
+ return;
1877
+ }
1878
+ seen.add(value);
1879
+ if (Array.isArray(value)) {
1880
+ for (const item of value) {
1881
+ collectGitHubRepos(item, collected, seen);
1882
+ }
1883
+ return;
1884
+ }
1885
+ for (const nested of Object.values(value)) {
1886
+ collectGitHubRepos(nested, collected, seen);
1887
+ }
1888
+ }
1889
+ function collectPossibleSkillNames(identifier, metadata, override) {
1890
+ const names = /* @__PURE__ */ new Set();
1891
+ if (override?.trim()) {
1892
+ names.add(override.trim());
1893
+ }
1894
+ const lastSegment = identifier.split(/[/:@]/).filter(Boolean).at(-1);
1895
+ if (lastSegment) {
1896
+ names.add(lastSegment);
1897
+ }
1898
+ const maybePush = (value) => {
1899
+ if (typeof value === "string" && value.trim()) {
1900
+ names.add(value.trim());
1901
+ }
1902
+ };
1903
+ maybePush(metadata.skill);
1904
+ maybePush(metadata.slug);
1905
+ maybePush(metadata.name);
1906
+ maybePush(metadata.id);
1907
+ return [...names];
1908
+ }
1909
+ function parseArchiveMetadata(metadata, identifier, requestedVersion) {
1910
+ const ownerFromMetadata = getNestedString(metadata, "owner", "handle") ?? getNestedString(metadata, "owner") ?? getNestedString(metadata, "author") ?? getNestedString(metadata, "publisher");
1911
+ const slugFromMetadata = getNestedString(metadata, "skill", "slug") ?? getNestedString(metadata, "slug") ?? getNestedString(metadata, "name");
1912
+ const versionFromMetadata = requestedVersion ?? getNestedString(metadata, "latestVersion", "version") ?? getNestedString(metadata, "version");
1913
+ const identifierParts = identifier.split("/").filter(Boolean);
1914
+ const ownerFromIdentifier = identifierParts.length >= 2 ? identifierParts[0] : void 0;
1915
+ const slugFromIdentifier = identifierParts.length > 0 ? identifierParts.at(-1) : void 0;
1916
+ const owner = ownerFromMetadata ?? ownerFromIdentifier;
1917
+ const slug = slugFromMetadata ?? slugFromIdentifier;
1918
+ if (!owner || !slug) {
1919
+ return null;
1920
+ }
1921
+ return { owner, slug, version: versionFromMetadata };
1922
+ }
1923
+ function parseClawHubModeration(metadata) {
1924
+ const isSuspicious = getNestedBoolean(metadata, "moderation", "isSuspicious") ?? getNestedBoolean(metadata, "isSuspicious");
1925
+ const isMalwareBlocked = getNestedBoolean(metadata, "moderation", "isMalwareBlocked") ?? getNestedBoolean(metadata, "isMalwareBlocked");
1926
+ const isRemoved = getNestedBoolean(metadata, "moderation", "isRemoved") ?? getNestedBoolean(metadata, "isRemoved");
1927
+ if (isSuspicious === void 0 && isMalwareBlocked === void 0 && isRemoved === void 0) {
1928
+ return void 0;
1929
+ }
1930
+ return { isSuspicious, isMalwareBlocked, isRemoved };
1931
+ }
1932
+ async function resolveSkillFromArchive(metadata, identifier, options) {
1933
+ const archiveMeta = parseArchiveMetadata(metadata, identifier, options.version);
1934
+ if (!archiveMeta) {
1935
+ return null;
1936
+ }
1937
+ const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2;
1938
+ const maxFileSizeBytes = options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE_BYTES;
1939
+ const maxTotalFiles = options.maxTotalFiles ?? DEFAULT_MAX_TOTAL_FILES;
1940
+ const params = new URLSearchParams({ slug: archiveMeta.slug });
1941
+ if (archiveMeta.version) {
1942
+ params.set("version", archiveMeta.version);
1943
+ }
1944
+ const downloadUrl = `${DEFAULT_ARCHIVE_BASE_URL}/api/v1/download?${params.toString()}`;
1945
+ const archiveBuffer = await fetchArrayBuffer(downloadUrl, timeoutMs);
1946
+ const zip = await import_jszip.default.loadAsync(Buffer.from(archiveBuffer));
1947
+ const files = new Array();
1948
+ const unverifiableReasons = [];
1949
+ let skillFilePath = null;
1950
+ const entries = Object.values(zip.files);
1951
+ for (const entry of entries) {
1952
+ if (entry.dir) {
1953
+ continue;
1954
+ }
1955
+ const normalizedPath = entry.name.replace(/\\/g, "/");
1956
+ if (!isLikelyTextFile2(normalizedPath)) {
1957
+ continue;
1958
+ }
1959
+ if (files.length >= maxTotalFiles) {
1960
+ unverifiableReasons.push(
1961
+ `Resolved archive file count exceeds maxTotalFiles=${maxTotalFiles}; scan truncated.`
1962
+ );
1963
+ break;
1964
+ }
1965
+ const contentBuffer = await entry.async("nodebuffer");
1966
+ if (contentBuffer.length > maxFileSizeBytes) {
1967
+ unverifiableReasons.push(
1968
+ `File too large to scan safely: ${normalizedPath}`
1969
+ );
1970
+ continue;
1971
+ }
1972
+ const content = contentBuffer.toString("utf8");
1973
+ if (isBinaryContent2(content)) {
1974
+ unverifiableReasons.push(`Binary content detected: ${normalizedPath}`);
1975
+ continue;
1976
+ }
1977
+ files.push({ path: normalizedPath, content });
1978
+ if (!skillFilePath && normalizedPath.toLowerCase().endsWith("/skill.md")) {
1979
+ skillFilePath = normalizedPath;
1980
+ }
1981
+ if (!skillFilePath && normalizedPath.toLowerCase() === "skill.md") {
1982
+ skillFilePath = normalizedPath;
1983
+ }
1984
+ }
1985
+ if (!skillFilePath) {
1986
+ skillFilePath = files.find((file) => file.path.toLowerCase().endsWith("skill.md"))?.path ?? null;
1987
+ }
1988
+ if (!skillFilePath || files.length === 0) {
1989
+ return null;
1990
+ }
1991
+ const moderation = parseClawHubModeration(metadata);
1992
+ return {
1993
+ source: `clawhub:${archiveMeta.owner}/${archiveMeta.slug}${archiveMeta.version ? `@${archiveMeta.version}` : ""}`,
1994
+ owner: archiveMeta.owner,
1995
+ repo: "clawhub-archive",
1996
+ defaultBranch: "archive",
1997
+ commitSha: archiveMeta.version ?? "archive",
1998
+ skillName: options.skillNameOverride ?? archiveMeta.slug,
1999
+ skillDir: import_node_path5.default.posix.dirname(skillFilePath),
2000
+ skillFilePath,
2001
+ files,
2002
+ unverifiableReasons: [...unverifiableReasons],
2003
+ sourceMetadata: moderation ? {
2004
+ clawhubModeration: moderation
2005
+ } : void 0
2006
+ };
2007
+ }
2008
+ function extractMetadataFromClawHubPage(html, pageUrl) {
2009
+ const metadata = {};
2010
+ const githubUrlMatch = html.match(/https?:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)/i);
2011
+ if (githubUrlMatch?.[1] && githubUrlMatch[2]) {
2012
+ metadata.repository = `https://github.com/${githubUrlMatch[1]}/${githubUrlMatch[2]}`;
2013
+ }
2014
+ const directRepoMatch = html.match(/"repository"\s*:\s*"([^"]+)"/i);
2015
+ if (directRepoMatch?.[1]) {
2016
+ metadata.repository = directRepoMatch[1];
2017
+ }
2018
+ const pathnameParts = pageUrl.pathname.split("/").filter(Boolean);
2019
+ if (pathnameParts.length >= 2 && pathnameParts[1]) {
2020
+ metadata.skill = pathnameParts[1];
2021
+ }
2022
+ return Object.keys(metadata).length > 0 ? metadata : null;
2023
+ }
2024
+ async function resolveFromClawHubPageUrl(input, timeoutMs) {
2025
+ const parsed = tryParseClawHubUrl(input);
2026
+ if (!parsed) {
2027
+ return null;
2028
+ }
2029
+ const html = await fetchText(parsed.toString(), timeoutMs);
2030
+ return extractMetadataFromClawHubPage(html, parsed);
2031
+ }
2032
+ async function resolveSkillFromClawHub(identifier, options = {}) {
2033
+ const identifierCandidates = extractIdentifierCandidates(identifier);
2034
+ if (identifierCandidates.length === 0) {
2035
+ throw new GuardSkillsError("CLAWHUB_UNKNOWN", "ClawHub identifier is required.");
2036
+ }
2037
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2;
2038
+ const registryBaseUrl = normalizeRegistryBaseUrl(options.registryBaseUrl);
2039
+ let metadata = null;
2040
+ let resolvedIdentifier = null;
2041
+ let lastError = null;
2042
+ for (const identifierCandidate of identifierCandidates) {
2043
+ const candidateUrls = toApiCandidates(registryBaseUrl, identifierCandidate);
2044
+ for (const url of candidateUrls) {
2045
+ try {
2046
+ metadata = await fetchJson(url, requestTimeoutMs);
2047
+ resolvedIdentifier = identifierCandidate;
2048
+ break;
2049
+ } catch (error) {
2050
+ const mapped = mapClawHubError(error, "ClawHub package lookup");
2051
+ lastError = mapped;
2052
+ if (mapped.code !== "CLAWHUB_NOT_FOUND") {
2053
+ break;
2054
+ }
2055
+ }
2056
+ }
2057
+ if (metadata) {
2058
+ break;
2059
+ }
2060
+ }
2061
+ if (!metadata) {
2062
+ try {
2063
+ metadata = await resolveFromClawHubPageUrl(identifier, requestTimeoutMs);
2064
+ if (metadata) {
2065
+ resolvedIdentifier = identifierCandidates[0] ?? identifier;
2066
+ }
2067
+ } catch (error) {
2068
+ lastError = mapClawHubError(error, "ClawHub skill page fallback");
2069
+ }
2070
+ }
2071
+ if (!metadata) {
2072
+ if (lastError) {
2073
+ throw lastError;
2074
+ }
2075
+ throw new GuardSkillsError(
2076
+ "CLAWHUB_NOT_FOUND",
2077
+ `Unable to resolve '${identifier}' from ClawHub. Candidates tried: ${identifierCandidates.join(", ")}.`
2078
+ );
2079
+ }
2080
+ const canonicalIdentifier = resolvedIdentifier ?? identifierCandidates[0] ?? identifier;
2081
+ const repos = /* @__PURE__ */ new Set();
2082
+ collectGitHubRepos(metadata, repos, /* @__PURE__ */ new Set());
2083
+ const skillCandidates = collectPossibleSkillNames(canonicalIdentifier, metadata, options.skillNameOverride);
2084
+ if (skillCandidates.length === 0) {
2085
+ throw new GuardSkillsError(
2086
+ "CLAWHUB_UNKNOWN",
2087
+ `Could not infer skill name for ClawHub package '${canonicalIdentifier}'. Use --skill.`
2088
+ );
2089
+ }
2090
+ let resolved = null;
2091
+ let resolveError;
2092
+ const githubOptions = {
2093
+ requestTimeoutMs: options.requestTimeoutMs,
2094
+ maxFileSizeBytes: options.maxFileSizeBytes,
2095
+ maxAuxFiles: options.maxAuxFiles,
2096
+ maxTotalFiles: options.maxTotalFiles,
2097
+ retries: options.retries,
2098
+ retryBaseDelayMs: options.retryBaseDelayMs
2099
+ };
2100
+ if (repos.size > 0) {
2101
+ for (const repo of repos) {
2102
+ for (const skillName of skillCandidates) {
2103
+ try {
2104
+ resolved = await resolveSkillFromGitHub(repo, skillName, githubOptions);
2105
+ break;
2106
+ } catch (error) {
2107
+ resolveError = error;
2108
+ }
2109
+ }
2110
+ if (resolved) {
2111
+ break;
2112
+ }
2113
+ }
2114
+ }
2115
+ if (!resolved) {
2116
+ const archiveResolved = await resolveSkillFromArchive(metadata, canonicalIdentifier, options);
2117
+ if (archiveResolved) {
2118
+ return archiveResolved;
2119
+ }
2120
+ if (repos.size === 0) {
2121
+ throw new GuardSkillsError(
2122
+ "CLAWHUB_UNKNOWN",
2123
+ `ClawHub package '${canonicalIdentifier}' did not expose a resolvable GitHub source or archive payload.`
2124
+ );
2125
+ }
2126
+ throw new GuardSkillsError(
2127
+ "SKILL_NOT_FOUND",
2128
+ `Unable to map ClawHub package '${canonicalIdentifier}' to a GitHub skill. Repos tried: ${[...repos].join(", ")}. Skill names tried: ${skillCandidates.join(", ")}.`,
2129
+ { cause: resolveError }
2130
+ );
2131
+ }
2132
+ const packageVersion = options.version ?? (typeof metadata.version === "string" ? metadata.version : void 0);
2133
+ const sourceSuffix = packageVersion ? `${canonicalIdentifier}@${packageVersion}` : canonicalIdentifier;
2134
+ const moderation = parseClawHubModeration(metadata);
2135
+ return {
2136
+ ...resolved,
2137
+ source: `clawhub:${sourceSuffix}`,
2138
+ unverifiableReasons: [...resolved.unverifiableReasons],
2139
+ sourceMetadata: moderation ? {
2140
+ ...resolved.sourceMetadata ?? {},
2141
+ clawhubModeration: moderation
2142
+ } : resolved.sourceMetadata
2143
+ };
2144
+ }
2145
+
2146
+ // src/commands/scan-clawhub.ts
2147
+ var cliScanClawHubOptionsSchema = import_zod4.z.object({
2148
+ config: import_zod4.z.string().optional(),
2149
+ strict: import_zod4.z.boolean().optional(),
2150
+ json: import_zod4.z.boolean().optional(),
2151
+ skill: import_zod4.z.string().min(1).optional(),
2152
+ version: import_zod4.z.string().min(1).optional(),
2153
+ clawhubRegistry: import_zod4.z.string().min(1).optional(),
2154
+ githubTimeoutMs: import_zod4.z.coerce.number().int().min(1e3).max(12e4).optional(),
2155
+ githubRetries: import_zod4.z.coerce.number().int().min(0).max(6).optional(),
2156
+ githubRetryBaseMs: import_zod4.z.coerce.number().int().min(50).max(5e3).optional(),
2157
+ maxFileBytes: import_zod4.z.coerce.number().int().min(4096).max(5e6).optional(),
2158
+ maxAuxFiles: import_zod4.z.coerce.number().int().min(1).max(200).optional(),
2159
+ maxTotalFiles: import_zod4.z.coerce.number().int().min(1).max(400).optional()
2160
+ });
2161
+ var effectiveScanClawHubOptionsSchema = import_zod4.z.object({
2162
+ strict: import_zod4.z.boolean(),
2163
+ json: import_zod4.z.boolean(),
2164
+ skill: import_zod4.z.string().min(1).optional(),
2165
+ version: import_zod4.z.string().min(1).optional(),
2166
+ clawhubRegistry: import_zod4.z.string().min(1),
2167
+ githubTimeoutMs: import_zod4.z.number().int().min(1e3).max(12e4),
2168
+ githubRetries: import_zod4.z.number().int().min(0).max(6),
2169
+ githubRetryBaseMs: import_zod4.z.number().int().min(50).max(5e3),
2170
+ maxFileBytes: import_zod4.z.number().int().min(4096).max(5e6),
2171
+ maxAuxFiles: import_zod4.z.number().int().min(1).max(200),
2172
+ maxTotalFiles: import_zod4.z.number().int().min(1).max(400)
2173
+ });
2174
+ var DEFAULT_OPTIONS3 = {
2175
+ strict: false,
2176
+ json: false,
2177
+ skill: void 0,
2178
+ version: void 0,
2179
+ clawhubRegistry: "https://clawhub.ai",
2180
+ githubTimeoutMs: 15e3,
2181
+ githubRetries: 2,
2182
+ githubRetryBaseMs: 300,
2183
+ maxFileBytes: 25e4,
2184
+ maxAuxFiles: 40,
2185
+ maxTotalFiles: 120
2186
+ };
2187
+ function alignWithClawHubModeration(baseDecision, moderation) {
2188
+ if (!moderation) {
2189
+ return { decision: baseDecision };
2190
+ }
2191
+ if (moderation.isMalwareBlocked) {
2192
+ return {
2193
+ decision: {
2194
+ ...baseDecision,
2195
+ riskScore: 100,
2196
+ safetyScore: 0,
2197
+ level: "CRITICAL",
2198
+ reason: "ClawHub moderation marked this skill as malware-blocked."
2199
+ },
2200
+ moderationNote: "Aligned with ClawHub moderation: malware-blocked."
2201
+ };
2202
+ }
2203
+ if (moderation.isSuspicious && baseDecision.level === "SAFE") {
2204
+ const adjustedRisk = Math.max(baseDecision.riskScore ?? 0, 30);
2205
+ return {
2206
+ decision: {
2207
+ ...baseDecision,
2208
+ riskScore: adjustedRisk,
2209
+ safetyScore: 100 - adjustedRisk,
2210
+ level: "WARNING",
2211
+ reason: "ClawHub moderation marked this skill as suspicious."
2212
+ },
2213
+ moderationNote: "Aligned with ClawHub moderation: suspicious."
2214
+ };
2215
+ }
2216
+ return { decision: baseDecision };
2217
+ }
2218
+ function resolveEffectiveScanClawHubOptions(cliOptions, config) {
2219
+ const defaults = config.defaults ?? {};
2220
+ const resolver = config.resolver ?? {};
2221
+ return effectiveScanClawHubOptionsSchema.parse({
2222
+ strict: cliOptions.strict ?? defaults.strict ?? DEFAULT_OPTIONS3.strict,
2223
+ json: cliOptions.json ?? defaults.json ?? DEFAULT_OPTIONS3.json,
2224
+ skill: cliOptions.skill ?? DEFAULT_OPTIONS3.skill,
2225
+ version: cliOptions.version ?? DEFAULT_OPTIONS3.version,
2226
+ clawhubRegistry: cliOptions.clawhubRegistry ?? DEFAULT_OPTIONS3.clawhubRegistry,
2227
+ githubTimeoutMs: cliOptions.githubTimeoutMs ?? resolver.githubTimeoutMs ?? DEFAULT_OPTIONS3.githubTimeoutMs,
2228
+ githubRetries: cliOptions.githubRetries ?? resolver.githubRetries ?? DEFAULT_OPTIONS3.githubRetries,
2229
+ githubRetryBaseMs: cliOptions.githubRetryBaseMs ?? resolver.githubRetryBaseMs ?? DEFAULT_OPTIONS3.githubRetryBaseMs,
2230
+ maxFileBytes: cliOptions.maxFileBytes ?? resolver.maxFileBytes ?? DEFAULT_OPTIONS3.maxFileBytes,
2231
+ maxAuxFiles: cliOptions.maxAuxFiles ?? resolver.maxAuxFiles ?? DEFAULT_OPTIONS3.maxAuxFiles,
2232
+ maxTotalFiles: cliOptions.maxTotalFiles ?? resolver.maxTotalFiles ?? DEFAULT_OPTIONS3.maxTotalFiles
2233
+ });
2234
+ }
2235
+ async function runScanClawHubCommand(identifier, rawOptions) {
2236
+ const cliOptions = cliScanClawHubOptionsSchema.parse(rawOptions);
2237
+ const loadedConfig = loadGuardSkillsConfig(cliOptions.config);
2238
+ const options = resolveEffectiveScanClawHubOptions(cliOptions, loadedConfig.config);
2239
+ const resolved = await resolveSkillFromClawHub(identifier, {
2240
+ registryBaseUrl: options.clawhubRegistry,
2241
+ version: options.version,
2242
+ skillNameOverride: options.skill,
2243
+ requestTimeoutMs: options.githubTimeoutMs,
2244
+ retries: options.githubRetries,
2245
+ retryBaseDelayMs: options.githubRetryBaseMs,
2246
+ maxFileSizeBytes: options.maxFileBytes,
2247
+ maxAuxFiles: options.maxAuxFiles,
2248
+ maxTotalFiles: options.maxTotalFiles
2249
+ });
2250
+ const scan = scanResolvedSkill(resolved);
2251
+ const baseDecision = calculateRiskScore(scan.findings, {
2252
+ strict: options.strict,
2253
+ trustCredits: 0,
2254
+ hasUnverifiableContent: scan.hasUnverifiableContent
2255
+ });
2256
+ const moderation = resolved.sourceMetadata?.clawhubModeration;
2257
+ const { decision, moderationNote } = alignWithClawHubModeration(baseDecision, moderation);
2258
+ const noteParts = ["ClawHub scan complete."];
2259
+ if (moderationNote) {
2260
+ noteParts.push(moderationNote);
2261
+ }
2262
+ if (decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE") {
2263
+ noteParts.push("Blocked-level risk detected.");
2264
+ }
2265
+ if (loadedConfig.path) {
2266
+ noteParts.push(`Config: ${loadedConfig.path}`);
2267
+ }
2268
+ const report = {
2269
+ command: "guardskills scan-clawhub",
2270
+ identifier,
2271
+ registry: options.clawhubRegistry,
2272
+ strict: options.strict,
2273
+ configPath: loadedConfig.path ?? void 0,
2274
+ decision,
2275
+ scanFiles: resolved.files.map((file) => file.path),
2276
+ skillDir: resolved.skillDir,
2277
+ repo: `${resolved.owner}/${resolved.repo}`,
2278
+ skill: resolved.skillName,
2279
+ version: options.version,
2280
+ commitSha: resolved.commitSha,
2281
+ moderation,
2282
+ unverifiableReasons: scan.unverifiableReasons,
2283
+ note: noteParts.join(" ")
2284
+ };
2285
+ if (options.json) {
2286
+ printJsonClawHubReport(report);
2287
+ } else {
2288
+ printHumanClawHubReport(report);
2289
+ }
2290
+ return decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE" ? 20 : 0;
2291
+ }
2292
+
2293
+ // src/commands/scan-local.ts
2294
+ var import_node_fs3 = __toESM(require("fs"), 1);
2295
+ var import_node_path6 = __toESM(require("path"), 1);
2296
+ var import_zod5 = require("zod");
2297
+ var ALLOWED_TEXT_EXTENSIONS4 = /* @__PURE__ */ new Set([
2298
+ ".md",
2299
+ ".txt",
2300
+ ".sh",
2301
+ ".bash",
2302
+ ".zsh",
2303
+ ".ps1",
2304
+ ".py",
2305
+ ".js",
2306
+ ".ts",
2307
+ ".mjs",
2308
+ ".cjs",
2309
+ ".json",
2310
+ ".yaml",
2311
+ ".yml",
2312
+ ".toml",
2313
+ ".ini",
2314
+ ".cfg"
2315
+ ]);
2316
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".turbo"]);
2317
+ var cliScanLocalOptionsSchema = import_zod5.z.object({
2318
+ config: import_zod5.z.string().optional(),
2319
+ strict: import_zod5.z.boolean().optional(),
2320
+ json: import_zod5.z.boolean().optional(),
2321
+ skill: import_zod5.z.string().min(1).optional(),
2322
+ maxFileBytes: import_zod5.z.coerce.number().int().min(4096).max(5e6).optional(),
2323
+ maxTotalFiles: import_zod5.z.coerce.number().int().min(1).max(400).optional()
2324
+ });
2325
+ var effectiveScanLocalOptionsSchema = import_zod5.z.object({
2326
+ strict: import_zod5.z.boolean(),
2327
+ json: import_zod5.z.boolean(),
2328
+ skill: import_zod5.z.string().min(1).optional(),
2329
+ maxFileBytes: import_zod5.z.number().int().min(4096).max(5e6),
2330
+ maxTotalFiles: import_zod5.z.number().int().min(1).max(400)
2331
+ });
2332
+ var DEFAULT_OPTIONS4 = {
2333
+ strict: false,
2334
+ json: false,
2335
+ skill: void 0,
2336
+ maxFileBytes: 25e4,
2337
+ maxTotalFiles: 120
2338
+ };
2339
+ function toPosixPath(filePath) {
2340
+ return filePath.replace(/\\/g, "/");
2341
+ }
2342
+ function getNearbyPathSuggestions(targetPath) {
2343
+ const parent = import_node_path6.default.dirname(targetPath);
2344
+ if (!import_node_fs3.default.existsSync(parent)) {
2345
+ return [];
2346
+ }
2347
+ const needle = import_node_path6.default.basename(targetPath).toLowerCase();
2348
+ const suggestions = [];
2349
+ for (const entry of import_node_fs3.default.readdirSync(parent, { withFileTypes: true })) {
2350
+ if (entry.name.toLowerCase().includes(needle)) {
2351
+ suggestions.push(import_node_path6.default.join(parent, entry.name));
2352
+ }
2353
+ }
2354
+ return suggestions.slice(0, 5);
2355
+ }
2356
+ function isSkillFile(filePath) {
2357
+ return import_node_path6.default.basename(filePath).toLowerCase() === "skill.md";
2358
+ }
2359
+ function isScannableTextFile(filePath) {
2360
+ if (isSkillFile(filePath)) {
2361
+ return true;
2362
+ }
2363
+ const ext = import_node_path6.default.extname(filePath).toLowerCase();
2364
+ return ALLOWED_TEXT_EXTENSIONS4.has(ext);
2365
+ }
2366
+ function findSkillDirs2(rootDir) {
2367
+ const found = /* @__PURE__ */ new Set();
2368
+ const stack = [{ dir: rootDir, depth: 0 }];
2369
+ let seen = 0;
2370
+ while (stack.length > 0) {
2371
+ const current = stack.pop();
2372
+ if (!current) {
2373
+ continue;
2374
+ }
2375
+ seen += 1;
2376
+ if (seen > 5e3) {
2377
+ break;
2378
+ }
2379
+ const skillFile = import_node_path6.default.join(current.dir, "SKILL.md");
2380
+ if (import_node_fs3.default.existsSync(skillFile) && import_node_fs3.default.statSync(skillFile).isFile()) {
2381
+ found.add(current.dir);
2382
+ continue;
2383
+ }
2384
+ if (current.depth >= 8) {
2385
+ continue;
2386
+ }
2387
+ let entries;
2388
+ try {
2389
+ entries = import_node_fs3.default.readdirSync(current.dir, { withFileTypes: true });
2390
+ } catch {
2391
+ continue;
2392
+ }
2393
+ for (const entry of entries) {
2394
+ if (!entry.isDirectory()) {
2395
+ continue;
2396
+ }
2397
+ if (SKIP_DIRS2.has(entry.name)) {
2398
+ continue;
2399
+ }
2400
+ stack.push({ dir: import_node_path6.default.join(current.dir, entry.name), depth: current.depth + 1 });
2401
+ }
2402
+ }
2403
+ return [...found].sort();
2404
+ }
2405
+ function formatCandidates(candidates) {
2406
+ return candidates.map((candidate) => `- ${toPosixPath(candidate)}`).join("\n");
2407
+ }
2408
+ function resolveSkillDirectory(inputPath, preferredSkillName) {
2409
+ const absoluteInput = import_node_path6.default.resolve(inputPath);
2410
+ if (!import_node_fs3.default.existsSync(absoluteInput)) {
2411
+ const suggestions = getNearbyPathSuggestions(absoluteInput);
2412
+ const suggestionText = suggestions.length > 0 ? `
2413
+ Nearby paths:
2414
+ ${formatCandidates(suggestions)}` : "";
2415
+ throw new GuardSkillsError(
2416
+ "INVALID_LOCAL_PATH",
2417
+ `Local path not found: ${toPosixPath(absoluteInput)}${suggestionText}`
2418
+ );
2419
+ }
2420
+ const stat = import_node_fs3.default.statSync(absoluteInput);
2421
+ if (stat.isFile()) {
2422
+ if (!isSkillFile(absoluteInput)) {
2423
+ throw new GuardSkillsError(
2424
+ "INVALID_LOCAL_PATH",
2425
+ "Local scan expects a directory or a SKILL.md file path."
2426
+ );
2427
+ }
2428
+ return {
2429
+ skillDir: import_node_path6.default.dirname(absoluteInput),
2430
+ note: "Using parent directory of provided SKILL.md file."
2431
+ };
2432
+ }
2433
+ const directSkillFile = import_node_path6.default.join(absoluteInput, "SKILL.md");
2434
+ if (import_node_fs3.default.existsSync(directSkillFile) && import_node_fs3.default.statSync(directSkillFile).isFile()) {
2435
+ return { skillDir: absoluteInput };
2436
+ }
2437
+ const discovered = findSkillDirs2(absoluteInput);
2438
+ if (discovered.length === 0) {
2439
+ throw new GuardSkillsError(
2440
+ "INVALID_LOCAL_PATH",
2441
+ `No SKILL.md found under: ${toPosixPath(absoluteInput)}`
2442
+ );
2443
+ }
2444
+ if (preferredSkillName) {
2445
+ const matches = discovered.filter(
2446
+ (directory) => import_node_path6.default.basename(directory).toLowerCase() === preferredSkillName.toLowerCase()
2447
+ );
2448
+ if (matches.length === 1) {
2449
+ const selected = matches[0];
2450
+ if (!selected) {
2451
+ throw new GuardSkillsError("INVALID_LOCAL_PATH", "Unable to resolve selected local skill.");
2452
+ }
2453
+ return {
2454
+ skillDir: selected,
2455
+ note: `Auto-selected skill '${preferredSkillName}' under the provided path.`
2456
+ };
2457
+ }
2458
+ const available = discovered.map((directory) => import_node_path6.default.basename(directory));
2459
+ throw new GuardSkillsError(
2460
+ "INVALID_LOCAL_PATH",
2461
+ `Requested --skill '${preferredSkillName}' was not found.
2462
+ Available skills: ${available.join(", ")}`
2463
+ );
2464
+ }
2465
+ if (discovered.length === 1) {
2466
+ const selected = discovered[0];
2467
+ if (!selected) {
2468
+ throw new GuardSkillsError("INVALID_LOCAL_PATH", "Unable to resolve discovered local skill.");
2469
+ }
2470
+ return {
2471
+ skillDir: selected,
2472
+ note: "Auto-selected the only SKILL.md found under the provided path."
2473
+ };
2474
+ }
2475
+ throw new GuardSkillsError(
2476
+ "INVALID_LOCAL_PATH",
2477
+ `Multiple skills found under path. Provide --skill <name>.
2478
+ ${formatCandidates(discovered)}`
2479
+ );
2480
+ }
2481
+ function collectLocalFiles2(skillDir, options) {
2482
+ const files = [];
2483
+ const unverifiableReasons = [];
2484
+ const stack = [skillDir];
2485
+ while (stack.length > 0) {
2486
+ const currentDir = stack.pop();
2487
+ if (!currentDir) {
2488
+ continue;
2489
+ }
2490
+ let entries;
2491
+ try {
2492
+ entries = import_node_fs3.default.readdirSync(currentDir, { withFileTypes: true });
2493
+ } catch {
2494
+ unverifiableReasons.push(`Cannot read directory: ${toPosixPath(currentDir)}`);
2495
+ continue;
2496
+ }
2497
+ for (const entry of entries) {
2498
+ const fullPath = import_node_path6.default.join(currentDir, entry.name);
2499
+ if (entry.isDirectory()) {
2500
+ if (!SKIP_DIRS2.has(entry.name)) {
2501
+ stack.push(fullPath);
2502
+ }
2503
+ continue;
2504
+ }
2505
+ if (!entry.isFile()) {
2506
+ continue;
2507
+ }
2508
+ const relativePath = toPosixPath(import_node_path6.default.relative(skillDir, fullPath));
2509
+ if (!isScannableTextFile(relativePath)) {
2510
+ continue;
2511
+ }
2512
+ if (files.length >= options.maxTotalFiles) {
2513
+ unverifiableReasons.push(
2514
+ `Reached maxTotalFiles=${options.maxTotalFiles}. Remaining files were not scanned.`
2515
+ );
2516
+ return { files, unverifiableReasons };
2517
+ }
2518
+ let sizeBytes = 0;
2519
+ try {
2520
+ sizeBytes = import_node_fs3.default.statSync(fullPath).size;
2521
+ } catch {
2522
+ unverifiableReasons.push(`Cannot stat file: ${relativePath}`);
2523
+ continue;
2524
+ }
2525
+ if (sizeBytes > options.maxFileBytes) {
2526
+ unverifiableReasons.push(
2527
+ `Skipped oversized file (${sizeBytes} bytes > ${options.maxFileBytes}): ${relativePath}`
2528
+ );
2529
+ continue;
2530
+ }
2531
+ try {
2532
+ files.push({
2533
+ path: relativePath,
2534
+ content: import_node_fs3.default.readFileSync(fullPath, "utf8")
2535
+ });
2536
+ } catch {
2537
+ unverifiableReasons.push(`Cannot read text content: ${relativePath}`);
2538
+ }
2539
+ }
2540
+ }
2541
+ return { files, unverifiableReasons };
2542
+ }
2543
+ function resolveEffectiveScanLocalOptions(cliOptions, config) {
2544
+ const defaults = config.defaults ?? {};
2545
+ const resolver = config.resolver ?? {};
2546
+ return effectiveScanLocalOptionsSchema.parse({
2547
+ strict: cliOptions.strict ?? defaults.strict ?? DEFAULT_OPTIONS4.strict,
2548
+ json: cliOptions.json ?? defaults.json ?? DEFAULT_OPTIONS4.json,
2549
+ skill: cliOptions.skill ?? DEFAULT_OPTIONS4.skill,
2550
+ maxFileBytes: cliOptions.maxFileBytes ?? resolver.maxFileBytes ?? DEFAULT_OPTIONS4.maxFileBytes,
2551
+ maxTotalFiles: cliOptions.maxTotalFiles ?? resolver.maxTotalFiles ?? DEFAULT_OPTIONS4.maxTotalFiles
2552
+ });
2553
+ }
2554
+ async function runScanLocalCommand(inputPath, rawOptions) {
2555
+ const cliOptions = cliScanLocalOptionsSchema.parse(rawOptions);
2556
+ const loadedConfig = loadGuardSkillsConfig(cliOptions.config);
2557
+ const options = resolveEffectiveScanLocalOptions(cliOptions, loadedConfig.config);
2558
+ const target = resolveSkillDirectory(inputPath, options.skill);
2559
+ const { files, unverifiableReasons } = collectLocalFiles2(target.skillDir, options);
2560
+ if (files.length === 0) {
2561
+ throw new GuardSkillsError(
2562
+ "INVALID_LOCAL_PATH",
2563
+ `No scannable text files found in: ${toPosixPath(target.skillDir)}`
2564
+ );
2565
+ }
2566
+ const resolvedSkill = {
2567
+ source: `local:${toPosixPath(target.skillDir)}`,
2568
+ owner: "local",
2569
+ repo: "local",
2570
+ defaultBranch: "local",
2571
+ commitSha: "local",
2572
+ skillName: options.skill ?? import_node_path6.default.basename(target.skillDir),
2573
+ skillDir: toPosixPath(target.skillDir),
2574
+ skillFilePath: "SKILL.md",
2575
+ files,
2576
+ unverifiableReasons
2577
+ };
2578
+ const scan = scanResolvedSkill(resolvedSkill);
2579
+ const decision = calculateRiskScore(scan.findings, {
2580
+ strict: options.strict,
2581
+ trustCredits: 0,
2582
+ hasUnverifiableContent: scan.hasUnverifiableContent
2583
+ });
2584
+ const noteParts = ["Local scan complete."];
2585
+ if (target.note) {
2586
+ noteParts.push(target.note);
2587
+ }
2588
+ if (decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE") {
2589
+ noteParts.push("Blocked-level risk detected.");
2590
+ }
2591
+ if (loadedConfig.path) {
2592
+ noteParts.push(`Config: ${loadedConfig.path}`);
2593
+ }
2594
+ const report = {
2595
+ command: "guardskills scan-local",
2596
+ inputPath,
2597
+ strict: options.strict,
2598
+ configPath: loadedConfig.path ?? void 0,
2599
+ decision,
2600
+ scanFiles: resolvedSkill.files.map((file) => file.path),
2601
+ skillDir: resolvedSkill.skillDir,
2602
+ unverifiableReasons: scan.unverifiableReasons,
2603
+ note: noteParts.join(" ")
2604
+ };
2605
+ if (options.json) {
2606
+ printJsonLocalReport(report);
2607
+ } else {
2608
+ printHumanLocalReport(report);
2609
+ }
2610
+ return decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE" ? 20 : 0;
1491
2611
  }
1492
2612
 
1493
2613
  // src/cli.ts
2614
+ function withCommonAddOptions(command, requireSkill) {
2615
+ const configured = command.option("--config <path>", "Path to guardskills.config.json").option("--strict", "Use stricter risk thresholds").option("--ci", "Deterministic CI mode: scan + gate only, no install handoff").option("--json", "Output machine-readable JSON").option("--yes", "Auto-confirm warnings").option("--dry-run", "Scan only, do not install").option("--force", "Override UNSAFE outcome").option("--allow-unverifiable", "Override UNVERIFIABLE outcome").option("--github-timeout-ms <ms>", "GitHub API request timeout in milliseconds").option("--github-retries <count>", "Retry count for retryable GitHub errors").option("--github-retry-base-ms <ms>", "Base backoff delay for GitHub retries").option("--max-file-bytes <bytes>", "Max file size to scan").option("--max-aux-files <count>", "Max auxiliary files from scripts/src folders").option("--max-total-files <count>", "Max total resolved files to scan");
2616
+ return requireSkill ? configured.requiredOption("--skill <name>", "Skill name to install") : configured.option("--skill <name>", "Skill name to install");
2617
+ }
1494
2618
  async function main() {
1495
2619
  const program = new import_commander.Command();
1496
- program.name("guardskills").description("Security wrapper around skills add").version("1.0.0");
1497
- program.command("add").description("Scan a skill source and conditionally install it via skills CLI").argument("<repo>", "GitHub repository URL or owner/repo").requiredOption("--skill <name>", "Skill name to install").option("--config <path>", "Path to guardskills.config.json").option("--strict", "Use stricter risk thresholds").option("--ci", "Deterministic CI mode: scan + gate only, no install handoff").option("--json", "Output machine-readable JSON").option("--yes", "Auto-confirm warnings").option("--dry-run", "Scan only, do not install").option("--force", "Override UNSAFE outcome").option("--allow-unverifiable", "Override UNVERIFIABLE outcome").option("--github-timeout-ms <ms>", "GitHub API request timeout in milliseconds").option("--github-retries <count>", "Retry count for retryable GitHub errors").option("--github-retry-base-ms <ms>", "Base backoff delay for GitHub retries").option("--max-file-bytes <bytes>", "Max file size to scan").option("--max-aux-files <count>", "Max auxiliary files from scripts/src folders").option("--max-total-files <count>", "Max total resolved files to scan").action(async (repo, options) => {
1498
- const code = await runAddCommand(repo, options);
2620
+ program.name("guardskills").description("Security wrapper around skill installation CLIs").version("1.1.0");
2621
+ const legacyAdd = program.command("add").description("Scan a skill source and conditionally install it via skills CLI").argument("<repo>", "GitHub repository URL or owner/repo");
2622
+ withCommonAddOptions(legacyAdd, true).action(
2623
+ async (repo, options) => {
2624
+ const code = await runAddCommand(repo, options);
2625
+ process.exitCode = code;
2626
+ }
2627
+ );
2628
+ const skills = program.command("skills").description("Guarded wrapper for skills.sh install commands");
2629
+ withCommonAddOptions(
2630
+ skills.command("add").description("Scan a skill source and conditionally install it via skills CLI").argument("<repo>", "GitHub repository URL or owner/repo"),
2631
+ false
2632
+ ).action(async (repo, options) => {
2633
+ const resolvedSkill = typeof options.skill === "string" ? options.skill : void 0;
2634
+ const code = resolvedSkill ? await runAddCommand(repo, options, {
2635
+ provider: "skills",
2636
+ commandName: "guardskills skills add"
2637
+ }) : await runInteractiveInstallCommand("skills", repo, void 0, options);
2638
+ process.exitCode = code;
2639
+ });
2640
+ const playbooks = program.command("playbooks").description("Guarded wrapper for Playbooks install commands");
2641
+ withCommonAddOptions(
2642
+ playbooks.command("add").description("Scan a skill source and conditionally install it via Playbooks CLI").argument("<resource>", "Playbooks resource type (must be 'skill')").argument("<repo>", "GitHub repository URL or owner/repo"),
2643
+ true
2644
+ ).action(async (resource, repo, options) => {
2645
+ if (resource !== "skill") {
2646
+ throw new GuardSkillsError(
2647
+ "INVALID_OPTIONS",
2648
+ `Unsupported playbooks resource '${resource}'. Use: guardskills playbooks add skill <repo> --skill <name>`
2649
+ );
2650
+ }
2651
+ const code = await runAddCommand(repo, options, {
2652
+ provider: "playbooks",
2653
+ commandName: "guardskills playbooks add skill"
2654
+ });
2655
+ process.exitCode = code;
2656
+ });
2657
+ const openskills = program.command("openskills").description("Guarded wrapper for OpenSkills install commands");
2658
+ withCommonAddOptions(
2659
+ openskills.command("install").description("Scan a skill source and conditionally install it via OpenSkills CLI").argument("<repo>", "GitHub repository URL or owner/repo").argument("[skill]", "Skill name to install"),
2660
+ false
2661
+ ).action(async (repo, skill, options) => {
2662
+ const resolvedSkill = skill ?? (typeof options.skill === "string" ? options.skill : void 0);
2663
+ const code = await runInteractiveInstallCommand("openskills", repo, resolvedSkill, options);
2664
+ process.exitCode = code;
2665
+ });
2666
+ const skillkit = program.command("skillkit").description("Guarded wrapper for skillkit install commands");
2667
+ withCommonAddOptions(
2668
+ skillkit.command("install").description("Scan a skill source and conditionally install it via skillkit CLI").argument("<repo>", "GitHub repository URL or owner/repo").argument("[skill]", "Skill name to install"),
2669
+ false
2670
+ ).action(async (repo, skill, options) => {
2671
+ const resolvedSkill = skill ?? (typeof options.skill === "string" ? options.skill : void 0);
2672
+ const code = await runInteractiveInstallCommand("skillkit", repo, resolvedSkill, options);
2673
+ process.exitCode = code;
2674
+ });
2675
+ program.command("scan-local").description("Scan a local skill folder and print a risk decision").argument("<path>", "Local folder path (or SKILL.md file path)").option("--skill <name>", "Skill directory name when path contains multiple skills").option("--config <path>", "Path to guardskills.config.json").option("--strict", "Use stricter risk thresholds").option("--json", "Output machine-readable JSON").option("--max-file-bytes <bytes>", "Max file size to scan").option("--max-total-files <count>", "Max total files to scan").action(async (repo, options) => {
2676
+ const code = await runScanLocalCommand(repo, options);
1499
2677
  process.exitCode = code;
1500
2678
  });
1501
- program.command("scan-local").description("Scan a local skill folder and print a risk decision").argument("<path>", "Local folder path (or SKILL.md file path)").option("--skill <name>", "Skill directory name when path contains multiple skills").option("--config <path>", "Path to guardskills.config.json").option("--strict", "Use stricter risk thresholds").option("--json", "Output machine-readable JSON").option("--max-file-bytes <bytes>", "Max file size to scan").option("--max-total-files <count>", "Max total files to scan").action(async (inputPath, options) => {
1502
- const code = await runScanLocalCommand(inputPath, options);
2679
+ program.command("scan-clawhub").description("Scan a ClawHub skill package and print a risk decision").argument("<identifier>", "ClawHub package identifier").option("--skill <name>", "Override skill folder name to resolve in source repository").option("--version <version>", "Preferred package version/tag").option("--clawhub-registry <url>", "ClawHub registry base URL").option("--config <path>", "Path to guardskills.config.json").option("--strict", "Use stricter risk thresholds").option("--json", "Output machine-readable JSON").option("--github-timeout-ms <ms>", "Upstream resolver request timeout in milliseconds").option("--github-retries <count>", "Retry count for retryable upstream errors").option("--github-retry-base-ms <ms>", "Base backoff delay for upstream retries").option("--max-file-bytes <bytes>", "Max file size to scan").option("--max-aux-files <count>", "Max auxiliary files from scripts/src folders").option("--max-total-files <count>", "Max total resolved files to scan").action(async (identifier, options) => {
2680
+ const code = await runScanClawHubCommand(identifier, options);
1503
2681
  process.exitCode = code;
1504
2682
  });
1505
2683
  await program.parseAsync(process.argv);