@rmyndharis/aimhooman 0.3.1 → 0.4.0
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +38 -0
- package/README.md +10 -5
- package/bin/aimhooman.mjs +222 -88
- package/docs/cli-reference.md +13 -7
- package/docs/faq.md +5 -3
- package/docs/secrets.md +1 -1
- package/package.json +1 -1
- package/src/githooks.mjs +65 -5
- package/src/report.mjs +17 -4
- package/src/scan-session.mjs +14 -6
- package/src/scan-target.mjs +55 -19
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ 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.4.0] - 2026-07-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- New managed `pre-push` hook (`pushcheck`) that scans every commit a push
|
|
13
|
+
would introduce, messages included. A commit vetoed by the
|
|
14
|
+
reference-transaction guard stays in the local object store, and pushing that
|
|
15
|
+
raw object ID (`git push <remote> <sha>:<ref>`) moved no local branch, so it
|
|
16
|
+
sailed past every guard; the veto message even printed the SHA. Such pushes
|
|
17
|
+
are now rejected before any objects are sent. Deletions carry nothing and
|
|
18
|
+
pass without scanning.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A scan whose only gap is a file over the per-file size budget no longer
|
|
23
|
+
wedges commits on `clean`/`compliance`: the final ref guard warns and
|
|
24
|
+
continues instead of vetoing, because path rules still covered the file.
|
|
25
|
+
`strict` stays fail-closed (exit 31) on any incomplete scan, and every other
|
|
26
|
+
gap (total byte budget, unreadable objects, rule-pack errors) stays
|
|
27
|
+
fail-closed on all profiles.
|
|
28
|
+
- Commit overhead: the `commit-msg` dispatcher greps the message for the
|
|
29
|
+
attribution-rule anchors and skips its Node spawn when none is present (a
|
|
30
|
+
local rule pack or a missing `pre-commit` dispatcher re-enables the spawn,
|
|
31
|
+
since the "tree already scanned" premise needs both), and `pre-commit` skips
|
|
32
|
+
its spawn when the index is empty. A typical commit goes from three Node
|
|
33
|
+
spawns to two; an empty commit to one.
|
|
34
|
+
- The `not very hooman.` banner now appears only when a finding actually stops
|
|
35
|
+
or reshapes the operation (a block). A pure review advisory, where the
|
|
36
|
+
commit proceeds untouched, prints without it so the banner keeps its meaning.
|
|
37
|
+
- A blocked strict-policy downgrade or deletion now prints the exact
|
|
38
|
+
`aimhooman policy-review` invocation — oids included — instead of pointing at
|
|
39
|
+
"the reviewed migration command"; running the printed command unlocks the
|
|
40
|
+
commit.
|
|
41
|
+
- A non-blocking "scan incomplete" warning (the oversized-file case) prints
|
|
42
|
+
once per tree and gap instead of once per hook: pre-commit, commit-msg, the
|
|
43
|
+
final ref guard, and pre-push share a notice marker, so the same oversized
|
|
44
|
+
file no longer warns three times in one commit.
|
|
45
|
+
|
|
8
46
|
## [0.3.1] - 2026-07-19
|
|
9
47
|
|
|
10
48
|
### 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.
|
|
12
|
+
<img src="https://img.shields.io/badge/version-v0.4.0-blue" alt="v0.4.0">
|
|
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">
|
|
@@ -41,7 +41,9 @@ One detection core, many enforcement surfaces. On the default profile the ordina
|
|
|
41
41
|
path repairs hygiene findings when it can; `commit-msg` checks the pinned would-be tree,
|
|
42
42
|
and the final ref boundary independently scans every commit introduced to `HEAD` or a
|
|
43
43
|
branch. A block that survives repair stops the commit; an incomplete scan warns on
|
|
44
|
-
`clean`/`compliance` and still vetoes at the final ref guard
|
|
44
|
+
`clean`/`compliance` and still vetoes at the final ref guard, unless the only gap is a
|
|
45
|
+
file over the per-file size budget. `pre-push` scans what a push would send, so a commit
|
|
46
|
+
pushed by raw object ID cannot bypass the local guards.
|
|
45
47
|
|
|
46
48
|
```mermaid
|
|
47
49
|
flowchart TD
|
|
@@ -57,15 +59,18 @@ flowchart TD
|
|
|
57
59
|
MSG -->|unsafe or unrepairable| BLOCK
|
|
58
60
|
REF -->|accepted| SHIP([Ref update commits])
|
|
59
61
|
REF -->|violation or incomplete scan| BLOCK
|
|
62
|
+
PUSH([git push]) --> PREPUSH["pre-push<br/>scans every commit the push would send,<br/>messages included — even a push by raw object ID"]
|
|
63
|
+
PREPUSH -->|accepted| SENT([Objects leave for the remote])
|
|
64
|
+
PREPUSH -->|violation| BLOCK
|
|
60
65
|
|
|
61
66
|
classDef entry fill:#f8fafc,stroke:#94a3b8,color:#0f172a,stroke-width:1.5px
|
|
62
67
|
classDef hook fill:#eef2ff,stroke:#6366f1,color:#312e81,stroke-width:2px
|
|
63
68
|
classDef ok fill:#ecfdf5,stroke:#10b981,color:#064e3b,stroke-width:1.5px
|
|
64
69
|
classDef stop fill:#fef2f2,stroke:#ef4444,color:#7f1d1d,stroke-width:2px
|
|
65
70
|
|
|
66
|
-
class SESS,AGENT,RUN,COMMIT,DIRECT entry
|
|
67
|
-
class PRE,MSG,REF hook
|
|
68
|
-
class SHIP ok
|
|
71
|
+
class SESS,AGENT,RUN,COMMIT,DIRECT,PUSH entry
|
|
72
|
+
class PRE,MSG,REF,PREPUSH hook
|
|
73
|
+
class SHIP,SENT ok
|
|
69
74
|
class BLOCK stop
|
|
70
75
|
```
|
|
71
76
|
|
package/bin/aimhooman.mjs
CHANGED
|
@@ -128,7 +128,7 @@ function main(argv) {
|
|
|
128
128
|
// switch below.
|
|
129
129
|
const SUBCOMMAND_HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
130
130
|
const knownSubcommands = new Set([
|
|
131
|
-
'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'init',
|
|
131
|
+
'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'pushcheck', 'init',
|
|
132
132
|
'status', 'explain', 'allow', 'deny', 'override', 'review',
|
|
133
133
|
'policy-review', 'fix', 'doctor', 'uninstall',
|
|
134
134
|
]);
|
|
@@ -148,6 +148,8 @@ function main(argv) {
|
|
|
148
148
|
return cmdCommitmsg(rest);
|
|
149
149
|
case 'refcheck':
|
|
150
150
|
return cmdRefcheck(rest);
|
|
151
|
+
case 'pushcheck':
|
|
152
|
+
return cmdPushcheck(rest);
|
|
151
153
|
case 'init':
|
|
152
154
|
return cmdInit(rest);
|
|
153
155
|
case 'status':
|
|
@@ -249,6 +251,31 @@ function incompleteMessage(scan, { blocking = true } = {}) {
|
|
|
249
251
|
return message + pathLines.join('');
|
|
250
252
|
}
|
|
251
253
|
|
|
254
|
+
// One oversized file trips the same non-blocking "scan incomplete" warning in
|
|
255
|
+
// up to three hooks of a single commit (pre-commit, commit-msg, the final ref
|
|
256
|
+
// guard) and again in pre-push. Print it once per tree and gap: the first
|
|
257
|
+
// guard to warn records the tree plus the skip signature, later guards stay
|
|
258
|
+
// silent for the identical gap. State failures degrade to printing — a
|
|
259
|
+
// duplicate warning is noise, a swallowed one is a miss.
|
|
260
|
+
const INCOMPLETE_NOTICE_VERSION = 1;
|
|
261
|
+
function warnIncompleteOnce(repo, treeSha, scan) {
|
|
262
|
+
const signature = createHash('sha256')
|
|
263
|
+
.update(JSON.stringify([scan.stats?.skipped || {}, scan.stats?.skippedPaths || {}]))
|
|
264
|
+
.digest('hex');
|
|
265
|
+
if (treeSha) {
|
|
266
|
+
try {
|
|
267
|
+
const statePath = join(repo.stateDir, 'incomplete-notice.json');
|
|
268
|
+
let previous = null;
|
|
269
|
+
try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
|
|
270
|
+
if (previous?.version === INCOMPLETE_NOTICE_VERSION
|
|
271
|
+
&& previous.tree === treeSha
|
|
272
|
+
&& previous.signature === signature) return;
|
|
273
|
+
writeFileSync(statePath, JSON.stringify({ version: INCOMPLETE_NOTICE_VERSION, tree: treeSha, signature }));
|
|
274
|
+
} catch { /* fall through to printing */ }
|
|
275
|
+
}
|
|
276
|
+
process.stderr.write(incompleteMessage(scan, { blocking: false }));
|
|
277
|
+
}
|
|
278
|
+
|
|
252
279
|
// Most skip reasons tally files; the three below tally something else, so the
|
|
253
280
|
// noun travels with the reason instead of reading "local-pack-error=1 file".
|
|
254
281
|
function skipCountNoun(reason, count) {
|
|
@@ -444,11 +471,15 @@ function cmdPrecommit(args) {
|
|
|
444
471
|
if (reviews.length) process.stderr.write(human(reviews, tone()));
|
|
445
472
|
if (!scan.complete) {
|
|
446
473
|
// Strict stops on an unchecked remainder. Clean/compliance go ahead
|
|
447
|
-
// with a warning
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
451
|
-
|
|
474
|
+
// with a warning — printed once per tree across all of a commit's
|
|
475
|
+
// hooks (see warnIncompleteOnce). The clean marker stays unwritten
|
|
476
|
+
// so commit-msg does not skip its own tree scan.
|
|
477
|
+
if (profile === 'strict') {
|
|
478
|
+
process.stderr.write(incompleteMessage(scan));
|
|
479
|
+
return 31;
|
|
480
|
+
}
|
|
481
|
+
warnIncompleteOnce(repo, stagedTreeSha(repo), scan);
|
|
482
|
+
return 0;
|
|
452
483
|
}
|
|
453
484
|
// W5 marker dedup: record that this staged tree scanned clean so the
|
|
454
485
|
// upcoming commit-msg hook can skip its duplicate tree scan. The tree
|
|
@@ -616,7 +647,8 @@ function cmdCommitmsg(args) {
|
|
|
616
647
|
}
|
|
617
648
|
// exitCode passes an incomplete tree scan on frictionless profiles; the
|
|
618
649
|
// skip still names itself so the commit does not sail through silently.
|
|
619
|
-
|
|
650
|
+
// pre-commit already printed this exact gap for this tree — warn once.
|
|
651
|
+
if (!treeScan.complete) warnIncompleteOnce(repo, options.tree, treeScan);
|
|
620
652
|
}
|
|
621
653
|
if (dispatchHooksChanged(repo, profile)) return 20;
|
|
622
654
|
// Strict must fail closed on an incomplete scan: a block still wins (10),
|
|
@@ -626,7 +658,7 @@ function cmdCommitmsg(args) {
|
|
|
626
658
|
process.stderr.write(incompleteMessage(scan));
|
|
627
659
|
return findings.some((finding) => finding.decision === 'block') ? 10 : 31;
|
|
628
660
|
}
|
|
629
|
-
if (!scan.complete)
|
|
661
|
+
if (!scan.complete) warnIncompleteOnce(repo, options.tree, scan);
|
|
630
662
|
if (!findings.length) return 0;
|
|
631
663
|
const blocks = findings.filter((finding) => finding.decision === 'block');
|
|
632
664
|
if (profile === 'strict') {
|
|
@@ -659,6 +691,180 @@ function cmdCommitmsg(args) {
|
|
|
659
691
|
return remaining.some((finding) => finding.decision === 'block') ? 10 : 0;
|
|
660
692
|
}
|
|
661
693
|
|
|
694
|
+
// resolveIntroduced maps each proposed update to the commits it introduces,
|
|
695
|
+
// with the review contexts and local-authorship flags the scan needs. Shared
|
|
696
|
+
// by refcheck (local ref updates) and pushcheck (about-to-be-pushed refs).
|
|
697
|
+
// includeStagedContexts carries a staged review into the direct tip's scan;
|
|
698
|
+
// pushcheck leaves it off because a pushed commit is judged as an object, not
|
|
699
|
+
// against the live index it may never have passed through.
|
|
700
|
+
function resolveIntroduced(repo, updates, { includeStagedContexts = true } = {}) {
|
|
701
|
+
const contextsByCommit = new Map();
|
|
702
|
+
// A commit's message belongs to whoever wrote it. Attribution and marker
|
|
703
|
+
// rules police the text a local developer can edit, so they are scoped
|
|
704
|
+
// to commits authored here: an update that introduces exactly one new
|
|
705
|
+
// commit on top of a non-zero old tip (a plain commit, an --amend, or a
|
|
706
|
+
// local --no-ff merge of an already-gated branch). Anything else — a new
|
|
707
|
+
// branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
|
|
708
|
+
// history — imports other people's commit text the developer cannot
|
|
709
|
+
// change, and scanning it only blocks the review.
|
|
710
|
+
const localAuthorTips = new Set();
|
|
711
|
+
for (const update of updates) {
|
|
712
|
+
const introduced = introducedCommits(repo, [update]);
|
|
713
|
+
if (!/^0+$/.test(update.oldObjectId)
|
|
714
|
+
&& introduced.length === 1
|
|
715
|
+
&& introduced[0] === update.newObjectId) {
|
|
716
|
+
localAuthorTips.add(update.newObjectId);
|
|
717
|
+
}
|
|
718
|
+
for (const revision of introduced) {
|
|
719
|
+
const contexts = contextsByCommit.get(revision) || [];
|
|
720
|
+
contexts.push({
|
|
721
|
+
head: update.newObjectId,
|
|
722
|
+
storedTransition: revision,
|
|
723
|
+
scanTransition: revision,
|
|
724
|
+
});
|
|
725
|
+
// A staged review is bound to the exact old tip. It can be
|
|
726
|
+
// carried into the final full-snapshot scan only for the direct
|
|
727
|
+
// proposed tip whose parent is that old tip, never for an
|
|
728
|
+
// intermediate commit or a newly created branch ancestry.
|
|
729
|
+
if (includeStagedContexts && revision === update.newObjectId && !/^0+$/.test(update.oldObjectId)) {
|
|
730
|
+
const { parents } = commitParents(repo, revision);
|
|
731
|
+
if (parents.includes(update.oldObjectId)) {
|
|
732
|
+
contexts.push({
|
|
733
|
+
head: update.oldObjectId,
|
|
734
|
+
storedTransition: 'staged',
|
|
735
|
+
scanTransition: revision,
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
contextsByCommit.set(revision, contexts);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return [...contextsByCommit].map(([revision, reviewContexts]) => [
|
|
743
|
+
revision,
|
|
744
|
+
reviewContexts,
|
|
745
|
+
localAuthorTips.has(revision),
|
|
746
|
+
]);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// scanProposedCommits scans each introduced commit and returns the first
|
|
750
|
+
// non-zero verdict. rejectNote(revision) supplies the caller-specific veto
|
|
751
|
+
// line(s) printed after the findings; callers without a note pass null.
|
|
752
|
+
function scanProposedCommits(repo, commits, { rejectNote }) {
|
|
753
|
+
const limits = scanLimits();
|
|
754
|
+
for (const [revision, reviewContexts, authoredLocally] of commits) {
|
|
755
|
+
let scan;
|
|
756
|
+
try {
|
|
757
|
+
scan = scanGitTarget(repo, {
|
|
758
|
+
kind: 'commit',
|
|
759
|
+
revision,
|
|
760
|
+
reviewContexts,
|
|
761
|
+
policyMigrationContexts: reviewContexts,
|
|
762
|
+
limits,
|
|
763
|
+
messageScope: authoredLocally ? 'commit' : 'changes-only',
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
console.error(`aimhooman: cannot scan proposed commit ${revision}: ${error.message}`);
|
|
768
|
+
return expectedErrorCode(error);
|
|
769
|
+
}
|
|
770
|
+
emitDiagnostics(scan.diagnostics);
|
|
771
|
+
// The reference transaction is the final boundary --no-verify cannot
|
|
772
|
+
// skip, so an incomplete scan vetoes the update on every profile, even
|
|
773
|
+
// though earlier guards let frictionless profiles through with a
|
|
774
|
+
// warning. The single carve-out (a size-limit-only gap, see exitCode)
|
|
775
|
+
// warns here instead of vetoing.
|
|
776
|
+
const code = exitCode(scan.findings, scan.profile, scan.complete, {
|
|
777
|
+
failClosedIncomplete: true,
|
|
778
|
+
incompleteReasons: scan.incomplete_reasons,
|
|
779
|
+
});
|
|
780
|
+
if (code !== 0) {
|
|
781
|
+
process.stderr.write(human(scan.findings, tone()));
|
|
782
|
+
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
783
|
+
if (rejectNote) process.stderr.write(rejectNote(revision));
|
|
784
|
+
return code;
|
|
785
|
+
}
|
|
786
|
+
if (!scan.complete) {
|
|
787
|
+
// The commit's tree is the staged tree pre-commit already warned
|
|
788
|
+
// about, so this is usually the duplicate the once-guard swallows.
|
|
789
|
+
// rev-parse runs only on this rare path.
|
|
790
|
+
let treeSha = null;
|
|
791
|
+
try {
|
|
792
|
+
treeSha = execFileSync('git', ['rev-parse', `${revision}^{tree}`], {
|
|
793
|
+
cwd: repo.root,
|
|
794
|
+
encoding: 'utf8',
|
|
795
|
+
timeout: GIT_TIMEOUT_MS,
|
|
796
|
+
}).trim();
|
|
797
|
+
} catch { /* warn without dedup */ }
|
|
798
|
+
warnIncompleteOnce(repo, treeSha, scan);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return 0;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function cmdPushcheck(args) {
|
|
805
|
+
parseNoArguments(args);
|
|
806
|
+
let input;
|
|
807
|
+
try { input = readFileSync(0, 'utf8'); }
|
|
808
|
+
catch (error) {
|
|
809
|
+
console.error(`aimhooman: cannot read pre-push input: ${error.message}`);
|
|
810
|
+
return 30;
|
|
811
|
+
}
|
|
812
|
+
// pre-push lines: <local ref> <local oid> <remote ref> <remote oid>. The
|
|
813
|
+
// local ref name is irrelevant (it is the raw oid for a dangling push);
|
|
814
|
+
// what matters is which objects are about to be published. Deletions
|
|
815
|
+
// (zero local oid) carry nothing and were already filtered by the
|
|
816
|
+
// dispatcher, but belt-and-braces here since pushcheck can be driven
|
|
817
|
+
// directly.
|
|
818
|
+
const updates = [];
|
|
819
|
+
for (const line of input.split('\n').filter(Boolean)) {
|
|
820
|
+
const match = /^(\S+) (\S+) (\S+) (\S+)$/.exec(line);
|
|
821
|
+
if (!match) {
|
|
822
|
+
console.error('aimhooman: malformed pre-push input; push stopped');
|
|
823
|
+
return 30;
|
|
824
|
+
}
|
|
825
|
+
const [, , localOid, remoteRef, remoteOid] = match;
|
|
826
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(localOid)
|
|
827
|
+
|| !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(remoteOid)) {
|
|
828
|
+
console.error('aimhooman: malformed object ID in pre-push input; push stopped');
|
|
829
|
+
return 30;
|
|
830
|
+
}
|
|
831
|
+
if (/^0+$/.test(localOid)) continue;
|
|
832
|
+
updates.push({ oldObjectId: remoteOid, newObjectId: localOid, ref: remoteRef });
|
|
833
|
+
}
|
|
834
|
+
if (!updates.length) return 0;
|
|
835
|
+
const repo = tryRepo();
|
|
836
|
+
if (!repo) {
|
|
837
|
+
// Same bare carve-out as refcheck: a global dispatcher must stay
|
|
838
|
+
// transparent where no worktree/index policy boundary exists.
|
|
839
|
+
if (currentRepositoryIsBare()) return 0;
|
|
840
|
+
console.error('aimhooman: not a git repository');
|
|
841
|
+
return 30;
|
|
842
|
+
}
|
|
843
|
+
let commits;
|
|
844
|
+
try {
|
|
845
|
+
// Every pushed commit gets its message scanned (final `true`):
|
|
846
|
+
// publishing is the act being gated, and a rejected commit pushed by
|
|
847
|
+
// raw oid has no local branch to vouch for its message. Reachability
|
|
848
|
+
// trust stays with refs/heads/*, exactly as in refcheck — remote
|
|
849
|
+
// tracking refs arrive ungated via fetch.
|
|
850
|
+
commits = resolveIntroduced(repo, updates, { includeStagedContexts: false })
|
|
851
|
+
.map(([revision, reviewContexts]) => [revision, reviewContexts, true]);
|
|
852
|
+
}
|
|
853
|
+
catch (error) {
|
|
854
|
+
console.error(`aimhooman: cannot resolve pushed commits: ${error.message}`);
|
|
855
|
+
return 30;
|
|
856
|
+
}
|
|
857
|
+
const code = scanProposedCommits(repo, commits, {
|
|
858
|
+
rejectNote: (revision) =>
|
|
859
|
+
`aimhooman: push of commit ${revision} was rejected before any objects were sent\n`,
|
|
860
|
+
});
|
|
861
|
+
if (code !== 0) return code;
|
|
862
|
+
// Same last-veto-point integrity check as refcheck: if a chained
|
|
863
|
+
// predecessor removed any required dispatcher, every profile stops.
|
|
864
|
+
if (dispatchHooksChanged(repo, 'clean')) return 20;
|
|
865
|
+
return 0;
|
|
866
|
+
}
|
|
867
|
+
|
|
662
868
|
function cmdRefcheck(args) {
|
|
663
869
|
const { positionals } = parseArguments(args, {
|
|
664
870
|
minPositionals: 1,
|
|
@@ -734,96 +940,24 @@ function cmdRefcheck(args) {
|
|
|
734
940
|
|
|
735
941
|
let commits;
|
|
736
942
|
try {
|
|
737
|
-
|
|
738
|
-
// A commit's message belongs to whoever wrote it. Attribution and marker
|
|
739
|
-
// rules police the text a local developer can edit, so they are scoped
|
|
740
|
-
// to commits authored here: an update that introduces exactly one new
|
|
741
|
-
// commit on top of a non-zero old tip (a plain commit, an --amend, or a
|
|
742
|
-
// local --no-ff merge of an already-gated branch). Anything else — a new
|
|
743
|
-
// branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
|
|
744
|
-
// history — imports other people's commit text the developer cannot
|
|
745
|
-
// change, and scanning it only blocks the review.
|
|
746
|
-
const localAuthorTips = new Set();
|
|
747
|
-
for (const update of updates) {
|
|
748
|
-
const introduced = introducedCommits(repo, [update]);
|
|
749
|
-
if (!/^0+$/.test(update.oldObjectId)
|
|
750
|
-
&& introduced.length === 1
|
|
751
|
-
&& introduced[0] === update.newObjectId) {
|
|
752
|
-
localAuthorTips.add(update.newObjectId);
|
|
753
|
-
}
|
|
754
|
-
for (const revision of introduced) {
|
|
755
|
-
const contexts = contextsByCommit.get(revision) || [];
|
|
756
|
-
contexts.push({
|
|
757
|
-
head: update.newObjectId,
|
|
758
|
-
storedTransition: revision,
|
|
759
|
-
scanTransition: revision,
|
|
760
|
-
});
|
|
761
|
-
// A staged review is bound to the exact old tip. It can be
|
|
762
|
-
// carried into the final full-snapshot scan only for the direct
|
|
763
|
-
// proposed tip whose parent is that old tip, never for an
|
|
764
|
-
// intermediate commit or a newly created branch ancestry.
|
|
765
|
-
if (revision === update.newObjectId && !/^0+$/.test(update.oldObjectId)) {
|
|
766
|
-
const { parents } = commitParents(repo, revision);
|
|
767
|
-
if (parents.includes(update.oldObjectId)) {
|
|
768
|
-
contexts.push({
|
|
769
|
-
head: update.oldObjectId,
|
|
770
|
-
storedTransition: 'staged',
|
|
771
|
-
scanTransition: revision,
|
|
772
|
-
});
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
contextsByCommit.set(revision, contexts);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
|
|
779
|
-
revision,
|
|
780
|
-
reviewContexts,
|
|
781
|
-
localAuthorTips.has(revision),
|
|
782
|
-
]);
|
|
943
|
+
commits = resolveIntroduced(repo, updates);
|
|
783
944
|
}
|
|
784
945
|
catch (error) {
|
|
785
946
|
console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
|
|
786
947
|
return 30;
|
|
787
948
|
}
|
|
788
|
-
const
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
try {
|
|
792
|
-
scan = scanGitTarget(repo, {
|
|
793
|
-
kind: 'commit',
|
|
794
|
-
revision,
|
|
795
|
-
reviewContexts,
|
|
796
|
-
policyMigrationContexts: reviewContexts,
|
|
797
|
-
limits,
|
|
798
|
-
messageScope: authoredLocally ? 'commit' : 'changes-only',
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
catch (error) {
|
|
802
|
-
console.error(`aimhooman: cannot scan proposed commit ${revision}: ${error.message}`);
|
|
803
|
-
return expectedErrorCode(error);
|
|
804
|
-
}
|
|
805
|
-
emitDiagnostics(scan.diagnostics);
|
|
806
|
-
// The reference transaction is the final boundary --no-verify cannot
|
|
807
|
-
// skip, so an incomplete scan vetoes the update on every profile, even
|
|
808
|
-
// though earlier guards let frictionless profiles through with a
|
|
809
|
-
// warning.
|
|
810
|
-
const code = exitCode(scan.findings, scan.profile, scan.complete, { failClosedIncomplete: true });
|
|
811
|
-
if (code !== 0) {
|
|
812
|
-
process.stderr.write(human(scan.findings, tone()));
|
|
813
|
-
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
814
|
-
process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
|
|
949
|
+
const code = scanProposedCommits(repo, commits, {
|
|
950
|
+
rejectNote: (revision) =>
|
|
951
|
+
`aimhooman: proposed commit ${revision} was rejected before refs changed\n`
|
|
815
952
|
// The vetoed commit stays in the object store: the ref never
|
|
816
953
|
// moved, but the bytes — including whatever triggered the block —
|
|
817
954
|
// are still on disk. Whether gc collects it depends on other refs,
|
|
818
955
|
// so phrase the cleanup as conditional; rotating an exposed secret
|
|
819
956
|
// stays the operator's call either way.
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
return code;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
957
|
+
+ 'aimhooman: note: the rejected commit object remains in the local object store; '
|
|
958
|
+
+ "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n",
|
|
959
|
+
});
|
|
960
|
+
if (code !== 0) return code;
|
|
827
961
|
// The reference-transaction hook is the last veto point. If its chained
|
|
828
962
|
// predecessor removed any required dispatcher, every profile stops: there
|
|
829
963
|
// is no downstream guard that can repair the lost boundary safely.
|
package/docs/cli-reference.md
CHANGED
|
@@ -62,12 +62,16 @@ manager runs the check but registers no managed guard, so the agent hook still
|
|
|
62
62
|
refuses the commit. Remove the override before retrying, or accept that the
|
|
63
63
|
repository is unguarded and do not run `init` there.
|
|
64
64
|
|
|
65
|
-
Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`,
|
|
66
|
-
`reference-transaction`, and preserves an existing hook as a
|
|
65
|
+
Repository `init` installs `pre-commit`, `pre-merge-commit`, `commit-msg`,
|
|
66
|
+
`reference-transaction`, and `pre-push`, and preserves an existing hook as a
|
|
67
|
+
predecessor. For
|
|
67
68
|
`commit-msg`, aimhooman pins the would-be tree before the predecessor runs, so a later
|
|
68
69
|
index change cannot select a weaker policy. The prepared reference transaction is the
|
|
69
70
|
last local check for cherry-pick, revert, rebase, `git am`, fetch/worktree branch
|
|
70
|
-
creation, and direct branch-ref updates.
|
|
71
|
+
creation, and direct branch-ref updates. The `pre-push` hook scans every commit a push
|
|
72
|
+
would introduce — including one pushed by raw object ID, which moves no local branch
|
|
73
|
+
and therefore never passes the reference-transaction guard. Every profile stops if a
|
|
74
|
+
predecessor removes
|
|
71
75
|
a required guard; the first running dispatcher that detects the loss aborts the
|
|
72
76
|
operation.
|
|
73
77
|
|
|
@@ -89,9 +93,11 @@ change, and strict previews unless `--apply` is supplied.
|
|
|
89
93
|
`rule` (see [docs/policy.md](policy.md#overrides)).
|
|
90
94
|
|
|
91
95
|
On `clean` and `compliance`, `check`, `fix`, and the commit hooks warn and continue
|
|
92
|
-
when a scan is incomplete; `strict` exits 31 instead. The final ref
|
|
93
|
-
(`reference-transaction`)
|
|
94
|
-
|
|
96
|
+
when a scan is incomplete; `strict` exits 31 instead. The final ref guards
|
|
97
|
+
(`reference-transaction`, `pre-push`) stay fail-closed on every profile and still
|
|
98
|
+
veto what they cannot fully scan — with one carve-out: a scan whose only gap is a
|
|
99
|
+
file over the per-file size budget warns and continues on `clean`/`compliance`,
|
|
100
|
+
because path rules still covered that file.
|
|
95
101
|
|
|
96
102
|
Machine reports use `schema_version: 1` and include target policy identity, completeness,
|
|
97
103
|
scan statistics, commit and object metadata. Schemas are published in [`schemas/`](../schemas/).
|
|
@@ -111,4 +117,4 @@ History cleanup is deliberately outside aimhooman's scope.
|
|
|
111
117
|
| 11 | review-required on a non-clean profile |
|
|
112
118
|
| 20 | usage, configuration, or rule-pack error |
|
|
113
119
|
| 30 | Git or I/O error |
|
|
114
|
-
| 31 | scan incomplete on `strict
|
|
120
|
+
| 31 | scan incomplete on `strict`, or at the final ref guard for any gap other than a per-file size-limit on `clean`/`compliance` (those warn and continue) |
|
package/docs/faq.md
CHANGED
|
@@ -16,9 +16,11 @@ and treats an incomplete scan as a stop.
|
|
|
16
16
|
Git objects in batches. Text-oriented rules skip binary files. Size and total budgets
|
|
17
17
|
are visible in reports. Files over 2 MiB or a scan over 64 MiB make the scan
|
|
18
18
|
incomplete (binary files skip complete; oversized text is what trips it): direct
|
|
19
|
-
checks and the Git
|
|
20
|
-
`strict
|
|
21
|
-
|
|
19
|
+
checks and the Git guards warn on `clean`/`compliance` and stop on
|
|
20
|
+
`strict`. At the final ref guard the carve-out narrows: a scan whose only gap is
|
|
21
|
+
an oversized file (path rules still ran on it) warns and continues there too, while
|
|
22
|
+
any other gap — a blown total budget, an unreadable object — stops on every profile
|
|
23
|
+
rather than claiming that content was checked.
|
|
22
24
|
|
|
23
25
|
**Can the agent bypass it?** Any local tool can ultimately be bypassed by a user with
|
|
24
26
|
commit access. The agent guard fails closed on what it cannot prove: empty, invalid,
|
package/docs/secrets.md
CHANGED
|
@@ -60,7 +60,7 @@ three commits back still gets caught.
|
|
|
60
60
|
### Running it alongside aimhooman
|
|
61
61
|
|
|
62
62
|
The two tools do not overlap and do not fight. aimhooman owns Git's
|
|
63
|
-
`pre-commit`, `commit-msg`,
|
|
63
|
+
`pre-commit`, `commit-msg`, `reference-transaction`, and `pre-push` hooks; gitleaks runs
|
|
64
64
|
as a pre-commit.com hook or in CI, so both fire on the same commit without
|
|
65
65
|
sharing state. A commit carrying an AI session file and an API key gets
|
|
66
66
|
stopped for both reasons, each reported by the tool that owns it. Keep
|
package/package.json
CHANGED
package/src/githooks.mjs
CHANGED
|
@@ -26,7 +26,15 @@ const MANAGED = {
|
|
|
26
26
|
'pre-merge-commit': 'precommit',
|
|
27
27
|
'commit-msg': 'commitmsg "$1"',
|
|
28
28
|
'reference-transaction': 'refcheck "$1"',
|
|
29
|
+
'pre-push': 'pushcheck',
|
|
29
30
|
};
|
|
31
|
+
// Literal anchors shared by every built-in message-kind rule. The commit-msg
|
|
32
|
+
// dispatcher greps the message for this ERE before paying a Node spawn: a
|
|
33
|
+
// message with no anchor cannot match any attribution rule, so skipping the
|
|
34
|
+
// spawn cannot let attribution through. Pinned by a test that walks
|
|
35
|
+
// rules/attribution.json — a new message rule must carry one of these anchors
|
|
36
|
+
// or the fast path would silently bypass it.
|
|
37
|
+
export const MESSAGE_ANCHOR_ERE = 'anthropic|openai|copilot|claude|chatgpt|gpt|codex|cursor|\\[bot\\]|bot@users';
|
|
30
38
|
const LEGACY_MANAGED = {
|
|
31
39
|
'pre-commit': 'precommit',
|
|
32
40
|
'commit-msg': 'commitmsg "$1"',
|
|
@@ -664,7 +672,57 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
664
672
|
}
|
|
665
673
|
`
|
|
666
674
|
: '';
|
|
667
|
-
|
|
675
|
+
// Fast paths that skip the Node spawn for work the guard can prove is a
|
|
676
|
+
// no-op. Both sit after the chained-hook call, so a chained predecessor
|
|
677
|
+
// always runs, and both fall through to the full guard on any doubt (grep
|
|
678
|
+
// or git erroring included).
|
|
679
|
+
// - commit-msg: every built-in message rule carries one of the
|
|
680
|
+
// MESSAGE_ANCHOR_ERE literals, so a message with no anchor match has
|
|
681
|
+
// nothing to strip and no finding to raise. Local packs can declare
|
|
682
|
+
// their own message rules, so their presence disables the fast path.
|
|
683
|
+
// The tree snapshot stays at the top of the script on purpose: it must
|
|
684
|
+
// capture the would-be tree BEFORE a chained predecessor can stage a
|
|
685
|
+
// policy change into the live index. The fast path also yields when the
|
|
686
|
+
// pre-commit dispatcher itself is gone: its "the tree was already
|
|
687
|
+
// scanned" premise no longer holds, so commit-msg pays the spawn and
|
|
688
|
+
// scans the captured tree itself.
|
|
689
|
+
// - pre-commit: an empty index holds nothing to scan (`--allow-empty`,
|
|
690
|
+
// `commit -m` with no staged change). git diff exits 1 on staged changes
|
|
691
|
+
// and >1 on read errors; only a proven-empty index skips.
|
|
692
|
+
// - pre-push: deletion lines (zero local oid) carry nothing to scan, so
|
|
693
|
+
// they are dropped before the spawn; a push of only deletions never
|
|
694
|
+
// starts Node. The chained predecessor still saw the full unfiltered
|
|
695
|
+
// input above. The filter is grep, not a shell `case` loop: case
|
|
696
|
+
// patterns break inside $( ) on older POSIX shells (bash 3.2 /bin/sh).
|
|
697
|
+
// grep exit 1 means "only deletions" — not an error; anything worse
|
|
698
|
+
// aborts the push rather than skipping the guard.
|
|
699
|
+
const preAimFilter = name === 'commit-msg'
|
|
700
|
+
? `AIMHOOMAN_COMMON=$(PATH="$AIMHOOMAN_PATH" git rev-parse --git-common-dir 2>/dev/null) || AIMHOOMAN_COMMON=
|
|
701
|
+
if [ ! -x "$(PATH="$AIMHOOMAN_PATH" dirname "$0")/pre-commit" ]; then
|
|
702
|
+
:
|
|
703
|
+
elif [ -n "$AIMHOOMAN_COMMON" ] && PATH="$AIMHOOMAN_PATH" ls "$AIMHOOMAN_COMMON/aimhooman/rules/"*.json >/dev/null 2>&1; then
|
|
704
|
+
:
|
|
705
|
+
else
|
|
706
|
+
PATH="$AIMHOOMAN_PATH" grep -Eiq -- ${shq(MESSAGE_ANCHOR_ERE)} "$1" 2>/dev/null
|
|
707
|
+
case $? in
|
|
708
|
+
1) exit 0 ;;
|
|
709
|
+
esac
|
|
710
|
+
fi
|
|
711
|
+
`
|
|
712
|
+
: name === 'pre-commit'
|
|
713
|
+
? `PATH="$AIMHOOMAN_PATH" git diff --cached --quiet
|
|
714
|
+
case $? in
|
|
715
|
+
0) exit 0 ;;
|
|
716
|
+
esac
|
|
717
|
+
`
|
|
718
|
+
: name === 'pre-push'
|
|
719
|
+
? `AIMHOOMAN_PUSH_UPDATES=$(
|
|
720
|
+
printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | PATH="$AIMHOOMAN_PATH" grep -v '^[^ ]* 0* ' || [ $? -eq 1 ]
|
|
721
|
+
) || exit $?
|
|
722
|
+
[ -n "$AIMHOOMAN_PUSH_UPDATES" ] || exit 0
|
|
723
|
+
`
|
|
724
|
+
: '';
|
|
725
|
+
const captureTransaction = name === 'reference-transaction' || name === 'pre-push'
|
|
668
726
|
? `AIMHOOMAN_REF_UPDATES=$(
|
|
669
727
|
while IFS= read -r AIMHOOMAN_REF_UPDATE || [ -n "$AIMHOOMAN_REF_UPDATE" ]; do
|
|
670
728
|
printf '%s\\n' "$AIMHOOMAN_REF_UPDATE" || exit $?
|
|
@@ -682,7 +740,7 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
682
740
|
// shell (/bin/sh -p), so its shebang is honoured only for sh-compatible
|
|
683
741
|
// scripts; bash-only predecessors are out of scope. exit inside the
|
|
684
742
|
// predecessor exits the subshell and propagates via || exit $?.
|
|
685
|
-
const chainedInvocation = name === 'reference-transaction'
|
|
743
|
+
const chainedInvocation = name === 'reference-transaction' || name === 'pre-push'
|
|
686
744
|
? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | ( . "$CHAINED" ) || exit $?`
|
|
687
745
|
: ` ( . "$CHAINED" ) || exit $?`;
|
|
688
746
|
const aimCommand = name === 'commit-msg'
|
|
@@ -692,7 +750,9 @@ function hookScript(name, cmd, cliPath, chainedPath) {
|
|
|
692
750
|
: cmd;
|
|
693
751
|
const aimInvocation = name === 'reference-transaction'
|
|
694
752
|
? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
|
|
695
|
-
:
|
|
753
|
+
: name === 'pre-push'
|
|
754
|
+
? `printf '%s\\n' "$AIMHOOMAN_PUSH_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
|
|
755
|
+
: `run_aimhooman ${aimCommand} || exit $?`;
|
|
696
756
|
// committed/aborted fire only after refs are locked in, and refcheck can do
|
|
697
757
|
// nothing but return 0 for them (see cmdRefcheck). Short-circuit in the shell
|
|
698
758
|
// so an ordinary commit no longer pays a Node cold start for the committed
|
|
@@ -711,7 +771,7 @@ case "$1" in prepared)
|
|
|
711
771
|
case "$AIMHOOMAN_REF_UPDATES" in
|
|
712
772
|
*refs/heads/*|*" HEAD"*) ;;
|
|
713
773
|
*)
|
|
714
|
-
for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction; do
|
|
774
|
+
for AIMHOOMAN_GUARD in pre-commit pre-merge-commit commit-msg reference-transaction pre-push; do
|
|
715
775
|
[ -x "$(dirname "$0")/$AIMHOOMAN_GUARD" ] || {
|
|
716
776
|
echo "aimhooman: required Git guards changed while reference-transaction was running; $AIMHOOMAN_GUARD is unavailable. The operation was stopped; run 'aimhooman init' and retry." >&2
|
|
717
777
|
exit 20
|
|
@@ -779,7 +839,7 @@ fi
|
|
|
779
839
|
if [ -x "$CHAINED" ]; then
|
|
780
840
|
${chainedInvocation}
|
|
781
841
|
fi
|
|
782
|
-
${phaseShortCircuit}${aimInvocation}
|
|
842
|
+
${phaseShortCircuit}${preAimFilter}${aimInvocation}
|
|
783
843
|
`;
|
|
784
844
|
const fingerprint = hookFingerprint(template);
|
|
785
845
|
return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);
|
package/src/report.mjs
CHANGED
|
@@ -9,13 +9,17 @@ const HUMAN_FINDING_CAP = 20;
|
|
|
9
9
|
|
|
10
10
|
export function human(findings, tone) {
|
|
11
11
|
if (!findings.length) return '';
|
|
12
|
-
let out = tone === 'professional' ? '\n' : '\nnot very hooman.\n\n';
|
|
13
12
|
let block = 0;
|
|
14
13
|
let review = 0;
|
|
15
14
|
for (const f of findings) {
|
|
16
15
|
if (f.decision === 'block') block += 1;
|
|
17
16
|
else if (f.decision === 'review') review += 1;
|
|
18
17
|
}
|
|
18
|
+
// The banner means "aimhooman stopped or reshaped your operation". A pure
|
|
19
|
+
// advisory (review findings only, the commit proceeds untouched) prints
|
|
20
|
+
// without it — otherwise the banner fires on allowed work and stops
|
|
21
|
+
// meaning anything when a real block lands.
|
|
22
|
+
let out = tone === 'professional' || block === 0 ? '\n' : '\nnot very hooman.\n\n';
|
|
19
23
|
// Render findings in order up to the cap, then collapse the rest into one
|
|
20
24
|
// summary line. Keeps the first findings fully visible (the actionable ones)
|
|
21
25
|
// while bounding stderr for repeated-rule scans.
|
|
@@ -92,8 +96,13 @@ function isSensitive(finding) {
|
|
|
92
96
|
// final ref boundary (failClosedIncomplete). Frictionless profiles warn and
|
|
93
97
|
// continue — the reference-transaction guard still scans introduced commits
|
|
94
98
|
// with failClosedIncomplete set, so the skipped content is checked before any
|
|
95
|
-
// ref moves.
|
|
96
|
-
|
|
99
|
+
// ref moves. One exception at that final boundary: a scan whose only gap is
|
|
100
|
+
// 'size-limit' (a file over maxFileBytes went unscanned for content rules —
|
|
101
|
+
// path rules still ran) warns and continues on frictionless profiles. Blocking
|
|
102
|
+
// the whole commit there cost developers a legit large file for a marker risk
|
|
103
|
+
// that clean/compliance treat as advisory anyway. Strict stays fail-closed on
|
|
104
|
+
// any gap, size-limit included.
|
|
105
|
+
export function exitCode(findings, profile, complete = true, { failClosedIncomplete = false, incompleteReasons = [] } = {}) {
|
|
97
106
|
let block = false;
|
|
98
107
|
let review = false;
|
|
99
108
|
for (const f of findings) {
|
|
@@ -101,7 +110,11 @@ export function exitCode(findings, profile, complete = true, { failClosedIncompl
|
|
|
101
110
|
else if (f.decision === 'review') review = true;
|
|
102
111
|
}
|
|
103
112
|
if (block) return 10;
|
|
104
|
-
if (!complete && (profile === 'strict' || failClosedIncomplete))
|
|
113
|
+
if (!complete && (profile === 'strict' || failClosedIncomplete)) {
|
|
114
|
+
const sizeLimitOnly = incompleteReasons.length > 0
|
|
115
|
+
&& incompleteReasons.every((reason) => reason === 'size-limit');
|
|
116
|
+
if (profile === 'strict' || !sizeLimitOnly) return 31;
|
|
117
|
+
}
|
|
105
118
|
if (review && findings.some((finding) => (finding.scanProfile || profile) !== 'clean')) return 11;
|
|
106
119
|
return 0;
|
|
107
120
|
}
|
package/src/scan-session.mjs
CHANGED
|
@@ -7,6 +7,17 @@ export const DEFAULT_SCAN_LIMITS = Object.freeze({
|
|
|
7
7
|
maxFindings: 1000,
|
|
8
8
|
});
|
|
9
9
|
|
|
10
|
+
// Skip reasons that mean a rule never ran on its input, so the scan cannot
|
|
11
|
+
// claim full coverage. What that costs is the caller's call: strict stops on
|
|
12
|
+
// any of them; the final ref guard stops on all but 'size-limit' (see
|
|
13
|
+
// exitCode in report.mjs). 'local-pack-error' never originates here — it is
|
|
14
|
+
// tallied by scan-target — but it belongs to the same contract.
|
|
15
|
+
export const FATAL_SKIP_REASONS = new Set([
|
|
16
|
+
'metadata-unavailable', 'size-limit', 'total-byte-limit',
|
|
17
|
+
'missing-object', 'unexpected-object', 'finding-limit',
|
|
18
|
+
'local-input-limit', 'local-pack-error',
|
|
19
|
+
]);
|
|
20
|
+
|
|
10
21
|
export function scanEntries(repo, engine, entries, options = {}) {
|
|
11
22
|
const limits = { ...DEFAULT_SCAN_LIMITS, ...options };
|
|
12
23
|
const stats = {
|
|
@@ -131,12 +142,9 @@ export function scanEntries(repo, engine, entries, options = {}) {
|
|
|
131
142
|
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
132
143
|
}
|
|
133
144
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
]);
|
|
138
|
-
const complete = !Object.keys(stats.skipped).some((reason) => incompleteReasons.has(reason));
|
|
139
|
-
return { findings, complete, stats };
|
|
145
|
+
const complete = !Object.keys(stats.skipped).some((reason) => FATAL_SKIP_REASONS.has(reason));
|
|
146
|
+
const incompleteReasons = Object.keys(stats.skipped).filter((reason) => FATAL_SKIP_REASONS.has(reason));
|
|
147
|
+
return { findings, complete, incompleteReasons, stats };
|
|
140
148
|
}
|
|
141
149
|
|
|
142
150
|
function readObjects(repo, objectIds, expectedBytes = 0) {
|
package/src/scan-target.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
unmergedPaths,
|
|
9
9
|
} from './gitx.mjs';
|
|
10
10
|
import { commitChanges, commitMessage, commitSnapshot, historyRange } from './history-scan.mjs';
|
|
11
|
-
import { DEFAULT_SCAN_LIMITS, scanEntries } from './scan-session.mjs';
|
|
11
|
+
import { DEFAULT_SCAN_LIMITS, FATAL_SKIP_REASONS, scanEntries } from './scan-session.mjs';
|
|
12
12
|
import { loadRulesWithDiagnostics } from './rules.mjs';
|
|
13
13
|
|
|
14
14
|
// A tiebreak for the one profile a range report has to name, not a strength
|
|
@@ -75,7 +75,7 @@ export function scanMessage(repo, text, options = {}) {
|
|
|
75
75
|
|
|
76
76
|
function scanIndex(repo, options) {
|
|
77
77
|
const kind = options.kind || 'staged';
|
|
78
|
-
const { rawPolicy, floor, policy, head, acknowledged } = resolveStagedPolicy(repo, options.explicitProfile);
|
|
78
|
+
const { rawPolicy, floor, policy, head, acknowledged, baselineObjectId } = resolveStagedPolicy(repo, options.explicitProfile);
|
|
79
79
|
const loaded = engineForPolicy(repo, policy, options.overrideHead || head);
|
|
80
80
|
const accumulator = createAccumulator(options.limits);
|
|
81
81
|
accumulator.addSkipped(loaded.skipped);
|
|
@@ -96,11 +96,14 @@ function scanIndex(repo, options) {
|
|
|
96
96
|
});
|
|
97
97
|
// A reviewed-path override can suppress a matching path rule, but it
|
|
98
98
|
// cannot turn an unresolved index into a complete commit snapshot.
|
|
99
|
-
accumulator.markIncomplete();
|
|
99
|
+
accumulator.markIncomplete('index-unresolved');
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
103
|
-
accumulator.add([protectedPolicyFinding(rawPolicy, policy, null
|
|
103
|
+
accumulator.add([protectedPolicyFinding(rawPolicy, policy, null, {
|
|
104
|
+
oldObjectId: baselineObjectId,
|
|
105
|
+
transition: 'staged',
|
|
106
|
+
})]);
|
|
104
107
|
}
|
|
105
108
|
scanEntryGroup(repo, loaded.engine, entries, policy, accumulator, {
|
|
106
109
|
allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
|
|
@@ -140,6 +143,7 @@ export function resolveStagedPolicy(repo, explicitProfile) {
|
|
|
140
143
|
floor,
|
|
141
144
|
head,
|
|
142
145
|
acknowledged,
|
|
146
|
+
baselineObjectId: baselineStrict ? baseline.policy_object_id : null,
|
|
143
147
|
policy: effectivePolicy(
|
|
144
148
|
rawPolicy,
|
|
145
149
|
explicitProfile,
|
|
@@ -209,11 +213,14 @@ function scanCommit(repo, options) {
|
|
|
209
213
|
throw new GitRevisionError(options.revision, message);
|
|
210
214
|
}
|
|
211
215
|
diagnostics.push({ level: 'warning', message });
|
|
212
|
-
accumulator.markIncomplete();
|
|
216
|
+
accumulator.markIncomplete('shallow-repository');
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
216
|
-
accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot
|
|
220
|
+
accumulator.add([protectedPolicyFinding(rawPolicy, policy, snapshot, {
|
|
221
|
+
oldObjectId: strictParentPolicies[0]?.policy_object_id ?? null,
|
|
222
|
+
transition: snapshot.commit,
|
|
223
|
+
})]);
|
|
217
224
|
}
|
|
218
225
|
// A commit is judged by what it changes, not by the whole tree it inherits.
|
|
219
226
|
// Scanning snapshot.entries (a full `ls-tree`) re-applied path rules to
|
|
@@ -284,7 +291,7 @@ function scanRange(repo, options) {
|
|
|
284
291
|
throw new GitRevisionError(options.range, message);
|
|
285
292
|
}
|
|
286
293
|
diagnostics.push({ level: 'warning', message });
|
|
287
|
-
accumulator.markIncomplete();
|
|
294
|
+
accumulator.markIncomplete('shallow-repository');
|
|
288
295
|
}
|
|
289
296
|
const policies = [];
|
|
290
297
|
let usedStrictFloor = false;
|
|
@@ -336,7 +343,10 @@ function scanRange(repo, options) {
|
|
|
336
343
|
diagnostics.push(...loaded.diagnostics);
|
|
337
344
|
|
|
338
345
|
if (floor && !isVersionedStrict(rawPolicy)) {
|
|
339
|
-
accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit
|
|
346
|
+
accumulator.add([protectedPolicyFinding(rawPolicy, policy, commit, {
|
|
347
|
+
oldObjectId: [...parentStrictObjects][0] ?? null,
|
|
348
|
+
transition: commit.commit,
|
|
349
|
+
})]);
|
|
340
350
|
}
|
|
341
351
|
scanEntryGroup(repo, loaded.engine, changes.entries, policy, accumulator, {
|
|
342
352
|
allowMissingPolicy: acknowledged && !rawPolicy.policy_object_id,
|
|
@@ -556,12 +566,27 @@ function isVersionedStrict(policy) {
|
|
|
556
566
|
));
|
|
557
567
|
}
|
|
558
568
|
|
|
559
|
-
function protectedPolicyFinding(rawPolicy, policy, entry) {
|
|
569
|
+
function protectedPolicyFinding(rawPolicy, policy, entry, migration = {}) {
|
|
560
570
|
const removed = !rawPolicy.policy_object_id;
|
|
561
571
|
const reason = removed
|
|
562
572
|
? 'A versioned strict project policy cannot be deleted or renamed away without a bound review acknowledgment.'
|
|
563
573
|
: 'A versioned strict project policy cannot be downgraded without a bound review acknowledgment.';
|
|
564
|
-
|
|
574
|
+
// Name the exact command, oids included: "use the reviewed migration
|
|
575
|
+
// command" sent developers to the docs for a three-flag incantation they
|
|
576
|
+
// could not guess (field-test rerun). The staged form binds to the live
|
|
577
|
+
// index; the transition form binds to the commit that carries the change.
|
|
578
|
+
const remediation = ['Restore the strict policy, or unstage the change to keep the current baseline.'];
|
|
579
|
+
if (migration.oldObjectId && migration.transition) {
|
|
580
|
+
const newOid = rawPolicy.policy_object_id ?? 'missing';
|
|
581
|
+
const form = migration.transition === 'staged'
|
|
582
|
+
? '--staged'
|
|
583
|
+
: `--transition ${migration.transition}`;
|
|
584
|
+
remediation.push(
|
|
585
|
+
`or acknowledge the migration: aimhooman policy-review --head HEAD ${form} --old ${migration.oldObjectId} --new ${newOid} --reason "reviewed policy migration"`
|
|
586
|
+
);
|
|
587
|
+
} else {
|
|
588
|
+
remediation.push('or acknowledge the migration with a reviewed aimhooman policy-review invocation.');
|
|
589
|
+
}
|
|
565
590
|
return decorate({
|
|
566
591
|
ruleId: 'generic.project-policy',
|
|
567
592
|
ruleVersion: 1,
|
|
@@ -637,10 +662,10 @@ function rangeReportPolicy(basePolicy, policies, usedStrictFloor, explicitProfil
|
|
|
637
662
|
};
|
|
638
663
|
}
|
|
639
664
|
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
665
|
+
// The canonical set of fatal skip reasons lives in scan-session.mjs
|
|
666
|
+
// (FATAL_SKIP_REASONS); the accumulator records which of them fired so callers
|
|
667
|
+
// can tell "only an oversized file went unscanned" apart from gaps that must
|
|
668
|
+
// stay fail-closed.
|
|
644
669
|
function createAccumulator(limits = {}) {
|
|
645
670
|
const effectiveLimits = { ...DEFAULT_SCAN_LIMITS, ...limits };
|
|
646
671
|
const findings = [];
|
|
@@ -657,6 +682,12 @@ function createAccumulator(limits = {}) {
|
|
|
657
682
|
skippedPaths: {},
|
|
658
683
|
};
|
|
659
684
|
let complete = true;
|
|
685
|
+
const incompleteReasons = new Set();
|
|
686
|
+
|
|
687
|
+
function flagIncomplete(reason) {
|
|
688
|
+
complete = false;
|
|
689
|
+
incompleteReasons.add(reason);
|
|
690
|
+
}
|
|
660
691
|
|
|
661
692
|
function add(values, total = values.length) {
|
|
662
693
|
stats.findings_total += total;
|
|
@@ -667,7 +698,7 @@ function createAccumulator(limits = {}) {
|
|
|
667
698
|
continue;
|
|
668
699
|
}
|
|
669
700
|
if (findings.length >= effectiveLimits.maxFindings) {
|
|
670
|
-
|
|
701
|
+
flagIncomplete('finding-limit');
|
|
671
702
|
increment(stats.skipped, 'finding-limit');
|
|
672
703
|
continue;
|
|
673
704
|
}
|
|
@@ -675,7 +706,7 @@ function createAccumulator(limits = {}) {
|
|
|
675
706
|
findings.push(finding);
|
|
676
707
|
}
|
|
677
708
|
stats.findings_returned = findings.length;
|
|
678
|
-
if (total > values.length)
|
|
709
|
+
if (total > values.length) flagIncomplete('finding-limit');
|
|
679
710
|
}
|
|
680
711
|
|
|
681
712
|
return {
|
|
@@ -698,16 +729,17 @@ function createAccumulator(limits = {}) {
|
|
|
698
729
|
if (stats.skippedPaths[reason].length < 10) stats.skippedPaths[reason].push(entry);
|
|
699
730
|
}
|
|
700
731
|
}
|
|
732
|
+
for (const reason of scanned.incompleteReasons || []) incompleteReasons.add(reason);
|
|
701
733
|
if (!scanned.complete) complete = false;
|
|
702
734
|
},
|
|
703
735
|
addSkipped(skipped = {}) {
|
|
704
736
|
for (const [reason, count] of Object.entries(skipped)) {
|
|
705
737
|
stats.skipped[reason] = (stats.skipped[reason] || 0) + count;
|
|
706
|
-
if (
|
|
738
|
+
if (FATAL_SKIP_REASONS.has(reason)) flagIncomplete(reason);
|
|
707
739
|
}
|
|
708
740
|
},
|
|
709
|
-
markIncomplete() {
|
|
710
|
-
|
|
741
|
+
markIncomplete(reason = 'target-unverifiable') {
|
|
742
|
+
flagIncomplete(reason);
|
|
711
743
|
},
|
|
712
744
|
remaining() {
|
|
713
745
|
return Math.max(0, effectiveLimits.maxFindings - findings.length);
|
|
@@ -718,6 +750,9 @@ function createAccumulator(limits = {}) {
|
|
|
718
750
|
isComplete() {
|
|
719
751
|
return complete;
|
|
720
752
|
},
|
|
753
|
+
get incompleteReasons() {
|
|
754
|
+
return [...incompleteReasons];
|
|
755
|
+
},
|
|
721
756
|
};
|
|
722
757
|
}
|
|
723
758
|
|
|
@@ -731,6 +766,7 @@ function result({ target, policy, accumulator, diagnostics, messageScanned, comm
|
|
|
731
766
|
policy_enforced_object_ids: policy.enforced_policy_object_ids,
|
|
732
767
|
} : {}),
|
|
733
768
|
complete: accumulator.isComplete(),
|
|
769
|
+
incomplete_reasons: accumulator.incompleteReasons,
|
|
734
770
|
stats: accumulator.stats,
|
|
735
771
|
findings: accumulator.findings,
|
|
736
772
|
diagnostics,
|