@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +36 -0
- package/README.md +1 -1
- package/bin/aimhooman.mjs +19 -1
- package/package.json +2 -2
- package/rules/paths.json +3 -2
- package/schemas/scan-report.schema.json +15 -0
- package/src/scan-session.mjs +80 -1
- package/src/scan-target.mjs +16 -1
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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":
|
|
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
|
package/src/scan-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/scan-target.mjs
CHANGED
|
@@ -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,
|
|
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 = {}) {
|