javi-forge 1.25.1 → 1.27.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 (49) hide show
  1. package/ci-local/hooks/commit-msg +7 -0
  2. package/ci-local/hooks/pre-commit +8 -0
  3. package/ci-local/hooks/pre-push +8 -0
  4. package/dist/cli/dispatch/ci.js +1 -1
  5. package/dist/cli/dispatch/skills-cmd.js +8 -0
  6. package/dist/commands/ci.js +19 -5
  7. package/dist/commands/doctor.d.ts +8 -0
  8. package/dist/commands/doctor.js +121 -0
  9. package/dist/commands/hooks/sections/deps.d.ts +28 -0
  10. package/dist/commands/hooks/sections/deps.js +126 -0
  11. package/dist/commands/hooks/sections/permissions.d.ts +33 -0
  12. package/dist/commands/hooks/sections/permissions.js +101 -0
  13. package/dist/commands/hooks/sections/secrets.d.ts +58 -0
  14. package/dist/commands/hooks/sections/secrets.js +182 -0
  15. package/dist/commands/hooks.d.ts +4 -4
  16. package/dist/commands/hooks.js +13 -4
  17. package/dist/commands/init/steps/ghagga.d.ts +3 -4
  18. package/dist/commands/init/steps/ghagga.js +5 -15
  19. package/dist/commands/init/steps/security.d.ts +10 -20
  20. package/dist/commands/init/steps/security.js +58 -78
  21. package/dist/commands/init.js +1 -2
  22. package/dist/commands/skills/analysis.js +31 -2
  23. package/dist/commands/skills/benchmark.js +10 -0
  24. package/dist/commands/skills/constants.d.ts +5 -0
  25. package/dist/commands/skills/constants.js +5 -0
  26. package/dist/commands/skills/parsing.d.ts +18 -3
  27. package/dist/commands/skills/parsing.js +29 -3
  28. package/dist/commands/skills/scoring.d.ts +7 -6
  29. package/dist/commands/skills/scoring.js +31 -1
  30. package/dist/constants.d.ts +6 -1
  31. package/dist/constants.js +12 -7
  32. package/dist/lib/context.d.ts +22 -0
  33. package/dist/lib/context.js +120 -79
  34. package/dist/lib/safe-read.d.ts +62 -0
  35. package/dist/lib/safe-read.js +221 -0
  36. package/dist/lib/security-analysis.d.ts +19 -2
  37. package/dist/lib/security-analysis.js +65 -13
  38. package/dist/lib/skill-scanner.d.ts +22 -1
  39. package/dist/lib/skill-scanner.js +76 -4
  40. package/dist/types/index.d.ts +18 -0
  41. package/dist/ui/Skills.js +12 -7
  42. package/package.json +1 -1
  43. package/templates/github/ghagga-review.yml +0 -30
  44. package/templates/security-hooks/commit-msg-signing +0 -29
  45. package/templates/security-hooks/pre-commit-permissions +0 -74
  46. package/templates/security-hooks/pre-commit-secrets +0 -74
  47. package/templates/security-hooks/pre-push-branch-protection +0 -62
  48. package/templates/security-hooks/pre-push-deps +0 -83
  49. package/templates/security-hooks/pre-push-signing +0 -67
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Bounded, non-throwing file reads.
3
+ *
4
+ * Every whole-file read in this CLI used to be an unguarded
5
+ * `fs.readFile(path, "utf-8")`: a 400 MB log, a minified bundle or a binary
6
+ * blob under a scanned directory could exhaust memory or stall a scan. This
7
+ * module is the single guarded entry point — it caps bytes during the read,
8
+ * rejects binaries by content sniffing, clamps pathological single lines, and
9
+ * returns a discriminated union instead of throwing for expected conditions.
10
+ *
11
+ * Deliberately NOT included: no path allow/block list. This is a local CLI
12
+ * operating on the user's own repository, so any path they can name they can
13
+ * already `cat`; a blocklist would add friction without adding a boundary.
14
+ */
15
+ import { open, stat } from "node:fs/promises";
16
+ // =============================================================================
17
+ // Constants
18
+ // =============================================================================
19
+ /** Default byte budget for a single read (1 MiB). */
20
+ export const DEFAULT_MAX_BYTES = 1024 * 1024;
21
+ /** Default per-line character clamp — catches minified bundles and data URIs. */
22
+ export const DEFAULT_MAX_LINE_LENGTH = 10_000;
23
+ /** Size of each read from the file handle. */
24
+ const READ_CHUNK_BYTES = 64 * 1024;
25
+ /** Bytes of the first chunk sniffed for NUL when classifying binary content. */
26
+ const BINARY_SNIFF_BYTES = 8 * 1024;
27
+ const BOM = "\uFEFF";
28
+ // =============================================================================
29
+ // Internal helpers
30
+ // =============================================================================
31
+ function errnoOf(err) {
32
+ return typeof err === "object" && err !== null && "code" in err
33
+ ? String(err.code)
34
+ : undefined;
35
+ }
36
+ function messageOf(err) {
37
+ return err instanceof Error ? err.message : String(err);
38
+ }
39
+ /**
40
+ * Drop a trailing partial UTF-8 sequence so a byte-capped buffer never decodes
41
+ * into a replacement character. Scans back at most 3 bytes (max lead distance
42
+ * for a 4-byte sequence) looking for a lead byte whose sequence would run past
43
+ * the end of the buffer.
44
+ */
45
+ function trimPartialUtf8(buf) {
46
+ const maxLookback = Math.min(3, buf.length);
47
+ for (let back = 0; back < maxLookback; back++) {
48
+ const index = buf.length - 1 - back;
49
+ const byte = buf[index];
50
+ // Continuation byte (10xxxxxx): keep walking back to its lead byte.
51
+ if ((byte & 0xc0) === 0x80)
52
+ continue;
53
+ // ASCII byte: the buffer ends on a complete codepoint.
54
+ if ((byte & 0x80) === 0)
55
+ return buf;
56
+ // Lead byte: does its full sequence fit in what we kept?
57
+ let needed = 0;
58
+ if ((byte & 0xe0) === 0xc0)
59
+ needed = 2;
60
+ else if ((byte & 0xf0) === 0xe0)
61
+ needed = 3;
62
+ else if ((byte & 0xf8) === 0xf0)
63
+ needed = 4;
64
+ else
65
+ return buf; // invalid lead byte — leave it to the decoder
66
+ const available = buf.length - index;
67
+ return available >= needed ? buf : buf.subarray(0, index);
68
+ }
69
+ return buf;
70
+ }
71
+ /** Clamp lines longer than `maxLineLength`, marking how much was dropped. */
72
+ function clampLongLines(content, maxLineLength) {
73
+ if (!Number.isFinite(maxLineLength) || maxLineLength <= 0) {
74
+ return { content, clamped: false };
75
+ }
76
+ if (content.length <= maxLineLength)
77
+ return { content, clamped: false };
78
+ const lines = content.split("\n");
79
+ let clamped = false;
80
+ for (let i = 0; i < lines.length; i++) {
81
+ const line = lines[i];
82
+ if (line.length <= maxLineLength)
83
+ continue;
84
+ const dropped = line.length - maxLineLength;
85
+ lines[i] = `${line.slice(0, maxLineLength)}…[clamped ${dropped} chars]`;
86
+ clamped = true;
87
+ }
88
+ return clamped
89
+ ? { content: lines.join("\n"), clamped }
90
+ : { content, clamped };
91
+ }
92
+ // =============================================================================
93
+ // Public API
94
+ // =============================================================================
95
+ /**
96
+ * Read a text file with a byte budget, binary rejection and line clamping.
97
+ *
98
+ * Never throws for expected conditions (missing file, directory, binary,
99
+ * oversized, permission denied) — inspect `result.ok` and branch on `reason`.
100
+ *
101
+ * Newlines are returned verbatim: CRLF is NOT normalized, because the callers
102
+ * migrated to this helper already tolerate `\r` (they `trim()` split lines) and
103
+ * silently rewriting bytes would make reported offsets diverge from the file.
104
+ */
105
+ export async function safeReadFile(filePath, opts = {}) {
106
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
107
+ const maxLineLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
108
+ const hardRejectOverBytes = opts.hardRejectOverBytes;
109
+ // -- Stat first: classify directories, sockets and missing paths cheaply ---
110
+ let totalBytes;
111
+ try {
112
+ const stats = await stat(filePath);
113
+ if (!stats.isFile()) {
114
+ return {
115
+ ok: false,
116
+ reason: "not-a-file",
117
+ detail: stats.isDirectory()
118
+ ? "path is a directory"
119
+ : "not a regular file",
120
+ };
121
+ }
122
+ totalBytes = stats.size;
123
+ }
124
+ catch (err) {
125
+ const code = errnoOf(err);
126
+ if (code === "ENOENT" || code === "ENOTDIR") {
127
+ return { ok: false, reason: "not-found", detail: filePath };
128
+ }
129
+ return { ok: false, reason: "io-error", detail: messageOf(err) };
130
+ }
131
+ if (hardRejectOverBytes !== undefined && totalBytes > hardRejectOverBytes) {
132
+ return {
133
+ ok: false,
134
+ reason: "too-large",
135
+ detail: `${totalBytes} bytes exceeds limit of ${hardRejectOverBytes}`,
136
+ };
137
+ }
138
+ if (maxBytes <= 0) {
139
+ return {
140
+ ok: true,
141
+ content: "",
142
+ truncated: totalBytes > 0,
143
+ bytesRead: 0,
144
+ totalBytes,
145
+ longLinesClamped: false,
146
+ };
147
+ }
148
+ // -- Read in chunks, enforcing the budget as we go (never buffer it all) ---
149
+ const chunks = [];
150
+ let collected = 0;
151
+ let truncated = false;
152
+ let handle;
153
+ try {
154
+ handle = await open(filePath, "r");
155
+ while (collected < maxBytes) {
156
+ const want = Math.min(READ_CHUNK_BYTES, maxBytes - collected);
157
+ const buf = Buffer.allocUnsafe(want);
158
+ const { bytesRead } = await handle.read(buf, 0, want, null);
159
+ if (bytesRead === 0)
160
+ break;
161
+ const chunk = buf.subarray(0, bytesRead);
162
+ // Binary sniff on the first chunk only — by content, never extension.
163
+ if (chunks.length === 0) {
164
+ const sniff = chunk.subarray(0, BINARY_SNIFF_BYTES);
165
+ if (sniff.includes(0)) {
166
+ return {
167
+ ok: false,
168
+ reason: "binary",
169
+ detail: "NUL byte in first chunk",
170
+ };
171
+ }
172
+ }
173
+ chunks.push(chunk);
174
+ collected += bytesRead;
175
+ }
176
+ // One probe byte tells us whether the budget actually cut something off.
177
+ if (collected >= maxBytes) {
178
+ const probe = Buffer.allocUnsafe(1);
179
+ const { bytesRead } = await handle.read(probe, 0, 1, null);
180
+ truncated = bytesRead > 0;
181
+ }
182
+ }
183
+ catch (err) {
184
+ return { ok: false, reason: "io-error", detail: messageOf(err) };
185
+ }
186
+ finally {
187
+ await handle?.close().catch(() => { });
188
+ }
189
+ let buffer = Buffer.concat(chunks, collected);
190
+ if (truncated)
191
+ buffer = trimPartialUtf8(buffer);
192
+ const bytesRead = buffer.length;
193
+ let content = buffer.toString("utf-8");
194
+ if (content.startsWith(BOM))
195
+ content = content.slice(BOM.length);
196
+ const { content: clampedContent, clamped } = clampLongLines(content, maxLineLength);
197
+ return {
198
+ ok: true,
199
+ content: clampedContent,
200
+ truncated,
201
+ bytesRead,
202
+ totalBytes,
203
+ longLinesClamped: clamped,
204
+ };
205
+ }
206
+ /** Human-readable one-liner for a failed read — for CLI notes and findings. */
207
+ export function describeSafeReadFailure(failure) {
208
+ switch (failure.reason) {
209
+ case "not-found":
210
+ return "file not found";
211
+ case "not-a-file":
212
+ return failure.detail ?? "not a regular file";
213
+ case "binary":
214
+ return "binary file";
215
+ case "too-large":
216
+ return failure.detail ? `too large (${failure.detail})` : "too large";
217
+ case "io-error":
218
+ return failure.detail ? `read error: ${failure.detail}` : "read error";
219
+ }
220
+ }
221
+ //# sourceMappingURL=safe-read.js.map
@@ -25,12 +25,29 @@ export interface SecurityAnalysisReport {
25
25
  projectDir: string;
26
26
  findings: SecurityAnalysisFinding[];
27
27
  summary: SecurityAnalysisSummary;
28
+ /**
29
+ * Files the pattern pass never saw — binary, oversized or unreadable. A scan
30
+ * that silently skipped files is not a clean scan, so they are reported.
31
+ */
32
+ skipped?: SecurityAnalysisSkippedFile[];
33
+ }
34
+ export interface SecurityAnalysisSkippedFile {
35
+ /** Path relative to `projectDir`, matching the finding paths. */
36
+ file: string;
37
+ reason: string;
28
38
  }
