@rmyndharis/aimhooman 0.1.4 → 0.1.6

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.
@@ -9,7 +9,7 @@
9
9
  "name": "aimhooman",
10
10
  "source": "./",
11
11
  "description": "Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
12
- "version": "0.1.4",
12
+ "version": "0.1.6",
13
13
  "license": "MIT",
14
14
  "keywords": [
15
15
  "git",
@@ -20,4 +20,4 @@
20
20
  ]
21
21
  }
22
22
  ]
23
- }
23
+ }
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman"
7
7
  },
8
8
  "hooks": "./hooks/hooks.json"
9
- }
9
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aimhooman",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI works. Hoomans ship. Keeps AI session files, secrets, and unwanted attribution out at the agent and Git boundary.",
5
5
  "author": {
6
6
  "name": "aimhooman maintainers",
@@ -34,4 +34,4 @@
34
34
  ],
35
35
  "brandColor": "#111827"
36
36
  }
37
- }
37
+ }
package/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.6] - 2026-07-18
9
+
10
+ ### Fixed
11
+
12
+ - A tracked file over the per-file scan budget no longer blocks every commit in
13
+ the repository. 0.1.5 content-scanned the whole tree on every commit, so a 3 MiB
14
+ vendored bundle that crossed the 2 MiB default budget wedged every later commit
15
+ with `scan incomplete (size-limit=1)` — even one-line edits to an unrelated file
16
+ — and the only way out was to raise the budget above the largest tracked file.
17
+ Path rules still run on the full tree, so a staged `.env` stays blocked, but the
18
+ content scan now reads only the files a commit actually changes. Editing the
19
+ oversized file itself still fails the commit until the budget is raised.
20
+ - An oversized file that is binary now skips as `binary` (complete) instead of
21
+ `size-limit` (incomplete). A PSD, WOFF, or PNG cannot hide the text-pattern
22
+ secrets the content scan looks for, so it was never really an incomplete scan.
23
+ The first 8 KB of each oversized file is probed for a NUL byte to tell binary
24
+ from text; files above 16 MiB stay `size-limit` without probing, because
25
+ `cat-file --batch` reads the whole blob into memory and probing a 500 MB file
26
+ just to check for NULs is not worth it. A text file over budget stays a real
27
+ `size-limit` skip, and the owner must raise `AIMHOOMAN_MAX_FILE_BYTES` to cover
28
+ it.
29
+ - `secret.dotenv` now excepts `*.minimal`. A `.env.minimal` shipped as a template
30
+ was blocked by the same rule that catches a real `.env`, because `*.sample`,
31
+ `*.template`, `*.dist`, and `*.defaults` were excepted but `*.minimal` was not.
32
+ The rule's version bumps to 4.
33
+
34
+ ### Added
35
+
36
+ - Incomplete-scan messages name the files that were dropped and why. A budget
37
+ miss used to print a count (`size-limit=3`) with no path, so the owner had to
38
+ hunt for the offender. The CLI now lists up to five skipped paths with their
39
+ sizes and reason, and the JSON scan report carries a `skippedPaths` object so a
40
+ caller can see every dropped file the same way. The schema documents the field.
41
+ - `aimhooman explain` prints a rule's `except` clause when it has one, so an
42
+ `allow`/`deny` override can be reasoned about without opening the rule pack.
43
+
44
+ ## [0.1.5] - 2026-07-18
45
+
46
+ ### Fixed
47
+
48
+ - AWS's own published example key no longer blocks a commit. 0.1.4 began matching bare
49
+ `AKIA…`/`ASIA…` access key IDs, which also matched `AKIAIOSFODNN7EXAMPLE` — the value AWS
50
+ prints in its own documentation, and one that lands in READMEs, Terraform samples, and test
51
+ fixtures. Because the rule's category is `secret`, neither a rule allow nor a plain path
52
+ allow is accepted, so a documentation example became an unconditional block on every profile
53
+ with only a per-file escape. Every AWS example key ends in `EXAMPLE`, so the pattern now
54
+ excludes that suffix and keeps blocking real keys. Path scoping was deliberately not used:
55
+ excluding `docs/` or `tests/` from secret scanning would hide genuine keys in the places they
56
+ most often leak.
57
+ - `uninstall --purge-state` no longer leaves a backup behind in a linked worktree. Git writes
58
+ the commit message file into the per-worktree Git directory, so 0.1.4's sweep — which looked
59
+ only in the common directory — printed "state purged" with `COMMIT_EDITMSG.aimhooman-bak`
60
+ still on disk. Uninstall disarms every worktree at once, so it now sweeps the main Git
61
+ directory and each linked one, and matches the `.aimhooman-bak` suffix so a backup left by a
62
+ merge goes with it.
63
+ - A plain `uninstall` keeps the attribution backup. It printed "state kept" and then deleted
64
+ the one file holding the lines stripped from the last commit message. Only `--purge-state`,
65
+ which promises to remove everything, takes it now.
66
+ - Sweeping an empty lock queue can no longer stop a concurrent `aimhooman init`. Between its
67
+ `mkdir` and its first publication a lock contender owns no file in the queue, so the sweep
68
+ saw an empty directory and removed it, and the contender then failed on a bare `ENOENT`. The
69
+ window is wide enough to lose because building the candidate probes the process identity,
70
+ which spawns `ps` on macOS and BSD. Publication now recreates the directory and retries once.
71
+ - The unstage summary no longer claims the files were "kept in your working tree". That is
72
+ false when a path was staged and then deleted before the commit, and when a staged deletion
73
+ is unstaged — the file is absent either way, though aimhooman never removed it. Unstaging
74
+ only ever touches the index, so the note says that instead of guessing where the file is.
75
+
76
+ ### Changed
77
+
78
+ - An ordinary commit no longer pays a Node cold start for a phase that does nothing. Git fires
79
+ `reference-transaction` twice per commit, and `refcheck` returns immediately for `committed`
80
+ and `aborted`, so the dispatcher now short-circuits those phases in the shell — after the
81
+ chained-hook call, so a chained hook still sees every phase. Measured locally, an ordinary
82
+ commit drops from about 870ms to about 710ms. The `prepared` scan, and the `--no-verify`
83
+ backstop that rides on it, are unchanged. A repository installed with an earlier release
84
+ keeps a valid dispatcher; re-run `aimhooman init` to pick up the faster one.
85
+ - The attribution backup no longer lingers indefinitely. `commit-msg` clears the previous run's
86
+ backup before it may write its own, so at most one exists at a time and a later clean commit
87
+ leaves none. The recovery window is now until your next commit rather than unbounded.
88
+
8
89
  ## [0.1.4] - 2026-07-18
