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