@rafter-security/cli 0.6.6 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +29 -10
  2. package/dist/commands/agent/audit-skill.js +22 -20
  3. package/dist/commands/agent/audit.js +27 -0
  4. package/dist/commands/agent/components.js +800 -0
  5. package/dist/commands/agent/config.js +2 -1
  6. package/dist/commands/agent/disable.js +47 -0
  7. package/dist/commands/agent/enable.js +50 -0
  8. package/dist/commands/agent/exec.js +2 -0
  9. package/dist/commands/agent/index.js +6 -0
  10. package/dist/commands/agent/init.js +162 -163
  11. package/dist/commands/agent/install-hook.js +15 -14
  12. package/dist/commands/agent/list.js +72 -0
  13. package/dist/commands/agent/scan.js +4 -3
  14. package/dist/commands/agent/verify.js +1 -1
  15. package/dist/commands/backend/run.js +12 -3
  16. package/dist/commands/backend/scan-status.js +3 -2
  17. package/dist/commands/brief.js +22 -2
  18. package/dist/commands/ci/init.js +25 -21
  19. package/dist/commands/completion.js +4 -3
  20. package/dist/commands/docs/index.js +18 -0
  21. package/dist/commands/docs/list.js +37 -0
  22. package/dist/commands/docs/show.js +64 -0
  23. package/dist/commands/mcp/server.js +84 -0
  24. package/dist/commands/report.js +42 -41
  25. package/dist/commands/scan/index.js +7 -5
  26. package/dist/commands/skill/index.js +14 -0
  27. package/dist/commands/skill/install.js +89 -0
  28. package/dist/commands/skill/list.js +79 -0
  29. package/dist/commands/skill/registry.js +273 -0
  30. package/dist/commands/skill/remote.js +333 -0
  31. package/dist/commands/skill/review.js +975 -0
  32. package/dist/commands/skill/uninstall.js +65 -0
  33. package/dist/core/audit-logger.js +262 -21
  34. package/dist/core/config-manager.js +3 -0
  35. package/dist/core/docs-loader.js +148 -0
  36. package/dist/core/policy-loader.js +72 -1
  37. package/dist/core/risk-rules.js +16 -3
  38. package/dist/index.js +19 -9
  39. package/dist/scanners/gitleaks.js +6 -2
  40. package/package.json +1 -1
  41. package/resources/skills/rafter/SKILL.md +77 -97
  42. package/resources/skills/rafter/docs/backend.md +106 -0
  43. package/resources/skills/rafter/docs/cli-reference.md +199 -0
  44. package/resources/skills/rafter/docs/finding-triage.md +79 -0
  45. package/resources/skills/rafter/docs/guardrails.md +91 -0
  46. package/resources/skills/rafter/docs/shift-left.md +64 -0
  47. package/resources/skills/rafter-agent-security/SKILL.md +1 -1
  48. package/resources/skills/rafter-code-review/SKILL.md +91 -0
  49. package/resources/skills/rafter-code-review/docs/api.md +90 -0
  50. package/resources/skills/rafter-code-review/docs/asvs.md +120 -0
  51. package/resources/skills/rafter-code-review/docs/cwe-top25.md +78 -0
  52. package/resources/skills/rafter-code-review/docs/investigation-playbook.md +101 -0
  53. package/resources/skills/rafter-code-review/docs/llm.md +87 -0
  54. package/resources/skills/rafter-code-review/docs/web-app.md +84 -0
  55. package/resources/skills/rafter-secure-design/SKILL.md +103 -0
  56. package/resources/skills/rafter-secure-design/docs/api-design.md +97 -0
  57. package/resources/skills/rafter-secure-design/docs/auth.md +67 -0
  58. package/resources/skills/rafter-secure-design/docs/data-storage.md +90 -0
  59. package/resources/skills/rafter-secure-design/docs/dependencies.md +101 -0
  60. package/resources/skills/rafter-secure-design/docs/deployment.md +104 -0
  61. package/resources/skills/rafter-secure-design/docs/ingestion.md +98 -0
  62. package/resources/skills/rafter-secure-design/docs/standards-pointers.md +102 -0
  63. package/resources/skills/rafter-secure-design/docs/threat-modeling.md +128 -0
  64. package/resources/skills/rafter-skill-review/SKILL.md +106 -0
  65. package/resources/skills/rafter-skill-review/docs/authorship-provenance.md +82 -0
  66. package/resources/skills/rafter-skill-review/docs/changelog-review.md +99 -0
  67. package/resources/skills/rafter-skill-review/docs/data-practices.md +88 -0
  68. package/resources/skills/rafter-skill-review/docs/malware-indicators.md +79 -0
  69. package/resources/skills/rafter-skill-review/docs/prompt-injection.md +85 -0
  70. package/resources/skills/rafter-skill-review/docs/telemetry.md +78 -0
