@vaultcompass/vault-guard-core 1.4.3 → 1.4.5

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.
@@ -62,9 +62,24 @@ export interface FormatOptions {
62
62
  /**
63
63
  * Base directory to render `file` paths relative to.
64
64
  * Defaults to `process.cwd()`. Files outside this root are kept absolute.
65
- * Pass `null` to skip relativization entirely.
65
+ * Pass `null` to skip relativization for `formatJson`'s `file` paths and
66
+ * for SARIF when no {@link scanRoot} is given either. A `scanRoot` still
67
+ * relativizes SARIF `artifactLocation.uri` (and diagnostic ctx) against
68
+ * itself in that case, so `cwd: null` does not skip SARIF relativization
69
+ * on its own.
66
70
  */
67
71
  cwd?: string | null;
72
+ /**
73
+ * Directory actually being scanned (the scan target), when it differs from
74
+ * {@link cwd}. SARIF only: `artifactLocation.uri` is relativized against
75
+ * this instead of `cwd`, so a finding outside the process cwd but inside the
76
+ * scan target still gets a relative uri. Defaults to `cwd`.
77
+ *
78
+ * `formatJson` ignores this field. Its `file` paths stay cwd-relative,
79
+ * because they are what the terminal output and the baseline fingerprints
80
+ * are keyed on.
81
+ */
82
+ scanRoot?: string;
68
83
  /** Non-fatal diagnostics to include in structured output. */
69
84
  diagnostics?: Diagnostic[];
70
85
  /** Scan timing / coverage stats for JSON and SARIF `runs[].properties`. */
@@ -37,24 +37,103 @@ function isWindowsStylePath(p) {
37
37
  * SARIF spec requires this even when the scanner itself runs on Windows,
38
38
  * where `path.relative` returns backslash-separated paths).
39
39
  *
40
+ * The base is the scan root (the directory actually being scanned), not the
41
+ * process cwd. Scanning an out-of-tree target from somewhere else used to
42
+ * leave every uri absolute, which published the developer's home directory
43
+ * and OS username to whoever reads the Code Scanning upload.
44
+ *
45
+ * A file genuinely outside the scan root still stays absolute rather than
46
+ * becoming a `../..` traversal: SARIF relative references are resolved
47
+ * against `%SRCROOT%`, so a traversal out of it is not a legal uri, and there
48
+ * is no other root to express such a path against. In practice this only
49
+ * happens for a path a caller injected from outside the scan, since every
50
+ * file the scanner itself walks is under the target it was given -- and a
51
+ * non-absolute `file` is resolved against cwd below before that check runs,
52
+ * so a literal `..` traversal segment never reaches the returned uri either.
53
+ *
40
54
  * Picks `path.win32` when either side of the comparison looks like a
41
55
  * Windows-style path, so this is correct both when the process itself runs
42
56
  * on Windows (native `path` is already `path.win32`) and when a
43
57
  * Windows-style path is normalized on a POSIX host (tests, or a SARIF file
44
58
  * produced elsewhere and re-normalized).
45
59
  */
46
- function toSarifArtifactUri(file, cwd) {
47
- if (cwd === null)
60
+ function toSarifArtifactUri(file, cwd, scanRoot) {
61
+ if (cwd === null && scanRoot === undefined)
48
62
  return file.split('\\').join('/');
49
- const base = cwd ?? process.cwd();
50
- const impl = isWindowsStylePath(file) || isWindowsStylePath(base) ? path_1.default.win32 : path_1.default;
51
- if (!impl.isAbsolute(file))
52
- return file.split('\\').join('/');
53
- const rel = impl.relative(base, file);
63
+ const anchor = cwd ?? process.cwd();
64
+ const impl = isWindowsStylePath(file) || isWindowsStylePath(anchor) ? path_1.default.win32 : path_1.default;
65
+ const abs = impl.isAbsolute(file) ? file : impl.resolve(anchor, file);
66
+ const base = resolveSarifBase(anchor, scanRoot, impl);
67
+ const rel = impl.relative(base, abs);
54
68
  if (rel.startsWith('..') || impl.isAbsolute(rel))
55
- return file.split('\\').join('/');
69
+ return abs.split('\\').join('/');
56
70
  return (rel || '.').split('\\').join('/');
57
71
  }
72
+ /**
73
+ * The effective SARIF `%SRCROOT%` base. `scanRoot` is honored only when it
74
+ * is genuinely outside `cwd`: a scan root nested inside cwd (or equal to
75
+ * it) must not narrow `%SRCROOT%` to a subdirectory, because GitHub Code
76
+ * Scanning (and any other SARIF consumer) resolves `%SRCROOT%` from its own
77
+ * knowledge of the checkout, not from this uri -- a uri relative to a
78
+ * subdirectory would then name a different file entirely. Mirrors
79
+ * `resolveScanRoot` in `packages/cli/src/utils/scan-utils.ts`, which applies
80
+ * the same rule when it derives `scanRoot` from the CLI's scan target in the
81
+ * first place.
82
+ */
83
+ function resolveSarifBase(cwd, scanRoot, impl) {
84
+ if (scanRoot === undefined)
85
+ return cwd;
86
+ const rel = impl.relative(cwd, scanRoot);
87
+ if (rel === '' || (!rel.startsWith('..') && !impl.isAbsolute(rel)))
88
+ return cwd;
89
+ return scanRoot;
90
+ }
91
+ /**
92
+ * Diagnostic `ctx` reaches SARIF notifications as `JSON.stringify(d.ctx)`
93
+ * (see `formatSarif` above), and some diagnostic sources (`fs.permission_denied`,
94
+ * `file.read_error`) carry the scanned directory or file as an absolute path,
95
+ * plus a `detail` field that is `String(error)` -- Node's own fs error text
96
+ * often bakes that same absolute path in (e.g. "EACCES: permission denied,
97
+ * scandir '/abs/dir'"). Both leak exactly what `artifactLocation.uri`
98
+ * exists to avoid leaking, so ctx gets the same relativize-or-keep-absolute
99
+ * treatment before it is stringified into a notification: any absolute-path
100
+ * ctx value (`dir`, `path`, `file`, or any other field shaped that way) is
101
+ * run through {@link toSarifArtifactUri}, and any other string value has
102
+ * literal occurrences of the base directory stripped out.
103
+ */
104
+ function sanitizeDiagnosticCtxForSarif(ctx, cwd, scanRoot) {
105
+ if (cwd === null && scanRoot === undefined)
106
+ return ctx;
107
+ const anchor = cwd ?? process.cwd();
108
+ const base = resolveSarifBase(anchor, scanRoot, path_1.default);
109
+ const out = {};
110
+ for (const [key, value] of Object.entries(ctx)) {
111
+ if (typeof value !== 'string') {
112
+ out[key] = value;
113
+ }
114
+ else if (path_1.default.isAbsolute(value)) {
115
+ out[key] = toSarifArtifactUri(value, cwd, scanRoot);
116
+ }
117
+ else {
118
+ out[key] = stripSarifBasePath(value, base);
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ /**
124
+ * Removes literal occurrences of the SARIF base directory from a free-form
125
+ * string (diagnostic `detail`, which is `String(error)` and can embed the
126
+ * scanned path inside Node's own message text). This is not a full
127
+ * relative-path rewrite of the string, just enough to keep the base
128
+ * directory out of the document, matching what `artifactLocation.uri` does
129
+ * for the path fields themselves.
130
+ */
131
+ function stripSarifBasePath(text, base) {
132
+ if (!text.includes(base))
133
+ return text;
134
+ const withTrailingSep = base.endsWith(path_1.default.sep) ? base : base + path_1.default.sep;
135
+ return text.split(withTrailingSep).join('').split(base).join('.');
136
+ }
58
137
  function formatJson(results, opts = {}) {
59
138
  const fpCwd = opts.cwd === undefined ? process.cwd() : opts.cwd;
60
139
  const output = {
@@ -112,7 +191,10 @@ function formatSarif(results, opts = {}) {
112
191
  locations: [
113
192
  {
114
193
  physicalLocation: {
115
- artifactLocation: { uri: toSarifArtifactUri(file, opts.cwd), uriBaseId: '%SRCROOT%' },
194
+ artifactLocation: {
195
+ uri: toSarifArtifactUri(file, opts.cwd, opts.scanRoot),
196
+ uriBaseId: '%SRCROOT%',
197
+ },
116
198
  region: {
117
199
  startLine: m.line,
118
200
  startColumn: m.column + 1,
@@ -133,7 +215,9 @@ function formatSarif(results, opts = {}) {
133
215
  ? opts.diagnostics.map(d => ({
134
216
  id: d.code,
135
217
  level: d.severity === 'error' ? 'error' : 'warning',
136
- message: { text: `${d.code}: ${JSON.stringify(d.ctx)}` },
218
+ message: {
219
+ text: `${d.code}: ${JSON.stringify(sanitizeDiagnosticCtxForSarif(d.ctx, opts.cwd, opts.scanRoot))}`,
220
+ },
137
221
  }))
138
222
  : undefined;
139
223
  const runProps = opts.run !== undefined
@@ -1,7 +1,16 @@
1
1
  export type HookManager = 'native' | 'husky' | 'lefthook' | 'precommit';
2
2
  export interface InstallHookOptions {
3
3
  manager?: HookManager;
4
- /** Working directory (git repo root). Defaults to `process.cwd()`. */
4
+ /**
5
+ * Working directory. MUST be the git repository root -- the directory
6
+ * containing `.git` -- not a package subdirectory in a monorepo, even
7
+ * one that owns its own `.husky`. `install()`/`uninstall()` refuse
8
+ * outright when `.git` is not directly present (see the check at the
9
+ * top of each), and core.hooksPath is resolved relative to the
10
+ * worktree root regardless of where a nested `.husky` lives, so running
11
+ * from anywhere else either fails fast or resolves the wrong hooks
12
+ * directory entirely. Defaults to `process.cwd()`.
13
+ */
5
14
  cwd?: string;
6
15
  }
7
16
  export declare class PreCommitHook {
@@ -31,14 +40,71 @@ export declare class PreCommitHook {
31
40
  hooksDir: string;
32
41
  viaHooksPath: boolean;
33
42
  };
43
+ /**
44
+ * Whether the resolved hooks directory is husky 9's GENERATED,
45
+ * gitignored directory (\`.husky/_\` by default) rather than a real
46
+ * hooks directory. Husky's own prepare script rewrites this directory on
47
+ * every \`pnpm install\`, so nothing vault-guard writes there survives;
48
+ * the durable, tracked hook lives one directory up at
49
+ * \`.husky/<hookname>\`.
50
+ *
51
+ * This is deliberately narrow: the ONLY signal that may trigger the
52
+ * redirect is the directory SHAPE -- a resolved basename of \`_\` under a
53
+ * directory literally named \`.husky\`. Two false-positive shapes were
54
+ * caught by review before shipping and must never redirect:
55
+ * - core.hooksPath pointing at an unrelated directory (e.g. .githooks)
56
+ * that happens to contain a file literally named \`h\` -- an \`h\` file
57
+ * is not evidence of husky on its own, only the directory shape is;
58
+ * - a dispatcher-shaped pre-commit file (the same two-line shebang
59
+ * body husky 9 writes) sitting somewhere that is NOT \`.husky/_\`
60
+ * (e.g. plain \`.git/hooks/pre-commit\`) -- content shape alone is not
61
+ * evidence either, since a foreign hook can coincidentally look like
62
+ * this.
63
+ * Neither the \`h\` shim nor dispatcher-shaped content is checked at all
64
+ * here; they would only ever have been used to confirm a shape match,
65
+ * never to trigger one on their own, and the shape check alone is both
66
+ * necessary and sufficient for every case this fix needs to handle.
67
+ */
68
+ isHuskyGeneratedHooksDir(hooksDir: string): boolean;
69
+ /**
70
+ * Where husky's own \`h\` shim actually resolves and executes the tracked
71
+ * hook, given a husky-generated hooksDir (isHuskyGeneratedHooksDir(hooksDir)
72
+ * must already be true). Husky computes this as the PARENT of the
73
+ * generated \`_\` directory -- fixed relative to hooksDir, never a fixed
74
+ * \`<cwd>/.husky\`, because core.hooksPath can point at a NESTED
75
+ * \`.husky/_\` (e.g. \`packages/app/.husky/_\`, the ordinary shape for a
76
+ * monorepo package that owns husky's "prepare" script but is not itself
77
+ * the git root). In that case husky's shim genuinely runs
78
+ * \`packages/app/.husky/<hookname>\`, not \`<cwd>/.husky/<hookname>\` --
79
+ * proven wrong by independent review with a functional shim and a real
80
+ * commit before this was fixed. getPreCommitHookPath, installNative,
81
+ * uninstallNative, and getPreCommitCmdPath all resolve through this one
82
+ * place so the redirect target can never drift between them.
83
+ */
84
+ private resolveHuskyDir;
85
+ /** \`absPath\`, relative to \`cwd\`, with forward slashes on every platform. */
86
+ private relFromCwd;
34
87
  /**
35
88
  * Absolute path to the \`pre-commit\` hook file for the given manager.
89
+ *
90
+ * For the \`native\` manager, when the resolved hooks directory is
91
+ * husky 9's generated directory (see isHuskyGeneratedHooksDir), this
92
+ * resolves to the TRACKED hook file husky's own \`h\` shim actually runs
93
+ * -- see resolveHuskyDir -- because nothing written under the generated
94
+ * directory survives husky's prepare script. See install()'s
95
+ * husky-delegation in installNative for the write side of this.
36
96
  */
37
97
  getPreCommitHookPath(cwd: string, manager?: HookManager): string;
38
98
  /**
39
- * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager only).
99
+ * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
100
+ * only). \`undefined\` under a husky-generated hooks directory: the .cmd
101
+ * companion is native-only and installNative's husky-redirect never
102
+ * writes one there (see installNative), so there is no meaningful path
103
+ * to report. Callers that used to guard this getter with their own
104
+ * isHuskyGeneratedHooksDir check (the CLI's foreignHookConflict did)
105
+ * can drop that guard now that it lives here instead.
40
106
  */
41
- getPreCommitCmdPath(cwd: string): string;
107
+ getPreCommitCmdPath(cwd: string): string | undefined;
42
108
  install(options?: InstallHookOptions): {
43
109
  success: boolean;
44
110
  message: string;
@@ -57,7 +123,34 @@ export declare class PreCommitHook {
57
123
  private removeNativeCmdCompanion;
58
124
  private installNative;
59
125
  private uninstallNative;
126
+ /**
127
+ * @param huskyDir Directory holding the tracked hook. Defaults to
128
+ * \`<cwd>/.husky\`, correct for the explicit \`husky\` manager (it never
129
+ * consults core.hooksPath). installNative's redirect passes the
130
+ * ACTUAL directory resolveHuskyDir computed instead, which can be
131
+ * nested (e.g. \`packages/app/.husky\`) -- see resolveHuskyDir.
132
+ */
60
133
  private installHusky;
134
+ /**
135
+ * @param huskyDir See installHusky.
136
+ *
137
+ * Fix for a defect the reviewer found while checking uninstall after
138
+ * the redirect started routing every husky 9 repo through this
139
+ * function (previously only reachable via the explicit `husky`
140
+ * manager): when installHusky wrote the WHOLE file from
141
+ * HUSKY_HOOK_SCRIPT (the fresh-install path -- what both the redirect
142
+ * and a from-scratch \`--manager husky\` install take), there is no
143
+ * "# --- vault-guard ---" appended-block marker to strip, so the old
144
+ * logic here matched nothing, rewrote the file byte-identical, and
145
+ * reported success:true with isInstalled still true. Distinguishing
146
+ * the two shapes vault-guard itself ever produces -- the whole-file
147
+ * header vs. the appended-stanza marker -- fixes this: a whole-file
148
+ * hook is removed entirely; an appended stanza is stripped, keeping
149
+ * the pre-existing foreign content; anything else that merely mentions
150
+ * "vault-guard" in neither recognized shape is left untouched, with an
151
+ * honest message and success only if it happens not to still read as
152
+ * installed.
153
+ */
61
154
  private uninstallHusky;
62
155
  private installLefthook;
63
156
  private uninstallLefthook;
@@ -8,12 +8,23 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const child_process_1 = require("child_process");
10
10
  const errors_1 = require("../errors");
11
+ /**
12
+ * Marks a hook file vault-guard wrote WHOLE, from a template -- as
13
+ * opposed to a stanza vault-guard appended to a pre-existing (foreign)
14
+ * hook it does not own. uninstallHusky uses this to decide whether it is
15
+ * safe to delete the file outright: present means the whole file is
16
+ * vault-guard's, so removing it entirely is correct; absent (even when
17
+ * the file mentions "vault-guard" some other way) means the file
18
+ * predates vault-guard or was never fully vault-guard's, so it must not
19
+ * be deleted wholesale.
20
+ */
21
+ const VAULT_GUARD_HOOK_HEADER = '# vault-guard pre-commit (installed by @vaultcompass/vault-guard)';
11
22
  /**
12
23
  * Shell hook body for **native** Git hooks (`core.hooksPath` or `.git/hooks`).
13
24
  * Scans **staged files only** — fast and matches what will actually be committed.
14
25
  */
15
26
  const NATIVE_HOOK_SCRIPT = `#!/bin/sh
16
- # vault-guard pre-commit (installed by @vaultcompass/vault-guard)
27
+ ${VAULT_GUARD_HOOK_HEADER}
17
28
  set -e
18
29
 
19
30
  # Re-attach stdin for GUI git clients when possible.
@@ -69,6 +80,7 @@ exit /b 0
69
80
  `;
70
81
  /** Husky-friendly hook (sources \`_/husky.sh\` when present). */
71
82
  const HUSKY_HOOK_SCRIPT = `#!/usr/bin/env sh
83
+ ${VAULT_GUARD_HOOK_HEADER}
72
84
  if [ -f "$(dirname "$0")/_/husky.sh" ]; then
73
85
  . "$(dirname "$0")/_/husky.sh"
74
86
  fi
@@ -151,20 +163,93 @@ class PreCommitHook {
151
163
  const worktreeRoot = this.resolveWorktreeRoot(cwd) ?? cwd;
152
164
  return { hooksDir: path_1.default.join(worktreeRoot, hooksPath), viaHooksPath: true };
153
165
  }
166
+ /**
167
+ * Whether the resolved hooks directory is husky 9's GENERATED,
168
+ * gitignored directory (\`.husky/_\` by default) rather than a real
169
+ * hooks directory. Husky's own prepare script rewrites this directory on
170
+ * every \`pnpm install\`, so nothing vault-guard writes there survives;
171
+ * the durable, tracked hook lives one directory up at
172
+ * \`.husky/<hookname>\`.
173
+ *
174
+ * This is deliberately narrow: the ONLY signal that may trigger the
175
+ * redirect is the directory SHAPE -- a resolved basename of \`_\` under a
176
+ * directory literally named \`.husky\`. Two false-positive shapes were
177
+ * caught by review before shipping and must never redirect:
178
+ * - core.hooksPath pointing at an unrelated directory (e.g. .githooks)
179
+ * that happens to contain a file literally named \`h\` -- an \`h\` file
180
+ * is not evidence of husky on its own, only the directory shape is;
181
+ * - a dispatcher-shaped pre-commit file (the same two-line shebang
182
+ * body husky 9 writes) sitting somewhere that is NOT \`.husky/_\`
183
+ * (e.g. plain \`.git/hooks/pre-commit\`) -- content shape alone is not
184
+ * evidence either, since a foreign hook can coincidentally look like
185
+ * this.
186
+ * Neither the \`h\` shim nor dispatcher-shaped content is checked at all
187
+ * here; they would only ever have been used to confirm a shape match,
188
+ * never to trigger one on their own, and the shape check alone is both
189
+ * necessary and sufficient for every case this fix needs to handle.
190
+ */
191
+ isHuskyGeneratedHooksDir(hooksDir) {
192
+ const base = path_1.default.basename(hooksDir);
193
+ const parentBase = path_1.default.basename(path_1.default.dirname(hooksDir));
194
+ return base === '_' && parentBase === '.husky';
195
+ }
196
+ /**
197
+ * Where husky's own \`h\` shim actually resolves and executes the tracked
198
+ * hook, given a husky-generated hooksDir (isHuskyGeneratedHooksDir(hooksDir)
199
+ * must already be true). Husky computes this as the PARENT of the
200
+ * generated \`_\` directory -- fixed relative to hooksDir, never a fixed
201
+ * \`<cwd>/.husky\`, because core.hooksPath can point at a NESTED
202
+ * \`.husky/_\` (e.g. \`packages/app/.husky/_\`, the ordinary shape for a
203
+ * monorepo package that owns husky's "prepare" script but is not itself
204
+ * the git root). In that case husky's shim genuinely runs
205
+ * \`packages/app/.husky/<hookname>\`, not \`<cwd>/.husky/<hookname>\` --
206
+ * proven wrong by independent review with a functional shim and a real
207
+ * commit before this was fixed. getPreCommitHookPath, installNative,
208
+ * uninstallNative, and getPreCommitCmdPath all resolve through this one
209
+ * place so the redirect target can never drift between them.
210
+ */
211
+ resolveHuskyDir(hooksDir) {
212
+ return path_1.default.dirname(hooksDir);
213
+ }
214
+ /** \`absPath\`, relative to \`cwd\`, with forward slashes on every platform. */
215
+ relFromCwd(cwd, absPath) {
216
+ return path_1.default.relative(cwd, absPath).split(path_1.default.sep).join('/');
217
+ }
154
218
  /**
155
219
  * Absolute path to the \`pre-commit\` hook file for the given manager.
220
+ *
221
+ * For the \`native\` manager, when the resolved hooks directory is
222
+ * husky 9's generated directory (see isHuskyGeneratedHooksDir), this
223
+ * resolves to the TRACKED hook file husky's own \`h\` shim actually runs
224
+ * -- see resolveHuskyDir -- because nothing written under the generated
225
+ * directory survives husky's prepare script. See install()'s
226
+ * husky-delegation in installNative for the write side of this.
156
227
  */
157
228
  getPreCommitHookPath(cwd, manager = 'native') {
158
229
  if (manager === 'husky') {
159
230
  return path_1.default.join(cwd, '.husky', 'pre-commit');
160
231
  }
161
- return path_1.default.join(this.getEffectiveHooksDir(cwd).hooksDir, 'pre-commit');
232
+ const { hooksDir } = this.getEffectiveHooksDir(cwd);
233
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
234
+ return path_1.default.join(this.resolveHuskyDir(hooksDir), 'pre-commit');
235
+ }
236
+ return path_1.default.join(hooksDir, 'pre-commit');
162
237
  }
163
238
  /**
164
- * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager only).
239
+ * Absolute path to the Windows \`pre-commit.cmd\` companion (native manager
240
+ * only). \`undefined\` under a husky-generated hooks directory: the .cmd
241
+ * companion is native-only and installNative's husky-redirect never
242
+ * writes one there (see installNative), so there is no meaningful path
243
+ * to report. Callers that used to guard this getter with their own
244
+ * isHuskyGeneratedHooksDir check (the CLI's foreignHookConflict did)
245
+ * can drop that guard now that it lives here instead.
165
246
  */
166
247
  getPreCommitCmdPath(cwd) {
167
- return path_1.default.join(this.getEffectiveHooksDir(cwd).hooksDir, 'pre-commit.cmd');
248
+ const { hooksDir } = this.getEffectiveHooksDir(cwd);
249
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
250
+ return undefined;
251
+ }
252
+ return path_1.default.join(hooksDir, 'pre-commit.cmd');
168
253
  }
169
254
  install(options = {}) {
170
255
  const cwd = options.cwd ?? process.cwd();
@@ -245,6 +330,25 @@ class PreCommitHook {
245
330
  }
246
331
  installNative(cwd) {
247
332
  const { hooksDir, viaHooksPath } = this.getEffectiveHooksDir(cwd);
333
+ // core.hooksPath points at husky 9's generated, gitignored directory
334
+ // (typically .husky/_, but see resolveHuskyDir for the nested case).
335
+ // Writing there is pointless -- husky's prepare script rewrites it on
336
+ // every `pnpm install` -- so install into the same tracked hook file
337
+ // the husky manager uses, and say so. Never write under the generated
338
+ // directory in this branch.
339
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
340
+ const huskyDir = this.resolveHuskyDir(hooksDir);
341
+ const result = this.installHusky(cwd, huskyDir);
342
+ if (!result.success) {
343
+ return result;
344
+ }
345
+ const relHookPath = this.relFromCwd(cwd, result.hookPath ?? path_1.default.join(huskyDir, 'pre-commit'));
346
+ return {
347
+ success: true,
348
+ message: `Hooks are managed by husky; installing into ${relHookPath}. ${result.message}`,
349
+ hookPath: result.hookPath,
350
+ };
351
+ }
248
352
  const hookPath = path_1.default.join(hooksDir, 'pre-commit');
249
353
  const cmdPath = path_1.default.join(hooksDir, 'pre-commit.cmd');
250
354
  try {
@@ -287,6 +391,14 @@ class PreCommitHook {
287
391
  }
288
392
  uninstallNative(cwd) {
289
393
  const { hooksDir } = this.getEffectiveHooksDir(cwd);
394
+ if (this.isHuskyGeneratedHooksDir(hooksDir)) {
395
+ const huskyDir = this.resolveHuskyDir(hooksDir);
396
+ const result = this.uninstallHusky(cwd, huskyDir);
397
+ return {
398
+ success: result.success,
399
+ message: `Hooks are managed by husky; ${result.message}`,
400
+ };
401
+ }
290
402
  const hookPath = path_1.default.join(hooksDir, 'pre-commit');
291
403
  const cmdRemoved = this.removeNativeCmdCompanion(hooksDir);
292
404
  if (!fs_1.default.existsSync(hookPath)) {
@@ -323,9 +435,16 @@ class PreCommitHook {
323
435
  // -------------------------------------------------------------------------
324
436
  // Husky — .husky/pre-commit
325
437
  // -------------------------------------------------------------------------
326
- installHusky(cwd) {
327
- const huskyDir = path_1.default.join(cwd, '.husky');
438
+ /**
439
+ * @param huskyDir Directory holding the tracked hook. Defaults to
440
+ * \`<cwd>/.husky\`, correct for the explicit \`husky\` manager (it never
441
+ * consults core.hooksPath). installNative's redirect passes the
442
+ * ACTUAL directory resolveHuskyDir computed instead, which can be
443
+ * nested (e.g. \`packages/app/.husky\`) -- see resolveHuskyDir.
444
+ */
445
+ installHusky(cwd, huskyDir = path_1.default.join(cwd, '.husky')) {
328
446
  const hookPath = path_1.default.join(huskyDir, 'pre-commit');
447
+ const relHookPath = this.relFromCwd(cwd, hookPath);
329
448
  try {
330
449
  if (!fs_1.default.existsSync(huskyDir)) {
331
450
  fs_1.default.mkdirSync(huskyDir, { recursive: true });
@@ -339,12 +458,12 @@ class PreCommitHook {
339
458
  return { success: true, message: 'Husky hook already contains vault-guard block', hookPath };
340
459
  }
341
460
  fs_1.default.appendFileSync(hookPath, `\n# --- vault-guard ---\nvault-guard scan --staged || {\n echo "❌ vault-guard blocked commit"\n exit 1\n}\n`, { encoding: 'utf-8' });
342
- return { success: true, message: 'Appended vault-guard to existing .husky/pre-commit', hookPath };
461
+ return { success: true, message: `Appended vault-guard to existing ${relHookPath}`, hookPath };
343
462
  }
344
463
  fs_1.default.writeFileSync(hookPath, HUSKY_HOOK_SCRIPT, { mode: 0o755 });
345
464
  return {
346
465
  success: true,
347
- message: 'Created .husky/pre-commit (run `npx husky init` first if _/husky.sh is missing)',
466
+ message: `Created ${relHookPath} (run \`npx husky init\` first if _/husky.sh is missing)`,
348
467
  hookPath,
349
468
  };
350
469
  }
@@ -353,28 +472,72 @@ class PreCommitHook {
353
472
  return { success: false, message: hookError.message };
354
473
  }
355
474
  }
356
- uninstallHusky(cwd) {
357
- const hookPath = path_1.default.join(cwd, '.husky', 'pre-commit');
475
+ /**
476
+ * @param huskyDir See installHusky.
477
+ *
478
+ * Fix for a defect the reviewer found while checking uninstall after
479
+ * the redirect started routing every husky 9 repo through this
480
+ * function (previously only reachable via the explicit `husky`
481
+ * manager): when installHusky wrote the WHOLE file from
482
+ * HUSKY_HOOK_SCRIPT (the fresh-install path -- what both the redirect
483
+ * and a from-scratch \`--manager husky\` install take), there is no
484
+ * "# --- vault-guard ---" appended-block marker to strip, so the old
485
+ * logic here matched nothing, rewrote the file byte-identical, and
486
+ * reported success:true with isInstalled still true. Distinguishing
487
+ * the two shapes vault-guard itself ever produces -- the whole-file
488
+ * header vs. the appended-stanza marker -- fixes this: a whole-file
489
+ * hook is removed entirely; an appended stanza is stripped, keeping
490
+ * the pre-existing foreign content; anything else that merely mentions
491
+ * "vault-guard" in neither recognized shape is left untouched, with an
492
+ * honest message and success only if it happens not to still read as
493
+ * installed.
494
+ */
495
+ uninstallHusky(cwd, huskyDir = path_1.default.join(cwd, '.husky')) {
496
+ const hookPath = path_1.default.join(huskyDir, 'pre-commit');
497
+ const relHookPath = this.relFromCwd(cwd, hookPath);
358
498
  if (!fs_1.default.existsSync(hookPath)) {
359
- return { success: true, message: 'No .husky/pre-commit to remove' };
499
+ return { success: true, message: `No ${relHookPath} to remove` };
360
500
  }
361
- let content = fs_1.default.readFileSync(hookPath, 'utf-8');
501
+ const content = fs_1.default.readFileSync(hookPath, 'utf-8');
362
502
  if (!content.includes('vault-guard')) {
363
- return { success: true, message: 'No vault-guard stanza in .husky/pre-commit' };
503
+ return { success: true, message: `No vault-guard stanza in ${relHookPath}` };
364
504
  }
365
- // Remove appended block if present.
366
- content = content.replace(/\n# --- vault-guard ---[\s\S]*$/m, '');
367
- // If entire file is only our husky template, delete file.
368
- if (!content.includes('vault-guard')) {
369
- if (content.trim().length === 0) {
505
+ // vault-guard wrote the ENTIRE file from HUSKY_HOOK_SCRIPT (whether
506
+ // via a fresh --manager husky install or the native husky-redirect):
507
+ // it is safe, and correct, to remove the file outright rather than
508
+ // try to strip individual lines from a template vault-guard owns.
509
+ if (content.includes(VAULT_GUARD_HOOK_HEADER)) {
510
+ fs_1.default.unlinkSync(hookPath);
511
+ return { success: true, message: `Removed ${relHookPath}` };
512
+ }
513
+ // vault-guard appended a stanza to a pre-existing (foreign) hook: strip
514
+ // just that stanza, preserving whatever the file had before.
515
+ if (content.includes('# --- vault-guard ---')) {
516
+ const stripped = content.replace(/\n# --- vault-guard ---[\s\S]*$/m, '');
517
+ if (stripped.trim().length === 0) {
370
518
  fs_1.default.unlinkSync(hookPath);
371
- return { success: true, message: 'Removed .husky/pre-commit' };
519
+ return { success: true, message: `Removed ${relHookPath}` };
372
520
  }
373
- fs_1.default.writeFileSync(hookPath, content, { mode: 0o755 });
374
- return { success: true, message: 'Removed vault-guard stanza from .husky/pre-commit' };
521
+ fs_1.default.writeFileSync(hookPath, stripped, { mode: 0o755 });
522
+ const stillInstalled = stripped.includes('vault-guard') && stripped.includes('scan --staged');
523
+ return {
524
+ success: !stillInstalled,
525
+ message: stillInstalled
526
+ ? `Removed the appended stanza from ${relHookPath}, but it still references vault-guard -- review manually`
527
+ : `Removed vault-guard stanza from ${relHookPath}`,
528
+ };
375
529
  }
376
- fs_1.default.writeFileSync(hookPath, content, { mode: 0o755 });
377
- return { success: true, message: 'Updated .husky/pre-commit (review manually if needed)' };
530
+ // Mentions "vault-guard" somewhere, but in NEITHER shape vault-guard
531
+ // itself ever writes (no whole-file header, no appended-stanza
532
+ // marker) -- do not guess at what to remove from a file this code did
533
+ // not write; leave it untouched and say so honestly. Report success
534
+ // only if it does not still read as installed (isInstalled uses the
535
+ // same two-substring check).
536
+ const stillInstalled = content.includes('vault-guard') && content.includes('scan --staged');
537
+ return {
538
+ success: !stillInstalled,
539
+ message: `${relHookPath} mentions vault-guard but does not match the shape this version writes; it may have been written by an older vault-guard, or by hand. Leaving it unchanged. Review and remove the vault-guard reference manually if needed.`,
540
+ };
378
541
  }
379
542
  // -------------------------------------------------------------------------
380
543
  // Lefthook — lefthook-local.yml (merged with lefthook.yml)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultcompass/vault-guard-core",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
4
4
  "description": "Secret-scanning engine: vendor-anchored patterns, entropy gating, baselines, SARIF/JSON, hook helpers.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",