29
39
  export interface SecurityAnalysisSummary {
30
40
  total: number;
31
41
  bySeverity: Record<SecuritySeverity, number>;
32
42
  byCategory: Record<string, number>;
33
43
  passed: boolean;
44
+ /**
45
+ * True when at least one file was skipped or only partially scanned (binary,
46
+ * oversized, I/O error, truncated, or clamped). The scan did not see the whole
47
+ * codebase, so `passed` is forced to `false` — a clean result over an
48
+ * incomplete scan would be a fail-open lie.
49
+ */
50
+ incomplete: boolean;
34
51
  failThreshold: SecuritySeverity;
35
52
  }
36
53
  export interface SecurityAnalysisOptions {
@@ -64,8 +81,8 @@ export declare function detectFileLanguage(filePath: string): string | null;
64
81
  export declare function matchRule(rule: SemgrepRule, content: string, filePath: string): SecurityAnalysisFinding[];
65
82
  export declare function collectFiles(dir: string): Promise<string[]>;
66
83
  export declare function severityAtOrAbove(severity: SecuritySeverity, threshold: SecuritySeverity): boolean;
67
- export declare function buildSummary(findings: SecurityAnalysisFinding[], failThreshold: SecuritySeverity): SecurityAnalysisSummary;
68
- export declare function buildReport(findings: SecurityAnalysisFinding[], projectDir: string, options?: SecurityAnalysisOptions): SecurityAnalysisReport;
84
+ export declare function buildSummary(findings: SecurityAnalysisFinding[], failThreshold: SecuritySeverity, incomplete?: boolean): SecurityAnalysisSummary;
85
+ export declare function buildReport(findings: SecurityAnalysisFinding[], projectDir: string, options?: SecurityAnalysisOptions, skipped?: SecurityAnalysisSkippedFile[]): SecurityAnalysisReport;
69
86
  export declare function filterRules(rules: SemgrepRule[], options?: SecurityAnalysisOptions): SemgrepRule[];
70
87
  export declare function loadCustomRules(rulesDir: string): Promise<SemgrepRule[]>;
71
88
  export declare function runSecurityAnalysis(projectDir: string, options?: SecurityAnalysisOptions): Promise<SecurityAnalysisReport>;
@@ -7,9 +7,15 @@
7
7
  */
8
8
  import path from "node:path";
9
9
  import fs from "fs-extra";
10
+ import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
10
11
  // =============================================================================
11
12
  // Constants
12
13
  // =============================================================================
14
+ /**
15
+ * Hard ceiling per scanned file. Source files past this are generated or
16
+ * vendored bundles: running every rule over them costs far more than it finds.
17
+ */
18
+ const MAX_ANALYSIS_BYTES = 2 * 1024 * 1024;
13
19
  const SEVERITY_ORDER = {
14
20
  critical: 5,
15
21
  high: 4,
@@ -316,7 +322,7 @@ export function severityAtOrAbove(severity, threshold) {
316
322
  // =============================================================================
317
323
  // Report generation
318
324
  // =============================================================================
319
- export function buildSummary(findings, failThreshold) {
325
+ export function buildSummary(findings, failThreshold, incomplete = false) {
320
326
  const bySeverity = {
321
327
  critical: 0,
322
328
  high: 0,
@@ -329,23 +335,29 @@ export function buildSummary(findings, failThreshold) {
329
335
  bySeverity[f.severity]++;
330
336
  byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
331
337
  }
332
- const passed = !findings.some((f) => severityAtOrAbove(f.severity, failThreshold));
338
+ // Fail closed on an incomplete scan: a file we never fully read could hold the
339
+ // very finding that would have failed the gate. No threshold finding is not
340
+ // the same as "clean" when part of the codebase was invisible to the scan.
341
+ const noThresholdFinding = !findings.some((f) => severityAtOrAbove(f.severity, failThreshold));
342
+ const passed = noThresholdFinding && !incomplete;
333
343
  return {
334
344
  total: findings.length,
335
345
  bySeverity,
336
346
  byCategory,
337
347
  passed,
348
+ incomplete,
338
349
  failThreshold,
339
350
  };
340
351
  }
341
- export function buildReport(findings, projectDir, options = {}) {
352
+ export function buildReport(findings, projectDir, options = {}, skipped = []) {
342
353
  const failThreshold = options.failThreshold ?? "high";
343
354
  return {
344
355
  engine: "semgrep",
345
356
  timestamp: new Date().toISOString(),
346
357
  projectDir,
347
358
  findings,
348
- summary: buildSummary(findings, failThreshold),
359
+ summary: buildSummary(findings, failThreshold, skipped.length > 0),
360
+ ...(skipped.length > 0 ? { skipped } : {}),
349
361
  };
350
362
  }
351
363
  // =============================================================================
@@ -426,16 +438,42 @@ export async function runSecurityAnalysis(projectDir, options = {}) {
426
438
  }
427
439
  // Run pattern matching
428
440
  const findings = [];
441
+ const skipped = [];
429
442
  for (const filePath of allFiles) {
430
- let content;
431
- try {
432
- content = await fs.readFile(filePath, "utf-8");
433
- }
434
- catch {
435
- continue;
436
- }
443
+ // Guarded read: a binary blob or a multi-megabyte bundle would otherwise
444
+ // be fed to every regex rule in the set. `maxBytes` is pinned to the same
445
+ // ceiling as the hard reject so the documented 2 MiB limit is the effective
446
+ // scan cap — otherwise the 1 MiB default would silently truncate every file
447
+ // between 1 and 2 MiB and MAX_ANALYSIS_BYTES would be a dead constant.
448
+ const read = await safeReadFile(filePath, {
449
+ maxBytes: MAX_ANALYSIS_BYTES,
450
+ hardRejectOverBytes: MAX_ANALYSIS_BYTES,
451
+ });
437
452
  // Use relative paths in findings for readability
438
453
  const relativePath = path.relative(projectDir, filePath);
454
+ if (!read.ok) {
455
+ skipped.push({
456
+ file: relativePath,
457
+ reason: describeSafeReadFailure(read),
458
+ });
459
+ continue;
460
+ }
461
+ const content = read.content;
462
+ if (read.truncated) {
463
+ skipped.push({
464
+ file: relativePath,
465
+ reason: `truncated at ${read.bytesRead} of ${read.totalBytes} bytes — scanned partially`,
466
+ });
467
+ }
468
+ // A clamped long line means the regex pass saw a shortened line — a payload
469
+ // hidden past the clamp would be invisible. Record it like a truncation so
470
+ // the scan is marked incomplete and cannot report a clean pass.
471
+ if (read.longLinesClamped) {
472
+ skipped.push({
473
+ file: relativePath,
474
+ reason: "long line(s) clamped — scanned partially",
475
+ });
476
+ }
439
477
  for (const rule of rules) {
440
478
  const matches = matchRule(rule, content, filePath);
441
479
  // Rewrite file paths to relative
@@ -452,7 +490,7 @@ export async function runSecurityAnalysis(projectDir, options = {}) {
452
490
  return sevDiff;
453
491
  return a.file.localeCompare(b.file);
454
492
  });
455
- return buildReport(findings, projectDir, options);
493
+ return buildReport(findings, projectDir, options, skipped);
456
494
  }
457
495
  // =============================================================================
458
496
  // Report formatting (for CI output)
@@ -468,7 +506,14 @@ export function formatReportText(report) {
468
506
  .map(([sev, count]) => `${count} ${sev}`)
469
507
  .join(", ") || "none"}`);
470
508
  lines.push(`Pass threshold: ${summary.failThreshold}`);
471
- lines.push(`Result: ${summary.passed ? "PASS" : "FAIL"}`);
509
+ lines.push(`Result: ${summary.passed
510
+ ? "PASS"
511
+ : summary.incomplete
512
+ ? "FAIL (incomplete scan — some files were not fully analysed)"
513
+ : "FAIL"}`);
514
+ if (report.skipped && report.skipped.length > 0) {
515
+ lines.push(`Skipped files: ${report.skipped.length}`);
516
+ }
472
517
  lines.push("");
473
518
  if (findings.length > 0) {
474
519
  lines.push("--- Findings ---");
@@ -481,6 +526,13 @@ export function formatReportText(report) {
481
526
  lines.push(` ${f.message}`);
482
527
  }
483
528
  }
529
+ if (report.skipped && report.skipped.length > 0) {
530
+ lines.push("");
531
+ lines.push("--- Skipped ---");
532
+ for (const s of report.skipped) {
533
+ lines.push(`${s.file}: ${s.reason}`);
534
+ }
535
+ }
484
536
  return lines.join("\n");
485
537
  }
486
538
  export function formatReportJson(report) {
@@ -15,13 +15,34 @@ export interface SkillThreat {
15
15
  context: string;
16
16
  message: string;
17
17
  }
18
- export type SkillScanVerdict = "pass" | "warn" | "block";
18
+ /**
19
+ * `unscannable` is a fail-closed verdict: the file could not be read in full
20
+ * (binary, oversized, I/O error) or a content-mutating clamp/truncation
21
+ * happened during the read, so we cannot certify it. A gate MUST treat it as a
22
+ * rejection exactly like `block` — see {@link isRejectedVerdict}, the single
23
+ * predicate every install/registry gate should use instead of `=== "block"`.
24
+ */
25
+ export type SkillScanVerdict = "pass" | "warn" | "block" | "unscannable";
26
+ /**
27
+ * The set of verdicts an install/registry gate rejects on. Fail-closed:
28
+ * `block` (a critical threat was found) and `unscannable` (the file could not
29
+ * be certified because it was not fully scanned) both mean "do not install".
30
+ * Use this everywhere instead of a bare `verdict === "block"` check so a future
31
+ * gate can never let an `unscannable` file slip through.
32
+ */
33
+ export declare function isRejectedVerdict(verdict: SkillScanVerdict): boolean;
19
34
  export interface SkillScanResult {
20
35
  skillPath: string;
21
36
  skillName: string;
22
37
  verdict: SkillScanVerdict;
23
38
  threats: SkillThreat[];
24
39
  summary: SkillScanSummary;
40
+ /**
41
+ * Scan-level notes — populated when the file could not be fully analysed
42
+ * (binary, oversized, unreadable) so a skipped file is visible in the report
43
+ * instead of masquerading as a clean pass.
44
+ */
45
+ notes?: string[];
25
46
  }
26
47
  export interface SkillScanSummary {
27
48
  total: number;
@@ -7,6 +7,17 @@
7
7
  */
8
8
  import path from "node:path";
9
9
  import fs from "fs-extra";
10
+ import { describeSafeReadFailure, safeReadFile } from "./safe-read.js";
11
+ /**
12
+ * The set of verdicts an install/registry gate rejects on. Fail-closed:
13
+ * `block` (a critical threat was found) and `unscannable` (the file could not
14
+ * be certified because it was not fully scanned) both mean "do not install".
15
+ * Use this everywhere instead of a bare `verdict === "block"` check so a future
16
+ * gate can never let an `unscannable` file slip through.
17
+ */
18
+ export function isRejectedVerdict(verdict) {
19
+ return verdict === "block" || verdict === "unscannable";
20
+ }
10
21
  /**
11
22
  * Ordered by severity (critical first). Each pattern is tested against
12
23
  * every non-comment line in the skill file.
@@ -282,18 +293,66 @@ export function extractSkillName(content, filePath) {
282
293
  // =============================================================================
283
294
  // Main scan function
284
295
  // =============================================================================
296
+ /**
297
+ * Hard ceiling for a scanned skill file. Past this it is not a skill document
298
+ * but a dumped log or a vendored bundle: scanning it would run every regex
299
+ * over megabytes of noise, so it is skipped and reported instead.
300
+ */
301
+ const MAX_SCAN_BYTES = 1024 * 1024;
285
302
  export async function scanSkillFile(filePath) {
286
- const content = await fs.readFile(filePath, "utf-8");
303
+ // `maxLineLength: 0` disables the per-line clamp for the scanner's own read:
304
+ // a padded single line hiding `rm -rf ~` past column 10k must reach the regex
305
+ // pass intact, not be silently truncated and then scanned as if complete. The
306
+ // total-byte ceiling still bounds memory (a file past it fails `too-large`).
307
+ const read = await safeReadFile(filePath, {
308
+ hardRejectOverBytes: MAX_SCAN_BYTES,
309
+ maxLineLength: 0,
310
+ });
311
+ // A file we could not read is not a clean file. Never crash the batch, and
312
+ // never report it as a pass: an unscannable file cannot be certified safe, so
313
+ // it fails closed with the strongest verdict a gate rejects on.
314
+ if (!read.ok) {
315
+ return {
316
+ skillPath: filePath,
317
+ skillName: path.basename(path.dirname(filePath)),
318
+ verdict: "unscannable",
319
+ threats: [],
320
+ summary: computeScanSummary([]),
321
+ notes: [
322
+ `scanning incomplete: ${describeSafeReadFailure(read)} — rejected (an unscannable file cannot be certified safe)`,
323
+ ],
324
+ };
325
+ }
326
+ const content = read.content;
287
327
  const skillName = extractSkillName(content, filePath);
288
328
  const threats = scanSkillContent(content, filePath);
289
- const verdict = computeVerdict(threats);
290
329
  const summary = computeScanSummary(threats);
330
+ // A content-mutating read (truncated bytes, or a clamped line) means the
331
+ // regex pass did NOT see the whole file. Even if no threat surfaced in what
332
+ // we did see, we cannot certify the rest — fail closed rather than emit a
333
+ // pass/warn over partial content. With `maxLineLength: 0` and the byte
334
+ // ceiling above these should not fire, but the guard is the safety net.
335
+ const incomplete = read.truncated || read.longLinesClamped;
336
+ const notes = [];
337
+ if (read.truncated) {
338
+ notes.push(`truncated: only the first ${read.bytesRead} of ${read.totalBytes} bytes were scanned`);
339
+ }
340
+ if (read.longLinesClamped) {
341
+ notes.push("clamped: one or more lines exceeded the per-line limit");
342
+ }
343
+ if (incomplete) {
344
+ notes.push("rejected: the file was not fully scanned and cannot be certified safe");
345
+ }
346
+ const verdict = incomplete
347
+ ? "unscannable"
348
+ : computeVerdict(threats);
291
349
  return {
292
350
  skillPath: filePath,
293
351
  skillName,
294
352
  verdict,
295
353
  threats,
296
354
  summary,
355
+ ...(notes.length > 0 ? { notes } : {}),
297
356
  };
298
357
  }
299
358
  /**
@@ -346,6 +405,12 @@ export function formatScanReport(result) {
346
405
  lines.push(`Verdict: ${verdict.toUpperCase()}`);
347
406
  lines.push(`Findings: ${summary.total} (${summary.critical} critical, ${summary.high} high, ${summary.moderate} moderate, ${summary.low} low)`);
348
407
  lines.push("");
408
+ if (result.notes && result.notes.length > 0) {
409
+ lines.push("--- Notes ---");
410
+ for (const note of result.notes)
411
+ lines.push(` ${note}`);
412
+ lines.push("");
413
+ }
349
414
  if (threats.length > 0) {
350
415
  lines.push("--- Threats ---");
351
416
  for (const t of threats) {
@@ -354,7 +419,11 @@ export function formatScanReport(result) {
354
419
  lines.push(` Context: ${t.context}`);
355
420
  }
356
421
  }
357
- if (verdict === "block") {
422
+ if (verdict === "unscannable") {
423
+ lines.push("");
424
+ lines.push("REJECTED: File could not be fully scanned, so it cannot be certified safe. Not installed.");
425
+ }
426
+ else if (verdict === "block") {
358
427
  lines.push("");
359
428
  lines.push("BLOCKED: Critical threats detected. Review and remove before installing.");
360
429
  }
@@ -367,11 +436,14 @@ export function formatScanReport(result) {
367
436
  export function formatBatchReport(results) {
368
437
  const lines = [];
369
438
  const blocked = results.filter((r) => r.verdict === "block");
439
+ const unscannable = results.filter((r) => r.verdict === "unscannable");
370
440
  const warned = results.filter((r) => r.verdict === "warn");
371
441
  const passed = results.filter((r) => r.verdict === "pass");
372
442
  lines.push(`=== SkillGuard Batch Scan ===`);
373
443
  lines.push(`Scanned: ${results.length} skill(s)`);
374
- lines.push(`Blocked: ${blocked.length}`);
444
+ lines.push(`Rejected: ${blocked.length + unscannable.length}`);
445
+ lines.push(` Blocked (threats): ${blocked.length}`);
446
+ lines.push(` Unscannable (not certified): ${unscannable.length}`);
375
447
  lines.push(`Warned: ${warned.length}`);
376
448
  lines.push(`Passed: ${passed.length}`);
377
449
  lines.push("");
@@ -226,6 +226,11 @@ export interface SkillBudgetEntry {
226
226
  skillName: string;
227
227
  skillPath: string;
228
228
  tokens: number;
229
+ /**
230
+ * Set when the SKILL.md could not be fully read (binary, oversized, I/O
231
+ * error) — `tokens` is then partial or zero and should be read with care.
232
+ */
233
+ note?: string;
229
234
  }
230
235
  export interface SkillBudgetSuggestion {
231
236
  /** Skills to disable in this suggestion set */
@@ -271,6 +276,13 @@ export interface SkillScore {
271
276
  grade: SkillGrade;
272
277
  threshold: number;
273
278
  passing: boolean;
279
+ /**
280
+ * Set when the SKILL.md could not be read (binary, oversized, I/O error). The
281
+ * numeric dimensions are then meaningless placeholders (all `0`, never a
282
+ * computed grade or `safety: 100`); callers must surface the file as unread
283
+ * rather than as a scored skill. `passing` is always `false` in this state.
284
+ */
285
+ unread?: string;
274
286
  }
275
287
  export interface SkillRegistryGateResult {
276
288
  skillName: string;
@@ -287,6 +299,12 @@ export interface SkillBenchmarkResult {
287
299
  skillName: string;
288
300
  checks: SkillBenchmarkCheck[];
289
301
  passRate: number;
302
+ /**
303
+ * Set when the SKILL.md could not be read (binary, oversized, I/O error). No
304
+ * checks were run (`checks` is empty, `passRate` is `0`); callers must surface
305
+ * the file as unread rather than as a benchmarked skill that failed every check.
306
+ */
307
+ unread?: string;
290
308
  }
291
309
  declare const WORKFLOW_FORMAT: {
292
310
  readonly DOT: "dot";
package/dist/ui/Skills.js CHANGED
@@ -66,13 +66,18 @@ export default function Skills({ mode, budget, deep, skillsDir }) {
66
66
  result.budget.budget,
67
67
  " tokens",
68
68
  result.budget.overBudget ? " (OVER BUDGET)" : "")),
69
- result.budget.entries.map((entry) => (React.createElement(Box, { key: entry.skillName, marginLeft: 4 },
70
- React.createElement(Text, { color: theme.muted }, entry.skillName),
71
- React.createElement(Text, { color: theme.muted, dimColor: true },
72
- " ",
73
- "~",
74
- entry.tokens,
75
- " tokens")))),
69
+ result.budget.entries.map((entry) => (React.createElement(Box, { key: entry.skillName, flexDirection: "column", marginLeft: 4 },
70
+ React.createElement(Box, null,
71
+ React.createElement(Text, { color: entry.note ? theme.warning : theme.muted }, entry.skillName),
72
+ React.createElement(Text, { color: theme.muted, dimColor: true },
73
+ " ",
74
+ "~",
75
+ entry.tokens,
76
+ " tokens")),
77
+ entry.note && (React.createElement(Box, { marginLeft: 2 },
78
+ React.createElement(Text, { color: theme.warning },
79
+ "! ",
80
+ entry.note)))))),
76
81
  result.budget.suggestions.map((s, i) => (React.createElement(Box, { key: `suggestion-${i}`, marginLeft: 4 },
77
82
  React.createElement(Text, { color: theme.warning },
78
83
  "! ",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.25.1",
3
+ "version": "1.27.0",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,30 +0,0 @@
1
- # ===========================================
2
- # GHAGGA AI Code Review
3
- # ===========================================
4
- # Triggers AI code review on pull requests.
5
- # Requires: GHAGGA GitHub Action (JNZader/ghagga@v1)
6
- #
7
- # Modes: simple (default), workflow, consensus
8
- # ===========================================
9
-
10
- name: Code Review
11
-
12
- on:
13
- pull_request:
14
- types: [opened, synchronize, reopened]
15
-
16
- permissions:
17
- pull-requests: write
18
- contents: read
19
-
20
- jobs:
21
- review:
22
- runs-on: ubuntu-latest
23
- steps:
24
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
25
- # TODO(pin): ghagga has no v1 tag upstream (releases start at v2) — this
26
- # ref cannot be SHA-pinned as-is; pick a real released tag and pin it.
27
- - uses: JNZader/ghagga@v1
28
- with:
29
- mode: simple
30
- provider: github