javi-forge 1.25.1 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/ci-local/hooks/commit-msg +7 -0
  2. package/ci-local/hooks/pre-commit +8 -0
  3. package/ci-local/hooks/pre-push +8 -0
  4. package/dist/cli/dispatch/ci.js +1 -1
  5. package/dist/cli/dispatch/skills-cmd.js +8 -0
  6. package/dist/commands/ci.js +19 -5
  7. package/dist/commands/doctor.d.ts +8 -0
  8. package/dist/commands/doctor.js +121 -0
  9. package/dist/commands/hooks/sections/deps.d.ts +28 -0
  10. package/dist/commands/hooks/sections/deps.js +126 -0
  11. package/dist/commands/hooks/sections/permissions.d.ts +33 -0
  12. package/dist/commands/hooks/sections/permissions.js +101 -0
  13. package/dist/commands/hooks/sections/secrets.d.ts +58 -0
  14. package/dist/commands/hooks/sections/secrets.js +182 -0
  15. package/dist/commands/hooks.d.ts +4 -4
  16. package/dist/commands/hooks.js +13 -4
  17. package/dist/commands/init/steps/ghagga.d.ts +3 -4
  18. package/dist/commands/init/steps/ghagga.js +5 -15
  19. package/dist/commands/init/steps/security.d.ts +10 -20
  20. package/dist/commands/init/steps/security.js +58 -78
  21. package/dist/commands/init.js +1 -2
  22. package/dist/commands/skills/analysis.js +31 -2
  23. package/dist/commands/skills/benchmark.js +10 -0
  24. package/dist/commands/skills/constants.d.ts +5 -0
  25. package/dist/commands/skills/constants.js +5 -0
  26. package/dist/commands/skills/parsing.d.ts +18 -3
  27. package/dist/commands/skills/parsing.js +29 -3
  28. package/dist/commands/skills/scoring.d.ts +7 -6
  29. package/dist/commands/skills/scoring.js +31 -1
  30. package/dist/constants.d.ts +6 -1
  31. package/dist/constants.js +12 -7
  32. package/dist/lib/context.d.ts +22 -0
  33. package/dist/lib/context.js +120 -79
  34. package/dist/lib/safe-read.d.ts +62 -0
  35. package/dist/lib/safe-read.js +221 -0
  36. package/dist/lib/security-analysis.d.ts +19 -2
  37. package/dist/lib/security-analysis.js +65 -13
  38. package/dist/lib/skill-scanner.d.ts +22 -1
  39. package/dist/lib/skill-scanner.js +76 -4
  40. package/dist/types/index.d.ts +18 -0
  41. package/dist/ui/Skills.js +12 -7
  42. package/package.json +1 -1
  43. package/templates/github/ghagga-review.yml +0 -30
  44. package/templates/security-hooks/commit-msg-signing +0 -29
  45. package/templates/security-hooks/pre-commit-permissions +0 -74
  46. package/templates/security-hooks/pre-commit-secrets +0 -74
  47. package/templates/security-hooks/pre-push-branch-protection +0 -62
  48. package/templates/security-hooks/pre-push-deps +0 -83
  49. package/templates/security-hooks/pre-push-signing +0 -67
@@ -1,6 +1,13 @@
1
1
  #!/bin/bash
2
2
  set -e
3
3
  # =============================================================================
4
+ # LEGACY — retained for reference only (hook-consolidation).
5
+ # The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
6
+ # (javi-forge ci init / init). The shipped commit-msg shim lives in
7
+ # assets/hooks/commit-msg; `javi-forge init` no longer copies this directory or
8
+ # sets `core.hooksPath=ci-local/hooks`. This body is kept for historical
9
+ # reference and is NOT the source of truth. See docs/commands.md → hooks.
10
+ # =============================================================================
4
11
  # COMMIT-MSG: Block accidental AI-attribution in commit messages
5
12
  # =============================================================================
