@vaultcompass/vault-guard 1.2.2 → 1.3.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.
@@ -14,6 +14,12 @@ export interface InitConflict {
14
14
  path: string;
15
15
  reason: 'exists' | 'foreign_manifest' | 'manifest_mismatch' | 'not_a_git_repository' | 'foreign_hook';
16
16
  }
17
+ export interface InitAdvisory {
18
+ /** Detected manager that may already own pre-commit. */
19
+ manager: 'husky' | 'lefthook' | 'precommit';
20
+ path: string;
21
+ guidance: string;
22
+ }
17
23
  export interface InitPlannedAction {
18
24
  kind: 'create' | 'hook-install' | 'skip';
19
25
  path: string;
@@ -26,6 +32,8 @@ export interface InitResult {
26
32
  alreadyInitialized: boolean;
27
33
  actions: InitPlannedAction[];
28
34
  conflicts: InitConflict[];
35
+ /** Non-blocking tips when other hook managers are present. */
36
+ advisories: InitAdvisory[];
29
37
  hook?: {
30
38
  manager: string;
31
39
  path?: string;
@@ -86,18 +86,8 @@ function hookRelativePath(cwd, hookPath) {
86
86
  }
87
87
  function foreignHookConflict(cwd, manager) {
88
88
  const hook = new vault_guard_core_1.PreCommitHook();
89
- const hookPath = hook.getPreCommitHookPath(cwd, manager);
90
- if (!fs.existsSync(hookPath))
91
- return undefined;
92
- if (hook.isInstalled({ cwd, manager }))
93
- return undefined;
94
- const rel = hookRelativePath(cwd, hookPath);
95
- if (manager === 'husky') {
96
- const content = readFileIfExists(hookPath) ?? '';
97
- if (content.includes('vault-guard'))
98
- return undefined;
99
- return { path: rel, reason: 'foreign_hook' };
100
- }
89
+ // Manager-specific paths first — lefthook/precommit do not use getPreCommitHookPath
90
+ // (that helper still resolves the native hooks dir for non-husky managers).
101
91
  if (manager === 'lefthook') {
102
92
  const localPath = path.join(cwd, 'lefthook-local.yml');
103
93
  if (!fs.existsSync(localPath))
@@ -116,10 +106,88 @@ function foreignHookConflict(cwd, manager) {
116
106
  return undefined;
117
107
  return { path: '.pre-commit-config.yaml', reason: 'foreign_hook' };
118
108
  }
119
- const content = readFileIfExists(hookPath) ?? '';
120
- if (content.trim().length === 0)
121
- return undefined;
122
- return { path: rel, reason: 'foreign_hook' };
109
+ if (manager === 'husky') {
110
+ const hookPath = hook.getPreCommitHookPath(cwd, 'husky');
111
+ if (!fs.existsSync(hookPath))
112
+ return undefined;
113
+ if (hook.isInstalled({ cwd, manager: 'husky' }))
114
+ return undefined;
115
+ const content = readFileIfExists(hookPath) ?? '';
116
+ if (content.includes('vault-guard'))
117
+ return undefined;
118
+ return { path: hookRelativePath(cwd, hookPath), reason: 'foreign_hook' };
119
+ }
120
+ // native: POSIX hook and optional Windows companion
121
+ const hookPath = hook.getPreCommitHookPath(cwd, 'native');
122
+ if (fs.existsSync(hookPath) && !hook.isInstalled({ cwd, manager: 'native' })) {
123
+ const content = readFileIfExists(hookPath) ?? '';
124
+ if (content.trim().length > 0) {
125
+ return { path: hookRelativePath(cwd, hookPath), reason: 'foreign_hook' };
126
+ }
127
+ }
128
+ const cmdPath = hook.getPreCommitCmdPath(cwd);
129
+ if (fs.existsSync(cmdPath)) {
130
+ const content = readFileIfExists(cmdPath) ?? '';
131
+ const isOurs = content.includes('vault-guard') && content.includes('scan --staged');
132
+ if (content.trim().length > 0 && !isOurs) {
133
+ return { path: hookRelativePath(cwd, cmdPath), reason: 'foreign_hook' };
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+ function detectOtherHookManagers(cwd, selected) {
139
+ const advisories = [];
140
+ const huskyDir = path.join(cwd, '.husky');
141
+ if (fs.existsSync(huskyDir) && selected !== 'husky') {
142
+ advisories.push({
143
+ manager: 'husky',
144
+ path: '.husky/',
145
+ guidance: 'Husky detected. Prefer `vault-guard init --manager husky` or `vault-guard install-hook --manager husky` so the scan runs from .husky/pre-commit. Native hooks may not run when husky owns core.hooksPath.',
146
+ });
147
+ }
148
+ const lefthookYml = path.join(cwd, 'lefthook.yml');
149
+ const lefthookLocal = path.join(cwd, 'lefthook-local.yml');
150
+ if ((fs.existsSync(lefthookYml) || fs.existsSync(lefthookLocal)) && selected !== 'lefthook') {
151
+ advisories.push({
152
+ manager: 'lefthook',
153
+ path: fs.existsSync(lefthookLocal) ? 'lefthook-local.yml' : 'lefthook.yml',
154
+ guidance: 'Lefthook detected. Prefer `vault-guard init --manager lefthook` (writes lefthook-local.yml) or merge `vault-guard scan --staged` under pre-commit.commands manually. Init never overwrites existing lefthook files.',
155
+ });
156
+ }
157
+ const precommitCfg = path.join(cwd, '.pre-commit-config.yaml');
158
+ if (fs.existsSync(precommitCfg) && selected !== 'precommit') {
159
+ advisories.push({
160
+ manager: 'precommit',
161
+ path: '.pre-commit-config.yaml',
162
+ guidance: 'pre-commit framework config detected. Prefer `vault-guard init --manager precommit` only if the file is absent, or merge the local vault-guard hook into repos: yourself. Init never overwrites an existing .pre-commit-config.yaml.',
163
+ });
164
+ }
165
+ return advisories;
166
+ }
167
+ function conflictGuidance(c) {
168
+ switch (c.reason) {
169
+ case 'exists':
170
+ return 'File already exists with different content — edit manually or move it aside, then re-run init.';
171
+ case 'foreign_manifest':
172
+ return 'Existing .vault-guard/init-manifest.json is invalid or foreign — fix or remove it, then re-run.';
173
+ case 'manifest_mismatch':
174
+ return 'Init manifest does not match current templates/options — run `vault-guard init --revert` then init again, or update files by hand.';
175
+ case 'not_a_git_repository':
176
+ return 'Run `git init` first, or pass `--skip-hook` to scaffold config/workflow without a hook.';
177
+ case 'foreign_hook':
178
+ if (c.path.includes('husky') || c.path.startsWith('.husky')) {
179
+ return 'Existing Husky pre-commit has no vault-guard stanza. Append `vault-guard scan --staged` yourself, or use `install-hook --manager husky` after reviewing the file.';
180
+ }
181
+ if (c.path.includes('lefthook')) {
182
+ return 'Add under pre-commit.commands:\n vault-guard:\n run: vault-guard scan --staged';
183
+ }
184
+ if (c.path.includes('pre-commit-config')) {
185
+ return 'Merge a local vault-guard hook into repos: (see `vault-guard install-hook --manager precommit` error output for a snippet).';
186
+ }
187
+ return 'An existing pre-commit hook is present without vault-guard. Merge `vault-guard scan --staged` manually, or remove the foreign hook if unused.';
188
+ default:
189
+ return 'Resolve manually, then re-run vault-guard init.';
190
+ }
123
191
  }
124
192
  function ensureParentDir(filePath) {
125
193
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -130,6 +198,7 @@ function planInit(options = {}) {
130
198
  const manager = options.manager ?? 'native';
131
199
  const actions = [];
132
200
  const conflicts = [];
201
+ const advisories = detectOtherHookManagers(cwd, manager);
133
202
  const trackedFiles = [];
134
203
  const manifestAbs = path.join(cwd, templates_1.MANIFEST_RELATIVE_PATH);
135
204
  const manifestRaw = readFileIfExists(manifestAbs);
@@ -219,17 +288,30 @@ function planInit(options = {}) {
219
288
  alreadyInitialized,
220
289
  actions,
221
290
  conflicts,
291
+ advisories,
222
292
  hook: hookState,
223
293
  manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
224
294
  mcpMergeHint: MCP_MERGE_HINT,
225
295
  };
226
296
  }
227
297
  function applyInit(plan, options = {}) {
228
- if (!plan.ok || plan.dryRun || plan.alreadyInitialized) {
298
+ if (!plan.ok || plan.dryRun) {
229
299
  return plan;
230
300
  }
231
301
  const cwd = options.cwd ?? process.cwd();
232
302
  const manager = options.manager ?? 'native';
303
+ // Idempotent native refresh: backfill/update optional pre-commit.cmd even when
304
+ // the repo is already fully initialized.
305
+ if (plan.alreadyInitialized &&
306
+ plan.hook &&
307
+ !options.skipHook &&
308
+ manager === 'native') {
309
+ new vault_guard_core_1.PreCommitHook().install({ cwd, manager: 'native' });
310
+ return plan;
311
+ }
312
+ if (plan.alreadyInitialized) {
313
+ return plan;
314
+ }
233
315
  const trackedFiles = [];
234
316
  const createdPaths = [];
235
317
  const rollbackCreatedFiles = () => {
@@ -244,28 +326,34 @@ function applyInit(plan, options = {}) {
244
326
  }
245
327
  }
246
328
  };
247
- if (plan.hook && !plan.hook.installed && !options.skipHook) {
329
+ if (plan.hook && !options.skipHook) {
248
330
  const hook = new vault_guard_core_1.PreCommitHook();
249
- const result = hook.install({ cwd, manager });
250
- if (!result.success) {
251
- return {
252
- ...plan,
253
- ok: false,
254
- actions: [
255
- ...plan.actions,
256
- {
257
- kind: 'skip',
258
- path: plan.hook.path ?? 'pre-commit',
259
- detail: `hook install failed: ${result.message}`,
260
- },
261
- ],
262
- };
331
+ // Always call install for native when planning a hook: first-time write, or
332
+ // idempotent refresh of the optional pre-commit.cmd companion.
333
+ if (!plan.hook.installed || manager === 'native') {
334
+ const result = hook.install({ cwd, manager });
335
+ if (!result.success && !plan.hook.installed) {
336
+ return {
337
+ ...plan,
338
+ ok: false,
339
+ actions: [
340
+ ...plan.actions,
341
+ {
342
+ kind: 'skip',
343
+ path: plan.hook.path ?? 'pre-commit',
344
+ detail: `hook install failed: ${result.message}`,
345
+ },
346
+ ],
347
+ };
348
+ }
349
+ if (result.success) {
350
+ plan.hook = {
351
+ manager,
352
+ path: result.hookPath ?? plan.hook.path,
353
+ installed: true,
354
+ };
355
+ }
263
356
  }
264
- plan.hook = {
265
- manager,
266
- path: result.hookPath ?? plan.hook.path,
267
- installed: true,
268
- };
269
357
  }
270
358
  try {
271
359
  for (const action of plan.actions) {
@@ -345,6 +433,7 @@ function revertInit(options = {}) {
345
433
  reverted: false,
346
434
  alreadyInitialized: false,
347
435
  actions: [],
436
+ advisories: [],
348
437
  conflicts: [{ path: templates_1.MANIFEST_RELATIVE_PATH, reason: 'foreign_manifest' }],
349
438
  manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
350
439
  mcpMergeHint: MCP_MERGE_HINT,
@@ -358,6 +447,7 @@ function revertInit(options = {}) {
358
447
  reverted: false,
359
448
  alreadyInitialized: false,
360
449
  actions: [],
450
+ advisories: [],
361
451
  conflicts: [{ path: templates_1.MANIFEST_RELATIVE_PATH, reason: 'foreign_manifest' }],
362
452
  manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
363
453
  mcpMergeHint: MCP_MERGE_HINT,
@@ -413,6 +503,7 @@ function revertInit(options = {}) {
413
503
  reverted: !dryRun,
414
504
  alreadyInitialized: false,
415
505
  actions,
506
+ advisories: [],
416
507
  conflicts: [],
417
508
  manifestPath: templates_1.MANIFEST_RELATIVE_PATH,
418
509
  mcpMergeHint: MCP_MERGE_HINT,
@@ -436,6 +527,14 @@ function printHuman(result, options) {
436
527
  console.error(chalk_1.default.red.bold('❌ Init blocked — conflicts (no automatic overwrites):'));
437
528
  for (const c of result.conflicts) {
438
529
  console.error(chalk_1.default.white(` ${c.path}`), chalk_1.default.gray(`(${c.reason})`));
530
+ console.error(chalk_1.default.gray(` → ${conflictGuidance(c)}`));
531
+ }
532
+ if (result.advisories.length > 0) {
533
+ console.error(chalk_1.default.yellow('\nAlso noted:'));
534
+ for (const a of result.advisories) {
535
+ console.error(chalk_1.default.yellow(` [${a.manager}] ${a.path}`));
536
+ console.error(chalk_1.default.gray(` ${a.guidance}`));
537
+ }
439
538
  }
440
539
  console.error(chalk_1.default.gray('\nResolve manually, then re-run vault-guard init.'));
441
540
  return;
@@ -458,6 +557,13 @@ function printHuman(result, options) {
458
557
  console.log(chalk_1.default.white(` ${result.dryRun ? 'install' : 'installed'} hook (${a.detail})`));
459
558
  }
460
559
  }
560
+ if (result.advisories.length > 0) {
561
+ console.log(chalk_1.default.yellow('\nHook manager notes:'));
562
+ for (const a of result.advisories) {
563
+ console.log(chalk_1.default.yellow(` [${a.manager}] ${a.path}`));
564
+ console.log(chalk_1.default.gray(` ${a.guidance}`));
565
+ }
566
+ }
461
567
  console.log(chalk_1.default.gray(`\n${result.mcpMergeHint}`));
462
568
  console.log(chalk_1.default.gray(`Manifest: ${result.manifestPath}`));
463
569
  }
@@ -473,7 +579,7 @@ async function initCommand(options = {}) {
473
579
  return result.ok ? 0 : 1;
474
580
  }
475
581
  const plan = planInit(options);
476
- const result = plan.dryRun || plan.alreadyInitialized ? plan : applyInit(plan, options);
582
+ const result = plan.dryRun ? plan : applyInit(plan, options);
477
583
  if (options.json) {
478
584
  process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
479
585
  }
@@ -16,7 +16,10 @@ async function installHookCommand(manager = 'native') {
16
16
  if (result.hookPath) {
17
17
  console.log(chalk_1.default.gray(` Path: ${result.hookPath}`));
18
18
  }
19
- console.log(chalk_1.default.gray('\nThe hook runs `vault-guard scan --staged` before each commit (staged files only).\n'));
19
+ console.log(chalk_1.default.gray('\nThe hook runs `vault-guard scan --staged` before each commit (staged files only).\n' +
20
+ (manager === 'native'
21
+ ? 'Also writes optional pre-commit.cmd (Git for Windows still runs the POSIX pre-commit via sh).\n'
22
+ : '')));
20
23
  }
21
24
  else {
22
25
  console.error(chalk_1.default.red('❌ Error:'), chalk_1.default.white(result.message));
@@ -95,6 +95,8 @@ async function scanCommand(targetPath, format = 'text', staged = false) {
95
95
  bus,
96
96
  stats,
97
97
  configIgnorePatterns,
98
+ fromGitIndex: true,
99
+ cwd,
98
100
  });
99
101
  }
100
102
  else {
@@ -27,10 +27,19 @@ export interface ScanOptions {
27
27
  * directory scans and staged-file scans.
28
28
  */
29
29
  configIgnorePatterns?: string[];
30
+ /**
31
+ * When true, read each path from the git index (`git show :path`) so
32
+ * `--staged` matches what will actually be committed — including `AD`
33
+ * (added in index, deleted in worktree) and partially staged files.
34
+ */
35
+ fromGitIndex?: boolean;
36
+ /** Repo root for `fromGitIndex` (defaults to `process.cwd()`). */
37
+ cwd?: string;
30
38
  }
31
39
  /**
32
40
  * Scan an explicit list of files (e.g. paths from \`git diff --cached\`).
33
- * Skips missing paths and non-files silently.
41
+ * Without `fromGitIndex`, skips missing worktree paths. With `fromGitIndex`,
42
+ * reads staged blobs so deleted worktree files are still scanned.
34
43
  */
35
44
  export declare function scanFileListAsync(files: string[], scanner: SecretScanner, options?: ScanOptions): Promise<ScanResult[]>;
36
45
  /**
@@ -35,15 +35,16 @@ const BINARY_EXTENSIONS = [
35
35
  ];
36
36
  /**
37
37
  * Scan an explicit list of files (e.g. paths from \`git diff --cached\`).
38
- * Skips missing paths and non-files silently.
38
+ * Without `fromGitIndex`, skips missing worktree paths. With `fromGitIndex`,
39
+ * reads staged blobs so deleted worktree files are still scanned.
39
40
  */
40
41
  async function scanFileListAsync(files, scanner, options = {}) {
41
- const { verbose = false, maxSize = MAX_FILE_SIZE, skipBinary = true, progress = false, concurrency = 10, configIgnorePatterns = [], } = options;
42
+ const { verbose = false, maxSize = MAX_FILE_SIZE, skipBinary = true, progress = false, concurrency = 10, configIgnorePatterns = [], fromGitIndex = false, cwd = process.cwd(), } = options;
42
43
  // Apply config ignore patterns to the explicit file list (e.g. staged files).
43
44
  // buildConfigIgnoreFilter matches relative to cwd so patterns like
44
45
  // `packages/**/__tests__/**` work identically for staged and directory scans.
45
46
  const configIgnoreTester = configIgnorePatterns.length > 0
46
- ? (0, vault_guard_core_1.buildConfigIgnoreFilter)(configIgnorePatterns, process.cwd())
47
+ ? (0, vault_guard_core_1.buildConfigIgnoreFilter)(configIgnorePatterns, cwd)
47
48
  : null;
48
49
  const filteredFiles = configIgnoreTester
49
50
  ? files.filter(f => !configIgnoreTester(f))
@@ -51,6 +52,27 @@ async function scanFileListAsync(files, scanner, options = {}) {
51
52
  const results = [];
52
53
  const scanFile = async (file) => {
53
54
  try {
55
+ if (fromGitIndex) {
56
+ const rel = path_1.default.relative(cwd, file).split(path_1.default.sep).join('/');
57
+ if (skipBinary && isBinaryFile(file))
58
+ return;
59
+ const content = (0, vault_guard_core_1.readGitIndexFile)(cwd, rel);
60
+ if (skipBinary && content.includes('\0'))
61
+ return;
62
+ const byteLen = Buffer.byteLength(content, 'utf-8');
63
+ if (byteLen > maxSize && verbose) {
64
+ console.warn(chalk_1.default.yellow(`⚠️ Large staged blob (scanning in memory):`), chalk_1.default.white(rel), chalk_1.default.gray(`(${(byteLen / 1024 / 1024).toFixed(2)}MB)`));
65
+ }
66
+ if (options.stats) {
67
+ options.stats.filesScanned += 1;
68
+ options.stats.bytesScanned += byteLen;
69
+ }
70
+ const matches = (0, vault_guard_core_1.applyPathAwareSeverity)(scanner.scanContent(content, { filePath: file }), file);
71
+ if (matches.length > 0) {
72
+ results.push({ file, matches });
73
+ }
74
+ return;
75
+ }
54
76
  if (!fs_1.default.existsSync(file))
55
77
  return;
56
78
  const st = await fs_1.default.promises.stat(file);
@@ -59,7 +81,7 @@ async function scanFileListAsync(files, scanner, options = {}) {
59
81
  if (skipBinary && isBinaryFile(file))
60
82
  return;
61
83
  if (st.size > maxSize && verbose) {
62
- console.warn(chalk_1.default.yellow(`⚠️ Large file (streaming line-by-line):`), chalk_1.default.white(path_1.default.relative(process.cwd(), file)), chalk_1.default.gray(`(${(st.size / 1024 / 1024).toFixed(2)}MB)`));
84
+ console.warn(chalk_1.default.yellow(`⚠️ Large file (streaming line-by-line):`), chalk_1.default.white(path_1.default.relative(cwd, file)), chalk_1.default.gray(`(${(st.size / 1024 / 1024).toFixed(2)}MB)`));
63
85
  }
64
86
  if (options.stats) {
65
87
  options.stats.filesScanned += 1;
@@ -77,11 +99,11 @@ async function scanFileListAsync(files, scanner, options = {}) {
77
99
  options.bus.add({
78
100
  code: 'file.read_error',
79
101
  severity: 'error',
80
- ctx: { file: path_1.default.relative(process.cwd(), file), detail: String(error) },
102
+ ctx: { file: path_1.default.relative(cwd, file), detail: String(error) },
81
103
  });
82
104
  }
83
105
  if (verbose) {
84
- console.error(chalk_1.default.red('❌ Error scanning file:'), chalk_1.default.white(path_1.default.relative(process.cwd(), file)));
106
+ console.error(chalk_1.default.red('❌ Error scanning file:'), chalk_1.default.white(path_1.default.relative(cwd, file)));
85
107
  console.error(chalk_1.default.gray(String(error)));
86
108
  }
87
109
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultcompass/vault-guard",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Block secrets at commit and in CI. Pre-commit hooks, SARIF output, and fast staged-file scans for AI-native workflows.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -36,8 +36,8 @@
36
36
  "dependencies": {
37
37
  "chalk": "^4.1.2",
38
38
  "commander": "^12.0.0",
39
- "@vaultcompass/vault-guard-core": "1.2.2",
40
- "@vaultcompass/vault-guard-telemetry": "1.2.2"
39
+ "@vaultcompass/vault-guard-core": "1.3.0",
40
+ "@vaultcompass/vault-guard-telemetry": "1.3.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/jest": "^30.0.0",