@rmyndharis/aimhooman 0.1.5 → 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.5",
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.5",
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.5",
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,42 @@ 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
+
8
44
  ## [0.1.5] - 2026-07-18
9
45
 
10
46
  ### 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.5-blue" alt="v0.1.5">
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
@@ -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) {
@@ -978,6 +995,7 @@ function cmdExplain(args) {
978
995
  console.log(`Actions: clean=${af('clean')} strict=${af('strict')} compliance=${af('compliance')}`);
979
996
  console.log(`Reason: ${r.reason}`);
980
997
  if (r.match?.paths) console.log(`Paths: ${r.match.paths.join(', ')}`);
998
+ if (r.match?.except) console.log(`Except: ${r.match.except.join(', ')}`);
981
999
  if (r.match?.content) console.log(`Content: ${r.match.content.join(', ')}`);
982
1000
  if (r.remediation?.length) {
983
1001
  console.log('Remediation:');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmyndharis/aimhooman",
3
- "version": "0.1.5",
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": {
@@ -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
@@ -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 = {}) {