@@ -0,0 +1,975 @@
1
+ import { Command } from "commander";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+ import { spawnSync } from "child_process";
6
+ import { PatternEngine } from "../../core/pattern-engine.js";
7
+ import { DEFAULT_SECRET_PATTERNS } from "../../scanners/secret-patterns.js";
8
+ import { fmt } from "../../utils/formatter.js";
9
+ import { discoverInstalledSkills, SKILL_PLATFORMS, } from "./registry.js";
10
+ import { isShorthand, parseShorthand, defaultCacheRoot, readResolution, writeResolution, resolutionIsFresh, contentKeyGit, contentKeyNpm, contentDir, contentWorkingTree, contentIsUsable, dropCacheEntry, defaultRemoteOps, extractNpmTarball, findSkillFiles, DEFAULT_CACHE_TTL_MS, } from "./remote.js";
11
+ const TEXT_EXT = new Set([
12
+ ".md",
13
+ ".mdx",
14
+ ".mdc",
15
+ ".txt",
16
+ ".json",
17
+ ".yaml",
18
+ ".yml",
19
+ ".toml",
20
+ ".ts",
21
+ ".tsx",
22
+ ".js",
23
+ ".jsx",
24
+ ".py",
25
+ ".sh",
26
+ ".bash",
27
+ ".zsh",
28
+ ".rb",
29
+ ".go",
30
+ ".rs",
31
+ ".java",
32
+ ".kt",
33
+ ".swift",
34
+ ".html",
35
+ ".css",
36
+ ".ini",
37
+ ".env",
38
+ ".cfg",
39
+ ".conf",
40
+ ]);
41
+ const SUSPICIOUS_EXT = new Set([
42
+ ".so",
43
+ ".dylib",
44
+ ".dll",
45
+ ".node",
46
+ ".exe",
47
+ ".wasm",
48
+ ".bin",
49
+ ]);
50
+ const MAX_FILE_BYTES = 2 * 1024 * 1024; // 2 MiB — anything larger gets treated as binary
51
+ const MAX_FILES = 2000; // hard cap to avoid runaway traversal
52
+ const HIGH_RISK_PATTERNS = [
53
+ { pattern: /rm\s+-rf\s+\/(?!\w)/gi, name: "rm -rf /" },
54
+ { pattern: /sudo\s+rm/gi, name: "sudo rm" },
55
+ { pattern: /curl[^|]*\|\s*(?:ba)?sh/gi, name: "curl | sh" },
56
+ { pattern: /wget[^|]*\|\s*(?:ba)?sh/gi, name: "wget | sh" },
57
+ { pattern: /iwr[^|]*\|\s*iex/gi, name: "iwr | iex" },
58
+ { pattern: /eval\s*\(/gi, name: "eval()" },
59
+ { pattern: /exec\s*\(/gi, name: "exec()" },
60
+ { pattern: /Function\s*\(\s*['"`]/g, name: "new Function(...)" },
61
+ { pattern: /chmod\s+777/gi, name: "chmod 777" },
62
+ { pattern: /:\(\)\{\s*:\|:&\s*\};:/g, name: "fork bomb" },
63
+ { pattern: /dd\s+if=\/dev\/(?:zero|random)\s+of=\/dev/gi, name: "dd to device" },
64
+ { pattern: /\bmkfs(?:\.\w+)?\b/gi, name: "mkfs (format)" },
65
+ { pattern: /base64\s+-d[^|]*\|\s*(?:ba)?sh/gi, name: "base64 decode | sh" },
66
+ { pattern: /\b(?:crontab|systemctl|launchctl)\s+(?:-e|edit|enable|load)/gi, name: "persistence primitive" },
67
+ ];
68
+ const ZERO_WIDTH_RE = /[\u200B-\u200F\u2060\uFEFF]/g;
69
+ const BIDI_RE = /[\u202A-\u202E\u2066-\u2069]/g;
70
+ const BASE64_BLOB_RE = /[A-Za-z0-9+/]{200,}={0,2}/g;
71
+ const HEX_ROPE_RE = /(?:\\x[0-9a-fA-F]{2}){8,}/g;
72
+ const URL_RE = /https?:\/\/[^\s<>"'`)]+/gi;
73
+ function isGitUrl(input) {
74
+ if (input.startsWith("git@"))
75
+ return true;
76
+ if (input.endsWith(".git"))
77
+ return true;
78
+ if (/^(https?|ssh):\/\//.test(input) && /github\.com|gitlab\.com|bitbucket\.org|codeberg\.org/.test(input)) {
79
+ return true;
80
+ }
81
+ return false;
82
+ }
83
+ function cloneShallow(url) {
84
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-skill-review-"));
85
+ const result = spawnSync("git", ["clone", "--depth", "1", "--quiet", url, tmp], {
86
+ stdio: ["ignore", "pipe", "pipe"],
87
+ timeout: 60000,
88
+ });
89
+ if (result.status !== 0) {
90
+ const err = (result.stderr ?? "").toString().trim() || "git clone failed";
91
+ try {
92
+ fs.rmSync(tmp, { recursive: true, force: true });
93
+ }
94
+ catch {
95
+ // ignore
96
+ }
97
+ throw new Error(`Failed to clone ${url}: ${err}`);
98
+ }
99
+ return tmp;
100
+ }
101
+ function looksBinary(buf) {
102
+ const n = Math.min(buf.length, 4096);
103
+ for (let i = 0; i < n; i++) {
104
+ if (buf[i] === 0)
105
+ return true;
106
+ }
107
+ return false;
108
+ }
109
+ function walkFiles(root) {
110
+ const out = [];
111
+ const stack = [root];
112
+ while (stack.length && out.length < MAX_FILES) {
113
+ const dir = stack.pop();
114
+ let entries;
115
+ try {
116
+ entries = fs.readdirSync(dir, { withFileTypes: true });
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ for (const entry of entries) {
122
+ const full = path.join(dir, entry.name);
123
+ if (entry.isDirectory()) {
124
+ if (entry.name === ".git" || entry.name === "node_modules" || entry.name === ".venv")
125
+ continue;
126
+ stack.push(full);
127
+ }
128
+ else if (entry.isFile()) {
129
+ out.push(full);
130
+ if (out.length >= MAX_FILES)
131
+ break;
132
+ }
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+ function parseFrontmatter(content) {
138
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
139
+ if (!match)
140
+ return {};
141
+ const out = {};
142
+ for (const line of match[1].split("\n")) {
143
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
144
+ if (!m)
145
+ continue;
146
+ let val = m[2].trim();
147
+ if (val.startsWith('"') && val.endsWith('"'))
148
+ val = val.slice(1, -1);
149
+ else if (val.startsWith("'") && val.endsWith("'"))
150
+ val = val.slice(1, -1);
151
+ out[m[1]] = val;
152
+ }
153
+ return out;
154
+ }
155
+ function parseAllowedTools(raw) {
156
+ if (!raw)
157
+ return undefined;
158
+ const trimmed = raw.trim();
159
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
160
+ return trimmed
161
+ .slice(1, -1)
162
+ .split(",")
163
+ .map((s) => s.trim())
164
+ .filter(Boolean);
165
+ }
166
+ return trimmed.split(/[,\s]+/).filter(Boolean);
167
+ }
168
+ function lineOf(content, index) {
169
+ return content.substring(0, index).split("\n").length;
170
+ }
171
+ function scanContent(relPath, content, patternEngine, report) {
172
+ for (const pm of patternEngine.scan(content)) {
173
+ report.secrets.push({
174
+ pattern: pm.pattern.name,
175
+ severity: pm.pattern.severity,
176
+ file: relPath,
177
+ line: pm.line ?? null,
178
+ redacted: pm.redacted ?? "",
179
+ });
180
+ }
181
+ for (const m of content.match(URL_RE) ?? []) {
182
+ // Strip trailing punctuation (common in markdown prose).
183
+ const cleaned = m.replace(/[).,;:]+$/, "");
184
+ report.urls.push(cleaned);
185
+ }
186
+ for (const { pattern, name } of HIGH_RISK_PATTERNS) {
187
+ pattern.lastIndex = 0;
188
+ let m;
189
+ while ((m = pattern.exec(content)) !== null) {
190
+ report.highRiskCommands.push({
191
+ command: name,
192
+ file: relPath,
193
+ line: lineOf(content, m.index),
194
+ });
195
+ }
196
+ }
197
+ for (const re of [ZERO_WIDTH_RE, BIDI_RE]) {
198
+ re.lastIndex = 0;
199
+ const m = re.exec(content);
200
+ if (m) {
201
+ report.obfuscation.push({
202
+ kind: re === ZERO_WIDTH_RE ? "zero-width-char" : "bidi-override",
203
+ file: relPath,
204
+ line: lineOf(content, m.index),
205
+ sample: `U+${m[0].charCodeAt(0).toString(16).padStart(4, "0").toUpperCase()}`,
206
+ });
207
+ }
208
+ }
209
+ BASE64_BLOB_RE.lastIndex = 0;
210
+ let bm;
211
+ while ((bm = BASE64_BLOB_RE.exec(content)) !== null) {
212
+ report.obfuscation.push({
213
+ kind: "base64-blob",
214
+ file: relPath,
215
+ line: lineOf(content, bm.index),
216
+ sample: `${bm[0].length} chars`,
217
+ });
218
+ }
219
+ HEX_ROPE_RE.lastIndex = 0;
220
+ let hm;
221
+ while ((hm = HEX_ROPE_RE.exec(content)) !== null) {
222
+ report.obfuscation.push({
223
+ kind: "hex-escape-rope",
224
+ file: relPath,
225
+ line: lineOf(content, hm.index),
226
+ sample: `${hm[0].length} chars`,
227
+ });
228
+ }
229
+ // HTML comments with imperative verbs hidden in markdown.
230
+ const HTML_IMPERATIVE_RE = /<!--[\s\S]{0,400}?\b(ignore|disregard|pretend|you are|system:|assistant:)\b[\s\S]{0,400}?-->/gi;
231
+ HTML_IMPERATIVE_RE.lastIndex = 0;
232
+ let cm;
233
+ while ((cm = HTML_IMPERATIVE_RE.exec(content)) !== null) {
234
+ report.obfuscation.push({
235
+ kind: "html-comment-imperative",
236
+ file: relPath,
237
+ line: lineOf(content, cm.index),
238
+ sample: cm[0].slice(0, 80).replace(/\s+/g, " "),
239
+ });
240
+ }
241
+ }
242
+ function summarize(report) {
243
+ const reasons = [];
244
+ let sev = "clean";
245
+ const highestSecret = report.secrets.reduce((acc, s) => {
246
+ const order = ["low", "medium", "high", "critical"];
247
+ if (!acc)
248
+ return s.severity;
249
+ return order.indexOf(s.severity) > order.indexOf(acc) ? s.severity : acc;
250
+ }, null);
251
+ if (report.secrets.length > 0) {
252
+ reasons.push(`${report.secrets.length} secret finding(s)`);
253
+ sev = highestSecret ?? "high";
254
+ }
255
+ if (report.highRiskCommands.length > 0) {
256
+ reasons.push(`${report.highRiskCommands.length} high-risk command(s)`);
257
+ if (sev === "clean" || sev === "low")
258
+ sev = "high";
259
+ }
260
+ const hardObf = report.obfuscation.filter((o) => o.kind === "bidi-override" || o.kind === "html-comment-imperative");
261
+ if (hardObf.length > 0) {
262
+ reasons.push(`${hardObf.length} hard obfuscation signal(s)`);
263
+ sev = "critical";
264
+ }
265
+ const softObf = report.obfuscation.filter((o) => o.kind === "zero-width-char" ||
266
+ o.kind === "base64-blob" ||
267
+ o.kind === "hex-escape-rope");
268
+ if (softObf.length > 0) {
269
+ reasons.push(`${softObf.length} obfuscation signal(s)`);
270
+ if (sev === "clean")
271
+ sev = "medium";
272
+ }
273
+ if (report.inventory.suspiciousFiles.length > 0) {
274
+ reasons.push(`${report.inventory.suspiciousFiles.length} suspicious file(s)`);
275
+ if (sev === "clean" || sev === "low")
276
+ sev = "medium";
277
+ }
278
+ report.summary = {
279
+ severity: sev,
280
+ findings: report.secrets.length +
281
+ report.highRiskCommands.length +
282
+ report.obfuscation.length +
283
+ report.inventory.suspiciousFiles.length,
284
+ reasons,
285
+ };
286
+ }
287
+ function buildReport(rootInput, resolvedPath, kind, source) {
288
+ const report = {
289
+ target: { input: rootInput, kind, resolvedPath, source },
290
+ frontmatter: [],
291
+ secrets: [],
292
+ urls: [],
293
+ highRiskCommands: [],
294
+ obfuscation: [],
295
+ inventory: { textFiles: 0, binaryFiles: 0, suspiciousFiles: [] },
296
+ summary: { severity: "clean", findings: 0, reasons: [] },
297
+ };
298
+ const patternEngine = new PatternEngine(DEFAULT_SECRET_PATTERNS);
299
+ const urlSet = new Set();
300
+ const files = kind === "file" ? [resolvedPath] : walkFiles(resolvedPath);
301
+ for (const file of files) {
302
+ const relPath = path.relative(kind === "file" ? path.dirname(resolvedPath) : resolvedPath, file) || path.basename(file);
303
+ let stat;
304
+ try {
305
+ stat = fs.statSync(file);
306
+ }
307
+ catch {
308
+ continue;
309
+ }
310
+ if (!stat.isFile())
311
+ continue;
312
+ const ext = path.extname(file).toLowerCase();
313
+ if (SUSPICIOUS_EXT.has(ext)) {
314
+ report.inventory.suspiciousFiles.push({ path: relPath, bytes: stat.size, kind: "binary" });
315
+ report.inventory.binaryFiles += 1;
316
+ continue;
317
+ }
318
+ if (stat.size > MAX_FILE_BYTES) {
319
+ report.inventory.suspiciousFiles.push({ path: relPath, bytes: stat.size, kind: "binary" });
320
+ report.inventory.binaryFiles += 1;
321
+ continue;
322
+ }
323
+ let buf;
324
+ try {
325
+ buf = fs.readFileSync(file);
326
+ }
327
+ catch {
328
+ continue;
329
+ }
330
+ if (looksBinary(buf)) {
331
+ report.inventory.binaryFiles += 1;
332
+ if (!TEXT_EXT.has(ext)) {
333
+ report.inventory.suspiciousFiles.push({ path: relPath, bytes: stat.size, kind: "binary" });
334
+ }
335
+ continue;
336
+ }
337
+ report.inventory.textFiles += 1;
338
+ const content = buf.toString("utf-8");
339
+ if (path.basename(file).toLowerCase() === "skill.md") {
340
+ const fm = parseFrontmatter(content);
341
+ report.frontmatter.push({
342
+ file: relPath,
343
+ name: fm.name,
344
+ version: fm.version,
345
+ description: fm.description,
346
+ allowedTools: parseAllowedTools(fm["allowed-tools"]),
347
+ });
348
+ }
349
+ scanContent(relPath, content, patternEngine, report);
350
+ }
351
+ for (const u of report.urls)
352
+ urlSet.add(u);
353
+ report.urls = [...urlSet].sort();
354
+ report.inventory.suspiciousFiles.sort((a, b) => a.path.localeCompare(b.path));
355
+ summarize(report);
356
+ return report;
357
+ }
358
+ function renderText(report) {
359
+ console.log(fmt.header(`Skill review: ${report.target.input}`));
360
+ console.log(fmt.divider());
361
+ const fm = report.frontmatter[0];
362
+ if (fm?.name) {
363
+ const ver = fm.version ? ` v${fm.version}` : "";
364
+ console.log(`Skill: ${fm.name}${ver}`);
365
+ if (fm.allowedTools && fm.allowedTools.length > 0) {
366
+ console.log(`allowed-tools: ${fm.allowedTools.join(", ")}`);
367
+ }
368
+ }
369
+ console.log(`Files: ${report.inventory.textFiles} text, ${report.inventory.binaryFiles} binary, ${report.inventory.suspiciousFiles.length} suspicious`);
370
+ console.log();
371
+ const line = (ok, label, detail) => console.log(`${ok ? fmt.success(label) : fmt.warning(label)}${detail ? ` ${detail}` : ""}`);
372
+ line(report.secrets.length === 0, `Secrets: ${report.secrets.length}`);
373
+ if (report.secrets.length > 0) {
374
+ for (const s of report.secrets.slice(0, 5)) {
375
+ console.log(` - [${s.severity}] ${s.pattern} at ${s.file}${s.line ? `:${s.line}` : ""}`);
376
+ }
377
+ if (report.secrets.length > 5)
378
+ console.log(` ... and ${report.secrets.length - 5} more`);
379
+ }
380
+ line(report.highRiskCommands.length === 0, `High-risk commands: ${report.highRiskCommands.length}`);
381
+ for (const c of report.highRiskCommands.slice(0, 5)) {
382
+ console.log(` - ${c.command} at ${c.file}:${c.line}`);
383
+ }
384
+ if (report.highRiskCommands.length > 5) {
385
+ console.log(` ... and ${report.highRiskCommands.length - 5} more`);
386
+ }
387
+ line(report.obfuscation.length === 0, `Obfuscation signals: ${report.obfuscation.length}`);
388
+ for (const o of report.obfuscation.slice(0, 5)) {
389
+ console.log(` - ${o.kind} at ${o.file}:${o.line} (${o.sample})`);
390
+ }
391
+ if (report.obfuscation.length > 5) {
392
+ console.log(` ... and ${report.obfuscation.length - 5} more`);
393
+ }
394
+ line(report.inventory.suspiciousFiles.length === 0, `Suspicious files: ${report.inventory.suspiciousFiles.length}`);
395
+ for (const f of report.inventory.suspiciousFiles.slice(0, 5)) {
396
+ console.log(` - ${f.path} (${f.bytes} bytes)`);
397
+ }
398
+ line(report.urls.length === 0, `External URLs: ${report.urls.length}`);
399
+ for (const u of report.urls.slice(0, 8)) {
400
+ console.log(` - ${u}`);
401
+ }
402
+ if (report.urls.length > 8)
403
+ console.log(` ... and ${report.urls.length - 8} more`);
404
+ console.log();
405
+ const sev = report.summary.severity.toUpperCase();
406
+ const label = `Overall: ${sev}`;
407
+ if (report.summary.severity === "clean")
408
+ console.log(fmt.success(label));
409
+ else if (report.summary.severity === "critical" || report.summary.severity === "high")
410
+ console.error(fmt.error(label));
411
+ else
412
+ console.log(fmt.warning(label));
413
+ if (report.summary.reasons.length > 0) {
414
+ console.log(` ${report.summary.reasons.join(", ")}`);
415
+ }
416
+ console.log();
417
+ console.log(fmt.info("Deterministic checks only. Pair with the `rafter-skill-review` skill for provenance / prompt-injection / data-practices review."));
418
+ }
419
+ const _SEVERITY_ORDER_LOCAL = [
420
+ "clean",
421
+ "low",
422
+ "medium",
423
+ "high",
424
+ "critical",
425
+ ];
426
+ function buildMultiReport(rootInput, resolvedPath, kind, skills, source) {
427
+ const severityCounts = {
428
+ clean: 0,
429
+ low: 0,
430
+ medium: 0,
431
+ high: 0,
432
+ critical: 0,
433
+ };
434
+ let worst = "clean";
435
+ let findings = 0;
436
+ const entries = [];
437
+ for (const loc of skills) {
438
+ // Each skill is audited scoped to its containing dir.
439
+ const sub = buildReport(rootInput, loc.dir, "directory", source);
440
+ // Overwrite target.kind / skillRelDir so the per-skill target still
441
+ // carries enough context to be useful standalone.
442
+ sub.target.kind = kind;
443
+ sub.target.skillRelDir = loc.relDir;
444
+ const fm = sub.frontmatter.find((f) => f.file.toLowerCase().endsWith("skill.md"));
445
+ entries.push({
446
+ relDir: loc.relDir,
447
+ name: fm?.name,
448
+ version: fm?.version,
449
+ report: sub,
450
+ });
451
+ severityCounts[sub.summary.severity] += 1;
452
+ findings += sub.summary.findings;
453
+ if (_SEVERITY_ORDER_LOCAL.indexOf(sub.summary.severity) >
454
+ _SEVERITY_ORDER_LOCAL.indexOf(worst)) {
455
+ worst = sub.summary.severity;
456
+ }
457
+ }
458
+ const reasons = [];
459
+ if (severityCounts.critical > 0)
460
+ reasons.push(`${severityCounts.critical} critical skill(s)`);
461
+ if (severityCounts.high > 0)
462
+ reasons.push(`${severityCounts.high} high-severity skill(s)`);
463
+ if (severityCounts.medium > 0)
464
+ reasons.push(`${severityCounts.medium} medium-severity skill(s)`);
465
+ if (severityCounts.low > 0)
466
+ reasons.push(`${severityCounts.low} low-severity skill(s)`);
467
+ if (reasons.length === 0)
468
+ reasons.push(`${severityCounts.clean} clean skill(s)`);
469
+ return {
470
+ target: {
471
+ input: rootInput,
472
+ kind,
473
+ resolvedPath,
474
+ mode: "multi-skill",
475
+ source,
476
+ },
477
+ skills: entries,
478
+ summary: {
479
+ totalSkills: entries.length,
480
+ severityCounts,
481
+ findings,
482
+ worst,
483
+ reasons,
484
+ },
485
+ };
486
+ }
487
+ function renderMultiText(report) {
488
+ console.log(fmt.header(`Skill review: ${report.target.input}`));
489
+ console.log(fmt.divider());
490
+ console.log(`Mode: multi-skill (${report.summary.totalSkills} SKILL.md files)`);
491
+ if (report.target.source?.sha)
492
+ console.log(`Commit: ${report.target.source.sha.slice(0, 12)}`);
493
+ if (report.target.source?.version)
494
+ console.log(`Version: ${report.target.source.version}`);
495
+ if (report.target.source?.cacheHit)
496
+ console.log(`Cache: hit`);
497
+ console.log();
498
+ for (const s of report.skills) {
499
+ const sev = s.report.summary.severity.toUpperCase();
500
+ const line = ` ${s.relDir.padEnd(40)} [${sev}] ${s.report.summary.findings} finding(s)`;
501
+ if (s.report.summary.severity === "critical" || s.report.summary.severity === "high") {
502
+ console.error(fmt.error(line));
503
+ }
504
+ else if (s.report.summary.severity === "medium" || s.report.summary.severity === "low") {
505
+ console.log(fmt.warning(line));
506
+ }
507
+ else {
508
+ console.log(fmt.success(line));
509
+ }
510
+ }
511
+ console.log();
512
+ console.log(`Worst severity: ${report.summary.worst.toUpperCase()} — ${report.summary.reasons.join(", ")}`);
513
+ }
514
+ function resolveShorthand(input, parsed, opts) {
515
+ const cacheRoot = opts.cacheRoot ?? defaultCacheRoot();
516
+ const ttlMs = opts.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
517
+ const ops = opts.ops ?? defaultRemoteOps;
518
+ if (parsed.kind === "npm") {
519
+ return resolveNpm(input, parsed, { ...opts, cacheRoot, cacheTtlMs: ttlMs, ops });
520
+ }
521
+ return resolveGit(input, parsed, { ...opts, cacheRoot, cacheTtlMs: ttlMs, ops });
522
+ }
523
+ function resolveGit(input, parsed, opts) {
524
+ const { cacheRoot, cacheTtlMs, ops } = opts;
525
+ let sha;
526
+ if (!opts.noCache) {
527
+ const r = readResolution(cacheRoot, input);
528
+ if (r && resolutionIsFresh(r, cacheTtlMs) && r.sha)
529
+ sha = r.sha;
530
+ }
531
+ if (!sha) {
532
+ sha = ops.gitLsRemoteHead(parsed.gitUrl);
533
+ if (!opts.noCache) {
534
+ writeResolution(cacheRoot, { shorthand: input, sha, resolvedAt: Date.now() });
535
+ }
536
+ }
537
+ const key = contentKeyGit(parsed, sha);
538
+ let cacheHit = false;
539
+ let treeRoot;
540
+ let cleanup = null;
541
+ if (!opts.noCache && contentIsUsable(cacheRoot, key)) {
542
+ treeRoot = contentWorkingTree(cacheRoot, key);
543
+ cacheHit = true;
544
+ }
545
+ else {
546
+ // Corrupt cache guard: drop and re-fetch.
547
+ if (!opts.noCache)
548
+ dropCacheEntry(cacheRoot, key);
549
+ if (opts.noCache) {
550
+ treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-skill-review-"));
551
+ cleanup = () => {
552
+ try {
553
+ fs.rmSync(treeRoot, { recursive: true, force: true });
554
+ }
555
+ catch { /* ignore */ }
556
+ };
557
+ ops.gitCloneAtSha(parsed.gitUrl, sha, treeRoot);
558
+ }
559
+ else {
560
+ const dir = contentDir(cacheRoot, key);
561
+ fs.mkdirSync(dir, { recursive: true });
562
+ treeRoot = contentWorkingTree(cacheRoot, key);
563
+ ops.gitCloneAtSha(parsed.gitUrl, sha, treeRoot);
564
+ const meta = {
565
+ source: "git",
566
+ shorthand: input,
567
+ key,
568
+ sha,
569
+ fetchedAt: Date.now(),
570
+ };
571
+ fs.writeFileSync(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2));
572
+ }
573
+ }
574
+ const kind = parsed.kind === "github" ? "github" : "gitlab";
575
+ const subpath = parsed.subpath && parsed.subpath.length > 0 ? parsed.subpath : undefined;
576
+ let resolvedPath = treeRoot;
577
+ if (subpath) {
578
+ const candidate = path.join(treeRoot, subpath);
579
+ if (!fs.existsSync(candidate)) {
580
+ if (cleanup)
581
+ cleanup();
582
+ throw new Error(`Subpath not found in ${parsed.kind}:${parsed.owner}/${parsed.repo}: ${subpath}`);
583
+ }
584
+ resolvedPath = candidate;
585
+ }
586
+ return {
587
+ kind,
588
+ resolvedPath,
589
+ treeRoot: resolvedPath, // multi-skill discovery runs inside subpath if given
590
+ source: {
591
+ url: parsed.gitUrl,
592
+ sha,
593
+ subpath,
594
+ cacheHit,
595
+ },
596
+ cleanup,
597
+ };
598
+ }
599
+ function resolveNpm(input, parsed, opts) {
600
+ const { cacheRoot, cacheTtlMs, ops } = opts;
601
+ let resolvedVersion;
602
+ let tarballUrl;
603
+ if (!opts.noCache) {
604
+ const r = readResolution(cacheRoot, input);
605
+ if (r && resolutionIsFresh(r, cacheTtlMs) && r.version)
606
+ resolvedVersion = r.version;
607
+ }
608
+ // We need the tarball URL regardless of cache — unless content cache is hit
609
+ // at a known version. Peek content cache first if we have a resolved version.
610
+ const probeKey = (v) => contentKeyNpm(parsed.pkg, v);
611
+ if (!resolvedVersion || !(!opts.noCache && contentIsUsable(cacheRoot, probeKey(resolvedVersion)))) {
612
+ // Fetch metadata to resolve version and get tarball URL.
613
+ const meta = ops.npmFetchMetadata(parsed.pkg);
614
+ const want = parsed.version ?? "latest";
615
+ let concrete;
616
+ if (want === "latest") {
617
+ concrete = meta["dist-tags"]?.["latest"];
618
+ }
619
+ else if (meta["dist-tags"]?.[want]) {
620
+ concrete = meta["dist-tags"][want];
621
+ }
622
+ else {
623
+ concrete = want;
624
+ }
625
+ if (!concrete || !meta.versions || !meta.versions[concrete]) {
626
+ throw new Error(`npm:${parsed.pkg}: unknown version "${want}"`);
627
+ }
628
+ tarballUrl = meta.versions[concrete].dist?.tarball;
629
+ if (!tarballUrl)
630
+ throw new Error(`npm:${parsed.pkg}@${concrete}: no tarball URL`);
631
+ resolvedVersion = concrete;
632
+ if (!opts.noCache) {
633
+ writeResolution(cacheRoot, {
634
+ shorthand: input,
635
+ version: concrete,
636
+ resolvedAt: Date.now(),
637
+ });
638
+ }
639
+ }
640
+ const key = contentKeyNpm(parsed.pkg, resolvedVersion);
641
+ let cacheHit = false;
642
+ let treeRoot;
643
+ let cleanup = null;
644
+ if (!opts.noCache && contentIsUsable(cacheRoot, key)) {
645
+ treeRoot = contentWorkingTree(cacheRoot, key);
646
+ cacheHit = true;
647
+ }
648
+ else {
649
+ if (!opts.noCache)
650
+ dropCacheEntry(cacheRoot, key);
651
+ // Need tarballUrl if we didn't fetch metadata above. Refetch if missing.
652
+ if (!tarballUrl) {
653
+ const meta = ops.npmFetchMetadata(parsed.pkg);
654
+ tarballUrl = meta.versions?.[resolvedVersion]?.dist?.tarball;
655
+ if (!tarballUrl)
656
+ throw new Error(`npm:${parsed.pkg}@${resolvedVersion}: no tarball URL`);
657
+ }
658
+ if (opts.noCache) {
659
+ treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "rafter-skill-review-"));
660
+ cleanup = () => {
661
+ try {
662
+ fs.rmSync(treeRoot, { recursive: true, force: true });
663
+ }
664
+ catch { /* ignore */ }
665
+ };
666
+ const tgz = path.join(treeRoot, "package.tgz");
667
+ ops.npmFetchTarball(tarballUrl, tgz);
668
+ extractNpmTarball(tgz, treeRoot);
669
+ try {
670
+ fs.unlinkSync(tgz);
671
+ }
672
+ catch { /* ignore */ }
673
+ }
674
+ else {
675
+ const dir = contentDir(cacheRoot, key);
676
+ fs.mkdirSync(dir, { recursive: true });
677
+ treeRoot = contentWorkingTree(cacheRoot, key);
678
+ fs.mkdirSync(treeRoot, { recursive: true });
679
+ const tgz = path.join(dir, "package.tgz");
680
+ ops.npmFetchTarball(tarballUrl, tgz);
681
+ extractNpmTarball(tgz, treeRoot);
682
+ try {
683
+ fs.unlinkSync(tgz);
684
+ }
685
+ catch { /* ignore */ }
686
+ const meta = {
687
+ source: "npm",
688
+ shorthand: input,
689
+ key,
690
+ version: resolvedVersion,
691
+ fetchedAt: Date.now(),
692
+ };
693
+ fs.writeFileSync(path.join(dir, "meta.json"), JSON.stringify(meta, null, 2));
694
+ }
695
+ }
696
+ return {
697
+ kind: "npm",
698
+ resolvedPath: treeRoot,
699
+ treeRoot,
700
+ source: {
701
+ url: tarballUrl,
702
+ version: resolvedVersion,
703
+ cacheHit,
704
+ },
705
+ cleanup,
706
+ };
707
+ }
708
+ export function runSkillReview(input, opts) {
709
+ let resolved = input;
710
+ let kind;
711
+ let cleanup = null;
712
+ let source;
713
+ let treeRoot = null;
714
+ try {
715
+ if (isShorthand(input)) {
716
+ let parsed;
717
+ try {
718
+ parsed = parseShorthand(input);
719
+ }
720
+ catch (e) {
721
+ console.error(fmt.error(e instanceof Error ? e.message : String(e)));
722
+ return { report: null, exitCode: 2 };
723
+ }
724
+ let r;
725
+ try {
726
+ r = resolveShorthand(input, parsed, opts);
727
+ }
728
+ catch (e) {
729
+ console.error(fmt.error(e instanceof Error ? e.message : String(e)));
730
+ return { report: null, exitCode: 2 };
731
+ }
732
+ resolved = r.resolvedPath;
733
+ kind = r.kind;
734
+ source = r.source;
735
+ cleanup = r.cleanup;
736
+ treeRoot = r.treeRoot;
737
+ }
738
+ else if (isGitUrl(input)) {
739
+ try {
740
+ resolved = cloneShallow(input);
741
+ }
742
+ catch (e) {
743
+ console.error(fmt.error(`${e instanceof Error ? e.message : String(e)}`));
744
+ return { report: null, exitCode: 2 };
745
+ }
746
+ const dir = resolved;
747
+ cleanup = () => {
748
+ try {
749
+ fs.rmSync(dir, { recursive: true, force: true });
750
+ }
751
+ catch { /* ignore */ }
752
+ };
753
+ kind = "git-url";
754
+ treeRoot = resolved;
755
+ }
756
+ else if (!fs.existsSync(input)) {
757
+ console.error(fmt.error(`Not found: ${input}`));
758
+ return { report: null, exitCode: 2 };
759
+ }
760
+ else {
761
+ const stat = fs.statSync(input);
762
+ resolved = path.resolve(input);
763
+ kind = stat.isDirectory() ? "directory" : "file";
764
+ treeRoot = stat.isDirectory() ? resolved : path.dirname(resolved);
765
+ }
766
+ // Multi-SKILL.md handling: only applicable when scanning a directory.
767
+ let report;
768
+ if (kind !== "file" && treeRoot) {
769
+ const locations = findSkillFiles(resolved);
770
+ if (locations.length > 1) {
771
+ report = buildMultiReport(input, resolved, kind, locations, source);
772
+ }
773
+ else {
774
+ report = buildReport(input, resolved, kind, source);
775
+ }
776
+ }
777
+ else {
778
+ report = buildReport(input, resolved, kind, source);
779
+ }
780
+ const format = opts.json ? "json" : opts.format ?? "text";
781
+ if (format === "json") {
782
+ console.log(JSON.stringify(report, null, 2));
783
+ }
784
+ else {
785
+ if ("skills" in report) {
786
+ renderMultiText(report);
787
+ }
788
+ else {
789
+ renderText(report);
790
+ }
791
+ }
792
+ const sev = "skills" in report ? report.summary.worst : report.summary.severity;
793
+ const exitCode = sev === "clean" ? 0 : 1;
794
+ return { report, exitCode };
795
+ }
796
+ finally {
797
+ if (cleanup) {
798
+ try {
799
+ cleanup();
800
+ }
801
+ catch { /* non-fatal */ }
802
+ }
803
+ }
804
+ }
805
+ const SEVERITY_ORDER = ["clean", "low", "medium", "high", "critical"];
806
+ export function runSkillReviewInstalled(opts) {
807
+ let filter;
808
+ if (opts.agent) {
809
+ const a = opts.agent;
810
+ if (!SKILL_PLATFORMS.includes(a)) {
811
+ throw new Error(`Unknown agent: ${opts.agent}. Known: ${SKILL_PLATFORMS.join(", ")}`);
812
+ }
813
+ filter = a;
814
+ }
815
+ const discovered = discoverInstalledSkills(filter);
816
+ const installations = [];
817
+ const severityCounts = {
818
+ clean: 0,
819
+ low: 0,
820
+ medium: 0,
821
+ high: 0,
822
+ critical: 0,
823
+ };
824
+ const platformCounts = {};
825
+ let findings = 0;
826
+ let worst = "clean";
827
+ for (const d of discovered) {
828
+ const report = buildReport(d.path, d.path, "file");
829
+ installations.push({
830
+ platform: d.platform,
831
+ skill: d.name,
832
+ path: d.path,
833
+ report,
834
+ });
835
+ severityCounts[report.summary.severity] += 1;
836
+ platformCounts[d.platform] = (platformCounts[d.platform] ?? 0) + 1;
837
+ findings += report.summary.findings;
838
+ if (SEVERITY_ORDER.indexOf(report.summary.severity) >
839
+ SEVERITY_ORDER.indexOf(worst)) {
840
+ worst = report.summary.severity;
841
+ }
842
+ }
843
+ const aggregate = {
844
+ target: { mode: "installed", agent: filter ?? "all" },
845
+ installations,
846
+ summary: {
847
+ totalSkills: installations.length,
848
+ severityCounts,
849
+ platformCounts,
850
+ findings,
851
+ worst,
852
+ },
853
+ };
854
+ // Exit 1 iff any HIGH or CRITICAL finding (per rf-61x contract). Lower
855
+ // severities do not fail the audit — use `rafter skill review <path>` for
856
+ // a stricter per-skill gate.
857
+ const exitCode = severityCounts.high + severityCounts.critical > 0 ? 1 : 0;
858
+ return { report: aggregate, exitCode };
859
+ }
860
+ function renderInstalledSummary(report) {
861
+ console.log(fmt.header(`Installed skill audit`));
862
+ console.log(fmt.divider());
863
+ const agent = report.target.agent;
864
+ console.log(`Agent filter: ${agent}`);
865
+ console.log(`Skills audited: ${report.summary.totalSkills}`);
866
+ console.log();
867
+ if (report.installations.length === 0) {
868
+ console.log(fmt.info("No installed skills found across the requested platform(s)."));
869
+ return;
870
+ }
871
+ // Column widths — clamp platform col at "claude-code" length (11) and skill
872
+ // col at 28 so the table stays readable on 80-col terminals.
873
+ const platW = 11;
874
+ const skillW = 28;
875
+ const sevW = 8;
876
+ const head = "PLATFORM".padEnd(platW) +
877
+ " " +
878
+ "SKILL".padEnd(skillW) +
879
+ " " +
880
+ "SEVERITY".padEnd(sevW) +
881
+ " " +
882
+ "FINDINGS";
883
+ console.log(head);
884
+ console.log("-".repeat(head.length));
885
+ for (const row of report.installations) {
886
+ const skill = row.skill.length > skillW ? row.skill.slice(0, skillW - 1) + "…" : row.skill;
887
+ const sev = row.report.summary.severity;
888
+ const findings = row.report.summary.findings;
889
+ const line = row.platform.padEnd(platW) +
890
+ " " +
891
+ skill.padEnd(skillW) +
892
+ " " +
893
+ sev.padEnd(sevW) +
894
+ " " +
895
+ String(findings);
896
+ if (sev === "critical" || sev === "high")
897
+ console.error(fmt.error(line));
898
+ else if (sev === "medium" || sev === "low")
899
+ console.log(fmt.warning(line));
900
+ else
901
+ console.log(fmt.success(line));
902
+ }
903
+ console.log();
904
+ const sc = report.summary.severityCounts;
905
+ console.log(`Totals: ${sc.clean} clean · ${sc.low} low · ${sc.medium} medium · ${sc.high} high · ${sc.critical} critical`);
906
+ if (sc.high + sc.critical > 0) {
907
+ console.error(fmt.error(`Worst severity: ${report.summary.worst.toUpperCase()} — review flagged skills before trusting them.`));
908
+ }
909
+ else {
910
+ console.log(fmt.success(`Worst severity: ${report.summary.worst.toUpperCase()}`));
911
+ }
912
+ }
913
+ /** Parse `--cache-ttl` values like "24h", "30m", "3600s", "1d", or bare seconds. */
914
+ export function parseCacheTtl(raw) {
915
+ const m = String(raw).trim().match(/^(\d+)\s*([smhd]?)$/i);
916
+ if (!m)
917
+ throw new Error(`Invalid --cache-ttl: ${raw} (try 24h / 30m / 3600s / 1d)`);
918
+ const n = parseInt(m[1], 10);
919
+ const unit = (m[2] || "s").toLowerCase();
920
+ const mult = unit === "s" ? 1000 : unit === "m" ? 60000 : unit === "h" ? 3600000 : 86400000;
921
+ return n * mult;
922
+ }
923
+ export function createReviewCommand() {
924
+ return new Command("review")
925
+ .description("Security review of a skill / plugin / extension before installing it. Accepts a local path, git URL, or shorthand (github:/gitlab:/npm:); or --installed to audit every skill on this machine.")
926
+ .argument("[path-or-url]", "Local path (file or directory) OR git URL (https/ssh/.git) OR shorthand (github:owner/repo[/subpath], gitlab:owner/repo[/subpath], npm:pkg[@version]). Omit when using --installed.")
927
+ .option("--json", "Emit JSON report to stdout (shortcut for --format json)")
928
+ .option("--format <format>", "Output format: text | json", "text")
929
+ .option("--installed", "Audit every installed skill across detected agent skill directories instead of a path")
930
+ .option("--agent <name>", `Restrict --installed to a single agent. One of: ${SKILL_PLATFORMS.join(", ")}`)
931
+ .option("--summary", "Print a terse human-readable table instead of JSON (only with --installed)")
932
+ .option("--cache-ttl <duration>", "TTL for the persistent skill-cache resolution entries (e.g. 24h, 30m, 3600s). Default: 24h.", "24h")
933
+ .option("--no-cache", "Bypass the persistent skill-cache; fetch fresh and skip writes.")
934
+ .action((input, opts) => {
935
+ if (opts.installed) {
936
+ if (input) {
937
+ console.error(fmt.error("Cannot pass both <path-or-url> and --installed. Use one."));
938
+ process.exit(1);
939
+ }
940
+ let result;
941
+ try {
942
+ result = runSkillReviewInstalled({ agent: opts.agent });
943
+ }
944
+ catch (e) {
945
+ console.error(fmt.error(`${e instanceof Error ? e.message : String(e)}`));
946
+ process.exit(1);
947
+ }
948
+ if (opts.summary) {
949
+ renderInstalledSummary(result.report);
950
+ }
951
+ else {
952
+ console.log(JSON.stringify(result.report, null, 2));
953
+ }
954
+ process.exit(result.exitCode);
955
+ }
956
+ if (!input) {
957
+ console.error(fmt.error("Missing <path-or-url>. Pass a path / git URL / shorthand, or use --installed to audit installed skills."));
958
+ process.exit(2);
959
+ }
960
+ let ttlMs;
961
+ try {
962
+ ttlMs = parseCacheTtl(opts.cacheTtl ?? "24h");
963
+ }
964
+ catch (e) {
965
+ console.error(fmt.error(e instanceof Error ? e.message : String(e)));
966
+ process.exit(2);
967
+ }
968
+ const { exitCode } = runSkillReview(input, {
969
+ ...opts,
970
+ noCache: opts.cache === false,
971
+ cacheTtlMs: ttlMs,
972
+ });
973
+ process.exit(exitCode);
974
+ });
975
+ }