guardskills 0.1.0-alpha.3 → 0.1.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -6
- package/dist/cli.cjs +366 -1
- package/dist/cli.js +366 -1
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -40,6 +40,7 @@ npx guardskills add https://github.com/vercel-labs/skills --skill find-skills
|
|
|
40
40
|
## Implemented Features
|
|
41
41
|
|
|
42
42
|
- `guardskills add <repo> --skill <name>`
|
|
43
|
+
- `guardskills scan-local <path>`
|
|
43
44
|
- GitHub resolver (`owner/repo` and `https://github.com/...`)
|
|
44
45
|
- Deterministic static scanner with rule matrix in `RULES.md`
|
|
45
46
|
- Score-based decision engine with hard-block guardrails
|
|
@@ -95,6 +96,12 @@ Local dry-run:
|
|
|
95
96
|
guardskills add https://github.com/vercel-labs/skills --skill find-skills --dry-run
|
|
96
97
|
```
|
|
97
98
|
|
|
99
|
+
Local folder check:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
guardskills scan-local C:\path\to\skill-folder
|
|
103
|
+
```
|
|
104
|
+
|
|
98
105
|
Deterministic CI gate:
|
|
99
106
|
|
|
100
107
|
```bash
|
|
@@ -115,15 +122,29 @@ guardskills add owner/repo --skill name \
|
|
|
115
122
|
|
|
116
123
|
## Local Check (Folder on Disk)
|
|
117
124
|
|
|
118
|
-
|
|
119
|
-
|
|
125
|
+
Scan any local skill directory:
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
guardskills scan-local C:\Felix\Skills\x-algo-skills\.github\skills\x-algo-post
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
JSON output:
|
|
120
132
|
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
npx tsx --eval "import fs from 'node:fs'; import path from 'node:path'; import { scanResolvedSkill } from './src/scanner/scan.ts'; import { calculateRiskScore } from './src/scoring/engine.ts'; const root=process.env.SKILL_DIR; if(!root){throw new Error('SKILL_DIR missing')} const files=[]; const walk=(d)=>{for(const e of fs.readdirSync(d,{withFileTypes:true})){const p=path.join(d,e.name); if(e.isDirectory()) walk(p); else files.push({path:path.relative(root,p).replace(/\\\\/g,'/'),content:fs.readFileSync(p,'utf8')});}}; walk(root); const scan=scanResolvedSkill({source:'local',owner:'local',repo:'local',defaultBranch:'local',commitSha:'local',skillName:path.basename(root),skillDir:root,skillFilePath:'SKILL.md',files,unverifiableReasons:[]}); const decision=calculateRiskScore(scan.findings,{hasUnverifiableContent:scan.hasUnverifiableContent,strict:false,trustCredits:0}); console.log(JSON.stringify({level:decision.level,riskScore:decision.riskScore,findings:scan.findings},null,2));"
|
|
133
|
+
```bash
|
|
134
|
+
guardskills scan-local C:\Felix\Skills\x-algo-skills\.github\skills\x-algo-post --json
|
|
124
135
|
```
|
|
125
136
|
|
|
126
|
-
If
|
|
137
|
+
If your path contains multiple skills, select one by directory name:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
guardskills scan-local C:\Felix\Skills\x-algo-skills\.github\skills --skill x-algo-post
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Path handling:
|
|
144
|
+
|
|
145
|
+
- missing path: fails with clear error and nearby suggestions
|
|
146
|
+
- folder with one discovered `SKILL.md`: auto-selects it
|
|
147
|
+
- folder with multiple discovered `SKILL.md`: asks for `--skill <name>`
|
|
127
148
|
|
|
128
149
|
## Configuration File
|
|
129
150
|
|
package/dist/cli.cjs
CHANGED
|
@@ -267,6 +267,47 @@ function printHumanReport(report) {
|
|
|
267
267
|
function printJsonReport(report) {
|
|
268
268
|
console.log(JSON.stringify(report, null, 2));
|
|
269
269
|
}
|
|
270
|
+
function printHumanLocalReport(report) {
|
|
271
|
+
console.log(`Command: ${report.command}`);
|
|
272
|
+
console.log(`Path: ${report.inputPath}`);
|
|
273
|
+
console.log(`Mode: ${report.strict ? "strict" : "standard"}`);
|
|
274
|
+
if (report.configPath) {
|
|
275
|
+
console.log(`Config: ${report.configPath}`);
|
|
276
|
+
}
|
|
277
|
+
console.log(`Skill Dir: ${report.skillDir}`);
|
|
278
|
+
console.log(`Files Scanned: ${report.scanFiles.length}`);
|
|
279
|
+
if (report.decision.riskScore === null) {
|
|
280
|
+
console.log("Result: UNVERIFIABLE");
|
|
281
|
+
} else {
|
|
282
|
+
console.log(`Risk Score: ${report.decision.riskScore.toFixed(1)}/100`);
|
|
283
|
+
console.log(`Decision: ${report.decision.level}`);
|
|
284
|
+
}
|
|
285
|
+
if (report.unverifiableReasons && report.unverifiableReasons.length > 0) {
|
|
286
|
+
console.log("Unverifiable Reasons:");
|
|
287
|
+
for (const reason of report.unverifiableReasons) {
|
|
288
|
+
console.log(`- ${reason}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (report.decision.chainMatches.length > 0) {
|
|
292
|
+
console.log("Attack Chains:");
|
|
293
|
+
for (const chain of report.decision.chainMatches) {
|
|
294
|
+
console.log(`- ${chain.id} (+${chain.bonus}): ${chain.description}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (report.decision.findings.length > 0) {
|
|
298
|
+
console.log("Findings:");
|
|
299
|
+
for (const finding of report.decision.findings.slice(0, 10)) {
|
|
300
|
+
const fileText = finding.file ? ` (${finding.file})` : "";
|
|
301
|
+
console.log(`- [${finding.severity}/${finding.confidence}] ${finding.title}${fileText}`);
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
console.log("Findings: none");
|
|
305
|
+
}
|
|
306
|
+
console.log(`Note: ${report.note}`);
|
|
307
|
+
}
|
|
308
|
+
function printJsonLocalReport(report) {
|
|
309
|
+
console.log(JSON.stringify(report, null, 2));
|
|
310
|
+
}
|
|
270
311
|
|
|
271
312
|
// src/resolver/github.ts
|
|
272
313
|
var import_node_path2 = __toESM(require("path"), 1);
|
|
@@ -1129,14 +1170,338 @@ async function runAddCommand(repo, rawOptions) {
|
|
|
1129
1170
|
return runSkillsInstall(repo, options.skill);
|
|
1130
1171
|
}
|
|
1131
1172
|
|
|
1173
|
+
// src/commands/scan-local.ts
|
|
1174
|
+
var import_node_fs2 = __toESM(require("fs"), 1);
|
|
1175
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
1176
|
+
var import_zod3 = require("zod");
|
|
1177
|
+
var ALLOWED_TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
1178
|
+
".md",
|
|
1179
|
+
".txt",
|
|
1180
|
+
".sh",
|
|
1181
|
+
".bash",
|
|
1182
|
+
".zsh",
|
|
1183
|
+
".ps1",
|
|
1184
|
+
".py",
|
|
1185
|
+
".js",
|
|
1186
|
+
".ts",
|
|
1187
|
+
".mjs",
|
|
1188
|
+
".cjs",
|
|
1189
|
+
".json",
|
|
1190
|
+
".yaml",
|
|
1191
|
+
".yml",
|
|
1192
|
+
".toml",
|
|
1193
|
+
".ini",
|
|
1194
|
+
".cfg"
|
|
1195
|
+
]);
|
|
1196
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".turbo"]);
|
|
1197
|
+
var cliScanLocalOptionsSchema = import_zod3.z.object({
|
|
1198
|
+
config: import_zod3.z.string().optional(),
|
|
1199
|
+
strict: import_zod3.z.boolean().optional(),
|
|
1200
|
+
json: import_zod3.z.boolean().optional(),
|
|
1201
|
+
skill: import_zod3.z.string().min(1).optional(),
|
|
1202
|
+
maxFileBytes: import_zod3.z.coerce.number().int().min(4096).max(5e6).optional(),
|
|
1203
|
+
maxTotalFiles: import_zod3.z.coerce.number().int().min(1).max(400).optional()
|
|
1204
|
+
});
|
|
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
|
+
var DEFAULT_OPTIONS2 = {
|
|
1213
|
+
strict: false,
|
|
1214
|
+
json: false,
|
|
1215
|
+
skill: void 0,
|
|
1216
|
+
maxFileBytes: 25e4,
|
|
1217
|
+
maxTotalFiles: 120
|
|
1218
|
+
};
|
|
1219
|
+
function toPosixPath(filePath) {
|
|
1220
|
+
return filePath.replace(/\\/g, "/");
|
|
1221
|
+
}
|
|
1222
|
+
function getNearbyPathSuggestions(targetPath) {
|
|
1223
|
+
const parent = import_node_path4.default.dirname(targetPath);
|
|
1224
|
+
if (!import_node_fs2.default.existsSync(parent)) {
|
|
1225
|
+
return [];
|
|
1226
|
+
}
|
|
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
|
+
}
|
|
1233
|
+
}
|
|
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;
|
|
1242
|
+
}
|
|
1243
|
+
const ext = import_node_path4.default.extname(filePath).toLowerCase();
|
|
1244
|
+
return ALLOWED_TEXT_EXTENSIONS2.has(ext);
|
|
1245
|
+
}
|
|
1246
|
+
function findSkillDirs(rootDir) {
|
|
1247
|
+
const found = /* @__PURE__ */ new Set();
|
|
1248
|
+
const stack = [{ dir: rootDir, depth: 0 }];
|
|
1249
|
+
let seen = 0;
|
|
1250
|
+
while (stack.length > 0) {
|
|
1251
|
+
const current = stack.pop();
|
|
1252
|
+
if (!current) {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
seen += 1;
|
|
1256
|
+
if (seen > 5e3) {
|
|
1257
|
+
break;
|
|
1258
|
+
}
|
|
1259
|
+
const skillFile = import_node_path4.default.join(current.dir, "SKILL.md");
|
|
1260
|
+
if (import_node_fs2.default.existsSync(skillFile) && import_node_fs2.default.statSync(skillFile).isFile()) {
|
|
1261
|
+
found.add(current.dir);
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
if (current.depth >= 8) {
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
let entries;
|
|
1268
|
+
try {
|
|
1269
|
+
entries = import_node_fs2.default.readdirSync(current.dir, { withFileTypes: true });
|
|
1270
|
+
} catch {
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
for (const entry of entries) {
|
|
1274
|
+
if (!entry.isDirectory()) {
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
if (SKIP_DIRS.has(entry.name)) {
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1280
|
+
stack.push({ dir: import_node_path4.default.join(current.dir, entry.name), depth: current.depth + 1 });
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
return [...found].sort();
|
|
1284
|
+
}
|
|
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
|
+
function collectLocalFiles(skillDir, options) {
|
|
1362
|
+
const files = [];
|
|
1363
|
+
const unverifiableReasons = [];
|
|
1364
|
+
const stack = [skillDir];
|
|
1365
|
+
while (stack.length > 0) {
|
|
1366
|
+
const currentDir = stack.pop();
|
|
1367
|
+
if (!currentDir) {
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
let entries;
|
|
1371
|
+
try {
|
|
1372
|
+
entries = import_node_fs2.default.readdirSync(currentDir, { withFileTypes: true });
|
|
1373
|
+
} catch {
|
|
1374
|
+
unverifiableReasons.push(`Cannot read directory: ${toPosixPath(currentDir)}`);
|
|
1375
|
+
continue;
|
|
1376
|
+
}
|
|
1377
|
+
for (const entry of entries) {
|
|
1378
|
+
const fullPath = import_node_path4.default.join(currentDir, entry.name);
|
|
1379
|
+
if (entry.isDirectory()) {
|
|
1380
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
1381
|
+
stack.push(fullPath);
|
|
1382
|
+
}
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
if (!entry.isFile()) {
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
const relativePath = toPosixPath(import_node_path4.default.relative(skillDir, fullPath));
|
|
1389
|
+
if (!isScannableTextFile(relativePath)) {
|
|
1390
|
+
continue;
|
|
1391
|
+
}
|
|
1392
|
+
if (files.length >= options.maxTotalFiles) {
|
|
1393
|
+
unverifiableReasons.push(
|
|
1394
|
+
`Reached maxTotalFiles=${options.maxTotalFiles}. Remaining files were not scanned.`
|
|
1395
|
+
);
|
|
1396
|
+
return { files, unverifiableReasons };
|
|
1397
|
+
}
|
|
1398
|
+
let sizeBytes = 0;
|
|
1399
|
+
try {
|
|
1400
|
+
sizeBytes = import_node_fs2.default.statSync(fullPath).size;
|
|
1401
|
+
} catch {
|
|
1402
|
+
unverifiableReasons.push(`Cannot stat file: ${relativePath}`);
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (sizeBytes > options.maxFileBytes) {
|
|
1406
|
+
unverifiableReasons.push(
|
|
1407
|
+
`Skipped oversized file (${sizeBytes} bytes > ${options.maxFileBytes}): ${relativePath}`
|
|
1408
|
+
);
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
try {
|
|
1412
|
+
files.push({
|
|
1413
|
+
path: relativePath,
|
|
1414
|
+
content: import_node_fs2.default.readFileSync(fullPath, "utf8")
|
|
1415
|
+
});
|
|
1416
|
+
} catch {
|
|
1417
|
+
unverifiableReasons.push(`Cannot read text content: ${relativePath}`);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
return { files, unverifiableReasons };
|
|
1422
|
+
}
|
|
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
|
+
);
|
|
1445
|
+
}
|
|
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);
|
|
1467
|
+
}
|
|
1468
|
+
if (decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE") {
|
|
1469
|
+
noteParts.push("Blocked-level risk detected.");
|
|
1470
|
+
}
|
|
1471
|
+
if (loadedConfig.path) {
|
|
1472
|
+
noteParts.push(`Config: ${loadedConfig.path}`);
|
|
1473
|
+
}
|
|
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);
|
|
1489
|
+
}
|
|
1490
|
+
return decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE" ? 20 : 0;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1132
1493
|
// src/cli.ts
|
|
1133
1494
|
async function main() {
|
|
1134
1495
|
const program = new import_commander.Command();
|
|
1135
|
-
program.name("guardskills").description("Security wrapper around skills add").version("0.1.0-alpha.
|
|
1496
|
+
program.name("guardskills").description("Security wrapper around skills add").version("0.1.0-alpha.3");
|
|
1136
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) => {
|
|
1137
1498
|
const code = await runAddCommand(repo, options);
|
|
1138
1499
|
process.exitCode = code;
|
|
1139
1500
|
});
|
|
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);
|
|
1503
|
+
process.exitCode = code;
|
|
1504
|
+
});
|
|
1140
1505
|
await program.parseAsync(process.argv);
|
|
1141
1506
|
}
|
|
1142
1507
|
main().catch((error) => {
|
package/dist/cli.js
CHANGED
|
@@ -243,6 +243,47 @@ function printHumanReport(report) {
|
|
|
243
243
|
function printJsonReport(report) {
|
|
244
244
|
console.log(JSON.stringify(report, null, 2));
|
|
245
245
|
}
|
|
246
|
+
function printHumanLocalReport(report) {
|
|
247
|
+
console.log(`Command: ${report.command}`);
|
|
248
|
+
console.log(`Path: ${report.inputPath}`);
|
|
249
|
+
console.log(`Mode: ${report.strict ? "strict" : "standard"}`);
|
|
250
|
+
if (report.configPath) {
|
|
251
|
+
console.log(`Config: ${report.configPath}`);
|
|
252
|
+
}
|
|
253
|
+
console.log(`Skill Dir: ${report.skillDir}`);
|
|
254
|
+
console.log(`Files Scanned: ${report.scanFiles.length}`);
|
|
255
|
+
if (report.decision.riskScore === null) {
|
|
256
|
+
console.log("Result: UNVERIFIABLE");
|
|
257
|
+
} else {
|
|
258
|
+
console.log(`Risk Score: ${report.decision.riskScore.toFixed(1)}/100`);
|
|
259
|
+
console.log(`Decision: ${report.decision.level}`);
|
|
260
|
+
}
|
|
261
|
+
if (report.unverifiableReasons && report.unverifiableReasons.length > 0) {
|
|
262
|
+
console.log("Unverifiable Reasons:");
|
|
263
|
+
for (const reason of report.unverifiableReasons) {
|
|
264
|
+
console.log(`- ${reason}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (report.decision.chainMatches.length > 0) {
|
|
268
|
+
console.log("Attack Chains:");
|
|
269
|
+
for (const chain of report.decision.chainMatches) {
|
|
270
|
+
console.log(`- ${chain.id} (+${chain.bonus}): ${chain.description}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (report.decision.findings.length > 0) {
|
|
274
|
+
console.log("Findings:");
|
|
275
|
+
for (const finding of report.decision.findings.slice(0, 10)) {
|
|
276
|
+
const fileText = finding.file ? ` (${finding.file})` : "";
|
|
277
|
+
console.log(`- [${finding.severity}/${finding.confidence}] ${finding.title}${fileText}`);
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
console.log("Findings: none");
|
|
281
|
+
}
|
|
282
|
+
console.log(`Note: ${report.note}`);
|
|
283
|
+
}
|
|
284
|
+
function printJsonLocalReport(report) {
|
|
285
|
+
console.log(JSON.stringify(report, null, 2));
|
|
286
|
+
}
|
|
246
287
|
|
|
247
288
|
// src/resolver/github.ts
|
|
248
289
|
import path2 from "path";
|
|
@@ -1105,14 +1146,338 @@ async function runAddCommand(repo, rawOptions) {
|
|
|
1105
1146
|
return runSkillsInstall(repo, options.skill);
|
|
1106
1147
|
}
|
|
1107
1148
|
|
|
1149
|
+
// src/commands/scan-local.ts
|
|
1150
|
+
import fs2 from "fs";
|
|
1151
|
+
import path4 from "path";
|
|
1152
|
+
import { z as z3 } from "zod";
|
|
1153
|
+
var ALLOWED_TEXT_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
1154
|
+
".md",
|
|
1155
|
+
".txt",
|
|
1156
|
+
".sh",
|
|
1157
|
+
".bash",
|
|
1158
|
+
".zsh",
|
|
1159
|
+
".ps1",
|
|
1160
|
+
".py",
|
|
1161
|
+
".js",
|
|
1162
|
+
".ts",
|
|
1163
|
+
".mjs",
|
|
1164
|
+
".cjs",
|
|
1165
|
+
".json",
|
|
1166
|
+
".yaml",
|
|
1167
|
+
".yml",
|
|
1168
|
+
".toml",
|
|
1169
|
+
".ini",
|
|
1170
|
+
".cfg"
|
|
1171
|
+
]);
|
|
1172
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", "node_modules", "dist", "build", ".next", ".turbo"]);
|
|
1173
|
+
var cliScanLocalOptionsSchema = z3.object({
|
|
1174
|
+
config: z3.string().optional(),
|
|
1175
|
+
strict: z3.boolean().optional(),
|
|
1176
|
+
json: z3.boolean().optional(),
|
|
1177
|
+
skill: z3.string().min(1).optional(),
|
|
1178
|
+
maxFileBytes: z3.coerce.number().int().min(4096).max(5e6).optional(),
|
|
1179
|
+
maxTotalFiles: z3.coerce.number().int().min(1).max(400).optional()
|
|
1180
|
+
});
|
|
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
|
+
var DEFAULT_OPTIONS2 = {
|
|
1189
|
+
strict: false,
|
|
1190
|
+
json: false,
|
|
1191
|
+
skill: void 0,
|
|
1192
|
+
maxFileBytes: 25e4,
|
|
1193
|
+
maxTotalFiles: 120
|
|
1194
|
+
};
|
|
1195
|
+
function toPosixPath(filePath) {
|
|
1196
|
+
return filePath.replace(/\\/g, "/");
|
|
1197
|
+
}
|
|
1198
|
+
function getNearbyPathSuggestions(targetPath) {
|
|
1199
|
+
const parent = path4.dirname(targetPath);
|
|
1200
|
+
if (!fs2.existsSync(parent)) {
|
|
1201
|
+
return [];
|
|
1202
|
+
}
|
|
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
|
+
}
|
|
1209
|
+
}
|
|
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;
|
|
1218
|
+
}
|
|
1219
|
+
const ext = path4.extname(filePath).toLowerCase();
|
|
1220
|
+
return ALLOWED_TEXT_EXTENSIONS2.has(ext);
|
|
1221
|
+
}
|
|
1222
|
+
function findSkillDirs(rootDir) {
|
|
1223
|
+
const found = /* @__PURE__ */ new Set();
|
|
1224
|
+
const stack = [{ dir: rootDir, depth: 0 }];
|
|
1225
|
+
let seen = 0;
|
|
1226
|
+
while (stack.length > 0) {
|
|
1227
|
+
const current = stack.pop();
|
|
1228
|
+
if (!current) {
|
|
1229
|
+
continue;
|
|
1230
|
+
}
|
|
1231
|
+
seen += 1;
|
|
1232
|
+
if (seen > 5e3) {
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
const skillFile = path4.join(current.dir, "SKILL.md");
|
|
1236
|
+
if (fs2.existsSync(skillFile) && fs2.statSync(skillFile).isFile()) {
|
|
1237
|
+
found.add(current.dir);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
if (current.depth >= 8) {
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
let entries;
|
|
1244
|
+
try {
|
|
1245
|
+
entries = fs2.readdirSync(current.dir, { withFileTypes: true });
|
|
1246
|
+
} catch {
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1249
|
+
for (const entry of entries) {
|
|
1250
|
+
if (!entry.isDirectory()) {
|
|
1251
|
+
continue;
|
|
1252
|
+
}
|
|
1253
|
+
if (SKIP_DIRS.has(entry.name)) {
|
|
1254
|
+
continue;
|
|
1255
|
+
}
|
|
1256
|
+
stack.push({ dir: path4.join(current.dir, entry.name), depth: current.depth + 1 });
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return [...found].sort();
|
|
1260
|
+
}
|
|
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
|
+
function collectLocalFiles(skillDir, options) {
|
|
1338
|
+
const files = [];
|
|
1339
|
+
const unverifiableReasons = [];
|
|
1340
|
+
const stack = [skillDir];
|
|
1341
|
+
while (stack.length > 0) {
|
|
1342
|
+
const currentDir = stack.pop();
|
|
1343
|
+
if (!currentDir) {
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
let entries;
|
|
1347
|
+
try {
|
|
1348
|
+
entries = fs2.readdirSync(currentDir, { withFileTypes: true });
|
|
1349
|
+
} catch {
|
|
1350
|
+
unverifiableReasons.push(`Cannot read directory: ${toPosixPath(currentDir)}`);
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
for (const entry of entries) {
|
|
1354
|
+
const fullPath = path4.join(currentDir, entry.name);
|
|
1355
|
+
if (entry.isDirectory()) {
|
|
1356
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
1357
|
+
stack.push(fullPath);
|
|
1358
|
+
}
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
if (!entry.isFile()) {
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
const relativePath = toPosixPath(path4.relative(skillDir, fullPath));
|
|
1365
|
+
if (!isScannableTextFile(relativePath)) {
|
|
1366
|
+
continue;
|
|
1367
|
+
}
|
|
1368
|
+
if (files.length >= options.maxTotalFiles) {
|
|
1369
|
+
unverifiableReasons.push(
|
|
1370
|
+
`Reached maxTotalFiles=${options.maxTotalFiles}. Remaining files were not scanned.`
|
|
1371
|
+
);
|
|
1372
|
+
return { files, unverifiableReasons };
|
|
1373
|
+
}
|
|
1374
|
+
let sizeBytes = 0;
|
|
1375
|
+
try {
|
|
1376
|
+
sizeBytes = fs2.statSync(fullPath).size;
|
|
1377
|
+
} catch {
|
|
1378
|
+
unverifiableReasons.push(`Cannot stat file: ${relativePath}`);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (sizeBytes > options.maxFileBytes) {
|
|
1382
|
+
unverifiableReasons.push(
|
|
1383
|
+
`Skipped oversized file (${sizeBytes} bytes > ${options.maxFileBytes}): ${relativePath}`
|
|
1384
|
+
);
|
|
1385
|
+
continue;
|
|
1386
|
+
}
|
|
1387
|
+
try {
|
|
1388
|
+
files.push({
|
|
1389
|
+
path: relativePath,
|
|
1390
|
+
content: fs2.readFileSync(fullPath, "utf8")
|
|
1391
|
+
});
|
|
1392
|
+
} catch {
|
|
1393
|
+
unverifiableReasons.push(`Cannot read text content: ${relativePath}`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return { files, unverifiableReasons };
|
|
1398
|
+
}
|
|
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
|
+
);
|
|
1421
|
+
}
|
|
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);
|
|
1443
|
+
}
|
|
1444
|
+
if (decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE") {
|
|
1445
|
+
noteParts.push("Blocked-level risk detected.");
|
|
1446
|
+
}
|
|
1447
|
+
if (loadedConfig.path) {
|
|
1448
|
+
noteParts.push(`Config: ${loadedConfig.path}`);
|
|
1449
|
+
}
|
|
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);
|
|
1465
|
+
}
|
|
1466
|
+
return decision.level === "UNSAFE" || decision.level === "CRITICAL" || decision.level === "UNVERIFIABLE" ? 20 : 0;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1108
1469
|
// src/cli.ts
|
|
1109
1470
|
async function main() {
|
|
1110
1471
|
const program = new Command();
|
|
1111
|
-
program.name("guardskills").description("Security wrapper around skills add").version("0.1.0-alpha.
|
|
1472
|
+
program.name("guardskills").description("Security wrapper around skills add").version("0.1.0-alpha.3");
|
|
1112
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) => {
|
|
1113
1474
|
const code = await runAddCommand(repo, options);
|
|
1114
1475
|
process.exitCode = code;
|
|
1115
1476
|
});
|
|
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);
|
|
1479
|
+
process.exitCode = code;
|
|
1480
|
+
});
|
|
1116
1481
|
await program.parseAsync(process.argv);
|
|
1117
1482
|
}
|
|
1118
1483
|
main().catch((error) => {
|
package/package.json
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "guardskills",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.4",
|
|
4
4
|
"description": "Security wrapper around skills add",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"security",
|
|
7
|
+
"skills",
|
|
8
|
+
"cli",
|
|
9
|
+
"malware-detection",
|
|
10
|
+
"static-analysis",
|
|
11
|
+
"supply-chain",
|
|
12
|
+
"agent-skills",
|
|
13
|
+
"guardrails"
|
|
14
|
+
],
|
|
5
15
|
"repository": {
|
|
6
16
|
"type": "git",
|
|
7
17
|
"url": "git+https://github.com/felixondesk/guardskills.git"
|