6
13
  # Best-effort, NOT an adversarial-grade security control.
@@ -1,5 +1,13 @@
1
1
  #!/bin/bash
2
2
  # =============================================================================
3
+ # LEGACY — retained for reference only (hook-consolidation).
4
+ # The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
5
+ # (javi-forge ci init / init), whose static shims exec the dispatcher
6
+ # `javi-forge hooks run <name>`. `javi-forge init` no longer copies this
7
+ # directory or sets `core.hooksPath=ci-local/hooks`; a repo still on that legacy
8
+ # path is auto-migrated on the next install. This body is kept for historical
9
+ # reference and is NOT the source of truth. See docs/commands.md → hooks.
10
+ # =============================================================================
3
11
  # PRE-COMMIT: Quick CI check via javi-forge
4
12
  # =============================================================================
5
13
  # Requires: npm install -g javi-forge
@@ -1,5 +1,13 @@
1
1
  #!/bin/bash
2
2
  # =============================================================================
3
+ # LEGACY — retained for reference only (hook-consolidation).
4
+ # The canonical hook mechanism is now `.git/hooks/` written by `installCIHooks`
5
+ # (javi-forge ci init / init), whose static shims exec the dispatcher
6
+ # `javi-forge hooks run <name>`. `javi-forge init` no longer copies this
7
+ # directory or sets `core.hooksPath=ci-local/hooks`; a repo still on that legacy
8
+ # path is auto-migrated on the next install. This body is kept for historical
9
+ # reference and is NOT the source of truth. See docs/commands.md → hooks.
10
+ # =============================================================================
3
11
  # PRE-PUSH: Full CI simulation via javi-forge
4
12
  # =============================================================================
5
13
  # Requires: npm install -g javi-forge
@@ -83,7 +83,7 @@ export async function handleCi(cli, ctx) {
83
83
  }
84
84
  if (installed.length > 0) {
85
85
  console.log(`✓ Installed git hooks: ${installed.join(", ")}`);
86
- console.log(" Hooks call javi-forge ci (with npx fallback)");
86
+ console.log(" Hooks call javi-forge hooks run (with npx fallback)");
87
87
  }
88
88
  // Upgrades are reported DISTINCTLY from fresh installs: replacing an
89
89
  // older javi-forge hook is not the same event as writing a new one.
@@ -57,6 +57,10 @@ export async function handleSkillsCmd(cli, ctx) {
57
57
  console.error(`\u2717 Skill not found: ${skillPath}`);
58
58
  process.exit(1);
59
59
  }
60
+ if (result.unread) {
61
+ console.error(`\u2717 Skill could not be scored \u2014 ${result.unread}: ${skillPath}`);
62
+ process.exit(1);
63
+ }
60
64
  console.log(`\nSkill: ${result.skillName}`);
61
65
  console.log(` Completeness: ${result.completeness}/100`);
62
66
  console.log(` Clarity: ${result.clarity}/100`);
@@ -77,6 +81,10 @@ export async function handleSkillsCmd(cli, ctx) {
77
81
  console.error(`\u2717 Skill not found: ${skillPath}`);
78
82
  process.exit(1);
79
83
  }
84
+ if (result.unread) {
85
+ console.error(`\u2717 Skill could not be benchmarked \u2014 ${result.unread}: ${skillPath}`);
86
+ process.exit(1);
87
+ }
80
88
  console.log(`\nBenchmark: ${result.skillName}`);
