@rmyndharis/aimhooman 0.1.5 → 0.1.7
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 +75 -0
- package/README.md +2 -2
- package/bin/aimhooman.mjs +69 -7
- package/docs/design/frictionless-enforcement.md +7 -4
- package/package.json +2 -2
- package/rules/paths.json +3 -2
- package/schemas/scan-report.schema.json +15 -0
- package/src/hook.mjs +60 -3
- package/src/scan-session.mjs +80 -1
- package/src/scan-target.mjs +39 -6
|
@@ -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.7",
|
|
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.7",
|
|
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.7",
|
|
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,81 @@ 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.7] - 2026-07-18
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- The final reference guard no longer holds a branch hostage to a file already
|
|
13
|
+
in its history. A commit was scanned against the full tree it inherited, so a
|
|
14
|
+
path allowed in once and then un-allowed blocked every later commit on the
|
|
15
|
+
branch, even one-line edits to an unrelated file — and the only way out was to
|
|
16
|
+
rewrite history. The guard now judges a commit by what it changes: a newly
|
|
17
|
+
staged `.env` still stops the commit, while a file the commit merely carries
|
|
18
|
+
forward from its parent stays silent. A secret already in history is not
|
|
19
|
+
forgotten — `aimhooman check --tracked` still names it — but it no longer
|
|
20
|
+
bricks the branch.
|
|
21
|
+
- Imported history no longer trips the attribution guard. `gh pr checkout` and
|
|
22
|
+
`git fetch` bring in other people's commits, whose messages a local developer
|
|
23
|
+
cannot edit; scanning them for AI co-author trailers blocked the checkout
|
|
24
|
+
whenever a PR commit carried one. The guard now scopes attribution and marker
|
|
25
|
+
rules to commits written in the repository (a plain commit, an `--amend`, a
|
|
26
|
+
local merge), and leaves imported commits' messages alone. A locally authored
|
|
27
|
+
`git commit --no-verify` that smuggles the same trailer is still stopped at the
|
|
28
|
+
final guard.
|
|
29
|
+
- `aimhooman status` no longer advertises a profile the hooks are not applying.
|
|
30
|
+
The enforcing guards resolve the project policy from the index, so a worktree
|
|
31
|
+
`.aimhooman.json` that has not been `git add`ed was invisible to them — yet
|
|
32
|
+
`status` reported its profile as active. `status` now shows the staged profile
|
|
33
|
+
the hooks actually enforce, prints the worktree value alongside when the two
|
|
34
|
+
differ, and names the remedy (`git add .aimhooman.json`).
|
|
35
|
+
- A pipeline into a shell with no commit in it is still refused, but the reason
|
|
36
|
+
no longer tells the developer to retry a commit they never wrote. The deny
|
|
37
|
+
text for `echo x | bash` and similar now names the real shape — a pipeline
|
|
38
|
+
whose sink can run arbitrary commands — instead of reusing the commit-themed
|
|
39
|
+
message.
|
|
40
|
+
- A timing or scheduling prefix no longer reads as a hook bypass. `time`,
|
|
41
|
+
`timeout`, `nice`, and `ionice` run the inner command with the same argv in the
|
|
42
|
+
same place, so `time git commit` is judged like `git commit` instead of being
|
|
43
|
+
refused as opaque shell indirection. The carve-out only applies when nothing
|
|
44
|
+
else injects risk; a `--no-verify` or a hooks-path override wrapped in the
|
|
45
|
+
prefix is still caught.
|
|
46
|
+
|
|
47
|
+
## [0.1.6] - 2026-07-18
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- A tracked file over the per-file scan budget no longer blocks every commit in
|
|
52
|
+
the repository. 0.1.5 content-scanned the whole tree on every commit, so a 3 MiB
|
|
53
|
+
vendored bundle that crossed the 2 MiB default budget wedged every later commit
|
|
54
|
+
with `scan incomplete (size-limit=1)` — even one-line edits to an unrelated file
|
|
55
|
+
— and the only way out was to raise the budget above the largest tracked file.
|
|
56
|
+
Path rules still run on the full tree, so a staged `.env` stays blocked, but the
|
|
57
|
+
content scan now reads only the files a commit actually changes. Editing the
|
|
58
|
+
oversized file itself still fails the commit until the budget is raised.
|
|
59
|
+
- An oversized file that is binary now skips as `binary` (complete) instead of
|
|
60
|
+
`size-limit` (incomplete). A PSD, WOFF, or PNG cannot hide the text-pattern
|
|
61
|
+
secrets the content scan looks for, so it was never really an incomplete scan.
|
|
62
|
+
The first 8 KB of each oversized file is probed for a NUL byte to tell binary
|
|
63
|
+
from text; files above 16 MiB stay `size-limit` without probing, because
|
|
64
|
+
`cat-file --batch` reads the whole blob into memory and probing a 500 MB file
|
|
65
|
+
just to check for NULs is not worth it. A text file over budget stays a real
|
|
66
|
+
`size-limit` skip, and the owner must raise `AIMHOOMAN_MAX_FILE_BYTES` to cover
|
|
67
|
+
it.
|
|
68
|
+
- `secret.dotenv` now excepts `*.minimal`. A `.env.minimal` shipped as a template
|
|
69
|
+
was blocked by the same rule that catches a real `.env`, because `*.sample`,
|
|
70
|
+
`*.template`, `*.dist`, and `*.defaults` were excepted but `*.minimal` was not.
|
|
71
|
+
The rule's version bumps to 4.
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- Incomplete-scan messages name the files that were dropped and why. A budget
|
|
76
|
+
miss used to print a count (`size-limit=3`) with no path, so the owner had to
|
|
77
|
+
hunt for the offender. The CLI now lists up to five skipped paths with their
|
|
78
|
+
sizes and reason, and the JSON scan report carries a `skippedPaths` object so a
|
|
79
|
+
caller can see every dropped file the same way. The schema documents the field.
|
|
80
|
+
- `aimhooman explain` prints a rule's `except` clause when it has one, so an
|
|
81
|
+
`allow`/`deny` override can be reasoned about without opening the rule pack.
|
|
82
|
+
|
|
8
83
|
## [0.1.5] - 2026-07-18
|
|
9
84
|
|
|
10
85
|
### 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.7-blue" alt="v0.1.7">
|
|
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">
|
|
@@ -76,7 +76,7 @@ flowchart TD
|
|
|
76
76
|
DIRECT([Sequencer or direct ref path<br/>cherry-pick · rebase · fetch · worktree · update-ref]) --> REF
|
|
77
77
|
PRE -->|clean: safe repair| MSG["commit-msg snapshots would-be tree,<br/>runs predecessor, then checks<br/>the message and pinned full tree"]
|
|
78
78
|
PRE -->|strict violation, incomplete scan,<br/>or failed repair| BLOCK([Operation stops])
|
|
79
|
-
MSG -->|message and tree accepted| REF["reference-transaction prepared<br/>
|
|
79
|
+
MSG -->|message and tree accepted| REF["reference-transaction prepared<br/>scans what each introduced commit changes<br/>(messages of locally authored commits only)"]
|
|
80
80
|
MSG -->|unsafe or unrepairable| BLOCK
|
|
81
81
|
REF -->|accepted| SHIP([Ref update commits])
|
|
82
82
|
REF -->|violation or incomplete scan| BLOCK
|
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) {
|
|
@@ -582,8 +599,23 @@ function cmdRefcheck(args) {
|
|
|
582
599
|
let commits;
|
|
583
600
|
try {
|
|
584
601
|
const contextsByCommit = new Map();
|
|
602
|
+
// A commit's message belongs to whoever wrote it. Attribution and marker
|
|
603
|
+
// rules police the text a local developer can edit, so they are scoped
|
|
604
|
+
// to commits authored here: an update that introduces exactly one new
|
|
605
|
+
// commit on top of a non-zero old tip (a plain commit, an --amend, or a
|
|
606
|
+
// local --no-ff merge of an already-gated branch). Anything else — a new
|
|
607
|
+
// branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
|
|
608
|
+
// history — imports other people's commit text the developer cannot
|
|
609
|
+
// change, and scanning it only blocks the review.
|
|
610
|
+
const localAuthorTips = new Set();
|
|
585
611
|
for (const update of updates) {
|
|
586
|
-
|
|
612
|
+
const introduced = introducedCommits(repo, [update]);
|
|
613
|
+
if (!/^0+$/.test(update.oldObjectId)
|
|
614
|
+
&& introduced.length === 1
|
|
615
|
+
&& introduced[0] === update.newObjectId) {
|
|
616
|
+
localAuthorTips.add(update.newObjectId);
|
|
617
|
+
}
|
|
618
|
+
for (const revision of introduced) {
|
|
587
619
|
const contexts = contextsByCommit.get(revision) || [];
|
|
588
620
|
contexts.push({
|
|
589
621
|
head: update.newObjectId,
|
|
@@ -607,14 +639,18 @@ function cmdRefcheck(args) {
|
|
|
607
639
|
contextsByCommit.set(revision, contexts);
|
|
608
640
|
}
|
|
609
641
|
}
|
|
610
|
-
commits = [...contextsByCommit]
|
|
642
|
+
commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
|
|
643
|
+
revision,
|
|
644
|
+
reviewContexts,
|
|
645
|
+
localAuthorTips.has(revision),
|
|
646
|
+
]);
|
|
611
647
|
}
|
|
612
648
|
catch (error) {
|
|
613
649
|
console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
|
|
614
650
|
return 30;
|
|
615
651
|
}
|
|
616
652
|
const limits = scanLimits();
|
|
617
|
-
for (const [revision, reviewContexts] of commits) {
|
|
653
|
+
for (const [revision, reviewContexts, authoredLocally] of commits) {
|
|
618
654
|
let scan;
|
|
619
655
|
try {
|
|
620
656
|
scan = scanGitTarget(repo, {
|
|
@@ -623,6 +659,7 @@ function cmdRefcheck(args) {
|
|
|
623
659
|
reviewContexts,
|
|
624
660
|
policyMigrationContexts: reviewContexts,
|
|
625
661
|
limits,
|
|
662
|
+
messageScope: authoredLocally ? 'commit' : 'changes-only',
|
|
626
663
|
});
|
|
627
664
|
}
|
|
628
665
|
catch (error) {
|
|
@@ -910,9 +947,17 @@ function cmdStatus(args) {
|
|
|
910
947
|
return 30;
|
|
911
948
|
}
|
|
912
949
|
let policy;
|
|
950
|
+
let stagedPolicy;
|
|
913
951
|
let overrides;
|
|
914
952
|
try {
|
|
953
|
+
// `policy` describes the worktree file as written; `stagedPolicy` is the
|
|
954
|
+
// one the enforcing hooks actually read (they resolve from the index, so
|
|
955
|
+
// a worktree profile that has not been staged is invisible to them).
|
|
956
|
+
// Reporting the worktree value alone used to advertise a profile the
|
|
957
|
+
// guard was not applying, so the two are compared below and the
|
|
958
|
+
// mismatch is named when they disagree.
|
|
915
959
|
policy = resolvePolicy(repo, { target: 'worktree' });
|
|
960
|
+
stagedPolicy = resolvePolicy(repo, { target: 'staged' });
|
|
916
961
|
overrides = loadOverrides(repo.stateDir);
|
|
917
962
|
} catch (e) {
|
|
918
963
|
console.error(`aimhooman: cannot load enforcement state: ${e.message}`);
|
|
@@ -935,9 +980,25 @@ function cmdStatus(args) {
|
|
|
935
980
|
excludeError = e.message;
|
|
936
981
|
excludes = { current: false, installed: false };
|
|
937
982
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
983
|
+
// The pre-commit and reference-transaction guards resolve the policy from
|
|
984
|
+
// the index, so the staged profile is what is actually enforced. Report it
|
|
985
|
+
// first; the worktree file is shown alongside so an edit that has not been
|
|
986
|
+
// staged (or a file that was never `git add`ed) cannot masquerade as active.
|
|
987
|
+
const policyLabelOf = (resolved) => (
|
|
988
|
+
resolved.source === 'worktree-policy'
|
|
989
|
+
|| resolved.source === 'staged-policy'
|
|
990
|
+
|| resolved.source === 'commit-policy'
|
|
991
|
+
) ? 'project' : resolved.source;
|
|
992
|
+
const enforced = stagedPolicy.profile;
|
|
993
|
+
const enforcedLabel = policyLabelOf(stagedPolicy);
|
|
994
|
+
const worktreeLabel = policyLabelOf(policy);
|
|
995
|
+
const policyDrift = policy.profile !== stagedPolicy.profile
|
|
996
|
+
|| policy.policy_object_id !== stagedPolicy.policy_object_id;
|
|
997
|
+
console.log(`profile: ${enforced}${policyDrift ? ` (worktree: ${policy.profile})` : ''}`);
|
|
998
|
+
console.log(`policy: ${enforcedLabel} (${stagedPolicy.source}, object=${stagedPolicy.policy_object_id || 'none'})${policyDrift ? `; worktree ${worktreeLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})` : ''}`);
|
|
999
|
+
if (policyDrift) {
|
|
1000
|
+
console.log(`warning: worktree .aimhooman.json is not staged, so the hooks enforce the staged profile (${enforced}); run \`git add .aimhooman.json\` to apply ${policy.profile}`);
|
|
1001
|
+
}
|
|
941
1002
|
console.log(`hooks: ${hooksComplete ? installed.join(', ') : installed.length ? `${installed.join(', ')} (incomplete; run: aimhooman init)` : 'not installed (run: aimhooman init)'}`);
|
|
942
1003
|
for (const hook of hooks) {
|
|
943
1004
|
console.log(`hook ${hook.name}: ${hook.managed ? 'managed' : 'not managed'}, ${hook.executable ? 'executable' : 'not executable'}, ${hook.reachable ? 'reachable' : hook.reason}`);
|
|
@@ -978,6 +1039,7 @@ function cmdExplain(args) {
|
|
|
978
1039
|
console.log(`Actions: clean=${af('clean')} strict=${af('strict')} compliance=${af('compliance')}`);
|
|
979
1040
|
console.log(`Reason: ${r.reason}`);
|
|
980
1041
|
if (r.match?.paths) console.log(`Paths: ${r.match.paths.join(', ')}`);
|
|
1042
|
+
if (r.match?.except) console.log(`Except: ${r.match.except.join(', ')}`);
|
|
981
1043
|
if (r.match?.content) console.log(`Content: ${r.match.content.join(', ')}`);
|
|
982
1044
|
if (r.remediation?.length) {
|
|
983
1045
|
console.log('Remediation:');
|
|
@@ -55,15 +55,18 @@ enforcement requires local or global hook setup.
|
|
|
55
55
|
| 2. Agent guard | PreToolUse reports paths and rejects unprovable protected Git mutations | plugin | advisory for paths; fail-closed for boundary bypass |
|
|
56
56
|
| 3. Strict policy | `strict` profile blocks instead of repairing (exit 10) | both | block |
|
|
57
57
|
| 4. Pinned tree | `commit-msg` checks the exact would-be tree and message | git hook | blocks a remaining violation or incomplete scan |
|
|
58
|
-
| 5. Final ref check | prepared `reference-transaction`
|
|
58
|
+
| 5. Final ref check | prepared `reference-transaction` scans what each commit introduced to `HEAD` or a branch changes (and the message of commits authored locally) | git hook | blocks a violation or incomplete scan |
|
|
59
59
|
|
|
60
60
|
Default profile `clean` uses layers 0, 1, 2, 4, and 5. Successful repair keeps the
|
|
61
61
|
ordinary path low-friction. The pinned-tree and final-ref scans deliberately stop if
|
|
62
|
-
a block remains
|
|
63
|
-
|
|
62
|
+
a block remains on a path the commit actually changes, or if any scan is incomplete.
|
|
63
|
+
A file already in history is not re-tried on every later commit: the final ref check
|
|
64
|
+
judges a commit by its change set, so an inherited path no longer blocks unrelated
|
|
65
|
+
work. Use `aimhooman check --tracked` (or scan the PR range in CI) to surface legacy
|
|
66
|
+
secrets without bricking the branch.
|
|
64
67
|
Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
|
|
65
68
|
hook checks dispatcher integrity there without scanning unresolved references,
|
|
66
|
-
then keeps the
|
|
69
|
+
then keeps the scan veto at `prepared`, after Git has locked them.
|
|
67
70
|
|
|
68
71
|
### Frictionless agent guard (the everyday commands run)
|
|
69
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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/hook.mjs
CHANGED
|
@@ -443,6 +443,14 @@ function hookPreToolUse(input) {
|
|
|
443
443
|
: '--no-verify or shell indirection bypasses the pre-commit guard';
|
|
444
444
|
const unmodelledPrefixReason =
|
|
445
445
|
'aimhooman cannot verify the final staged snapshot or Git hooks after an earlier unmodelled command; run that command separately, then retry the commit.';
|
|
446
|
+
// A pipeline whose sink can execute code (a shell, an interpreter fed on
|
|
447
|
+
// stdin) can hide a commit or run arbitrary commands, and there is no argv
|
|
448
|
+
// to read a --no-verify out of. The earlier message reused the commit text
|
|
449
|
+
// above, which told a developer to "retry the commit" for a command that
|
|
450
|
+
// was not a commit at all — this names the real shape instead.
|
|
451
|
+
const opaquePipelineReason = commit
|
|
452
|
+
? unmodelledPrefixReason
|
|
453
|
+
: 'aimhooman cannot prove this pipeline is read-only: a shell or code-executing segment can hide a commit or run arbitrary commands. Drop that segment (for example the `| bash`) and run the pieces separately.';
|
|
446
454
|
const blocks = [];
|
|
447
455
|
// potentialCommit treats a command as leading to a commit. uncertainShell
|
|
448
456
|
// was too broad: it flagged any pipe, so a benign read-only pipeline
|
|
@@ -522,7 +530,7 @@ function hookPreToolUse(input) {
|
|
|
522
530
|
if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
|
|
523
531
|
return emitDecision(
|
|
524
532
|
'deny',
|
|
525
|
-
|
|
533
|
+
opaquePipelineReason,
|
|
526
534
|
);
|
|
527
535
|
}
|
|
528
536
|
// Nothing found and nothing to object to, so emit nothing and leave the
|
|
@@ -552,7 +560,7 @@ function hookPreToolUse(input) {
|
|
|
552
560
|
if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
|
|
553
561
|
return emitDecision(
|
|
554
562
|
'deny',
|
|
555
|
-
|
|
563
|
+
opaquePipelineReason,
|
|
556
564
|
);
|
|
557
565
|
}
|
|
558
566
|
const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
|
|
@@ -861,6 +869,41 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
|
|
|
861
869
|
environmentRisk,
|
|
862
870
|
'git',
|
|
863
871
|
);
|
|
872
|
+
// A passthrough prefix (time, timeout, nice, ...) execs the inner
|
|
873
|
+
// command with the same argv in the same place, so it cannot
|
|
874
|
+
// inject a flag or hide a --no-verify the way eval/sudo/bash -c
|
|
875
|
+
// can. Re-parse the inner command from its `git` token so the
|
|
876
|
+
// commit is judged exactly as if the prefix were not there
|
|
877
|
+
// (--no-verify still denied, a clean commit still allowed). The
|
|
878
|
+
// carve-out only applies when nothing else injects risk;
|
|
879
|
+
// otherwise the wrapper falls back to the closed uncertain path.
|
|
880
|
+
const passthroughExecutable = PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))
|
|
881
|
+
&& environmentRisk.length === 0
|
|
882
|
+
&& !targetEnvironmentRisk
|
|
883
|
+
&& !indirectTargetEnvironmentRisk
|
|
884
|
+
&& !commandTargetUncertain
|
|
885
|
+
&& !indirect.targetUncertain
|
|
886
|
+
&& !indirect.pathDialectUncertain
|
|
887
|
+
&& !commandPathDialectUncertain
|
|
888
|
+
&& !unwrapped.uncertain
|
|
889
|
+
? shellExecutable(g0)
|
|
890
|
+
: null;
|
|
891
|
+
if (passthroughExecutable) {
|
|
892
|
+
const gitIndex = toks.findIndex((token, index) => (
|
|
893
|
+
index > 0 && isGitExecutable(token.replace(/^\(+/, '').replace(/\)+$/, ''))
|
|
894
|
+
));
|
|
895
|
+
if (gitIndex > 0) {
|
|
896
|
+
const inner = parseGit(toks.slice(gitIndex).join(' '), unwrapped.cwd);
|
|
897
|
+
commit ||= inner.commit;
|
|
898
|
+
noVerify ||= inner.noVerify;
|
|
899
|
+
bypassHooks ||= inner.bypassHooks;
|
|
900
|
+
uncertainShell ||= inner.uncertainShell;
|
|
901
|
+
addPaths.push(...inner.addPaths);
|
|
902
|
+
commands.push(...inner.commands);
|
|
903
|
+
sawAdd ||= inner.addPaths.length > 0;
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
864
907
|
commit = true;
|
|
865
908
|
uncertainShell = true;
|
|
866
909
|
commands.push({
|
|
@@ -883,7 +926,8 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
|
|
|
883
926
|
|| indirect.pathDialectUncertain,
|
|
884
927
|
});
|
|
885
928
|
}
|
|
886
|
-
if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))
|
|
929
|
+
if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))
|
|
930
|
+
&& !PASSTHROUGH_PREFIX_EXECUTORS.has(shellExecutable(g0))) {
|
|
887
931
|
prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
|
|
888
932
|
prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
|
|
889
933
|
prefixRisk = true;
|
|
@@ -2687,6 +2731,19 @@ const UNCERTAIN_TARGET_INDIRECT_EXECUTORS = new Set([
|
|
|
2687
2731
|
'systemd-run', 'unshare', 'watch', 'wsl', 'xargs',
|
|
2688
2732
|
]);
|
|
2689
2733
|
|
|
2734
|
+
// Passthrough prefixes exec the inner command verbatim: the same argv, the same
|
|
2735
|
+
// cwd, the same target. `time`, `timeout`, `nice`, `ionice` and friends only
|
|
2736
|
+
// measure or schedule — they cannot inject a flag, rewrite the command line, or
|
|
2737
|
+
// hide a --no-verify the way eval/sudo/bash -c can. An indirect commit seen
|
|
2738
|
+
// through one of these is therefore treated as direct, so `time git commit` is
|
|
2739
|
+
// judged like `git commit` instead of being refused as opaque indirection. The
|
|
2740
|
+
// carve-out only applies when nothing else is risky (no environment override,
|
|
2741
|
+
// no uncertain target); otherwise the wrapper falls back to the closed path.
|
|
2742
|
+
const PASSTHROUGH_PREFIX_EXECUTORS = new Set([
|
|
2743
|
+
'time', 'timeout', 'nice', 'ionice', 'chrt', 'taskset', 'numactl',
|
|
2744
|
+
'stdbuf', 'setsid', 'nohup', 'prlimit', 'faketime',
|
|
2745
|
+
]);
|
|
2746
|
+
|
|
2690
2747
|
function indirectCommitInvocation(words, initialCwd) {
|
|
2691
2748
|
if (!words.length) return false;
|
|
2692
2749
|
const executable = shellExecutable(words[0]);
|
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
|
@@ -215,20 +215,39 @@ function scanCommit(repo, options) {
|
|
|
215
215
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
216
216
|
accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot)]);
|
|
217
217
|
}
|
|
218
|
-
|
|
218
|
+
// A commit is judged by what it changes, not by the whole tree it inherits.
|
|
219
|
+
// Scanning snapshot.entries (a full `ls-tree`) re-applied path rules to
|
|
220
|
+
// every file the commit merely carried forward from its parent, so a path
|
|
221
|
+
// allowed into history once (under an override that was later removed, or
|
|
222
|
+
// before the guard existed) blocked every later commit on the branch. The
|
|
223
|
+
// change set already drives content scanning; routing path rules through it
|
|
224
|
+
// too means a newly added `.env` still fires while an inherited one stays
|
|
225
|
+
// silent, matching how scanRange has always judged history.
|
|
226
|
+
scanEntryGroup(repo, loaded.engine, snapshot.changes, policy, accumulator, {
|
|
219
227
|
reviewPathEntries: snapshot.changes,
|
|
228
|
+
contentEntries: snapshot.changes,
|
|
220
229
|
allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
|
|
221
230
|
transition: snapshot.commit,
|
|
222
231
|
});
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
232
|
+
// The message is the author's words, so it is scanned for attribution and
|
|
233
|
+
// markers only when the commit was written here. Fetched history (a PR
|
|
234
|
+
// checked out for review, a branch pulled from a remote) carries other
|
|
235
|
+
// people's commit text that a local developer cannot edit, so flagging it
|
|
236
|
+
// only blocks the review. cmdRefcheck passes messageScope='changes-only'
|
|
237
|
+
// for commits that were imported rather than authored; a direct `aimhooman
|
|
238
|
+
// check --commit` still defaults to scanning the message.
|
|
239
|
+
const scanMessageText = options.messageScope !== 'changes-only';
|
|
240
|
+
if (scanMessageText) {
|
|
241
|
+
accumulator.add(loaded.engine.checkMessage(snapshot.message)
|
|
242
|
+
.map((finding) => decorate(finding, snapshot, policy)));
|
|
243
|
+
accumulator.addSkipped(loaded.engine.takeSkipped());
|
|
244
|
+
}
|
|
226
245
|
return result({
|
|
227
246
|
target: `commit:${snapshot.commit}`,
|
|
228
247
|
policy,
|
|
229
248
|
accumulator,
|
|
230
249
|
diagnostics,
|
|
231
|
-
messageScanned:
|
|
250
|
+
messageScanned: scanMessageText,
|
|
232
251
|
commit: snapshot.commit,
|
|
233
252
|
});
|
|
234
253
|
}
|
|
@@ -367,8 +386,15 @@ function scanEntryGroup(repo, engine, entries, policy, accumulator, options = {}
|
|
|
367
386
|
})
|
|
368
387
|
.map((finding) => decorate(finding, entry, policy)));
|
|
369
388
|
}
|
|
389
|
+
// Content scanning can target a narrower set than the path check. When
|
|
390
|
+
// contentEntries is provided (e.g. only changed files in a commit), read
|
|
391
|
+
// blobs only for those entries instead of the full snapshot. Path-based
|
|
392
|
+
// rules already ran on the full tree above, so secrets like .env are still
|
|
393
|
+
// caught even when their blob isn't re-read.
|
|
394
|
+
const contentScannable = (options.contentEntries ?? entries)
|
|
395
|
+
.filter((entry) => entry.status !== 'D' && entry.type !== 'deleted');
|
|
370
396
|
const remaining = accumulator.remaining();
|
|
371
|
-
const scanned = scanEntries(repo, engine,
|
|
397
|
+
const scanned = scanEntries(repo, engine, contentScannable, {
|
|
372
398
|
maxFileBytes: accumulator.limits.maxFileBytes,
|
|
373
399
|
maxTotalBytes: accumulator.remainingBytes(),
|
|
374
400
|
maxFindings: remaining,
|
|
@@ -628,6 +654,7 @@ function createAccumulator(limits = {}) {
|
|
|
628
654
|
findings_total: 0,
|
|
629
655
|
findings_returned: 0,
|
|
630
656
|
skipped: {},
|
|
657
|
+
skippedPaths: {},
|
|
631
658
|
};
|
|
632
659
|
let complete = true;
|
|
633
660
|
|
|
@@ -665,6 +692,12 @@ function createAccumulator(limits = {}) {
|
|
|
665
692
|
for (const [reason, count] of Object.entries(scanned.stats.skipped || {})) {
|
|
666
693
|
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
667
694
|
}
|
|
695
|
+
for (const [reason, paths] of Object.entries(scanned.stats.skippedPaths || {})) {
|
|
696
|
+
if (!stats.skippedPaths[reason]) stats.skippedPaths[reason] = [];
|
|
697
|
+
for (const entry of paths) {
|
|
698
|
+
if (stats.skippedPaths[reason].length < 10) stats.skippedPaths[reason].push(entry);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
668
701
|
if (!scanned.complete) complete = false;
|
|
669
702
|
},
|
|
670
703
|
addSkipped(skipped = {}) {
|