9
90
 
10
91
  ### Fixed
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  <p align="center">
11
11
  <img src="https://img.shields.io/github/actions/workflow/status/rmyndharis/aimhooman/test.yml?branch=main&label=CI" alt="CI">
12
- <img src="https://img.shields.io/badge/version-v0.1.4-blue" alt="v0.1.4">
12
+ <img src="https://img.shields.io/badge/version-v0.1.6-blue" alt="v0.1.6">
13
13
  <img src="https://img.shields.io/badge/node-%E2%89%A522.8-339933?logo=node.js&logoColor=white" alt="Node 22.8+">
14
14
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="Zero dependencies">
15
15
  <img src="https://img.shields.io/badge/license-MIT-111111" alt="MIT">
package/bin/aimhooman.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFileSync } from 'node:child_process';
3
- import { chmodSync, lstatSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
3
+ import { chmodSync, lstatSync, readdirSync, readFileSync, rmdirSync, rmSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
@@ -212,7 +212,24 @@ function incompleteMessage(scan) {
212
212
  : budgeted
213
213
  ? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
214
214
  : 'reduce the target or limits and retry';
215
- return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
215
+ let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
216
+ const skippedPaths = scan.stats?.skippedPaths || {};
217
+ const pathLines = [];
218
+ for (const [reason, entries] of Object.entries(skippedPaths)) {
219
+ for (const entry of entries.slice(0, 5)) {
220
+ const sizeStr = formatBytes(entry.size);
221
+ pathLines.push(` skipped: ${entry.path} (${reason}, ${sizeStr})\n`);
222
+ }
223
+ if (entries.length > 5) pathLines.push(` ... and ${entries.length - 5} more\n`);
224
+ }
225
+ return message + pathLines.join('');
226
+ }
227
+
228
+ function formatBytes(bytes) {
229
+ if (bytes == null || bytes < 0) return '?';
230
+ if (bytes < 1024) return `${bytes} B`;
231
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
232
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
216
233
  }
217
234
 
218
235
  function snapshotFile(path) {
@@ -372,7 +389,7 @@ function cmdPrecommit(args) {
372
389
  emptied = stagedBefore !== null
373
390
  && stagedBefore.every((path) => unstageTargets.has(path));
374
391
  process.stderr.write(
375
- `aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')}${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
392
+ `aimhooman: unstaged ${paths.length} file(s) from this commit: ${paths.map(visible).join(', ')} (index only; nothing on disk was deleted)${emptied ? ' — nothing else was staged, so the commit is stopped rather than left empty' : ''}\n`
376
393
  );
377
394
  } catch (e) {
378
395
  process.stderr.write(
@@ -409,6 +426,10 @@ function cmdCommitmsg(args) {
409
426
  const file = positionals[0];
410
427
  const repo = tryRepo();
411
428
  if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
429
+ // A .aimhooman-bak from an earlier commit went stale the moment that commit
430
+ // finished, and git reuses this message path. Clear the previous run's backup
431
+ // now, before this run may write its own, so at most one ever lingers.
432
+ try { rmSync(`${file}.aimhooman-bak`, { force: true }); } catch { /* best effort */ }
412
433
  // Frictionless profiles (clean/compliance) never cancel a commit merely
413
434
  // because the message file cannot be read; only strict fails closed.
414
435
  let hookProfile = 'clean';
@@ -974,6 +995,7 @@ function cmdExplain(args) {
974
995
  console.log(`Actions: clean=${af('clean')} strict=${af('strict')} compliance=${af('compliance')}`);
975
996
  console.log(`Reason: ${r.reason}`);
976
997
  if (r.match?.paths) console.log(`Paths: ${r.match.paths.join(', ')}`);
998
+ if (r.match?.except) console.log(`Except: ${r.match.except.join(', ')}`);
977
999
  if (r.match?.content) console.log(`Content: ${r.match.content.join(', ')}`);
978
1000
  if (r.remediation?.length) {
979
1001
  console.log('Remediation:');
@@ -1696,9 +1718,29 @@ function cmdUninstall(args) {
1696
1718
  ]) {
1697
1719
  try { rmdirSync(queue); } catch { /* held by another aimhooman, or already gone */ }
1698
1720
  }
1699
- // A one-commit-ago attribution backup that git's next COMMIT_EDITMSG makes stale.
1700
- try { rmSync(join(repo.commonDir, 'COMMIT_EDITMSG.aimhooman-bak'), { force: true }); }
1701
- catch { /* nothing to remove */ }
1721
+ // The attribution backup is the user's only copy of the lines stripped from
1722
+ // their last message — commit-msg already clears the previous one, so what
1723
+ // survives here is current, not stale. A plain uninstall says "state kept"
1724
+ // and must not delete it; only --purge-state, which promises to remove
1725
+ // everything, sweeps it. Git names the message file per operation
1726
+ // (COMMIT_EDITMSG, MERGE_MSG, ...) and the backup inherits that name, so
1727
+ // match the suffix rather than one filename. It lands beside that file, in
1728
+ // the per-worktree git directory rather than the common one, and uninstall
1729
+ // disarms every worktree at once — so cover the main one and each linked.
1730
+ if (purge) {
1731
+ const messageDirs = [repo.commonDir];
1732
+ try {
1733
+ const linked = join(repo.commonDir, 'worktrees');
1734
+ for (const name of readdirSync(linked)) messageDirs.push(join(linked, name));
1735
+ } catch { /* no linked worktrees */ }
1736
+ for (const dir of messageDirs) {
1737
+ try {
1738
+ for (const name of readdirSync(dir)) {
1739
+ if (name.endsWith('.aimhooman-bak')) rmSync(join(dir, name), { force: true });
1740
+ }
1741
+ } catch { /* directory missing or unreadable */ }
1742
+ }
1743
+ }
1702
1744
  return exitStatus;
1703
1745
  }
1704
1746
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI works. Hoomans ship. Keep AI session files, secrets, and attribution out of your commits.",
5
5
  "homepage": "https://github.com/rmyndharis/aimhooman#readme",
6
6
  "repository": {
@@ -74,4 +74,4 @@
74
74
  "developer-tools",
75
75
  "pre-commit"
76
76
  ]
77
- }
77
+ }
package/rules/paths.json CHANGED
@@ -218,7 +218,7 @@
218
218
  },
219
219
  {
220
220
  "id": "secret.dotenv",
221
- "version": 3,
221
+ "version": 4,
222
222
  "provider": "generic",
223
223
  "category": "secret",
224
224
  "confidence": "high",
@@ -236,7 +236,8 @@
236
236
  "**/*.sample",
237
237
  "**/*.template",
238
238
  "**/*.dist",
239
- "**/*.defaults"
239
+ "**/*.defaults",
240
+ "**/*.minimal"
240
241
  ]
241
242
  },
242
243
  "actions": {
@@ -53,7 +53,7 @@
53
53
  "match": {
54
54
  "content": [
55
55
  "(?i)\\baws_(?:secret_access_key|session_token)\\b\\s*[:=]\\s*[\\\"']?[A-Za-z0-9/+=]{32,}(?![A-Za-z0-9/+=])",
56
- "\\b(?:AKIA|ASIA)[A-Z0-9]{16}\\b"
56
+ "\\b(?:AKIA|ASIA)(?![A-Z0-9]{9}EXAMPLE\\b)[A-Z0-9]{16}\\b"
57
57
  ]
58
58
  },
59
59
  "actions": {
@@ -82,6 +82,21 @@
82
82
  "skipped": {
83
83
  "type": "object",
84
84
  "additionalProperties": { "type": "integer", "minimum": 1 }
85
+ },
86
+ "skippedPaths": {
87
+ "type": "object",
88
+ "additionalProperties": {
89
+ "type": "array",
90
+ "items": {
91
+ "type": "object",
92
+ "required": ["path", "size"],
93
+ "properties": {
94
+ "path": { "type": "string" },
95
+ "size": { "type": ["integer", "null"], "minimum": 0 }
96
+ },
97
+ "additionalProperties": false
98
+ }
99
+ }
85
100
  }
86
101
  },
87
102
  "additionalProperties": false
@@ -259,18 +259,34 @@ export function withLock(lockPath, fn, options = {}) {
259
259
  let held = false;
260
260
  let published = false;
261
261
  let primaryError = null;
262
+ // Between the mkdir above and the first publication below this contender owns
263
+ // no file in the queue, so a concurrent cleanup that removes empty queue
264
+ // directories — `aimhooman uninstall` sweeps them — can delete it out from
265
+ // under us. The gap is wide enough to lose: building the candidate probes the
266
+ // process identity, which spawns `ps` on macOS and BSD. Recreate and retry
267
+ // once rather than failing the caller with a bare ENOENT. Once a candidate is
268
+ // published the directory is no longer empty, so no cleanup can remove it.
269
+ const publish = () => {
270
+ try {
271
+ publishCandidate(candidatePath, candidate, options.candidateOperations);
272
+ } catch (error) {
273
+ if (error?.code !== 'ENOENT') throw error;
274
+ mkdirSync(queueDir, { recursive: true });
275
+ publishCandidate(candidatePath, candidate, options.candidateOperations);
276
+ }
277
+ };
262
278
  try {
263
279
  // Own the UUID pathname before publication begins. If the rename lands
264
280
  // but its directory fsync fails, finally still removes the candidate;
265
281
  // unlinking ENOENT is harmless when publication failed earlier.
266
282
  published = true;
267
- publishCandidate(candidatePath, candidate, options.candidateOperations);
283
+ publish();
268
284
  const observed = queueCandidates(queueDir, staleMs);
269
285
  candidate.ticket = observed.reduce((maximum, peer) => (
270
286
  peer.ticket === null ? maximum : Math.max(maximum, peer.ticket)
271
287
  ), 0) + 1;
272
288
  candidate.choosing = false;
273
- publishCandidate(candidatePath, candidate, options.candidateOperations);
289
+ publish();
274
290
 
275
291
  for (let attempt = 0; attempt < retries; attempt += 1) {
276
292
  // A file at the pre-queue lock path may belong to a concurrently
package/src/githooks.mjs CHANGED
@@ -642,6 +642,14 @@ function hookScript(name, cmd, cliPath, chainedPath) {
642
642
  const aimInvocation = name === 'reference-transaction'
643
643
  ? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
644
644
  : `run_aimhooman ${aimCommand} || exit $?`;
645
+ // committed/aborted fire only after refs are locked in, and refcheck can do
646
+ // nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
647
+ // so an ordinary commit no longer pays a Node cold start for the committed
648
+ // phase. Placed after the chained-hook call, so a chained hook still sees
649
+ // every phase.
650
+ const phaseShortCircuit = name === 'reference-transaction'
651
+ ? 'case "$1" in committed|aborted) exit 0 ;; esac\n'
652
+ : '';
645
653
  const template = `#!/bin/sh -p
646
654
  ${MARKER} (${name})
647
655
  # aimhooman-hook-version: ${HOOK_FORMAT_VERSION}
@@ -691,7 +699,7 @@ fi
691
699
  if [ -x "$CHAINED" ]; then
692
700
  ${chainedInvocation}
693
701
  fi
694
- ${aimInvocation}
702
+ ${phaseShortCircuit}${aimInvocation}
695
703
  `;
696
704
  const fingerprint = hookFingerprint(template);
697
705
  return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);
@@ -18,9 +18,11 @@ export function scanEntries(repo, engine, entries, options = {}) {
18
18
  findings_total: 0,
19
19
  findings_returned: 0,
20
20
  skipped: {},
21
+ skippedPaths: {},
21
22
  };
22
23
  const candidates = [];
23
24
  let selectedBytes = 0;
25
+ const oversized = [];
24
26
 
25
27
  for (const entry of entries) {
26
28
  if (entry.type !== 'blob') {
@@ -37,17 +39,48 @@ export function scanEntries(repo, engine, entries, options = {}) {
37
39
  continue;
38
40
  }
39
41
  if (entry.size > limits.maxFileBytes) {
40
- increment(stats.skipped, 'size-limit');
42
+ oversized.push(entry);
41
43
  continue;
42
44
  }
43
45
  if (selectedBytes + entry.size > limits.maxTotalBytes) {
44
46
  increment(stats.skipped, 'total-byte-limit');
47
+ appendPath(stats.skippedPaths, 'total-byte-limit', entry.path, entry.size);
45
48
  continue;
46
49
  }
47
50
  selectedBytes += entry.size;
48
51
  candidates.push(entry);
49
52
  }
50
53
 
54
+ // Oversized files: probe the first 8 KB to separate binary from text.
55
+ // A binary file (PSD, WOFF, PNG) can't hide text-pattern secrets that
56
+ // the full content scan would find, so it skips as 'binary' (complete).
57
+ // A text file that exceeds the budget is a genuine 'size-limit' skip
58
+ // (incomplete) and the caller must raise the limit to cover it.
59
+ // Cap probing at 16 MiB: cat-file --batch reads the full blob into memory,
60
+ // so probing a 500 MB file just to check for NULs is wasteful. Files above
61
+ // the cap are classified as size-limit without probing.
62
+ const PROBE_CAP = 16 * 1024 * 1024;
63
+ const probeable = oversized.filter((entry) => entry.size <= PROBE_CAP);
64
+ const tooBig = oversized.filter((entry) => entry.size > PROBE_CAP);
65
+ for (const entry of tooBig) {
66
+ increment(stats.skipped, 'size-limit');
67
+ appendPath(stats.skippedPaths, 'size-limit', entry.path, entry.size);
68
+ }
69
+ if (probeable.length) {
70
+ const probeOids = [...new Set(probeable.map((entry) => entry.oid))];
71
+ const probes = probeObjects(repo, probeOids, probeable);
72
+ for (const entry of probeable) {
73
+ const header = probes.get(entry.oid);
74
+ if (header && isBinary(header)) {
75
+ increment(stats.skipped, 'binary');
76
+ appendPath(stats.skippedPaths, 'binary', entry.path, entry.size);
77
+ } else {
78
+ increment(stats.skipped, 'size-limit');
79
+ appendPath(stats.skippedPaths, 'size-limit', entry.path, entry.size);
80
+ }
81
+ }
82
+ }
83
+
51
84
  const objectIds = [...new Set(candidates.map((entry) => entry.oid))];
52
85
  const batch = readObjects(repo, objectIds, selectedBytes);
53
86
  stats.objects_read = batch.objects.size;
@@ -65,6 +98,7 @@ export function scanEntries(repo, engine, entries, options = {}) {
65
98
  let matched;
66
99
  if (isBinary(blob)) {
67
100
  increment(stats.skipped, 'binary');
101
+ appendPath(stats.skippedPaths, 'binary', entry.path, blob.length);
68
102
  // Binary classification only skips text-oriented policy rules. Secret
69
103
  // signatures are ASCII byte sequences, so latin1 preserves a
70
104
  // one-byte-to-one-code-unit view and keeps the existing byte limits.
@@ -148,10 +182,55 @@ function readObjects(repo, objectIds, expectedBytes = 0) {
148
182
  return { objects, failures };
149
183
  }
150
184
 
185
+ // probeObjects reads the first 8000 bytes of each blob to decide whether it is
186
+ // binary (contains a NUL). cat-file --batch outputs full blobs, so the buffer
187
+ // must accommodate total blob sizes, but we only inspect the leading bytes.
188
+ function probeObjects(repo, objectIds, entries) {
189
+ const unique = [...new Set(objectIds.filter(Boolean))];
190
+ if (!unique.length) return new Map();
191
+ const PROBE_BYTES = 8000;
192
+ // Sum the actual sizes so the buffer can hold the full output.
193
+ const sizeByOid = new Map();
194
+ for (const entry of entries) {
195
+ if (entry.oid && entry.size > 0) sizeByOid.set(entry.oid, Math.max(sizeByOid.get(entry.oid) || 0, entry.size));
196
+ }
197
+ const totalExpected = unique.reduce((sum, oid) => sum + (sizeByOid.get(oid) || 0), 0);
198
+ const output = execFileSync('git', ['cat-file', '--batch'], {
199
+ cwd: repo.root,
200
+ env: gitEnvironment(),
201
+ input: Buffer.from(unique.join('\n') + '\n'),
202
+ encoding: 'buffer',
203
+ maxBuffer: Math.max(2 * 1024 * 1024, totalExpected + unique.length * 256 + 1024),
204
+ timeout: GIT_TIMEOUT_MS,
205
+ stdio: ['pipe', 'pipe', 'pipe'],
206
+ });
207
+ const probes = new Map();
208
+ let offset = 0;
209
+ for (const requested of unique) {
210
+ const newline = output.indexOf(0x0a, offset);
211
+ if (newline < 0) break;
212
+ const header = output.subarray(offset, newline).toString('utf8');
213
+ offset = newline + 1;
214
+ const fields = header.split(' ');
215
+ if (fields[1] === 'missing' || fields.length !== 3 || fields[1] !== 'blob') continue;
216
+ const size = Number(fields[2]);
217
+ const end = offset + size;
218
+ if (end > output.length || output[end] !== 0x0a) break;
219
+ probes.set(requested, output.subarray(offset, offset + Math.min(size, PROBE_BYTES)));
220
+ offset = end + 1;
221
+ }
222
+ return probes;
223
+ }
224
+
151
225
  function increment(record, key) {
152
226
  record[key] = (record[key] || 0) + 1;
153
227
  }
154
228
 
229
+ function appendPath(record, reason, path, size) {
230
+ if (!record[reason]) record[reason] = [];
231
+ if (record[reason].length < 10) record[reason].push({ path, size });
232
+ }
233
+
155
234
  function isBinary(buffer) {
156
235
  const length = Math.min(buffer.length, 8000);
157
236
  for (let index = 0; index < length; index++) if (buffer[index] === 0) return true;
@@ -217,6 +217,7 @@ function scanCommit(repo, options) {
217
217
  }
218
218
  scanEntryGroup(repo, loaded.engine, snapshot.entries, policy, accumulator, {
219
219
  reviewPathEntries: snapshot.changes,
220
+ contentEntries: snapshot.changes,
220
221
  allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
221
222
  transition: snapshot.commit,
222
223
  });
@@ -367,8 +368,15 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
367
368
  })
368
369
  .map((finding) => decorate(finding, entry, policy)));
369
370
  }
371
+ // Content scanning can target a narrower set than the path check. When
372
+ // contentEntries is provided (e.g. only changed files in a commit), read
373
+ // blobs only for those entries instead of the full snapshot. Path-based
374
+ // rules already ran on the full tree above, so secrets like .env are still
375
+ // caught even when their blob isn't re-read.
376
+ const contentScannable = (options.contentEntries ?? entries)
377
+ .filter((entry) => entry.status !== 'D' && entry.type !== 'deleted');
370
378
  const remaining = accumulator.remaining();
371
- const scanned = scanEntries(repo, engine, scannable, {
379
+ const scanned = scanEntries(repo, engine, contentScannable, {
372
380
  maxFileBytes: accumulator.limits.maxFileBytes,
373
381
  maxTotalBytes: accumulator.remainingBytes(),
374
382
  maxFindings: remaining,
@@ -628,6 +636,7 @@ function createAccumulator(limits = {}) {
628
636
  findings_total: 0,
629
637
  findings_returned: 0,
630
638
  skipped: {},
639
+ skippedPaths: {},
631
640
  };
632
641
  let complete = true;
633
642
 
@@ -665,6 +674,12 @@ function createAccumulator(limits = {}) {
665
674
  for (const [reason, count] of Object.entries(scanned.stats.skipped || {})) {
666
675
  stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
667
676
  }
677
+ for (const [reason, paths] of Object.entries(scanned.stats.skippedPaths || {})) {
678
+ if (!stats.skippedPaths[reason]) stats.skippedPaths[reason] = [];
679
+ for (const entry of paths) {
680
+ if (stats.skippedPaths[reason].length < 10) stats.skippedPaths[reason].push(entry);
681
+ }
682
+ }
668
683
  if (!scanned.complete) complete = false;
669
684
  },
670
685
  addSkipped(skipped = {}) {