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.
- 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/skills-cmd.js +8 -0
- package/dist/commands/ci.js +19 -5
- package/dist/commands/doctor.d.ts +8 -0
- package/dist/commands/doctor.js +121 -0
- package/dist/commands/hooks/sections/deps.d.ts +28 -0
- package/dist/commands/hooks/sections/deps.js +126 -0
- package/dist/commands/hooks/sections/permissions.d.ts +33 -0
- package/dist/commands/hooks/sections/permissions.js +101 -0
- package/dist/commands/hooks/sections/secrets.d.ts +58 -0
- package/dist/commands/hooks/sections/secrets.js +182 -0
- package/dist/commands/hooks.d.ts +4 -4
- package/dist/commands/hooks.js +13 -4
- package/dist/commands/init/steps/ghagga.d.ts +3 -4
- package/dist/commands/init/steps/ghagga.js +5 -15
- package/dist/commands/init/steps/security.d.ts +10 -20
- package/dist/commands/init/steps/security.js +58 -78
- package/dist/commands/init.js +1 -2
- 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/constants.d.ts +6 -1
- package/dist/constants.js +12 -7
- package/dist/lib/context.d.ts +22 -0
- package/dist/lib/context.js +120 -79
- 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-scanner.d.ts +22 -1
- package/dist/lib/skill-scanner.js +76 -4
- package/dist/types/index.d.ts +18 -0
- package/dist/ui/Skills.js +12 -7
- package/package.json +1 -1
- package/templates/github/ghagga-review.yml +0 -30
- package/templates/security-hooks/commit-msg-signing +0 -29
- package/templates/security-hooks/pre-commit-permissions +0 -74
- package/templates/security-hooks/pre-commit-secrets +0 -74
- package/templates/security-hooks/pre-push-branch-protection +0 -62
- package/templates/security-hooks/pre-push-deps +0 -83
- package/templates/security-hooks/pre-push-signing +0 -67
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1 secret-scan section (hook-consolidation S4).
|
|
3
|
+
*
|
|
4
|
+
* Port of `templates/security-hooks/pre-commit-secrets` into TypeScript. The
|
|
5
|
+
* bash body built the staged-file list with `git diff --cached --name-only`
|
|
6
|
+
* and then piped it through `xargs git diff` (pre-commit-secrets:52) — a
|
|
7
|
+
* whitespace split that silently dropped any path containing a space and
|
|
8
|
+
* swallowed the git error with `|| true` (K-005). This port builds the staged
|
|
9
|
+
* list NUL-safe (`-z` → split on `"\0"`) and passes each filename as a SEPARATE
|
|
10
|
+
* argv element to `git diff` (no shell, no xargs), so a path like
|
|
11
|
+
* `app secrets.env` is scanned as ONE path and can never be split.
|
|
12
|
+
*
|
|
13
|
+
* A matched pattern in an ADDED diff line blocks the commit (ok:false).
|
|
14
|
+
*/
|
|
15
|
+
import { execFileAsync } from "../../../lib/exec.js";
|
|
16
|
+
/**
|
|
17
|
+
* Ported from `templates/security-hooks/pre-commit-secrets` SECRET_PATTERNS.
|
|
18
|
+
* bash `(?i)` → the JS `/i` flag; bash `\x27` (a single quote) → a literal `'`.
|
|
19
|
+
* No `/g` flag is used, so `.test()` never carries `lastIndex` state between
|
|
20
|
+
* lines. Order is preserved from the bash array.
|
|
21
|
+
*/
|
|
22
|
+
const SECRET_PATTERNS = [
|
|
23
|
+
// AWS access key id
|
|
24
|
+
{ name: "aws-access-key", re: /AKIA[0-9A-Z]{16}/ },
|
|
25
|
+
// Generic API keys / tokens assigned to key-like vars
|
|
26
|
+
{
|
|
27
|
+
name: "generic-api-key",
|
|
28
|
+
re: /(api[_-]?key|api[_-]?secret|access[_-]?token|auth[_-]?token)\s*[:=]\s*["'][A-Za-z0-9+/=_-]{20,}["']/i,
|
|
29
|
+
},
|
|
30
|
+
// Private key headers
|
|
31
|
+
{
|
|
32
|
+
name: "private-key",
|
|
33
|
+
re: /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/,
|
|
34
|
+
},
|
|
35
|
+
// GitHub tokens
|
|
36
|
+
{ name: "github-token", re: /gh[pousr]_[A-Za-z0-9_]{36,}/ },
|
|
37
|
+
// Generic password assignments
|
|
38
|
+
{
|
|
39
|
+
name: "password-assignment",
|
|
40
|
+
re: /(password|passwd|pwd)\s*[:=]\s*["'][^\s"']{8,}["']/i,
|
|
41
|
+
},
|
|
42
|
+
// Slack tokens
|
|
43
|
+
{ name: "slack-token", re: /xox[baprs]-[0-9a-zA-Z-]+/ },
|
|
44
|
+
// Stripe keys
|
|
45
|
+
{ name: "stripe-secret-key", re: /sk_live_[0-9a-zA-Z]{24,}/ },
|
|
46
|
+
{ name: "stripe-restricted-key", re: /rk_live_[0-9a-zA-Z]{24,}/ },
|
|
47
|
+
// SendGrid
|
|
48
|
+
{
|
|
49
|
+
name: "sendgrid-key",
|
|
50
|
+
re: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/,
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
/** ARG_MAX guard: cap the number of file paths per `git diff` argv batch. */
|
|
54
|
+
export const DEFAULT_CHUNK_SIZE = 512;
|
|
55
|
+
/**
|
|
56
|
+
* maxBuffer for the content `git diff` read. Node's default is 1 MiB; a staged
|
|
57
|
+
* diff larger than that rejects and (caught) blocks every large legitimate
|
|
58
|
+
* commit with a cryptic buffer error. Raise the ceiling to a generous but
|
|
59
|
+
* BOUNDED 64 MiB so ordinary large commits scan, while a genuinely pathological
|
|
60
|
+
* diff still fails closed instead of exhausting memory.
|
|
61
|
+
*/
|
|
62
|
+
export const DIFF_MAX_BUFFER = 64 * 1024 * 1024;
|
|
63
|
+
function defaultDeps() {
|
|
64
|
+
return {
|
|
65
|
+
execFile: async (cmd, args, opts) => {
|
|
66
|
+
const { stdout, stderr } = await execFileAsync(cmd, args, opts);
|
|
67
|
+
return { stdout: String(stdout), stderr: String(stderr) };
|
|
68
|
+
},
|
|
69
|
+
log: (m) => console.log(m),
|
|
70
|
+
chunkSize: DEFAULT_CHUNK_SIZE,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** NUL-safe staged-file list (`-z` → split on "\0", drop the trailing empty). */
|
|
74
|
+
async function stagedFiles(deps, projectDir) {
|
|
75
|
+
const { stdout } = await deps.execFile("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], { cwd: projectDir });
|
|
76
|
+
return stdout.split("\0").filter((f) => f.length > 0);
|
|
77
|
+
}
|
|
78
|
+
function chunk(items, size) {
|
|
79
|
+
const out = [];
|
|
80
|
+
for (let i = 0; i < items.length; i += size)
|
|
81
|
+
out.push(items.slice(i, i + size));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Walk a unified diff and report every ADDED line (`+`, never `+++`) that
|
|
86
|
+
* matches a secret pattern, tracking the new-file path and line number so a
|
|
87
|
+
* finding reads `path:line pattern`.
|
|
88
|
+
*/
|
|
89
|
+
export function scanDiff(diff) {
|
|
90
|
+
const findings = [];
|
|
91
|
+
let currentFile = "";
|
|
92
|
+
let newLine = 0;
|
|
93
|
+
for (const line of diff.split("\n")) {
|
|
94
|
+
if (line.startsWith("+++ ")) {
|
|
95
|
+
const p = line.slice(4).trim();
|
|
96
|
+
currentFile = p === "/dev/null" ? "" : p.replace(/^b\//, "");
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (line.startsWith("--- "))
|
|
100
|
+
continue;
|
|
101
|
+
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
102
|
+
if (hunk) {
|
|
103
|
+
newLine = Number.parseInt(hunk[1], 10);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (line.startsWith("diff --git") ||
|
|
107
|
+
line.startsWith("index ") ||
|
|
108
|
+
line.startsWith("old mode") ||
|
|
109
|
+
line.startsWith("new mode") ||
|
|
110
|
+
line.startsWith("similarity ") ||
|
|
111
|
+
line.startsWith("rename ") ||
|
|
112
|
+
line.startsWith("new file") ||
|
|
113
|
+
line.startsWith("deleted file")) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith("+")) {
|
|
117
|
+
const content = line.slice(1);
|
|
118
|
+
for (const { name, re } of SECRET_PATTERNS) {
|
|
119
|
+
if (re.test(content)) {
|
|
120
|
+
findings.push({ file: currentFile, line: newLine, pattern: name });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
newLine++;
|
|
124
|
+
}
|
|
125
|
+
else if (line.startsWith("-")) {
|
|
126
|
+
// removed line — does not advance the new-file line counter
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// context (leading space) or blank line
|
|
130
|
+
newLine++;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return findings;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The `secrets` section factory. Injectable seams default to the real
|
|
137
|
+
* `git`-via-execFileAsync implementation; tests pass mocks. A thrown git error
|
|
138
|
+
* becomes a blocking `{ ok: false }` (never an unhandled rejection), matching
|
|
139
|
+
* the ciSection/tddSection hardening.
|
|
140
|
+
*/
|
|
141
|
+
export function secretsSection(overrides = {}) {
|
|
142
|
+
const deps = { ...defaultDeps(), ...overrides };
|
|
143
|
+
return {
|
|
144
|
+
id: "secrets",
|
|
145
|
+
blocking: true,
|
|
146
|
+
async run({ projectDir }) {
|
|
147
|
+
try {
|
|
148
|
+
const files = await stagedFiles(deps, projectDir);
|
|
149
|
+
if (files.length === 0)
|
|
150
|
+
return { ok: true };
|
|
151
|
+
const findings = [];
|
|
152
|
+
for (const batch of chunk(files, deps.chunkSize)) {
|
|
153
|
+
// `--no-color` is MANDATORY: under `color.ui=always` (or
|
|
154
|
+
// `color.diff=always`) git emits ANSI escapes even to a pipe, so
|
|
155
|
+
// added lines render as `\x1b[32m+secret\x1b[m` and both the
|
|
156
|
+
// `+`/`+++ ` line checks in scanDiff fail — the scanner would then
|
|
157
|
+
// find zero secrets and fail OPEN. Forcing color off keeps the diff
|
|
158
|
+
// plain-text and the scan deterministic.
|
|
159
|
+
const { stdout } = await deps.execFile("git", ["diff", "--no-color", "--cached", "--", ...batch], { cwd: projectDir, maxBuffer: DIFF_MAX_BUFFER });
|
|
160
|
+
findings.push(...scanDiff(stdout));
|
|
161
|
+
}
|
|
162
|
+
if (findings.length === 0)
|
|
163
|
+
return { ok: true };
|
|
164
|
+
const summary = findings
|
|
165
|
+
.slice(0, 10)
|
|
166
|
+
.map((f) => `${f.file || "<staged>"}:${f.line} ${f.pattern}`)
|
|
167
|
+
.join("; ");
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
detail: `${findings.length} potential secret(s): ${summary}`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
detail: e instanceof Error ? e.message : String(e),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=secrets.js.map
|
package/dist/commands/hooks.d.ts
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* behavior); a config that FAILS to validate exits 1 — a broken config never
|
|
11
11
|
* silently skips a gate.
|
|
12
12
|
*
|
|
13
|
-
* S1a/S3 scope: the `ci`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* S1a/S3/S4 scope: the `ci`, `tdd`, `secrets`, `permissions` and `deps`
|
|
14
|
+
* sections all have factories now. `composeSections` still skips any enabled
|
|
15
|
+
* feature with no registered factory, so a future feature can land the same
|
|
16
|
+
* way.
|
|
17
17
|
*/
|
|
18
18
|
import { type CIHooksConfig } from "../lib/ci-config.js";
|
|
19
19
|
import type { Stack } from "../types/index.js";
|
package/dist/commands/hooks.js
CHANGED
|
@@ -10,14 +10,17 @@
|
|
|
10
10
|
* behavior); a config that FAILS to validate exits 1 — a broken config never
|
|
11
11
|
* silently skips a gate.
|
|
12
12
|
*
|
|
13
|
-
* S1a/S3 scope: the `ci`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* S1a/S3/S4 scope: the `ci`, `tdd`, `secrets`, `permissions` and `deps`
|
|
14
|
+
* sections all have factories now. `composeSections` still skips any enabled
|
|
15
|
+
* feature with no registered factory, so a future feature can land the same
|
|
16
|
+
* way.
|
|
17
17
|
*/
|
|
18
18
|
import { spawn } from "node:child_process";
|
|
19
19
|
import { findCIConfig, loadCIConfig, } from "../lib/ci-config.js";
|
|
20
20
|
import { detectCIStack, runCI } from "./ci.js";
|
|
21
|
+
import { depsSection } from "./hooks/sections/deps.js";
|
|
22
|
+
import { permissionsSection } from "./hooks/sections/permissions.js";
|
|
23
|
+
import { secretsSection } from "./hooks/sections/secrets.js";
|
|
21
24
|
import { getTddTestCommand } from "./tdd.js";
|
|
22
25
|
/** Fixed cheap→expensive order per hook (deterministic is a feature). */
|
|
23
26
|
const PRE_COMMIT_ORDER = ["secrets", "permissions", "tdd", "ci"];
|
|
@@ -193,6 +196,12 @@ export function defaultRegistry(runCIImpl, log, tddDeps = {}) {
|
|
|
193
196
|
runCommand: tddDeps.runCommand ?? runTestCommand,
|
|
194
197
|
log: tddDeps.log ?? log,
|
|
195
198
|
}),
|
|
199
|
+
// S4 security sections. Each resolves its git/fs/exec seams at run time
|
|
200
|
+
// via the section's own defaults; unit tests inject mocks by importing
|
|
201
|
+
// the factory directly (see src/commands/hooks/sections/*.test.ts).
|
|
202
|
+
secrets: () => secretsSection({ log }),
|
|
203
|
+
permissions: () => permissionsSection({ log }),
|
|
204
|
+
deps: () => depsSection({ log }),
|
|
196
205
|
};
|
|
197
206
|
}
|
|
198
207
|
/** Resolve the parsed `hooks:` config for a project (null → default [ci]). */
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { StepFn } from "../types.js";
|
|
2
2
|
/**
|
|
3
|
-
* Step 9: Install GHAGGA review
|
|
3
|
+
* Step 9: Install the local GHAGGA review module.
|
|
4
4
|
*
|
|
5
5
|
* - When ghagga is false, reports "skipped".
|
|
6
6
|
* - Copies <MODULES_DIR>/ghagga → <project>/.javi-forge/modules/ghagga (no overwrite).
|
|
7
|
-
* - For GitHub provider, also copies the ghagga-review.yml caller workflow into
|
|
8
|
-
* <project>/.github/workflows/.
|
|
9
7
|
* - If module source dir is missing, reports "error" with "module not found".
|
|
10
8
|
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
11
9
|
*
|
|
12
|
-
*
|
|
10
|
+
* The GitHub Action review workflow is intentionally NOT scaffolded — ghagga
|
|
11
|
+
* runs locally/self-hosted, not as a GitHub Action.
|
|
13
12
|
*/
|
|
14
13
|
export declare const stepGhagga: StepFn;
|
|
15
14
|
//# sourceMappingURL=ghagga.d.ts.map
|
|
@@ -1,23 +1,22 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
|
-
import {
|
|
3
|
+
import { MODULES_DIR } from "../../../constants.js";
|
|
4
4
|
import { ensureDirExists } from "../../../lib/common.js";
|
|
5
5
|
import { report } from "../report.js";
|
|
6
6
|
/**
|
|
7
|
-
* Step 9: Install GHAGGA review
|
|
7
|
+
* Step 9: Install the local GHAGGA review module.
|
|
8
8
|
*
|
|
9
9
|
* - When ghagga is false, reports "skipped".
|
|
10
10
|
* - Copies <MODULES_DIR>/ghagga → <project>/.javi-forge/modules/ghagga (no overwrite).
|
|
11
|
-
* - For GitHub provider, also copies the ghagga-review.yml caller workflow into
|
|
12
|
-
* <project>/.github/workflows/.
|
|
13
11
|
* - If module source dir is missing, reports "error" with "module not found".
|
|
14
12
|
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
15
13
|
*
|
|
16
|
-
*
|
|
14
|
+
* The GitHub Action review workflow is intentionally NOT scaffolded — ghagga
|
|
15
|
+
* runs locally/self-hosted, not as a GitHub Action.
|
|
17
16
|
*/
|
|
18
17
|
export const stepGhagga = async (ctx) => {
|
|
19
18
|
const { projectDir, dryRun, onStep, options } = ctx;
|
|
20
|
-
const { ghagga
|
|
19
|
+
const { ghagga } = options;
|
|
21
20
|
const stepId = "ghagga";
|
|
22
21
|
report(onStep, stepId, "Install GHAGGA review system", "running");
|
|
23
22
|
try {
|
|
@@ -31,15 +30,6 @@ export const stepGhagga = async (ctx) => {
|
|
|
31
30
|
overwrite: false,
|
|
32
31
|
errorOnExist: false,
|
|
33
32
|
});
|
|
34
|
-
// Copy ghagga caller workflow to CI provider location
|
|
35
|
-
if (ciProvider === "github") {
|
|
36
|
-
const workflowSrc = path.join(FORGE_ROOT, "templates", "github", "ghagga-review.yml");
|
|
37
|
-
if (await fs.pathExists(workflowSrc)) {
|
|
38
|
-
const workflowDest = path.join(projectDir, ".github", "workflows", "ghagga-review.yml");
|
|
39
|
-
await ensureDirExists(path.dirname(workflowDest));
|
|
40
|
-
await fs.copy(workflowSrc, workflowDest, { overwrite: false });
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
33
|
}
|
|
44
34
|
report(onStep, stepId, "Install GHAGGA review system", "done");
|
|
45
35
|
}
|
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
import type { StepFn } from "../types.js";
|
|
2
2
|
/**
|
|
3
|
-
* Step 14: Scaffold security hooks.
|
|
3
|
+
* Step 14: Scaffold security hooks (hook-consolidation S4 fold).
|
|
4
4
|
*
|
|
5
5
|
* - When options.securityHooks is false, reports "skipped".
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
6
|
+
* - Otherwise:
|
|
7
|
+
* 1. Copies the kiteguard-style runtime settings to `.claude/settings.json`
|
|
8
|
+
* when absent (KEPT — this is a real feature).
|
|
9
|
+
* 2. Merges the `hooks:` security sections for the selected reliability
|
|
10
|
+
* profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
|
|
11
|
+
* minimal `version: 2` config when absent). The dispatcher composes these
|
|
12
|
+
* sections at hook-run time (see src/commands/hooks.ts).
|
|
13
|
+
* - The old inert `ci-local/hooks/security/` git-hook copy is GONE (those hook
|
|
14
|
+
* bodies were ported to TypeScript sections in S4).
|
|
10
15
|
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
11
|
-
*
|
|
12
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
13
|
-
* Grouped with stepHookProfile for cohesion — both manage ci-local/hooks/.
|
|
14
16
|
*/
|
|
15
17
|
export declare const stepSecurityHooks: StepFn;
|
|
16
|
-
/**
|
|
17
|
-
* Step 14b: Write hook reliability profile.
|
|
18
|
-
*
|
|
19
|
-
* - When options.securityHooks is false, reports "skipped".
|
|
20
|
-
* - Otherwise writes ci-local/hooks/profile.json with the resolved profile
|
|
21
|
-
* (defaults to "standard" when hookProfile is undefined).
|
|
22
|
-
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
23
|
-
*
|
|
24
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
25
|
-
* Grouped with stepSecurityHooks for cohesion — both manage ci-local/hooks/.
|
|
26
|
-
*/
|
|
27
|
-
export declare const stepHookProfile: StepFn;
|
|
28
18
|
//# sourceMappingURL=security.d.ts.map
|
|
@@ -1,103 +1,83 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import fs from "fs-extra";
|
|
3
3
|
import { SECURITY_HOOKS_DIR } from "../../../constants.js";
|
|
4
|
+
import { setHookFeature } from "../../../lib/ci-config.js";
|
|
4
5
|
import { ensureDirExists } from "../../../lib/common.js";
|
|
5
6
|
import { report } from "../report.js";
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
+
* Hook-feature preset per reliability profile (hook-consolidation S4).
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
17
|
-
* Grouped with stepHookProfile for cohesion — both manage ci-local/hooks/.
|
|
10
|
+
* The old `stepHookProfile` wrote a `ci-local/hooks/profile.json` that had ZERO
|
|
11
|
+
* runtime readers (design D7). The selector is repurposed: the chosen profile
|
|
12
|
+
* now drives WHICH `hooks:` security sections get merged into
|
|
13
|
+
* `.javi-forge/ci.yaml`, using the EXISTING `HookProfile` values (no "relaxed"):
|
|
14
|
+
* - strict → secrets + permissions + deps (every section)
|
|
15
|
+
* - standard → secrets + deps
|
|
16
|
+
* - minimal → CI gate only (no security sections)
|
|
18
17
|
*/
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
report(onStep, stepId, "Scaffold security hooks", "running");
|
|
24
|
-
try {
|
|
25
|
-
if (securityHooks) {
|
|
26
|
-
if (await fs.pathExists(SECURITY_HOOKS_DIR)) {
|
|
27
|
-
if (!dryRun) {
|
|
28
|
-
// Copy 6-layer git security hooks into ci-local/hooks/security/
|
|
29
|
-
const secHooksDest = path.join(projectDir, "ci-local", "hooks", "security");
|
|
30
|
-
await ensureDirExists(secHooksDest);
|
|
31
|
-
const hookFiles = await fs.readdir(SECURITY_HOOKS_DIR);
|
|
32
|
-
const gitHooks = hookFiles.filter((f) => !f.endsWith(".json"));
|
|
33
|
-
for (const hook of gitHooks) {
|
|
34
|
-
const src = path.join(SECURITY_HOOKS_DIR, hook);
|
|
35
|
-
const dest = path.join(secHooksDest, hook);
|
|
36
|
-
await fs.copy(src, dest, { overwrite: false });
|
|
37
|
-
await fs.chmod(dest, 0o755);
|
|
38
|
-
}
|
|
39
|
-
// Copy runtime security settings (kiteguard-style) to .claude/
|
|
40
|
-
const settingsSrc = path.join(SECURITY_HOOKS_DIR, "claude-settings-security.json");
|
|
41
|
-
if (await fs.pathExists(settingsSrc)) {
|
|
42
|
-
const claudeDir = path.join(projectDir, ".claude");
|
|
43
|
-
await ensureDirExists(claudeDir);
|
|
44
|
-
const settingsDest = path.join(claudeDir, "settings.json");
|
|
45
|
-
if (!(await fs.pathExists(settingsDest))) {
|
|
46
|
-
await fs.copy(settingsSrc, settingsDest);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
report(onStep, stepId, "Scaffold security hooks", "done", dryRun
|
|
51
|
-
? "dry-run: would scaffold security hooks"
|
|
52
|
-
: "6 git layers + runtime hooks");
|
|
53
|
-
}
|
|
54
|
-
else {
|
|
55
|
-
report(onStep, stepId, "Scaffold security hooks", "error", "security-hooks templates not found");
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
else {
|
|
59
|
-
report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
catch (e) {
|
|
63
|
-
report(onStep, stepId, "Scaffold security hooks", "error", String(e));
|
|
64
|
-
}
|
|
18
|
+
const PROFILE_PRESET = {
|
|
19
|
+
minimal: { preCommit: [], prePush: [] },
|
|
20
|
+
standard: { preCommit: ["secrets"], prePush: ["deps"] },
|
|
21
|
+
strict: { preCommit: ["secrets", "permissions"], prePush: ["deps"] },
|
|
65
22
|
};
|
|
66
23
|
/**
|
|
67
|
-
* Step
|
|
24
|
+
* Step 14: Scaffold security hooks (hook-consolidation S4 fold).
|
|
68
25
|
*
|
|
69
26
|
* - When options.securityHooks is false, reports "skipped".
|
|
70
|
-
* - Otherwise
|
|
71
|
-
*
|
|
27
|
+
* - Otherwise:
|
|
28
|
+
* 1. Copies the kiteguard-style runtime settings to `.claude/settings.json`
|
|
29
|
+
* when absent (KEPT — this is a real feature).
|
|
30
|
+
* 2. Merges the `hooks:` security sections for the selected reliability
|
|
31
|
+
* profile into `.javi-forge/ci.yaml` via `setHookFeature` (creating a
|
|
32
|
+
* minimal `version: 2` config when absent). The dispatcher composes these
|
|
33
|
+
* sections at hook-run time (see src/commands/hooks.ts).
|
|
34
|
+
* - The old inert `ci-local/hooks/security/` git-hook copy is GONE (those hook
|
|
35
|
+
* bodies were ported to TypeScript sections in S4).
|
|
72
36
|
* - Errors are swallowed and reported as status:"error" — never thrown.
|
|
73
|
-
*
|
|
74
|
-
* Extracted VERBATIM from src/commands/init.ts (PR 5 of 6).
|
|
75
|
-
* Grouped with stepSecurityHooks for cohesion — both manage ci-local/hooks/.
|
|
76
37
|
*/
|
|
77
|
-
export const
|
|
38
|
+
export const stepSecurityHooks = async (ctx) => {
|
|
78
39
|
const { projectDir, dryRun, onStep, options } = ctx;
|
|
79
40
|
const { securityHooks, hookProfile } = options;
|
|
80
|
-
const stepId = "
|
|
81
|
-
report(onStep, stepId, "
|
|
41
|
+
const stepId = "security-hooks";
|
|
42
|
+
report(onStep, stepId, "Scaffold security hooks", "running");
|
|
82
43
|
try {
|
|
83
|
-
if (securityHooks) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
44
|
+
if (!securityHooks) {
|
|
45
|
+
report(onStep, stepId, "Scaffold security hooks", "skipped", "not selected");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const profile = hookProfile ?? "standard";
|
|
49
|
+
const preset = PROFILE_PRESET[profile];
|
|
50
|
+
if (dryRun) {
|
|
51
|
+
report(onStep, stepId, "Scaffold security hooks", "done", `dry-run: would merge ${profile} hooks preset + copy .claude/settings.json`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
// 1. Copy the kiteguard-style runtime security settings to .claude/.
|
|
55
|
+
const settingsSrc = path.join(SECURITY_HOOKS_DIR, "claude-settings-security.json");
|
|
56
|
+
if (await fs.pathExists(settingsSrc)) {
|
|
57
|
+
const claudeDir = path.join(projectDir, ".claude");
|
|
58
|
+
await ensureDirExists(claudeDir);
|
|
59
|
+
const settingsDest = path.join(claudeDir, "settings.json");
|
|
60
|
+
if (!(await fs.pathExists(settingsDest))) {
|
|
61
|
+
await fs.copy(settingsSrc, settingsDest);
|
|
90
62
|
}
|
|
91
|
-
report(onStep, stepId, "Write hook reliability profile", "done", dryRun
|
|
92
|
-
? `dry-run: would write profile.json (${hookProfile ?? "standard"})`
|
|
93
|
-
: `ci-local/hooks/profile.json (${hookProfile ?? "standard"})`);
|
|
94
63
|
}
|
|
95
|
-
|
|
96
|
-
|
|
64
|
+
// 2. Merge the profile's security sections into .javi-forge/ci.yaml.
|
|
65
|
+
for (const feature of preset.preCommit) {
|
|
66
|
+
await setHookFeature(projectDir, "pre-commit", feature, true);
|
|
67
|
+
}
|
|
68
|
+
for (const feature of preset.prePush) {
|
|
69
|
+
await setHookFeature(projectDir, "pre-push", feature, true);
|
|
97
70
|
}
|
|
71
|
+
const merged = [
|
|
72
|
+
...preset.preCommit.map((f) => `pre-commit.${f}`),
|
|
73
|
+
...preset.prePush.map((f) => `pre-push.${f}`),
|
|
74
|
+
];
|
|
75
|
+
report(onStep, stepId, "Scaffold security hooks", "done", merged.length > 0
|
|
76
|
+
? `${profile} preset: ${merged.join(", ")}`
|
|
77
|
+
: `${profile} preset: CI gate only (no security sections)`);
|
|
98
78
|
}
|
|
99
79
|
catch (e) {
|
|
100
|
-
report(onStep, stepId, "
|
|
80
|
+
report(onStep, stepId, "Scaffold security hooks", "error", String(e));
|
|
101
81
|
}
|
|
102
82
|
};
|
|
103
83
|
//# sourceMappingURL=security.js.map
|
package/dist/commands/init.js
CHANGED
|
@@ -14,7 +14,7 @@ import { stepManifest } from "./init/steps/manifest.js";
|
|
|
14
14
|
import { stepMemory } from "./init/steps/memory.js";
|
|
15
15
|
import { stepMock } from "./init/steps/mock.js";
|
|
16
16
|
import { stepSDD } from "./init/steps/sdd.js";
|
|
17
|
-
import {
|
|
17
|
+
import { stepSecurityHooks } from "./init/steps/security.js";
|
|
18
18
|
/**
|
|
19
19
|
* Main init orchestrator: bootstraps a project with CI, git hooks,
|
|
20
20
|
* memory module, AI config sync, SDD, ghagga, and friends.
|
|
@@ -43,7 +43,6 @@ export async function initProject(options, onStep) {
|
|
|
43
43
|
await stepClaudeMd(ctx);
|
|
44
44
|
await stepDockerDeploy(ctx);
|
|
45
45
|
await stepSecurityHooks(ctx);
|
|
46
|
-
await stepHookProfile(ctx);
|
|
47
46
|
await stepCodeGraph(ctx);
|
|
48
47
|
await stepLocalAi(ctx);
|
|
49
48
|
await stepAgentSkills(ctx);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from "fs-extra";
|
|
2
|
+
import { CHARS_PER_TOKEN, CONTRADICTION_PAIRS, DEFAULT_BUDGET, } from "./constants.js";
|
|
2
3
|
import { detectDirectiveClash } from "./directives.js";
|
|
3
4
|
import { discoverSkills, estimateTokens, parseSkillFile } from "./parsing.js";
|
|
4
5
|
// ── Budget Optimization ─────────────────────────────────────────────────────
|
|
@@ -98,6 +99,12 @@ export async function findConflicts(skillsDir) {
|
|
|
98
99
|
const parsed = await parseSkillFile(sp);
|
|
99
100
|
if (!parsed)
|
|
100
101
|
continue;
|
|
102
|
+
// A skipped file has no readable rules to compare. Don't treat its empty
|
|
103
|
+
// rule list as "no conflicts" — it is surfaced as a skip in the budget
|
|
104
|
+
// section (calculateBudget always runs alongside conflict detection), so
|
|
105
|
+
// skip it here explicitly rather than letting it pass silently.
|
|
106
|
+
if (parsed.skip)
|
|
107
|
+
continue;
|
|
101
108
|
for (const rule of parsed.rules) {
|
|
102
109
|
allRules.push({
|
|
103
110
|
skillName: parsed.name,
|
|
@@ -138,10 +145,32 @@ export async function calculateBudget(skillsDir, budget = DEFAULT_BUDGET) {
|
|
|
138
145
|
const parsed = await parseSkillFile(sp);
|
|
139
146
|
if (!parsed)
|
|
140
147
|
continue;
|
|
148
|
+
// Token count reflects the bytes actually kept by the guarded read, so a
|
|
149
|
+
// skipped or truncated skill is reported rather than counted as normal.
|
|
150
|
+
const note = parsed.skip
|
|
151
|
+
? `skipped: ${parsed.skip.message}`
|
|
152
|
+
: parsed.truncated
|
|
153
|
+
? "truncated: read budget reached"
|
|
154
|
+
: undefined;
|
|
155
|
+
// A skipped skill has empty `rawContent`, so token estimation from it would
|
|
156
|
+
// be 0 and the file would sort last — the budget tool would hide the very
|
|
157
|
+
// oversized file it exists to flag. Fall back to the real on-disk byte size
|
|
158
|
+
// so an unread skill still counts toward the total and over-budget check.
|
|
159
|
+
let tokens = estimateTokens(parsed.rawContent);
|
|
160
|
+
if (parsed.skip) {
|
|
161
|
+
try {
|
|
162
|
+
const { size } = await fs.stat(sp);
|
|
163
|
+
tokens = Math.ceil(size / CHARS_PER_TOKEN);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Vanished between discovery and stat — leave 0; the note still flags it.
|
|
167
|
+
}
|
|
168
|
+
}
|
|
141
169
|
entries.push({
|
|
142
170
|
skillName: parsed.name,
|
|
143
171
|
skillPath: sp,
|
|
144
|
-
tokens
|
|
172
|
+
tokens,
|
|
173
|
+
...(note ? { note } : {}),
|
|
145
174
|
});
|
|
146
175
|
}
|
|
147
176
|
// Sort by token count descending (biggest consumers first)
|
|
@@ -9,6 +9,16 @@ export async function benchmarkSkill(skillPath) {
|
|
|
9
9
|
const parsed = await parseSkillFile(skillPath);
|
|
10
10
|
if (!parsed)
|
|
11
11
|
return null;
|
|
12
|
+
// A file that could not be read has no structure to benchmark. Reporting it
|
|
13
|
+
// as a skill that failed every check is misleading — mark it unread instead.
|
|
14
|
+
if (parsed.skip) {
|
|
15
|
+
return {
|
|
16
|
+
skillName: parsed.name,
|
|
17
|
+
checks: [],
|
|
18
|
+
passRate: 0,
|
|
19
|
+
unread: parsed.skip.message,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
12
22
|
const checks = [];
|
|
13
23
|
// Check 1: Has YAML frontmatter with name
|
|
14
24
|
checks.push({
|
|
@@ -6,5 +6,10 @@ export declare const CHARS_PER_TOKEN = 4;
|
|
|
6
6
|
export declare const DEFAULT_THRESHOLD = 50;
|
|
7
7
|
/** Default registry quality threshold */
|
|
8
8
|
export declare const DEFAULT_REGISTRY_THRESHOLD = 60;
|
|
9
|
+
/**
|
|
10
|
+
* Hard ceiling for a SKILL.md. A skill file past this is not a skill — it is a
|
|
11
|
+
* dumped log or a vendored bundle — so it is skipped instead of truncated.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MAX_SKILL_BYTES: number;
|
|
9
14
|
export declare const CONTRADICTION_PAIRS: [RegExp, RegExp][];
|
|
10
15
|
//# sourceMappingURL=constants.d.ts.map
|
|
@@ -8,6 +8,11 @@ export const CHARS_PER_TOKEN = 4;
|
|
|
8
8
|
export const DEFAULT_THRESHOLD = 50;
|
|
9
9
|
/** Default registry quality threshold */
|
|
10
10
|
export const DEFAULT_REGISTRY_THRESHOLD = 60;
|
|
11
|
+
/**
|
|
12
|
+
* Hard ceiling for a SKILL.md. A skill file past this is not a skill — it is a
|
|
13
|
+
* dumped log or a vendored bundle — so it is skipped instead of truncated.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_SKILL_BYTES = 1024 * 1024;
|
|
11
16
|
// ── Contradiction keywords (pairs that signal opposite intent) ───────────────
|
|
12
17
|
export const CONTRADICTION_PAIRS = [
|
|
13
18
|
[/\buse semicolons\b/i, /\bno semicolons\b/i],
|
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
import { type SafeReadFailureReason } from "../../lib/safe-read.js";
|
|
1
2
|
/** Estimate token count from a string */
|
|
2
3
|
export declare function estimateTokens(text: string): number;
|
|
3
|
-
/**
|
|
4
|
-
export
|
|
4
|
+
/** Why a SKILL.md could not be analysed — reportable, never thrown */
|
|
5
|
+
export interface SkillReadSkip {
|
|
6
|
+
reason: SafeReadFailureReason;
|
|
7
|
+
message: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ParsedSkillFile {
|
|
5
10
|
name: string;
|
|
6
11
|
rules: string[];
|
|
7
12
|
rawContent: string;
|
|
8
13
|
triggers: string[];
|
|
9
|
-
|
|
14
|
+
/**
|
|
15
|
+
* Non-null when the file could not be read as analysable text (binary,
|
|
16
|
+
* oversized, permission denied). `rawContent` is empty in that case, so
|
|
17
|
+
* callers can report the skip instead of scoring an empty skill.
|
|
18
|
+
*/
|
|
19
|
+
skip: SkillReadSkip | null;
|
|
20
|
+
/** True when the file was longer than the read budget and got cut short. */
|
|
21
|
+
truncated: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Read a SKILL.md and extract its name + critical rules section */
|
|
24
|
+
export declare function parseSkillFile(skillPath: string): Promise<ParsedSkillFile | null>;
|
|
10
25
|
/** Extract critical rules from markdown content */
|
|
11
26
|
export declare function extractCriticalRules(content: string): string[];
|
|
12
27
|
/** Extract trigger keywords from a skill description */
|