javi-forge 1.26.0 → 1.28.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.
- package/ci-local/hooks/commit-msg +7 -0
- package/ci-local/hooks/pre-commit +8 -0
- package/ci-local/hooks/pre-push +8 -0
- package/dist/cli/dispatch/ci.js +1 -1
- package/dist/cli/dispatch/simple-renderers.js +1 -1
- package/dist/cli/dispatch/skills-cmd.js +9 -1
- package/dist/cli/help.d.ts +1 -1
- package/dist/cli/help.js +12 -0
- package/dist/commands/ci.js +5 -1
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/init/steps/ghagga.d.ts +3 -4
- package/dist/commands/init/steps/ghagga.js +5 -15
- package/dist/commands/plugin.d.ts +4 -2
- package/dist/commands/plugin.js +4 -4
- package/dist/commands/skills/analysis.js +31 -2
- package/dist/commands/skills/benchmark.js +10 -0
- package/dist/commands/skills/constants.d.ts +5 -0
- package/dist/commands/skills/constants.js +5 -0
- package/dist/commands/skills/parsing.d.ts +18 -3
- package/dist/commands/skills/parsing.js +29 -3
- package/dist/commands/skills/scoring.d.ts +7 -6
- package/dist/commands/skills/scoring.js +31 -1
- package/dist/lib/agent-skills.d.ts +1 -0
- package/dist/lib/agent-skills.js +155 -1
- package/dist/lib/auto-skill-install.d.ts +5 -0
- package/dist/lib/auto-skill-install.js +40 -2
- package/dist/lib/context.d.ts +22 -0
- package/dist/lib/context.js +120 -79
- package/dist/lib/plugin.d.ts +1 -0
- package/dist/lib/plugin.js +58 -1
- package/dist/lib/safe-read.d.ts +62 -0
- package/dist/lib/safe-read.js +221 -0
- package/dist/lib/security-analysis.d.ts +19 -2
- package/dist/lib/security-analysis.js +65 -13
- package/dist/lib/skill-install-gate.d.ts +31 -0
- package/dist/lib/skill-install-gate.js +30 -0
- package/dist/lib/skill-scanner.d.ts +65 -1
- package/dist/lib/skill-scanner.js +307 -4
- package/dist/types/index.d.ts +18 -0
- package/dist/ui/AutoSkills.d.ts +3 -1
- package/dist/ui/AutoSkills.js +17 -2
- package/dist/ui/Plugin.d.ts +3 -1
- package/dist/ui/Plugin.js +4 -4
- package/dist/ui/Skills.js +12 -7
- package/package.json +9 -5
- package/templates/github/ghagga-review.yml +0 -30
|
@@ -15,13 +15,34 @@ export interface SkillThreat {
|
|
|
15
15
|
context: string;
|
|
16
16
|
message: string;
|
|
17
17
|
}
|
|
18
|
-
|
|
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;
|
|
@@ -57,6 +78,49 @@ export declare function scanSkillFile(filePath: string): Promise<SkillScanResult
|
|
|
57
78
|
* Useful for scanning a plugin's skills directory before installation.
|
|
58
79
|
*/
|
|
59
80
|
export declare function scanSkillsDirectory(dir: string): Promise<SkillScanResult[]>;
|
|
81
|
+
export interface SkillCoverageScan {
|
|
82
|
+
/**
|
|
83
|
+
* Scan results for the declared-entry SKILL.md files — the ONLY files the
|
|
84
|
+
* walk content-scans (JD-005). A declared file that is a symlink is never
|
|
85
|
+
* read through (it is already in {@link symlinks}); a missing declared
|
|
86
|
+
* SKILL.md fails closed as `unscannable`.
|
|
87
|
+
*/
|
|
88
|
+
declared: SkillScanResult[];
|
|
89
|
+
/**
|
|
90
|
+
* Skill-shaped files (basename `SKILL.md`/`skill.md`) found in the tree
|
|
91
|
+
* OUTSIDE the declared set — including under `node_modules`/`.git` (JD-007).
|
|
92
|
+
* Paths only; content is never read during the walk (JD-005).
|
|
93
|
+
*/
|
|
94
|
+
undeclared: string[];
|
|
95
|
+
/**
|
|
96
|
+
* ANY symlink (file or dir) found in the tree. The caller refuses on this
|
|
97
|
+
* (manifest-integrity, block-level, force never lifts — JD-007); the walk
|
|
98
|
+
* never dereferences them (JD-003).
|
|
99
|
+
*/
|
|
100
|
+
symlinks: string[];
|
|
101
|
+
/**
|
|
102
|
+
* Paths the walk could not enumerate or stat (realpath/readdir/lstat I/O
|
|
103
|
+
* failure — e.g. an unreadable subtree). An incomplete walk cannot certify
|
|
104
|
+
* the installed footprint, so the caller refuses on this (manifest-
|
|
105
|
+
* integrity, block-level, force never lifts — JD-013); a silent `return`/
|
|
106
|
+
* `continue` would treat the broken subtree as empty and let the install
|
|
107
|
+
* proceed un-scanned.
|
|
108
|
+
*/
|
|
109
|
+
errors: string[];
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* SKILL.md-only coverage walk for the install gates (JD-006/JD-007).
|
|
113
|
+
*
|
|
114
|
+
* Visits the ENTIRE tree with NO `node_modules`/`.git` exemption, so the visit
|
|
115
|
+
* set is exactly the footprint `fs.move`/`fs.copy` will place (JD-007). Collects
|
|
116
|
+
* only basename `SKILL.md`/`skill.md` files — never `PLUGIN.md`/README content
|
|
117
|
+
* (JD-002) — and flags ANY symlink (file or dir) without dereferencing it
|
|
118
|
+
* (JD-007/JD-003). A realpath visited-set terminates cycles defensively even if
|
|
119
|
+
* a future caller ever recurses through a link (JD-003). The walk itself does NO
|
|
120
|
+
* content reads: only the declared-entry files are handed to
|
|
121
|
+
* {@link scanSkillFile} afterwards (JD-005).
|
|
122
|
+
*/
|
|
123
|
+
export declare function scanSkillsWithCoverage(dir: string, declaredPaths: string[]): Promise<SkillCoverageScan>;
|
|
60
124
|
export declare function formatScanReport(result: SkillScanResult): string;
|
|
61
125
|
export declare function formatBatchReport(results: SkillScanResult[]): string;
|
|
62
126
|
export {};
|
|
@@ -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
|
-
|
|
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
|
/**
|
|
@@ -335,6 +394,237 @@ export async function scanSkillsDirectory(dir) {
|
|
|
335
394
|
await walk(dir);
|
|
336
395
|
return results;
|
|
337
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* SKILL.md-only coverage walk for the install gates (JD-006/JD-007).
|
|
399
|
+
*
|
|
400
|
+
* Visits the ENTIRE tree with NO `node_modules`/`.git` exemption, so the visit
|
|
401
|
+
* set is exactly the footprint `fs.move`/`fs.copy` will place (JD-007). Collects
|
|
402
|
+
* only basename `SKILL.md`/`skill.md` files — never `PLUGIN.md`/README content
|
|
403
|
+
* (JD-002) — and flags ANY symlink (file or dir) without dereferencing it
|
|
404
|
+
* (JD-007/JD-003). A realpath visited-set terminates cycles defensively even if
|
|
405
|
+
* a future caller ever recurses through a link (JD-003). The walk itself does NO
|
|
406
|
+
* content reads: only the declared-entry files are handed to
|
|
407
|
+
* {@link scanSkillFile} afterwards (JD-005).
|
|
408
|
+
*/
|
|
409
|
+
export async function scanSkillsWithCoverage(dir, declaredPaths) {
|
|
410
|
+
// Resolve the scan root once. Declared entries must stay inside it — a
|
|
411
|
+
// hostile manifest can never make the gate read outside the staged clone
|
|
412
|
+
// (JD-003: "no read outside the staged clone"; the interface contract names
|
|
413
|
+
// declared paths realpath-contained).
|
|
414
|
+
const rootAbs = path.resolve(dir);
|
|
415
|
+
const rootReal = await fs.realpath(rootAbs);
|
|
416
|
+
// Resolve each declared entry once (containment-verified) and reuse it for
|
|
417
|
+
// both the coverage set and the content scan — never twice.
|
|
418
|
+
const declaredDirs = new Map();
|
|
419
|
+
for (const entry of declaredPaths) {
|
|
420
|
+
declaredDirs.set(entry, await resolveContained(rootAbs, rootReal, entry));
|
|
421
|
+
}
|
|
422
|
+
// Declared skill DIRECTORIES — membership is case-insensitive by declared
|
|
423
|
+
// directory, never by file-basename spelling (R1-001/R3-001/R4-001). The
|
|
424
|
+
// walk collects ANY entry whose lowercased basename is `skill.md`, because
|
|
425
|
+
// the installed footprint is the on-disk tree whether the author wrote
|
|
426
|
+
// `SKILL.md`, `Skill.md`, `SKILL.MD` or any other fold — a declared skill's
|
|
427
|
+
// file must be recognized as declared no matter its case. Seeding exact-case
|
|
428
|
+
// basenames (F1/JD-011 seeded `SKILL.md` + `skill.md`) still missed every
|
|
429
|
+
// other fold: the file was collected, failed membership, and landed in
|
|
430
|
+
// `undeclared` — a block-level refusal `--force` never lifts — while the
|
|
431
|
+
// declared scan reported `unscannable` (permanent lockout for a legit
|
|
432
|
+
// declared skill). Membership by declared DIRECTORY keeps the smuggling
|
|
433
|
+
// refusal intact: a skill-shaped file whose parent dir is NOT a declared
|
|
434
|
+
// dir still misses the set and refuses as undeclared (CASE3).
|
|
435
|
+
const declaredDirAbs = new Set(declaredDirs.values());
|
|
436
|
+
// R1-F2-N1: declared-dir membership must ALSO fold case on the DIRECTORY
|
|
437
|
+
// name. `declaredDirAbs` retains MANIFEST case (`resolveContained` returns
|
|
438
|
+
// the manifest-spelled `entryAbs`); a package authored on a case-
|
|
439
|
+
// insensitive FS can declare `skills/Alpha` while the disk tree carries
|
|
440
|
+
// `skills/alpha` — the on-disk footprint the walk actually sees. Exact-
|
|
441
|
+
// case membership left the walk's dirname check missing the set → the file
|
|
442
|
+
// landed `undeclared` (block-level, force never lifts) while the declared
|
|
443
|
+
// scan reported the manifest-case path `unscannable` — the third instance
|
|
444
|
+
// of the case-lockout class (JD-011 file, R1-001 file-fold, dir-name
|
|
445
|
+
// fold). Compare lowercased on BOTH sides; the real on-disk path is still
|
|
446
|
+
// the one scanned below (never invent casing for file access).
|
|
447
|
+
const declaredDirAbsLower = new Set([...declaredDirAbs].map((d) => d.toLowerCase()));
|
|
448
|
+
const undeclared = [];
|
|
449
|
+
const symlinks = [];
|
|
450
|
+
const errors = [];
|
|
451
|
+
// Real on-disk dirs the walk actually visited (readdir-spelled casing) —
|
|
452
|
+
// used AFTER the walk to resolve declared dirs whose manifest spelling
|
|
453
|
+
// differs in case from the disk (R1-F2-N1); see the declared scan below.
|
|
454
|
+
const walkDirs = [];
|
|
455
|
+
const visited = new Set();
|
|
456
|
+
async function walk(currentDir) {
|
|
457
|
+
// realpath visited-set: a defensive cycle invariant (JD-003). Symlinks are
|
|
458
|
+
// never recursed into, so no cycle can form through the walk itself; the
|
|
459
|
+
// set guarantees termination even if that ever changes.
|
|
460
|
+
let real;
|
|
461
|
+
try {
|
|
462
|
+
real = await fs.realpath(currentDir);
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
// Fail-closed (JD-013): an unlistable subtree must surface as an
|
|
466
|
+
// error the caller refuses on, not silently read as empty.
|
|
467
|
+
errors.push(currentDir);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (visited.has(real))
|
|
471
|
+
return;
|
|
472
|
+
visited.add(real);
|
|
473
|
+
walkDirs.push(currentDir);
|
|
474
|
+
let entries;
|
|
475
|
+
try {
|
|
476
|
+
entries = await fs.readdir(currentDir);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// Fail-closed (JD-013): same as realpath above — record, do not
|
|
480
|
+
// swallow, so the caller can refuse an incomplete walk.
|
|
481
|
+
errors.push(currentDir);
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
for (const entry of entries) {
|
|
485
|
+
const fullPath = path.join(currentDir, entry);
|
|
486
|
+
let lst;
|
|
487
|
+
try {
|
|
488
|
+
lst = await fs.lstat(fullPath);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
// Fail-closed (JD-013): a path we cannot stat (race, I/O, or
|
|
492
|
+
// permission) must not silently vanish from the footprint
|
|
493
|
+
// inventory — record it and keep walking the rest.
|
|
494
|
+
errors.push(fullPath);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
// Symlinks are never dereferenced: flagged for the caller's
|
|
498
|
+
// manifest-integrity refusal, never recursed into, never scanned
|
|
499
|
+
// (JD-007/JD-003).
|
|
500
|
+
if (lst.isSymbolicLink()) {
|
|
501
|
+
symlinks.push(fullPath);
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (lst.isDirectory()) {
|
|
505
|
+
await walk(fullPath);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
// SKILL.md-only collection: basename SKILL.md/skill.md (any case
|
|
509
|
+
// fold), never PLUGIN.md or README (JD-002). Declared-ness is
|
|
510
|
+
// decided by the parent DIRECTORY being declared (R1-001): any case
|
|
511
|
+
// fold of the file inside a declared dir is declared, so the file
|
|
512
|
+
// is scanned — never flagged undeclared (block-level, force never
|
|
513
|
+
// lifts) for the exact-case spelling it happens to carry on disk.
|
|
514
|
+
if (entry.toLowerCase() === "skill.md") {
|
|
515
|
+
const resolved = path.resolve(fullPath);
|
|
516
|
+
if (!declaredDirAbsLower.has(path.dirname(resolved).toLowerCase())) {
|
|
517
|
+
undeclared.push(fullPath);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// R3-F3-N1: walk the ABSOLUTE root, never the caller's raw `dir`. With a
|
|
523
|
+
// relative invocation (`javi-forge plugin import <relative-dir>`), the
|
|
524
|
+
// walkDirs two-tier lookup below compares walk-visited paths against
|
|
525
|
+
// `declaredAbs` (always absolute) — a relative walk made every declared dir
|
|
526
|
+
// fall back to the manifest path, reporting an existing declared file as
|
|
527
|
+
// `unscannable` (force would lift it and install un-scanned content).
|
|
528
|
+
// Starting from `rootAbs` also keeps undeclared/symlinks/errors absolute
|
|
529
|
+
// and consistent with the gate's realpath expectations.
|
|
530
|
+
await walk(rootAbs);
|
|
531
|
+
// Content-scanned results for declared entries only (JD-005), in declared
|
|
532
|
+
// order so reports are deterministic. A declared file that is a symlink is
|
|
533
|
+
// already in `symlinks` — reading through it would escape the tree (JD-003),
|
|
534
|
+
// so it is skipped here (the caller refuses on `symlinks` first anyway).
|
|
535
|
+
const symlinkSet = new Set(symlinks.map((p) => path.resolve(p)));
|
|
536
|
+
const declared = [];
|
|
537
|
+
// Iterating the map keeps declared order (insertion order == declaredPaths)
|
|
538
|
+
// and guarantees an entry cannot be absent once resolved.
|
|
539
|
+
for (const declaredAbs of declaredDirs.values()) {
|
|
540
|
+
// R1-F2-N1: the declared scan reads the REAL on-disk dir the walk saw —
|
|
541
|
+
// a manifest spelling `skills/Alpha` against a disk tree `skills/alpha`
|
|
542
|
+
// must scan the lowercase dir that exists. The two-tier lookup prefers
|
|
543
|
+
// the exact-case real dir when present, then a case-fold match; a truly
|
|
544
|
+
// missing declared dir (no real dir matches) falls back to the manifest
|
|
545
|
+
// path so it still fails closed as `unscannable` (unchanged). Only
|
|
546
|
+
// the real on-disk path is ever handed to
|
|
547
|
+
// `declaredSkillFileOnDisk`/`scanSkillFile` — no casing is invented for
|
|
548
|
+
// file access.
|
|
549
|
+
const realDir = walkDirs.find((d) => d === declaredAbs) ??
|
|
550
|
+
walkDirs.find((d) => d.toLowerCase() === declaredAbs.toLowerCase()) ??
|
|
551
|
+
declaredAbs;
|
|
552
|
+
// Case-tolerant resolution (JD-011/R1-001): a declared skill whose
|
|
553
|
+
// on-disk file is lowercase `skill.md` — or any other case fold
|
|
554
|
+
// (`Skill.md`, `SKILL.MD`, …) — is the same declared entry; scan the
|
|
555
|
+
// file that actually exists instead of reporting the exact-case path
|
|
556
|
+
// as a missing/unscannable file.
|
|
557
|
+
const file = await declaredSkillFileOnDisk(realDir);
|
|
558
|
+
// Whether the canonical or the lowercase variant, a symlinked declared
|
|
559
|
+
// file is already in `symlinks` — never read through it (JD-003/JD-007).
|
|
560
|
+
if (symlinkSet.has(path.resolve(file)))
|
|
561
|
+
continue;
|
|
562
|
+
declared.push(await scanSkillFile(file));
|
|
563
|
+
}
|
|
564
|
+
return { declared, undeclared, symlinks, errors };
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Resolve the on-disk skill file for a declared skill directory. The coverage
|
|
568
|
+
* walk recognizes ANY case fold of the `skill.md`/`SKILL.md` basename in a
|
|
569
|
+
* declared dir as the declared file (R1-001), so a declared entry may
|
|
570
|
+
* legitimately carry any fold on disk; favor the conventional exact-case name,
|
|
571
|
+
* fall back to the lowercase variant (JD-011), then to any other case fold via
|
|
572
|
+
* a case-insensitive readdir. When no skill-shaped file exists at all, return
|
|
573
|
+
* the canonical path so `scanSkillFile` reports the declared skill as
|
|
574
|
+
* `unscannable` (fail-closed, unchanged behavior).
|
|
575
|
+
*/
|
|
576
|
+
async function declaredSkillFileOnDisk(absDir) {
|
|
577
|
+
const canonical = path.join(absDir, "SKILL.md");
|
|
578
|
+
if (await fs.pathExists(canonical))
|
|
579
|
+
return canonical;
|
|
580
|
+
const lower = path.join(absDir, "skill.md");
|
|
581
|
+
if (await fs.pathExists(lower))
|
|
582
|
+
return lower;
|
|
583
|
+
// Any other case fold (`Skill.md`, `SKILL.MD`, `skill.MD`, …) is the same
|
|
584
|
+
// declared file (R1-001): the declared scan must read what actually exists
|
|
585
|
+
// or the declared entry reports `unscannable` while the walk collects it as
|
|
586
|
+
// declared. Prefer the canonical name when both exist (JD-F1-N1 residual).
|
|
587
|
+
try {
|
|
588
|
+
const entries = await fs.readdir(absDir);
|
|
589
|
+
const fold = entries.find((e) => e.toLowerCase() === "skill.md");
|
|
590
|
+
if (fold)
|
|
591
|
+
return path.join(absDir, fold);
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Unreadable declared dir → return the canonical path so scanSkillFile
|
|
595
|
+
// fails closed as `unscannable` (no new behavior).
|
|
596
|
+
}
|
|
597
|
+
return canonical;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Resolve a declared skill entry to an absolute directory and verify it stays
|
|
601
|
+
* inside the scan root — both lexically (`../../x`, absolute paths) and by
|
|
602
|
+
* realpath, so an in-tree symlink cannot redirect the declared read outside the
|
|
603
|
+
* staged clone (JD-003). Throws when the entry escapes; the caller denies.
|
|
604
|
+
*/
|
|
605
|
+
async function resolveContained(rootAbs, rootReal, entry) {
|
|
606
|
+
const entryAbs = path.resolve(rootAbs, entry);
|
|
607
|
+
// Lexical containment — catches `../outside` and absolute entries.
|
|
608
|
+
const rel = path.relative(rootAbs, entryAbs);
|
|
609
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
610
|
+
throw new Error(`skillguard: declared skill path escapes scan root — ${entry}`);
|
|
611
|
+
}
|
|
612
|
+
// Realpath containment — catches a directory inside the tree whose real
|
|
613
|
+
// location is outside it. A missing declared dir (later `unscannable`) has
|
|
614
|
+
// no realpath yet; its lexical containment above is then the whole guard.
|
|
615
|
+
let real;
|
|
616
|
+
try {
|
|
617
|
+
real = await fs.realpath(entryAbs);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return entryAbs;
|
|
621
|
+
}
|
|
622
|
+
const relReal = path.relative(rootReal, real);
|
|
623
|
+
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
624
|
+
throw new Error(`skillguard: declared skill path escapes scan root — ${entry}`);
|
|
625
|
+
}
|
|
626
|
+
return entryAbs;
|
|
627
|
+
}
|
|
338
628
|
// =============================================================================
|
|
339
629
|
// Report formatting
|
|
340
630
|
// =============================================================================
|
|
@@ -346,6 +636,12 @@ export function formatScanReport(result) {
|
|
|
346
636
|
lines.push(`Verdict: ${verdict.toUpperCase()}`);
|
|
347
637
|
lines.push(`Findings: ${summary.total} (${summary.critical} critical, ${summary.high} high, ${summary.moderate} moderate, ${summary.low} low)`);
|
|
348
638
|
lines.push("");
|
|
639
|
+
if (result.notes && result.notes.length > 0) {
|
|
640
|
+
lines.push("--- Notes ---");
|
|
641
|
+
for (const note of result.notes)
|
|
642
|
+
lines.push(` ${note}`);
|
|
643
|
+
lines.push("");
|
|
644
|
+
}
|
|
349
645
|
if (threats.length > 0) {
|
|
350
646
|
lines.push("--- Threats ---");
|
|
351
647
|
for (const t of threats) {
|
|
@@ -354,7 +650,11 @@ export function formatScanReport(result) {
|
|
|
354
650
|
lines.push(` Context: ${t.context}`);
|
|
355
651
|
}
|
|
356
652
|
}
|
|
357
|
-
if (verdict === "
|
|
653
|
+
if (verdict === "unscannable") {
|
|
654
|
+
lines.push("");
|
|
655
|
+
lines.push("REJECTED: File could not be fully scanned, so it cannot be certified safe. Not installed.");
|
|
656
|
+
}
|
|
657
|
+
else if (verdict === "block") {
|
|
358
658
|
lines.push("");
|
|
359
659
|
lines.push("BLOCKED: Critical threats detected. Review and remove before installing.");
|
|
360
660
|
}
|
|
@@ -367,11 +667,14 @@ export function formatScanReport(result) {
|
|
|
367
667
|
export function formatBatchReport(results) {
|
|
368
668
|
const lines = [];
|
|
369
669
|
const blocked = results.filter((r) => r.verdict === "block");
|
|
670
|
+
const unscannable = results.filter((r) => r.verdict === "unscannable");
|
|
370
671
|
const warned = results.filter((r) => r.verdict === "warn");
|
|
371
672
|
const passed = results.filter((r) => r.verdict === "pass");
|
|
372
673
|
lines.push(`=== SkillGuard Batch Scan ===`);
|
|
373
674
|
lines.push(`Scanned: ${results.length} skill(s)`);
|
|
374
|
-
lines.push(`
|
|
675
|
+
lines.push(`Rejected: ${blocked.length + unscannable.length}`);
|
|
676
|
+
lines.push(` Blocked (threats): ${blocked.length}`);
|
|
677
|
+
lines.push(` Unscannable (not certified): ${unscannable.length}`);
|
|
375
678
|
lines.push(`Warned: ${warned.length}`);
|
|
376
679
|
lines.push(`Passed: ${passed.length}`);
|
|
377
680
|
lines.push("");
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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/AutoSkills.d.ts
CHANGED
|
@@ -3,7 +3,9 @@ interface AutoSkillsProps {
|
|
|
3
3
|
projectDir: string;
|
|
4
4
|
skillsDir?: string;
|
|
5
5
|
dryRun?: boolean;
|
|
6
|
+
/** Bypass the skillguard gate for unscannable sources ONLY — block always refuses (D5) */
|
|
7
|
+
force?: boolean;
|
|
6
8
|
}
|
|
7
|
-
export default function AutoSkills({ projectDir, skillsDir, dryRun, }: AutoSkillsProps): React.JSX.Element;
|
|
9
|
+
export default function AutoSkills({ projectDir, skillsDir, dryRun, force, }: AutoSkillsProps): React.JSX.Element;
|
|
8
10
|
export {};
|
|
9
11
|
//# sourceMappingURL=AutoSkills.d.ts.map
|
package/dist/ui/AutoSkills.js
CHANGED
|
@@ -2,10 +2,11 @@ import { Box, Text, useApp, useInput } from "ink";
|
|
|
2
2
|
import Spinner from "ink-spinner";
|
|
3
3
|
import React, { useCallback, useEffect, useState } from "react";
|
|
4
4
|
import { autoInstallSkills } from "../lib/auto-skill-install.js";
|
|
5
|
+
import { formatScanReport } from "../lib/skill-scanner.js";
|
|
5
6
|
import { useCIMode } from "./CIContext.js";
|
|
6
7
|
import Header from "./Header.js";
|
|
7
8
|
import { theme } from "./theme.js";
|
|
8
|
-
export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
9
|
+
export default function AutoSkills({ projectDir, skillsDir, dryRun, force = false, }) {
|
|
9
10
|
const { exit } = useApp();
|
|
10
11
|
const isCI = useCIMode();
|
|
11
12
|
const [result, setResult] = useState(null);
|
|
@@ -20,6 +21,7 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
|
20
21
|
skillsSourceDir: skillsDir,
|
|
21
22
|
skillsTargetDir: skillsDir,
|
|
22
23
|
dryRun: dryRun ?? false,
|
|
24
|
+
force,
|
|
23
25
|
})
|
|
24
26
|
.then((r) => {
|
|
25
27
|
setResult(r);
|
|
@@ -29,7 +31,7 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
|
29
31
|
setError(String(e));
|
|
30
32
|
setLoading(false);
|
|
31
33
|
});
|
|
32
|
-
}, [projectDir, skillsDir, dryRun]);
|
|
34
|
+
}, [projectDir, skillsDir, dryRun, force]);
|
|
33
35
|
useEffect(() => {
|
|
34
36
|
runDetection();
|
|
35
37
|
}, [runDetection]);
|
|
@@ -116,6 +118,19 @@ export default function AutoSkills({ projectDir, skillsDir, dryRun, }) {
|
|
|
116
118
|
React.createElement(Text, { dimColor: true, color: theme.warning },
|
|
117
119
|
" ",
|
|
118
120
|
"not found in source")))))),
|
|
121
|
+
result.blocked.length > 0 && (React.createElement(Box, { flexDirection: "column" }, result.blocked.map((scan) => (React.createElement(Box, { key: `blocked-${scan.skillName}`, flexDirection: "column" },
|
|
122
|
+
React.createElement(Box, { marginLeft: 4 },
|
|
123
|
+
React.createElement(Text, { color: theme.error },
|
|
124
|
+
"\u2717",
|
|
125
|
+
" ",
|
|
126
|
+
scan.skillName,
|
|
127
|
+
" \u2014 refused")),
|
|
128
|
+
React.createElement(Box, { marginLeft: 6 },
|
|
129
|
+
React.createElement(Text, { color: theme.muted, dimColor: true }, formatScanReport(scan)
|
|
130
|
+
.split("\n")
|
|
131
|
+
.map((l) => l.trim())
|
|
132
|
+
.filter(Boolean)
|
|
133
|
+
.join(" | ")))))))),
|
|
119
134
|
totalSkills === 0 && (React.createElement(Box, { marginLeft: 4 },
|
|
120
135
|
React.createElement(Text, { color: theme.muted }, "No skills recommended for this stack")))))),
|
|
121
136
|
!loading && (React.createElement(Box, { marginTop: 1 },
|
package/dist/ui/Plugin.d.ts
CHANGED
|
@@ -4,7 +4,9 @@ interface PluginProps {
|
|
|
4
4
|
target?: string;
|
|
5
5
|
dryRun: boolean;
|
|
6
6
|
codex?: boolean;
|
|
7
|
+
/** Bypass the skillguard gate for unscannable sources ONLY — block always refuses (D5) */
|
|
8
|
+
force?: boolean;
|
|
7
9
|
}
|
|
8
|
-
export default function Plugin({ action, target, dryRun, codex, }: PluginProps): React.JSX.Element;
|
|
10
|
+
export default function Plugin({ action, target, dryRun, codex, force, }: PluginProps): React.JSX.Element;
|
|
9
11
|
export {};
|
|
10
12
|
//# sourceMappingURL=Plugin.d.ts.map
|
package/dist/ui/Plugin.js
CHANGED
|
@@ -16,7 +16,7 @@ const STATUS_COLOR = {
|
|
|
16
16
|
error: theme.error,
|
|
17
17
|
skipped: theme.muted,
|
|
18
18
|
};
|
|
19
|
-
export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
19
|
+
export default function Plugin({ action, target, dryRun, codex = false, force = false, }) {
|
|
20
20
|
const [steps, setSteps] = useState([]);
|
|
21
21
|
const [done, setDone] = useState(false);
|
|
22
22
|
const onStep = (step) => {
|
|
@@ -44,7 +44,7 @@ export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
|
44
44
|
});
|
|
45
45
|
break;
|
|
46
46
|
}
|
|
47
|
-
await runPluginAdd(target, dryRun, onStep);
|
|
47
|
+
await runPluginAdd(target, dryRun, onStep, { force });
|
|
48
48
|
break;
|
|
49
49
|
case "remove":
|
|
50
50
|
if (!target) {
|
|
@@ -106,7 +106,7 @@ export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
|
106
106
|
});
|
|
107
107
|
break;
|
|
108
108
|
}
|
|
109
|
-
await runPluginImport(target, dryRun, onStep);
|
|
109
|
+
await runPluginImport(target, dryRun, onStep, force);
|
|
110
110
|
break;
|
|
111
111
|
case "export-skills":
|
|
112
112
|
if (target === "global") {
|
|
@@ -129,7 +129,7 @@ export default function Plugin({ action, target, dryRun, codex = false, }) {
|
|
|
129
129
|
setDone(true);
|
|
130
130
|
};
|
|
131
131
|
run();
|
|
132
|
-
}, [action, target, dryRun]);
|
|
132
|
+
}, [action, target, dryRun, force]);
|
|
133
133
|
return (React.createElement(Box, { flexDirection: "column", padding: 1 },
|
|
134
134
|
React.createElement(Box, { marginBottom: 1 },
|
|
135
135
|
React.createElement(Text, { bold: true, color: theme.primary }, "javi-forge"),
|
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(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
3
|
+
"version": "1.28.0",
|
|
4
4
|
"description": "Project scaffolding and AI-ready CI bootstrap",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -85,12 +85,16 @@
|
|
|
85
85
|
"overrides": {
|
|
86
86
|
"handlebars": "^4.7.9",
|
|
87
87
|
"picomatch": "^4.0.4",
|
|
88
|
-
"brace-expansion": "^5.0.
|
|
88
|
+
"brace-expansion": "^5.0.9",
|
|
89
89
|
"lodash": "^4.18.0",
|
|
90
90
|
"lodash-es": "^4.18.0",
|
|
91
|
-
"fast-uri": "^3.1.
|
|
92
|
-
"postcss": "^8.5.
|
|
93
|
-
"vite": "^8.0.
|
|
91
|
+
"fast-uri": "^3.1.5",
|
|
92
|
+
"postcss": "^8.5.18",
|
|
93
|
+
"vite": "^8.0.16",
|
|
94
|
+
"js-yaml": "^4.3.1",
|
|
95
|
+
"nanoid": "^3.3.17",
|
|
96
|
+
"undici": "^7.29.0",
|
|
97
|
+
"ws": "^8.21.0"
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
}
|