@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/CHANGELOG.md +81 -0
- package/README.md +1 -1
- package/bin/aimhooman.mjs +48 -6
- package/package.json +2 -2
- package/rules/paths.json +3 -2
- package/rules/secrets.json +1 -1
- package/schemas/scan-report.schema.json +15 -0
- package/src/atomic-write.mjs +18 -2
- package/src/githooks.mjs +9 -1
- 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,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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1700
|
-
|
|
1701
|
-
|
|
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.
|
|
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": {
|
package/rules/secrets.json
CHANGED
|
@@ -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
|
package/src/atomic-write.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
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 = {}) {
|