81
89
  for (const check of result.checks) {
82
90
  const icon = check.passed ? "\u2713" : "\u2717";
@@ -269,11 +269,21 @@ export async function resolveCIRunners(projectDir, options = {}) {
269
269
  const configPath = config ?? (await findCIConfig(projectDir));
270
270
  if (configPath) {
271
271
  const ciConfig = await loadCIConfig(configPath);
272
- const runners = [];
273
- for (const runnerConfig of ciConfig.runners) {
274
- runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
272
+ const hasRunners = ciConfig.runners.length > 0;
273
+ const hasGates = (ciConfig.gates ?? []).length > 0;
274
+ // A config that declares runners OR gates is a genuine "user configured CI"
275
+ // signal: resolve it as the `config` source (a gates-only v2 config keeps
276
+ // its zero-runner, gates-run contract — see describeRunners). A hooks-only
277
+ // (or otherwise empty) v2 config declares NEITHER runners NOR gates, so it
278
+ // must NOT suppress zero-config auto-detection: fall through so `javi-forge
279
+ // ci` still runs a real lint/compile/test runner instead of a no-op.
280
+ if (hasRunners || hasGates) {
281
+ const runners = [];
282
+ for (const runnerConfig of ciConfig.runners) {
283
+ runners.push(await resolveConfiguredRunner(projectDir, runnerConfig));
284
+ }
285
+ return freezeRunners("config", runners, ciConfig.gates ?? []);
275
286
  }
276
- return freezeRunners("config", runners, ciConfig.gates ?? []);
277
287
  }
278
288
  // Zero-config default: single auto-detected runner (unchanged behavior).
279
289
  const info = await detectCIStack(projectDir);
@@ -477,7 +487,11 @@ export async function runCI(options, onStep, onGateOutcome) {
477
487
  try {
478
488
  const ctxResult = await refreshContextDir(projectDir);
479
489
  if (ctxResult) {
480
- report(onStep, stepContext, "Refresh .context/ directory", "done", "INDEX.md + summary.md updated");
490
+ // An unreadable manifest is reported, not silently treated as no deps.
491
+ const detail = ctxResult.warnings.length > 0
492
+ ? `INDEX.md + summary.md updated (manifest warnings: ${ctxResult.warnings.join("; ")})`
493
+ : "INDEX.md + summary.md updated";
494
+ report(onStep, stepContext, "Refresh .context/ directory", "done", detail);
481
495
  }
482
496
  else {
483
497
  report(onStep, stepContext, "Refresh .context/ directory", "skipped", "no .context/ or no manifest");
@@ -1,7 +1,15 @@
1
1
  import type { DoctorResult } from "../types/index.js";
2
2
  export type CheckStatus = "ok" | "fail" | "skip";
3
+ interface RemoteInfo {
4
+ host: "github" | "gitlab" | "other";
5
+ owner: string;
6
+ repo: string;
7
+ }
8
+ /** Parse an origin remote URL (ssh or https) into host/owner/repo. */
9
+ export declare function parseRemote(url: string): RemoteInfo | null;
3
10
  /**
4
11
  * Run comprehensive health checks for the project and framework.
5
12
  */
6
13
  export declare function runDoctor(projectDir?: string): Promise<DoctorResult>;
14
+ export {};
7
15
  //# sourceMappingURL=doctor.d.ts.map
@@ -34,6 +34,110 @@ async function countDir(dir) {
34
34
  const entries = await fs.readdir(dir);
35
35
  return entries.filter((e) => !e.startsWith(".")).length;
36
36
  }
37
+ /** Read a single git config value, "" when unset or on any error. */
38
+ async function gitConfigValue(cwd, key) {
39
+ try {
40
+ const { stdout } = await execFileAsync("git", ["config", "--get", key], {
41
+ cwd,
42
+ });
43
+ return stdout.trim();
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ /** Parse an origin remote URL (ssh or https) into host/owner/repo. */
50
+ export function parseRemote(url) {
51
+ const trimmed = url.trim();
52
+ if (!trimmed)
53
+ return null;
54
+ // git@host:owner/repo(.git) | ssh://git@host/owner/repo | https://host/owner/repo(.git)
55
+ const m = trimmed.match(/(?:git@|https?:\/\/|ssh:\/\/(?:git@)?)([^/:]+)[/:]([^/]+)\/(.+?)(?:\.git)?$/);
56
+ if (!m)
57
+ return null;
58
+ const [, hostRaw, owner, repo] = m;
59
+ const host = hostRaw.includes("github")
60
+ ? "github"
61
+ : hostRaw.includes("gitlab")
62
+ ? "gitlab"
63
+ : "other";
64
+ return { host, owner, repo };
65
+ }
66
+ /**
67
+ * L4+L6 advisory (merged — signing is one check): report `ok` when commit
68
+ * signing is fully configured (`commit.gpgsign=true` AND `user.signingkey`
69
+ * set), otherwise `skip` with the enable snippet. Doctor is read-only — this is
70
+ * a recommendation, never a gate (a hook enforcing it was trivially bypassed
71
+ * with `--no-verify`).
72
+ */
73
+ async function commitSigningCheck(cwd) {
74
+ const gpgSign = await gitConfigValue(cwd, "commit.gpgsign");
75
+ const signingKey = await gitConfigValue(cwd, "user.signingkey");
76
+ if (gpgSign === "true" && signingKey) {
77
+ return {
78
+ label: "Commit signing",
79
+ status: "ok",
80
+ detail: "signing enabled (commit.gpgsign=true)",
81
+ };
82
+ }
83
+ return {
84
+ label: "Commit signing",
85
+ status: "skip",
86
+ detail: "not configured — enable with: git config commit.gpgsign true && git config user.signingkey <KEY>",
87
+ };
88
+ }
89
+ /**
90
+ * L5 advisory: server-side branch protection is the real control (the old local
91
+ * push-blocking hook was CI-exempt and `--no-verify`-bypassable). When `gh` is
92
+ * on PATH and origin is GitHub, probe the branch protection API; a missing
93
+ * probe or protection is a non-alarming `skip` (advisory, not a failure).
94
+ * GitLab or no `gh` → `skip` with a note to verify in the forge UI.
95
+ */
96
+ async function branchProtectionCheck(cwd) {
97
+ const remote = parseRemote(await gitConfigValue(cwd, "remote.origin.url"));
98
+ const label = "Branch protection";
99
+ if (!remote || remote.host === "other") {
100
+ return {
101
+ label,
102
+ status: "skip",
103
+ detail: "no GitHub/GitLab origin — verify branch protection in the forge UI",
104
+ };
105
+ }
106
+ if (remote.host === "gitlab" || !(await which("gh"))) {
107
+ return {
108
+ label,
109
+ status: "skip",
110
+ detail: "verify branch protection in the forge UI",
111
+ };
112
+ }
113
+ // gh + GitHub: resolve the default branch, then probe protection.
114
+ let branch = "main";
115
+ try {
116
+ const { stdout } = await execFileAsync("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], { cwd });
117
+ branch = stdout.trim().replace(/^origin\//, "") || "main";
118
+ }
119
+ catch {
120
+ /* fall back to main */
121
+ }
122
+ try {
123
+ await execFileAsync("gh", [
124
+ "api",
125
+ `repos/${remote.owner}/${remote.repo}/branches/${branch}/protection`,
126
+ ]);
127
+ return {
128
+ label,
129
+ status: "ok",
130
+ detail: `server-side protection enabled on ${branch}`,
131
+ };
132
+ }
133
+ catch {
134
+ return {
135
+ label,
136
+ status: "skip",
137
+ detail: `no server-side branch protection detected on ${branch}`,
138
+ };
139
+ }
140
+ }
37
141
  /**
38
142
  * Run comprehensive health checks for the project and framework.
39
143
  */
@@ -76,6 +180,14 @@ export async function runDoctor(projectDir) {
76
180
  }
77
181
  }
78
182
  sections.push({ title: "System Tools", checks: toolChecks });
183
+ // ── 1b. Security (advisories, read-only) ────────────────────────────────────
184
+ // L4/L6 (commit signing) and L5 (branch protection) are advisories, NOT hooks
185
+ // (hook-consolidation D9): doctor reports, nothing blocks.
186
+ const securityChecks = [
187
+ await commitSigningCheck(cwd),
188
+ await branchProtectionCheck(cwd),
189
+ ];
190
+ sections.push({ title: "Security", checks: securityChecks });
79
191
  // ── 2. Framework Structure ─────────────────────────────────────────────────
80
192
  const structureChecks = [];
81
193
  const expectedDirs = [
@@ -207,6 +319,15 @@ export async function runDoctor(projectDir) {
207
319
  status: "ok",
208
320
  detail: "INDEX.md + summary.md updated",
209
321
  });
322
+ // A manifest that could not be read is not "no dependencies" — surface
323
+ // each warning so an unreadable/oversized/invalid manifest is visible.
324
+ for (const warning of result.warnings) {
325
+ contextChecks.push({
326
+ label: "dependency manifest",
327
+ status: "fail",
328
+ detail: warning,
329
+ });
330
+ }
210
331
  }
211
332
  else {
212
333
  contextChecks.push({
@@ -0,0 +1,28 @@
1
+ /**
2
+ * L2 dependency-audit section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-push-deps`. Same manifest→tool ladder,
5
+ * run via `execFileAsync` (argv, no shell). A tool that is not installed is an
6
+ * advisory skip (ok:true) exactly like the bash "not installed. Skipping"
7
+ * branch — a missing auditor must never block a push. A genuine audit finding
8
+ * (the auditor ran and exited non-zero) is a blocking failure (ok:false).
9
+ */
10
+ import type { HookSection } from "../../hooks.js";
11
+ export interface DepsSectionDeps {
12
+ execFile: (cmd: string, args: string[], opts?: {
13
+ cwd?: string;
14
+ }) => Promise<{
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ which: (bin: string) => Promise<boolean>;
19
+ fileExists: (p: string) => Promise<boolean>;
20
+ log: (msg: string) => void;
21
+ }
22
+ /**
23
+ * The `deps` section factory. Detects the project's manifest and runs the
24
+ * matching auditor. No manifest → advisory skip. A thrown error is caught and
25
+ * mapped to a blocking failure (never an unhandled rejection).
26
+ */
27
+ export declare function depsSection(overrides?: Partial<DepsSectionDeps>): HookSection;
28
+ //# sourceMappingURL=deps.d.ts.map
@@ -0,0 +1,126 @@
1
+ /**
2
+ * L2 dependency-audit section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-push-deps`. Same manifest→tool ladder,
5
+ * run via `execFileAsync` (argv, no shell). A tool that is not installed is an
6
+ * advisory skip (ok:true) exactly like the bash "not installed. Skipping"
7
+ * branch — a missing auditor must never block a push. A genuine audit finding
8
+ * (the auditor ran and exited non-zero) is a blocking failure (ok:false).
9
+ */
10
+ import path from "node:path";
11
+ import fs from "fs-extra";
12
+ import { execFileAsync } from "../../../lib/exec.js";
13
+ async function whichReal(bin) {
14
+ try {
15
+ await execFileAsync("which", [bin]);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
22
+ function defaultDeps() {
23
+ return {
24
+ execFile: async (cmd, args, opts) => {
25
+ const { stdout, stderr } = await execFileAsync(cmd, args, opts);
26
+ return { stdout: String(stdout), stderr: String(stderr) };
27
+ },
28
+ which: whichReal,
29
+ fileExists: (p) => fs.pathExists(p),
30
+ log: (m) => console.log(m),
31
+ };
32
+ }
33
+ /** ENOENT ⇒ the auditor binary is absent (advisory skip, never blocking). */
34
+ function isMissingBinary(e) {
35
+ return e?.code === "ENOENT";
36
+ }
37
+ /**
38
+ * Run an auditor. A clean exit is ok:true; a non-zero exit (the tool ran and
39
+ * found something) is a blocking ok:false; a missing binary (ENOENT) is an
40
+ * advisory skip ok:true (parity with the bash "not installed" branch).
41
+ */
42
+ async function runAudit(deps, projectDir, cmd, args) {
43
+ deps.log(` ▶ ${cmd} ${args.join(" ")}`);
44
+ try {
45
+ await deps.execFile(cmd, args, { cwd: projectDir });
46
+ return { ok: true };
47
+ }
48
+ catch (e) {
49
+ if (isMissingBinary(e)) {
50
+ deps.log(` ⓘ ${cmd} not installed. Skipping (does not block).`);
51
+ return { ok: true };
52
+ }
53
+ return {
54
+ ok: false,
55
+ detail: `high/critical vulnerabilities found (${cmd} ${args.join(" ")})`,
56
+ };
57
+ }
58
+ }
59
+ /**
60
+ * The `deps` section factory. Detects the project's manifest and runs the
61
+ * matching auditor. No manifest → advisory skip. A thrown error is caught and
62
+ * mapped to a blocking failure (never an unhandled rejection).
63
+ */
64
+ export function depsSection(overrides = {}) {
65
+ const deps = { ...defaultDeps(), ...overrides };
66
+ const at = (projectDir, name) => path.join(projectDir, name);
67
+ return {
68
+ id: "deps",
69
+ blocking: true,
70
+ async run({ projectDir }) {
71
+ try {
72
+ if (await deps.fileExists(at(projectDir, "package.json"))) {
73
+ if (await deps.fileExists(at(projectDir, "pnpm-lock.yaml"))) {
74
+ return runAudit(deps, projectDir, "pnpm", [
75
+ "audit",
76
+ "--audit-level=high",
77
+ ]);
78
+ }
79
+ if (await deps.fileExists(at(projectDir, "yarn.lock"))) {
80
+ return runAudit(deps, projectDir, "yarn", [
81
+ "npm",
82
+ "audit",
83
+ "--severity",
84
+ "high",
85
+ ]);
86
+ }
87
+ return runAudit(deps, projectDir, "npm", [
88
+ "audit",
89
+ "--audit-level=high",
90
+ ]);
91
+ }
92
+ if ((await deps.fileExists(at(projectDir, "requirements.txt"))) ||
93
+ (await deps.fileExists(at(projectDir, "pyproject.toml")))) {
94
+ if (!(await deps.which("pip-audit"))) {
95
+ deps.log(" ⓘ pip-audit not installed. Skipping (does not block).");
96
+ return { ok: true };
97
+ }
98
+ return runAudit(deps, projectDir, "pip-audit", []);
99
+ }
100
+ if (await deps.fileExists(at(projectDir, "Cargo.toml"))) {
101
+ if (!(await deps.which("cargo-audit"))) {
102
+ deps.log(" ⓘ cargo-audit not installed. Skipping (does not block).");
103
+ return { ok: true };
104
+ }
105
+ return runAudit(deps, projectDir, "cargo-audit", []);
106
+ }
107
+ if (await deps.fileExists(at(projectDir, "go.mod"))) {
108
+ if (!(await deps.which("govulncheck"))) {
109
+ deps.log(" ⓘ govulncheck not installed. Skipping (does not block).");
110
+ return { ok: true };
111
+ }
112
+ return runAudit(deps, projectDir, "govulncheck", ["./..."]);
113
+ }
114
+ deps.log(" ⓘ no supported dependency manifest found. Skipping.");
115
+ return { ok: true };
116
+ }
117
+ catch (e) {
118
+ return {
119
+ ok: false,
120
+ detail: e instanceof Error ? e.message : String(e),
121
+ };
122
+ }
123
+ },
124
+ };
125
+ }
126
+ //# sourceMappingURL=deps.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * L3 permission-boundary section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-commit-permissions`. Builds the staged
5
+ * list NUL-safe (`-z` → split on "\0") and does the mode checks with `fs.stat`
6
+ * in TS instead of shelling out to `stat`/`grep`. Two issue classes block the
7
+ * commit: a world-writable file (`mode & 0o002`) and an executable
8
+ * (`mode & 0o111`) that is NOT an allowed script/hook/shebang file.
9
+ */
10
+ import type { HookSection } from "../../hooks.js";
11
+ export interface PermissionsSectionDeps {
12
+ execFile: (cmd: string, args: string[], opts?: {
13
+ cwd?: string;
14
+ }) => Promise<{
15
+ stdout: string;
16
+ stderr: string;
17
+ }>;
18
+ /** stat a path; returns null when the file does not exist / is not a regular file. */
19
+ statFile: (p: string) => Promise<{
20
+ mode: number;
21
+ isFile: boolean;
22
+ } | null>;
23
+ /** first line of a file (for the shebang allowance); "" on any read error. */
24
+ firstLine: (p: string) => Promise<string>;
25
+ log: (msg: string) => void;
26
+ }
27
+ /**
28
+ * The `permissions` section factory. Injectable seams default to the real
29
+ * git/fs implementation. A thrown error is caught and mapped to a blocking
30
+ * failure (never an unhandled rejection).
31
+ */
32
+ export declare function permissionsSection(overrides?: Partial<PermissionsSectionDeps>): HookSection;
33
+ //# sourceMappingURL=permissions.d.ts.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * L3 permission-boundary section (hook-consolidation S4).
3
+ *
4
+ * Port of `templates/security-hooks/pre-commit-permissions`. Builds the staged
5
+ * list NUL-safe (`-z` → split on "\0") and does the mode checks with `fs.stat`
6
+ * in TS instead of shelling out to `stat`/`grep`. Two issue classes block the
7
+ * commit: a world-writable file (`mode & 0o002`) and an executable
8
+ * (`mode & 0o111`) that is NOT an allowed script/hook/shebang file.
9
+ */
10
+ import path from "node:path";
11
+ import fs from "fs-extra";
12
+ import { execFileAsync } from "../../../lib/exec.js";
13
+ /** Extensions that legitimately carry the executable bit. */
14
+ const SCRIPT_EXT = /\.(sh|bash|zsh|py|rb|pl)$/;
15
+ async function statFileReal(p) {
16
+ try {
17
+ const st = await fs.stat(p);
18
+ return { mode: st.mode, isFile: st.isFile() };
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ async function firstLineReal(p) {
25
+ try {
26
+ const content = await fs.readFile(p, "utf-8");
27
+ return content.split("\n", 1)[0] ?? "";
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ function defaultDeps() {
34
+ return {
35
+ execFile: async (cmd, args, opts) => {
36
+ const { stdout, stderr } = await execFileAsync(cmd, args, opts);
37
+ return { stdout: String(stdout), stderr: String(stderr) };
38
+ },
39
+ statFile: statFileReal,
40
+ firstLine: firstLineReal,
41
+ log: (m) => console.log(m),
42
+ };
43
+ }
44
+ async function stagedFiles(deps, projectDir) {
45
+ const { stdout } = await deps.execFile("git", ["diff", "--cached", "--name-only", "--diff-filter=ACM", "-z"], { cwd: projectDir });
46
+ return stdout.split("\0").filter((f) => f.length > 0);
47
+ }
48
+ /**
49
+ * The `permissions` section factory. Injectable seams default to the real
50
+ * git/fs implementation. A thrown error is caught and mapped to a blocking
51
+ * failure (never an unhandled rejection).
52
+ */
53
+ export function permissionsSection(overrides = {}) {
54
+ const deps = { ...defaultDeps(), ...overrides };
55
+ return {
56
+ id: "permissions",
57
+ blocking: true,
58
+ async run({ projectDir }) {
59
+ try {
60
+ const files = await stagedFiles(deps, projectDir);
61
+ if (files.length === 0)
62
+ return { ok: true };
63
+ const issues = [];
64
+ for (const file of files) {
65
+ const full = path.join(projectDir, file);
66
+ const st = await deps.statFile(full);
67
+ if (!st?.isFile)
68
+ continue;
69
+ if ((st.mode & 0o002) !== 0) {
70
+ issues.push(`world-writable: ${file}`);
71
+ }
72
+ if ((st.mode & 0o111) !== 0) {
73
+ const allowedByExt = SCRIPT_EXT.test(file);
74
+ const allowedByHooksDir = file.includes("/hooks/");
75
+ const allowedByShebang = allowedByExt || allowedByHooksDir
76
+ ? true
77
+ : (await deps.firstLine(full)).startsWith("#!");
78
+ if (!allowedByExt && !allowedByHooksDir && !allowedByShebang) {
79
+ issues.push(`unexpected executable: ${file}`);
80
+ }
81
+ }
82
+ }
83
+ if (issues.length === 0)
84
+ return { ok: true };
85
+ return {
86
+ ok: false,
87
+ detail: `${issues.length} permission issue(s): ${issues
88
+ .slice(0, 10)
89
+ .join("; ")}`,
90
+ };
91
+ }
92
+ catch (e) {
93
+ return {
94
+ ok: false,
95
+ detail: e instanceof Error ? e.message : String(e),
96
+ };
97
+ }
98
+ },
99
+ };
100
+ }
101
+ //# sourceMappingURL=permissions.js.map
@@ -0,0 +1,58 @@
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 type { HookSection } from "../../hooks.js";
16
+ /** ARG_MAX guard: cap the number of file paths per `git diff` argv batch. */
17
+ export declare const DEFAULT_CHUNK_SIZE = 512;
18
+ /**
19
+ * maxBuffer for the content `git diff` read. Node's default is 1 MiB; a staged
20
+ * diff larger than that rejects and (caught) blocks every large legitimate
21
+ * commit with a cryptic buffer error. Raise the ceiling to a generous but
22
+ * BOUNDED 64 MiB so ordinary large commits scan, while a genuinely pathological
23
+ * diff still fails closed instead of exhausting memory.
24
+ */
25
+ export declare const DIFF_MAX_BUFFER: number;
26
+ interface Finding {
27
+ file: string;
28
+ line: number;
29
+ pattern: string;
30
+ }
31
+ export interface SecretsSectionDeps {
32
+ /** argv-only exec (no shell). Defaults to the real `git` via execFileAsync. */
33
+ execFile: (cmd: string, args: string[], opts?: {
34
+ cwd?: string;
35
+ maxBuffer?: number;
36
+ }) => Promise<{
37
+ stdout: string;
38
+ stderr: string;
39
+ }>;
40
+ log: (msg: string) => void;
41
+ /** File-count per `git diff` batch (ARG_MAX guard). Defaults to 512. */
42
+ chunkSize: number;
43
+ }
44
+ /**
45
+ * Walk a unified diff and report every ADDED line (`+`, never `+++`) that
46
+ * matches a secret pattern, tracking the new-file path and line number so a
47
+ * finding reads `path:line pattern`.
48
+ */
49
+ export declare function scanDiff(diff: string): Finding[];
50
+ /**
51
+ * The `secrets` section factory. Injectable seams default to the real
52
+ * `git`-via-execFileAsync implementation; tests pass mocks. A thrown git error
53
+ * becomes a blocking `{ ok: false }` (never an unhandled rejection), matching
54
+ * the ciSection/tddSection hardening.
55
+ */
56
+ export declare function secretsSection(overrides?: Partial<SecretsSectionDeps>): HookSection;
57
+ export {};
58
+ //# sourceMappingURL=secrets.d.ts.map