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
package/dist/lib/plugin.js
CHANGED
|
@@ -4,6 +4,8 @@ import { PLUGIN_ASSET_DIRS, PLUGIN_MANIFEST_FILE, PLUGIN_REGISTRY_URL, PLUGINS_D
|
|
|
4
4
|
import { generateAgentSkillsManifest } from "./agent-skills.js";
|
|
5
5
|
import { autoWirePlugins } from "./auto-wire.js";
|
|
6
6
|
import { execFileAsync } from "./exec.js";
|
|
7
|
+
import { evaluateInstallGate } from "./skill-install-gate.js";
|
|
8
|
+
import { formatBatchReport, scanSkillsWithCoverage } from "./skill-scanner.js";
|
|
7
9
|
const KEBAB_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
8
10
|
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
9
11
|
// ── Validation ──────────────────────────────────────────────────────────────
|
|
@@ -112,7 +114,7 @@ export async function validatePlugin(pluginDir) {
|
|
|
112
114
|
* Clones the repo to a temp dir, validates, then copies to plugins dir.
|
|
113
115
|
*/
|
|
114
116
|
export async function installPlugin(source, options = {}) {
|
|
115
|
-
const { dryRun = false } = options;
|
|
117
|
+
const { dryRun = false, force = false } = options;
|
|
116
118
|
// Normalize source to a git URL
|
|
117
119
|
const gitUrl = normalizeGitUrl(source);
|
|
118
120
|
if (!gitUrl) {
|
|
@@ -151,6 +153,61 @@ export async function installPlugin(source, options = {}) {
|
|
|
151
153
|
const pluginName = validation.manifest.name;
|
|
152
154
|
const destDir = path.join(PLUGINS_DIR, pluginName);
|
|
153
155
|
if (!dryRun) {
|
|
156
|
+
// ── SkillGuard runtime gate (D1/D3, JD-006/JD-007) ────────────
|
|
157
|
+
// Runs BEFORE the existing-install remove and fs.move: a refusal
|
|
158
|
+
// leaves staging intact (removed by `finally`) and never destroys a
|
|
159
|
+
// prior install. dryRun skips the gate entirely (no staged clone).
|
|
160
|
+
// Scanner/eval errors deny unconditionally (D7 — a throw is not a
|
|
161
|
+
// verdict, so no force branch consults it).
|
|
162
|
+
let gate;
|
|
163
|
+
// Declared results, hoisted for the refusal report: the batch report
|
|
164
|
+
// renders the FULL declared set (header "Scanned: N" + per-skill rows,
|
|
165
|
+
// D6) while the lead line still names the rejected count (JD-014).
|
|
166
|
+
let declaredResults = [];
|
|
167
|
+
try {
|
|
168
|
+
const declaredPaths = (validation.manifest.skills ?? []).map((skill) => path.join("skills", skill));
|
|
169
|
+
const coverage = await scanSkillsWithCoverage(tmpDir, declaredPaths);
|
|
170
|
+
// Manifest-integrity refusals — block-level, force NEVER lifts
|
|
171
|
+
// (JD-007: ANY symlink; JD-006: undeclared SKILL.md incl.
|
|
172
|
+
// node_modules/.git). A walk with I/O errors cannot certify the
|
|
173
|
+
// installed footprint — refuse first, before symlink/undeclared
|
|
174
|
+
// checks, because the broken subtree may hide either (JD-013).
|
|
175
|
+
if (coverage.errors.length > 0) {
|
|
176
|
+
return {
|
|
177
|
+
success: false,
|
|
178
|
+
error: `skillguard: install refused — ${coverage.errors.length} path(s) could not be read (walk incomplete; manifest-integrity, force never lifts):\n${coverage.errors.map((p) => ` ${p}`).join("\n")}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (coverage.symlinks.length > 0) {
|
|
182
|
+
return {
|
|
183
|
+
success: false,
|
|
184
|
+
error: `skillguard: install refused — symlink(s) in tree (manifest-integrity, force never lifts):\n${coverage.symlinks.map((p) => ` ${p}`).join("\n")}`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (coverage.undeclared.length > 0) {
|
|
188
|
+
return {
|
|
189
|
+
success: false,
|
|
190
|
+
error: `skillguard: install refused — undeclared SKILL.md(s) in tree (every skill-shaped file must be declared; force never lifts):\n${coverage.undeclared.map((p) => ` ${p}`).join("\n")}`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
declaredResults = coverage.declared;
|
|
194
|
+
gate = evaluateInstallGate(coverage.declared, { force });
|
|
195
|
+
}
|
|
196
|
+
catch (scanError) {
|
|
197
|
+
const msg = scanError instanceof Error ? scanError.message : String(scanError);
|
|
198
|
+
return {
|
|
199
|
+
success: false,
|
|
200
|
+
error: `skillguard scan failed — ${msg}`,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (!gate.allowed) {
|
|
204
|
+
const blocked = gate.rejected.filter((r) => r.verdict === "block").length;
|
|
205
|
+
const unscannable = gate.rejected.filter((r) => r.verdict === "unscannable").length;
|
|
206
|
+
return {
|
|
207
|
+
success: false,
|
|
208
|
+
error: `skillguard: install refused — ${gate.rejected.length} rejected (${blocked} blocked, ${unscannable} unscannable)\n${formatBatchReport(declaredResults)}`,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
154
211
|
// Remove existing version if present
|
|
155
212
|
if (await fs.pathExists(destDir)) {
|
|
156
213
|
await fs.remove(destDir);
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
/** Default byte budget for a single read (1 MiB). */
|
|
16
|
+
export declare const DEFAULT_MAX_BYTES: number;
|
|
17
|
+
/** Default per-line character clamp — catches minified bundles and data URIs. */
|
|
18
|
+
export declare const DEFAULT_MAX_LINE_LENGTH = 10000;
|
|
19
|
+
export interface SafeReadOptions {
|
|
20
|
+
/** Maximum bytes kept. Anything beyond is dropped and `truncated` is set. */
|
|
21
|
+
maxBytes?: number;
|
|
22
|
+
/** Per-line character clamp. Use `0` or `Infinity` to disable. */
|
|
23
|
+
maxLineLength?: number;
|
|
24
|
+
/**
|
|
25
|
+
* If the file is larger than this, fail with `too-large` instead of
|
|
26
|
+
* truncating. Off by default — truncation is the normal behavior.
|
|
27
|
+
*/
|
|
28
|
+
hardRejectOverBytes?: number;
|
|
29
|
+
}
|
|
30
|
+
export type SafeReadFailureReason = "not-found" | "not-a-file" | "binary" | "too-large" | "io-error";
|
|
31
|
+
export interface SafeReadSuccess {
|
|
32
|
+
ok: true;
|
|
33
|
+
content: string;
|
|
34
|
+
/** True when the file had more bytes than the budget allowed. */
|
|
35
|
+
truncated: boolean;
|
|
36
|
+
/** Bytes actually decoded into `content` (before BOM stripping). */
|
|
37
|
+
bytesRead: number;
|
|
38
|
+
/** File size reported by `stat` at the time of the read. */
|
|
39
|
+
totalBytes: number;
|
|
40
|
+
/** True when at least one line hit the per-line clamp. */
|
|
41
|
+
longLinesClamped: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface SafeReadFailure {
|
|
44
|
+
ok: false;
|
|
45
|
+
reason: SafeReadFailureReason;
|
|
46
|
+
detail?: string;
|
|
47
|
+
}
|
|
48
|
+
export type SafeReadResult = SafeReadSuccess | SafeReadFailure;
|
|
49
|
+
/**
|
|
50
|
+
* Read a text file with a byte budget, binary rejection and line clamping.
|
|
51
|
+
*
|
|
52
|
+
* Never throws for expected conditions (missing file, directory, binary,
|
|
53
|
+
* oversized, permission denied) — inspect `result.ok` and branch on `reason`.
|
|
54
|
+
*
|
|
55
|
+
* Newlines are returned verbatim: CRLF is NOT normalized, because the callers
|
|
56
|
+
* migrated to this helper already tolerate `\r` (they `trim()` split lines) and
|
|
57
|
+
* silently rewriting bytes would make reported offsets diverge from the file.
|
|
58
|
+
*/
|
|
59
|
+
export declare function safeReadFile(filePath: string, opts?: SafeReadOptions): Promise<SafeReadResult>;
|
|
60
|
+
/** Human-readable one-liner for a failed read — for CLI notes and findings. */
|
|
61
|
+
export declare function describeSafeReadFailure(failure: SafeReadFailure): string;
|
|
62
|
+
//# sourceMappingURL=safe-read.d.ts.map
|
|
@@ -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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
|
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) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install-gate evaluation for the skillguard runtime gate (D2).
|
|
3
|
+
*
|
|
4
|
+
* Pure helper: verdict evaluation for a set of declared skill scans lives in
|
|
5
|
+
* exactly one place so all three entrypoints (plugin add, plugin import, skills
|
|
6
|
+
* auto-install) share identical force semantics. Scanning + try/catch stay at
|
|
7
|
+
* the call sites (a pure helper cannot own the I/O); this module imports no fs.
|
|
8
|
+
*
|
|
9
|
+
* Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
|
|
10
|
+
* `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
|
|
11
|
+
* any symlink, empty/missing `skills` on import, declared paths escaping the
|
|
12
|
+
* source dir) are NOT verdicts — call sites enforce them BEFORE this helper
|
|
13
|
+
* runs, so `force` can never lift them either.
|
|
14
|
+
*/
|
|
15
|
+
import type { SkillScanResult } from "./skill-scanner.js";
|
|
16
|
+
export interface InstallGateDecision {
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
/** Rejected results when `!allowed`, else `[]`. */
|
|
19
|
+
rejected: SkillScanResult[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Evaluate a set of declared-skill scan results against the install gate.
|
|
23
|
+
*
|
|
24
|
+
* `allowed = !hasBlock && (rejected.length === 0 || force)` — a `block` verdict
|
|
25
|
+
* refuses unconditionally; `force` bypasses ONLY `unscannable`. Empty,
|
|
26
|
+
* pass, and warn results are allowed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function evaluateInstallGate(results: SkillScanResult[], options?: {
|
|
29
|
+
force?: boolean;
|
|
30
|
+
}): InstallGateDecision;
|
|
31
|
+
//# sourceMappingURL=skill-install-gate.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared install-gate evaluation for the skillguard runtime gate (D2).
|
|
3
|
+
*
|
|
4
|
+
* Pure helper: verdict evaluation for a set of declared skill scans lives in
|
|
5
|
+
* exactly one place so all three entrypoints (plugin add, plugin import, skills
|
|
6
|
+
* auto-install) share identical force semantics. Scanning + try/catch stay at
|
|
7
|
+
* the call sites (a pure helper cannot own the I/O); this module imports no fs.
|
|
8
|
+
*
|
|
9
|
+
* Force rule (fail-closed): `block` always refuses; `--force` lifts ONLY
|
|
10
|
+
* `unscannable`. Manifest-integrity refusals (undeclared SKILL.md in the tree,
|
|
11
|
+
* any symlink, empty/missing `skills` on import, declared paths escaping the
|
|
12
|
+
* source dir) are NOT verdicts — call sites enforce them BEFORE this helper
|
|
13
|
+
* runs, so `force` can never lift them either.
|
|
14
|
+
*/
|
|
15
|
+
import { isRejectedVerdict } from "./skill-scanner.js";
|
|
16
|
+
/**
|
|
17
|
+
* Evaluate a set of declared-skill scan results against the install gate.
|
|
18
|
+
*
|
|
19
|
+
* `allowed = !hasBlock && (rejected.length === 0 || force)` — a `block` verdict
|
|
20
|
+
* refuses unconditionally; `force` bypasses ONLY `unscannable`. Empty,
|
|
21
|
+
* pass, and warn results are allowed.
|
|
22
|
+
*/
|
|
23
|
+
export function evaluateInstallGate(results, options) {
|
|
24
|
+
const rejected = results.filter((r) => isRejectedVerdict(r.verdict));
|
|
25
|
+
const hasBlock = results.some((r) => r.verdict === "block");
|
|
26
|
+
const force = options?.force ?? false;
|
|
27
|
+
const allowed = !hasBlock && (rejected.length === 0 || force);
|
|
28
|
+
return { allowed, rejected: allowed ? [] : rejected };
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=skill-install-gate.js.map
|