@rmyndharis/aimhooman 0.1.1 → 0.1.3
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/.agents/rules/aimhooman.md +5 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/aimhooman.md +5 -3
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +5 -3
- package/.github/copilot-instructions.md +5 -3
- package/.kiro/steering/aimhooman.md +5 -3
- package/.windsurf/rules/aimhooman.md +5 -3
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +177 -1
- package/CONTRIBUTING.md +36 -3
- package/GEMINI.md +5 -3
- package/README.md +18 -8
- package/bin/aimhooman.mjs +163 -49
- package/docs/design/frictionless-enforcement.md +28 -0
- package/package.json +1 -1
- package/rules/attribution.json +8 -8
- package/rules/markers.json +31 -5
- package/rules/paths.json +60 -33
- package/rules/secrets.json +2 -1
- package/skills/aimhooman/SKILL.md +5 -3
- package/src/atomic-write.mjs +18 -1
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +113 -19
- package/src/gitx.mjs +65 -270
- package/src/history-scan.mjs +8 -1
- package/src/hook.mjs +424 -47
- package/src/scan-session.mjs +11 -4
- package/src/scan-target.mjs +29 -3
package/bin/aimhooman.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
3
3
|
import { chmodSync, lstatSync, readFileSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
6
7
|
import { newEngineWithDiagnostics } from '../src/scan.mjs';
|
|
7
8
|
import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
8
9
|
import {
|
|
@@ -19,7 +20,7 @@ import {
|
|
|
19
20
|
} from '../src/gitx.mjs';
|
|
20
21
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
21
22
|
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
22
|
-
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
23
|
+
import { hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
23
24
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
24
25
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
25
26
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
@@ -37,8 +38,7 @@ const LIFECYCLE_LOCK_OPTIONS = { retries: 1000 };
|
|
|
37
38
|
function tryRepo() {
|
|
38
39
|
try {
|
|
39
40
|
return openRepo();
|
|
40
|
-
} catch
|
|
41
|
-
if (error?.name === 'StateMigrationError') throw error;
|
|
41
|
+
} catch {
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
@@ -48,6 +48,7 @@ function currentRepositoryIsBare() {
|
|
|
48
48
|
return execFileSync('git', ['rev-parse', '--is-bare-repository'], {
|
|
49
49
|
encoding: 'utf8',
|
|
50
50
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
51
|
+
timeout: GIT_TIMEOUT_MS,
|
|
51
52
|
}).trim() === 'true';
|
|
52
53
|
} catch {
|
|
53
54
|
return false;
|
|
@@ -58,6 +59,42 @@ function tone() {
|
|
|
58
59
|
return process.env.AIMHOOMAN_TONE === 'professional' || process.env.CI ? 'professional' : 'playful';
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// The per-scan budget sizes the `cat-file --batch` read buffer, so a value that
|
|
63
|
+
// travelled with a clone would let a hostile repository drive the verifier out
|
|
64
|
+
// of memory instead of being rejected by it. These stay in the environment for
|
|
65
|
+
// that reason, never in .aimhooman.json, and a raise is capped for the same one.
|
|
66
|
+
// Lowering needs no ceiling: a smaller budget only skips more, and a skip is
|
|
67
|
+
// already an incomplete scan, which fails closed at 31.
|
|
68
|
+
const MAX_SCAN_LIMIT_BYTES = 1 << 30;
|
|
69
|
+
|
|
70
|
+
// Crossing a scan budget marks the scan incomplete on every profile, and an
|
|
71
|
+
// incomplete scan is a ref update the reference-transaction hook refuses — the
|
|
72
|
+
// one boundary --no-verify does not skip. A repository tracking a lockfile, an
|
|
73
|
+
// image or a vendored bundle over the default therefore could not commit at all,
|
|
74
|
+
// and "reduce the target or limits and retry" named something no flag, schema
|
|
75
|
+
// field or config key could do. This is that knob.
|
|
76
|
+
function scanLimits() {
|
|
77
|
+
const limits = {};
|
|
78
|
+
for (const [key, name] of [
|
|
79
|
+
['maxFileBytes', 'AIMHOOMAN_MAX_FILE_BYTES'],
|
|
80
|
+
['maxTotalBytes', 'AIMHOOMAN_MAX_TOTAL_BYTES'],
|
|
81
|
+
]) {
|
|
82
|
+
const raw = process.env[name];
|
|
83
|
+
if (raw === undefined || raw === '') continue;
|
|
84
|
+
if (!/^\d+$/.test(raw)) {
|
|
85
|
+
throw new ArgumentError(`${name} must be a whole number of bytes; got "${visible(raw)}"`);
|
|
86
|
+
}
|
|
87
|
+
const value = Number(raw);
|
|
88
|
+
if (value < 1 || value > MAX_SCAN_LIMIT_BYTES) {
|
|
89
|
+
throw new ArgumentError(
|
|
90
|
+
`${name} must be between 1 and ${MAX_SCAN_LIMIT_BYTES} bytes; got ${raw}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
limits[key] = value;
|
|
94
|
+
}
|
|
95
|
+
return limits;
|
|
96
|
+
}
|
|
97
|
+
|
|
61
98
|
function configuredEngine(profile, repo) {
|
|
62
99
|
const { engine, errors } = newEngineWithDiagnostics(profile, repo?.stateDir);
|
|
63
100
|
if (errors.length && profile === 'strict') {
|
|
@@ -159,10 +196,23 @@ function emitDiagnostics(diagnostics = []) {
|
|
|
159
196
|
}
|
|
160
197
|
|
|
161
198
|
function incompleteMessage(scan) {
|
|
162
|
-
const
|
|
199
|
+
const reasons = scan.stats?.skipped || {};
|
|
200
|
+
const skipped = Object.entries(reasons)
|
|
163
201
|
.map(([reason, count]) => `${reason}=${count}`)
|
|
164
202
|
.join(', ');
|
|
165
|
-
|
|
203
|
+
// Every other reason is a size or budget the caller can shrink. A pack that
|
|
204
|
+
// will not compile is not, and the warning above already names the file and
|
|
205
|
+
// the error, so point at that instead of misdirecting to the limits. When a
|
|
206
|
+
// byte budget is what stopped the scan, name the budget: the caller whose own
|
|
207
|
+
// tree outgrew it needs to raise one, and "reduce the limits" sends them the
|
|
208
|
+
// wrong way down a road they cannot leave.
|
|
209
|
+
const budgeted = reasons['size-limit'] || reasons['total-byte-limit'];
|
|
210
|
+
const hint = reasons['local-pack-error']
|
|
211
|
+
? 'fix the reported rule pack and retry'
|
|
212
|
+
: budgeted
|
|
213
|
+
? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
|
|
214
|
+
: 'reduce the target or limits and retry';
|
|
215
|
+
return `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
|
|
166
216
|
}
|
|
167
217
|
|
|
168
218
|
function snapshotFile(path) {
|
|
@@ -193,6 +243,7 @@ function gitConfigAtScope(root, scope, key) {
|
|
|
193
243
|
cwd: root,
|
|
194
244
|
encoding: 'utf8',
|
|
195
245
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
246
|
+
timeout: GIT_TIMEOUT_MS,
|
|
196
247
|
}).trim();
|
|
197
248
|
} catch {
|
|
198
249
|
return '';
|
|
@@ -200,7 +251,7 @@ function gitConfigAtScope(root, scope, key) {
|
|
|
200
251
|
}
|
|
201
252
|
|
|
202
253
|
function gitVersion() {
|
|
203
|
-
try { return execFileSync('git', ['--version'], { encoding: 'utf8' }).trim(); }
|
|
254
|
+
try { return execFileSync('git', ['--version'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim(); }
|
|
204
255
|
catch { return 'Git unavailable'; }
|
|
205
256
|
}
|
|
206
257
|
|
|
@@ -247,9 +298,10 @@ function cmdPrecommit(args) {
|
|
|
247
298
|
parseNoArguments(args);
|
|
248
299
|
const repo = tryRepo();
|
|
249
300
|
if (!repo) { console.error('aimhooman: not a git repository'); return 30; }
|
|
301
|
+
const limits = scanLimits();
|
|
250
302
|
let scan;
|
|
251
303
|
try {
|
|
252
|
-
scan = scanGitTarget(repo, { kind: 'staged' });
|
|
304
|
+
scan = scanGitTarget(repo, { kind: 'staged', limits });
|
|
253
305
|
} catch (e) {
|
|
254
306
|
console.error(`aimhooman: cannot scan staged content: ${e.message}`);
|
|
255
307
|
return expectedErrorCode(e);
|
|
@@ -275,6 +327,7 @@ function cmdPrecommit(args) {
|
|
|
275
327
|
return 10;
|
|
276
328
|
}
|
|
277
329
|
const paths = [...new Set(blocks.map((f) => f.path).filter(Boolean))];
|
|
330
|
+
let emptied = false;
|
|
278
331
|
try {
|
|
279
332
|
const unstageTargets = new Set(paths);
|
|
280
333
|
for (const finding of blocks) {
|
|
@@ -283,15 +336,15 @@ function cmdPrecommit(args) {
|
|
|
283
336
|
}
|
|
284
337
|
}
|
|
285
338
|
for (const source of stagedRenameSources(repo, paths)) unstageTargets.add(source);
|
|
286
|
-
//
|
|
287
|
-
// repair, not from a second git read after
|
|
288
|
-
// post-repair read followed an index write
|
|
289
|
-
// the wrong state under heavy CI load,
|
|
290
|
-
// unstagePaths is atomic (it throws on
|
|
291
|
-
// path is a repair target the index
|
|
292
|
-
// is best-effort and runs before the
|
|
293
|
-
// unstage still runs and the
|
|
294
|
-
// than
|
|
339
|
+
// Whether the repair empties the index is derived from the staged paths
|
|
340
|
+
// captured before repair, not from a second git read after
|
|
341
|
+
// `git restore --staged`. That post-repair read followed an index write
|
|
342
|
+
// and could transiently report the wrong state under heavy CI load,
|
|
343
|
+
// flaking the repair tests. unstagePaths is atomic (it throws on
|
|
344
|
+
// failure), so when every staged path is a repair target the index
|
|
345
|
+
// matches HEAD afterward. The capture is best-effort and runs before the
|
|
346
|
+
// repair: if this read fails the unstage still runs and the commit is
|
|
347
|
+
// left to proceed rather than blocked on a state we could not read.
|
|
295
348
|
let stagedBefore;
|
|
296
349
|
try {
|
|
297
350
|
stagedBefore = stagedPaths(repo);
|
|
@@ -316,10 +369,10 @@ function cmdPrecommit(args) {
|
|
|
316
369
|
if (!pending.length) break;
|
|
317
370
|
unstagePaths(repo, pending);
|
|
318
371
|
}
|
|
319
|
-
|
|
372
|
+
emptied = stagedBefore !== null
|
|
320
373
|
&& stagedBefore.every((path) => unstageTargets.has(path));
|
|
321
374
|
process.stderr.write(
|
|
322
|
-
`aimhooman: unstaged ${paths.length}
|
|
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`
|
|
323
376
|
);
|
|
324
377
|
} catch (e) {
|
|
325
378
|
process.stderr.write(
|
|
@@ -328,8 +381,20 @@ function cmdPrecommit(args) {
|
|
|
328
381
|
);
|
|
329
382
|
return 10;
|
|
330
383
|
}
|
|
331
|
-
|
|
384
|
+
// Seven of the rules that can reach the summary above are secret rules, so it
|
|
385
|
+
// cannot name a cause without guessing wrong for a private key or an AWS
|
|
386
|
+
// credential — and it used to be the only thing printed for a block. Let the
|
|
387
|
+
// findings speak instead: human() already carries the rule id, the path, the
|
|
388
|
+
// reason and the remediation, and already redacts secret text. It labels each
|
|
389
|
+
// one BLOCK, which is the decision that unstaged the path, not a stopped
|
|
390
|
+
// commit; the summary above says what actually happened to them.
|
|
391
|
+
process.stderr.write(human([...blocks, ...reviews], tone()));
|
|
332
392
|
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
393
|
+
// Git refuses a commit with nothing staged; repair runs after git has already
|
|
394
|
+
// decided otherwise, so carrying on here mints the empty commit git would
|
|
395
|
+
// not. The request was to commit the artifact, not to stamp the history with
|
|
396
|
+
// its message and no content.
|
|
397
|
+
if (emptied) return 10;
|
|
333
398
|
return scan.complete ? 0 : 31;
|
|
334
399
|
}
|
|
335
400
|
|
|
@@ -371,12 +436,13 @@ function cmdCommitmsg(args) {
|
|
|
371
436
|
}
|
|
372
437
|
const text = messageBytes.toString('utf8');
|
|
373
438
|
const validUtf8 = Buffer.from(text, 'utf8').equals(messageBytes);
|
|
439
|
+
const limits = scanLimits();
|
|
374
440
|
let scan;
|
|
375
441
|
let treeScan = null;
|
|
376
442
|
try {
|
|
377
443
|
const checked = againstWouldBeTree(() => ({
|
|
378
444
|
message: scanMessage(repo, text, { target: 'staged' }),
|
|
379
|
-
tree: options.tree ? scanGitTarget(repo, { kind: 'staged' }) : null,
|
|
445
|
+
tree: options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null,
|
|
380
446
|
}));
|
|
381
447
|
scan = checked.message;
|
|
382
448
|
treeScan = checked.tree;
|
|
@@ -452,18 +518,12 @@ function cmdRefcheck(args) {
|
|
|
452
518
|
if (phase === 'committed' || phase === 'aborted') return 0;
|
|
453
519
|
|
|
454
520
|
const repo = tryRepo();
|
|
455
|
-
if (!repo) {
|
|
456
|
-
// Global hooks also run in bare repositories. Bare repositories have no
|
|
457
|
-
// worktree/index policy boundary and are intentionally unsupported, so
|
|
458
|
-
// a global dispatcher must remain transparent there instead of making
|
|
459
|
-
// every receive-pack or update-ref fail merely because --show-toplevel
|
|
460
|
-
// is unavailable.
|
|
461
|
-
if (currentRepositoryIsBare()) return 0;
|
|
462
|
-
console.error('aimhooman: not a git repository');
|
|
463
|
-
return 30;
|
|
464
|
-
}
|
|
465
521
|
if (phase === 'preparing') {
|
|
466
|
-
|
|
522
|
+
// This phase never scans, and without a repository there is no
|
|
523
|
+
// dispatcher directory to inspect either. Git fires it while `git init`
|
|
524
|
+
// is still building the repository, so refusing here would only stop
|
|
525
|
+
// repository creation on behalf of a phase with nothing to say.
|
|
526
|
+
return repo && dispatchHooksChanged(repo, 'clean') ? 20 : 0;
|
|
467
527
|
}
|
|
468
528
|
let input;
|
|
469
529
|
try { input = readFileSync(0, 'utf8'); }
|
|
@@ -487,7 +547,32 @@ function cmdRefcheck(args) {
|
|
|
487
547
|
return 30;
|
|
488
548
|
}
|
|
489
549
|
if (/^0+$/.test(newObjectId)) continue;
|
|
490
|
-
updates.push({ oldObjectId, newObjectId });
|
|
550
|
+
updates.push({ oldObjectId, newObjectId, ref });
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (!repo) {
|
|
554
|
+
// Git writes the initial HEAD inside a reference transaction and fires
|
|
555
|
+
// this hook while the repository is still being built: GIT_DIR is
|
|
556
|
+
// exported but nothing can be queried yet, so every rev-parse fails,
|
|
557
|
+
// --is-bare-repository included. The bare carve-out below therefore
|
|
558
|
+
// cannot speak for `git init` or `git init --bare`, and reading the
|
|
559
|
+
// silence as "not a git repository" aborted the transaction and left a
|
|
560
|
+
// half-built .git behind that a second `git init` could not repair.
|
|
561
|
+
// That payload is `0000..0000 ref:refs/heads/<name> HEAD`, which the
|
|
562
|
+
// symref filter above already dropped, so no policy-relevant update
|
|
563
|
+
// survives and there is nothing to scan. The scope is "the payload
|
|
564
|
+
// carries nothing", never "git failed to answer": a transaction holding
|
|
565
|
+
// a real object still demands a repository, so no one can disable the
|
|
566
|
+
// last veto by arranging for openRepo to fail.
|
|
567
|
+
if (!updates.length) return 0;
|
|
568
|
+
// Global hooks also run in bare repositories. Bare repositories have no
|
|
569
|
+
// worktree/index policy boundary and are intentionally unsupported, so
|
|
570
|
+
// a global dispatcher must remain transparent there instead of making
|
|
571
|
+
// every receive-pack or update-ref fail merely because --show-toplevel
|
|
572
|
+
// is unavailable.
|
|
573
|
+
if (currentRepositoryIsBare()) return 0;
|
|
574
|
+
console.error('aimhooman: not a git repository');
|
|
575
|
+
return 30;
|
|
491
576
|
}
|
|
492
577
|
|
|
493
578
|
let commits;
|
|
@@ -524,6 +609,7 @@ function cmdRefcheck(args) {
|
|
|
524
609
|
console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
|
|
525
610
|
return 30;
|
|
526
611
|
}
|
|
612
|
+
const limits = scanLimits();
|
|
527
613
|
for (const [revision, reviewContexts] of commits) {
|
|
528
614
|
let scan;
|
|
529
615
|
try {
|
|
@@ -532,6 +618,7 @@ function cmdRefcheck(args) {
|
|
|
532
618
|
revision,
|
|
533
619
|
reviewContexts,
|
|
534
620
|
policyMigrationContexts: reviewContexts,
|
|
621
|
+
limits,
|
|
535
622
|
});
|
|
536
623
|
}
|
|
537
624
|
catch (error) {
|
|
@@ -584,6 +671,7 @@ function cmdCheck(args) {
|
|
|
584
671
|
return 30;
|
|
585
672
|
}
|
|
586
673
|
}
|
|
674
|
+
const limits = scanLimits();
|
|
587
675
|
let scan;
|
|
588
676
|
try {
|
|
589
677
|
if (o.message && !o.staged && !o.tracked) {
|
|
@@ -597,6 +685,7 @@ function cmdCheck(args) {
|
|
|
597
685
|
revision: o.commit,
|
|
598
686
|
range: o.range,
|
|
599
687
|
explicitProfile: o.profile,
|
|
688
|
+
limits,
|
|
600
689
|
...(o.message ? { messageText } : {}),
|
|
601
690
|
});
|
|
602
691
|
}
|
|
@@ -650,7 +739,7 @@ function cmdInit(args) {
|
|
|
650
739
|
const aimDir = globalHooksDir();
|
|
651
740
|
let existing = '';
|
|
652
741
|
try {
|
|
653
|
-
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
742
|
+
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
654
743
|
} catch { /* unset */ }
|
|
655
744
|
if (existing && existing !== aimDir) {
|
|
656
745
|
console.error(
|
|
@@ -663,7 +752,7 @@ function cmdInit(args) {
|
|
|
663
752
|
// could silently disable a system-wide hook manager, so refuse here too.
|
|
664
753
|
let systemHooksPath = '';
|
|
665
754
|
try {
|
|
666
|
-
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
755
|
+
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
667
756
|
} catch { /* unset or no system config */ }
|
|
668
757
|
if (systemHooksPath) {
|
|
669
758
|
console.error(
|
|
@@ -687,7 +776,7 @@ function cmdInit(args) {
|
|
|
687
776
|
console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
|
|
688
777
|
return 20;
|
|
689
778
|
}
|
|
690
|
-
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir]);
|
|
779
|
+
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
|
|
691
780
|
} catch (error) {
|
|
692
781
|
const rollbackFailures = [];
|
|
693
782
|
for (const snapshot of hookSnapshots.reverse()) {
|
|
@@ -697,8 +786,8 @@ function cmdInit(args) {
|
|
|
697
786
|
}
|
|
698
787
|
}
|
|
699
788
|
try {
|
|
700
|
-
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing]);
|
|
701
|
-
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore' });
|
|
789
|
+
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
|
|
790
|
+
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
702
791
|
} catch (rollbackError) {
|
|
703
792
|
rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
|
|
704
793
|
}
|
|
@@ -759,7 +848,13 @@ function cmdInit(args) {
|
|
|
759
848
|
rep = installHooks(repo, CLI_PATH);
|
|
760
849
|
const activeHooks = installedHooks(repo);
|
|
761
850
|
if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
|
|
762
|
-
|
|
851
|
+
// installHooks declines rather than throws when the hooks directory is
|
|
852
|
+
// not ours, and its warnings are the only record of why. They are
|
|
853
|
+
// printed on the success path only, so carry them into the failure or
|
|
854
|
+
// the user is told nothing but "incomplete". The prefix is load-bearing:
|
|
855
|
+
// the exit-code branch below matches on it.
|
|
856
|
+
const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
|
|
857
|
+
throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
|
|
763
858
|
}
|
|
764
859
|
saveConfig(repo.stateDir, { profile });
|
|
765
860
|
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
@@ -847,7 +942,6 @@ function cmdStatus(args) {
|
|
|
847
942
|
console.log(`overrides: ${overrides.allow.length} allow, ${overrides.deny.length} deny`);
|
|
848
943
|
console.log(`excludes: ${excludeError ? `unknown (malformed markers: ${excludeError}; run: aimhooman init)` : excludes.current ? 'current' : excludes.installed ? 'out of date (run: aimhooman init)' : 'not installed (run: aimhooman init)'}`);
|
|
849
944
|
for (const error of errors) console.log(`warning: ${error.message}`);
|
|
850
|
-
for (const diagnostic of repo.stateDiagnostics || []) console.log(`${diagnostic.level || 'info'}: ${diagnostic.message}`);
|
|
851
945
|
const localHooks = gitConfigAtScope(repo.root, '--local', 'core.hooksPath');
|
|
852
946
|
const globalHooks = gitConfigAtScope(repo.root, '--global', 'core.hooksPath');
|
|
853
947
|
console.log(`hooks path: local=${localHooks || 'unset'}, global=${globalHooks || 'unset'}`);
|
|
@@ -1414,10 +1508,6 @@ function cmdDoctor(args) {
|
|
|
1414
1508
|
} else {
|
|
1415
1509
|
console.log(`ok host adapters present (${adapters.present}/${adapters.total})`);
|
|
1416
1510
|
}
|
|
1417
|
-
for (const diagnostic of repo.stateDiagnostics || []) {
|
|
1418
|
-
console.log(`${diagnostic.level === 'error' ? 'x' : '!'} state: ${diagnostic.message}`);
|
|
1419
|
-
if (diagnostic.level === 'error') ok = false;
|
|
1420
|
-
}
|
|
1421
1511
|
const gitRuntime = supportedGitVersion();
|
|
1422
1512
|
if (gitRuntime.supported) {
|
|
1423
1513
|
console.log(`ok runtime Node ${process.versions.node}; ${gitRuntime.display}`);
|
|
@@ -1485,7 +1575,7 @@ function cmdUninstall(args) {
|
|
|
1485
1575
|
const aimDir = globalHooksDir();
|
|
1486
1576
|
const readGlobalHooksPath = () => {
|
|
1487
1577
|
try {
|
|
1488
|
-
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim();
|
|
1578
|
+
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
1489
1579
|
} catch { return ''; }
|
|
1490
1580
|
};
|
|
1491
1581
|
const current = readGlobalHooksPath();
|
|
@@ -1493,7 +1583,7 @@ function cmdUninstall(args) {
|
|
|
1493
1583
|
if (current === aimDir) {
|
|
1494
1584
|
unsetAttempted = true;
|
|
1495
1585
|
try {
|
|
1496
|
-
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
1586
|
+
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
|
|
1497
1587
|
} catch { /* may have failed; re-verify below */ }
|
|
1498
1588
|
// A read-only or locked ~/.gitconfig can make --unset fail silently.
|
|
1499
1589
|
// Do not remove the dispatchers while core.hooksPath still resolves to
|
|
@@ -1527,12 +1617,33 @@ function cmdUninstall(args) {
|
|
|
1527
1617
|
}
|
|
1528
1618
|
return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
1529
1619
|
const rep = uninstallHooks(repo);
|
|
1530
|
-
|
|
1531
|
-
|
|
1620
|
+
// The irreversible work is already done above, and the report is still
|
|
1621
|
+
// below. A throw from here unwound past all of it, so a damaged marker was
|
|
1622
|
+
// the only thing the user heard while four dispatchers kept guarding every
|
|
1623
|
+
// commit. Report the failure beside the removal report instead of in place
|
|
1624
|
+
// of it. Nothing is swallowed: this is also where the symlink and permission
|
|
1625
|
+
// guards surface, and their messages still reach the user and still exit 30.
|
|
1626
|
+
let excludeFailure = '';
|
|
1627
|
+
try {
|
|
1628
|
+
removeExclude(repo.excludeFile);
|
|
1629
|
+
} catch (error) {
|
|
1630
|
+
excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
|
|
1631
|
+
}
|
|
1632
|
+
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1633
|
+
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1634
|
+
const remaining = remainingDispatchers(repo);
|
|
1635
|
+
if (remaining.length) {
|
|
1636
|
+
console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
|
|
1637
|
+
for (const path of remaining) console.error(` ${path}`);
|
|
1638
|
+
console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
|
|
1639
|
+
} else {
|
|
1640
|
+
console.log('aimhooman: uninstalled');
|
|
1641
|
+
}
|
|
1532
1642
|
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1533
1643
|
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1534
1644
|
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1535
|
-
for (const f of rep.failures || []) console.
|
|
1645
|
+
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1646
|
+
if (excludeFailure) console.error(` failure: ${excludeFailure}`);
|
|
1536
1647
|
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1537
1648
|
if (purge) {
|
|
1538
1649
|
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
@@ -1560,6 +1671,7 @@ function cmdUninstall(args) {
|
|
|
1560
1671
|
try {
|
|
1561
1672
|
globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
1562
1673
|
encoding: 'utf8',
|
|
1674
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1563
1675
|
}).trim();
|
|
1564
1676
|
} catch { /* unset */ }
|
|
1565
1677
|
if (globalHooksPath === aimDir) {
|
|
@@ -1567,7 +1679,9 @@ function cmdUninstall(args) {
|
|
|
1567
1679
|
console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
|
|
1568
1680
|
console.log(' run `aimhooman uninstall --global` to remove it.');
|
|
1569
1681
|
}
|
|
1570
|
-
|
|
1682
|
+
// The managed block is still in the exclude file and still ignoring paths, so
|
|
1683
|
+
// the uninstall is genuinely incomplete and 30 is the honest answer.
|
|
1684
|
+
return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
|
|
1571
1685
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1572
1686
|
}
|
|
1573
1687
|
|
|
@@ -65,6 +65,30 @@ Git 2.54 also emits an earlier `preparing` reference-transaction callback. The
|
|
|
65
65
|
hook checks dispatcher integrity there without scanning unresolved references,
|
|
66
66
|
then keeps the full-scan veto at `prepared`, after Git has locked them.
|
|
67
67
|
|
|
68
|
+
### Frictionless agent guard (the everyday commands run)
|
|
69
|
+
|
|
70
|
+
The layer-2 PreToolUse guard reports paths and refuses unprovable protected Git
|
|
71
|
+
mutations. It does not stand between a developer and the commands they run to read a
|
|
72
|
+
repository. A read-only Git subcommand piped to a filter is a source: `git log | head`,
|
|
73
|
+
`git status | grep modified`, `git diff | cat`, and the `cd repo && git log | head`
|
|
74
|
+
prefix produce stdout for the downstream filter and cannot hide or feed a commit, so
|
|
75
|
+
they run with no refusal. Build and test toolchains (`npm`, `cargo`, `make`, `jest`,
|
|
76
|
+
`eslint`, `tsc`, `pytest`, ...) are allowed as a source for the same reason. The listing
|
|
77
|
+
forms of branch/tag/remote/stash/notes move no ref and run; their mutating forms
|
|
78
|
+
(`git branch -D`, `git tag v1`) still reach the reference-transaction guard. A commit
|
|
79
|
+
made of already-safe halves runs as a whole: `build && git add . && git commit` is
|
|
80
|
+
allowed, because an unmodelled prefix is not a bypass and pre-commit still scans the real
|
|
81
|
+
index at commit time.
|
|
82
|
+
|
|
83
|
+
What stays refused is what can actually get past the scan. Git as a pipe sink
|
|
84
|
+
(`cat patch | git apply`) reads stdin, which can drive `apply`/`am`/`hash-object`.
|
|
85
|
+
Pipe-to-shell, subshells, `eval`, command substitution, and readers that execute their
|
|
86
|
+
own input stay opaque. `git commit --no-verify`, an explicit `core.hooksPath` override,
|
|
87
|
+
and `git push` refuse on the tiers that own them. The boundary that decides whether a
|
|
88
|
+
commit is scanned is the pre-commit and reference-transaction hook, never a denial of the
|
|
89
|
+
shell line that reads the repo. Refusing the read taught agents to drop the `&&` gate
|
|
90
|
+
rather than run the command on its own, which is the friction this tier exists to avoid.
|
|
91
|
+
|
|
68
92
|
### Components
|
|
69
93
|
|
|
70
94
|
1. **Rule catalog** — `rules/paths.json`, `rules/attribution.json`,
|
|
@@ -118,6 +142,10 @@ means local rules only add restrictions and can never override a core block.
|
|
|
118
142
|
transparent in unsupported bare repositories.
|
|
119
143
|
- **HEAD-safe unstage.** `git restore --staged` needs HEAD; on a repository's
|
|
120
144
|
initial commit it falls back to `git rm --cached --ignore-unmatch`.
|
|
145
|
+
- **Repair never mints an empty commit.** When the pre-commit repair unstages the last
|
|
146
|
+
staged path (stage only a `.env`, then `git commit`), it exits 10 so Git stops, the
|
|
147
|
+
same outcome as committing with nothing staged. Carrying on would create a commit Git
|
|
148
|
+
itself would have refused and leave the developer a junk commit to `git reset --hard`.
|
|
121
149
|
- **Anchored attribution rules.** The AI-noreply rule is anchored to trailer
|
|
122
150
|
lines (`*-by:`) so it never strips a prose line that merely mentions the email.
|
|
123
151
|
- **`compliance` keeps disclosure.** AI-attribution rules resolve to `allow`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rmyndharis/aimhooman",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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": {
|
package/rules/attribution.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "attribution.claude-coauthor",
|
|
4
|
-
"version":
|
|
4
|
+
"version": 2,
|
|
5
5
|
"provider": "claude-code",
|
|
6
6
|
"category": "ai-attribution",
|
|
7
7
|
"confidence": "high",
|
|
8
8
|
"kind": "message",
|
|
9
9
|
"match": {
|
|
10
10
|
"content": [
|
|
11
|
-
"(?i)^\\s*co-authored-by
|
|
11
|
+
"(?i)^\\s*co-authored-by:[^<>]+<noreply@anthropic\\.com>\\s*$"
|
|
12
12
|
]
|
|
13
13
|
},
|
|
14
14
|
"actions": {
|
|
@@ -49,14 +49,14 @@
|
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"id": "attribution.codex-coauthor",
|
|
52
|
-
"version":
|
|
52
|
+
"version": 2,
|
|
53
53
|
"provider": "codex",
|
|
54
54
|
"category": "ai-attribution",
|
|
55
55
|
"confidence": "high",
|
|
56
56
|
"kind": "message",
|
|
57
57
|
"match": {
|
|
58
58
|
"content": [
|
|
59
|
-
"(?i)^\\s*co-authored-by
|
|
59
|
+
"(?i)^\\s*co-authored-by:[^<>]+<noreply@openai\\.com>\\s*$"
|
|
60
60
|
]
|
|
61
61
|
},
|
|
62
62
|
"actions": {
|
|
@@ -95,15 +95,15 @@
|
|
|
95
95
|
},
|
|
96
96
|
{
|
|
97
97
|
"id": "attribution.generated-with",
|
|
98
|
-
"version":
|
|
98
|
+
"version": 3,
|
|
99
99
|
"provider": "generic",
|
|
100
100
|
"category": "ai-attribution",
|
|
101
101
|
"confidence": "high",
|
|
102
102
|
"kind": "message",
|
|
103
103
|
"match": {
|
|
104
104
|
"content": [
|
|
105
|
-
"(?i)^\\s*generated\\s+(?:with|by)\\s+(?:claude(
|
|
106
|
-
"(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[claude
|
|
105
|
+
"(?i)^\\s*(?:🤖\\s*)?generated\\s+(?:with|by)\\s+(?:claude|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)(?:\\s+[A-Za-z0-9.]+){0,3}[.!]?\\s*(?:🤖\\s*)?$",
|
|
106
|
+
"(?i)^\\s*(?:🤖\\s*)?generated\\s+with\\s+\\[(?:claude|(?:github\\s+)?copilot|chatgpt|gpt(?:-[0-9]+(?:\\.[0-9]+)?)?|(?:openai\\s+)?codex|cursor)(?:\\s+[A-Za-z0-9.]+){0,3}\\]\\([^)]*\\)\\s*(?:🤖\\s*)?$"
|
|
107
107
|
]
|
|
108
108
|
},
|
|
109
109
|
"actions": {
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"kind": "message",
|
|
127
127
|
"match": {
|
|
128
128
|
"content": [
|
|
129
|
-
"(?i)^\\s*[a-z-]+-by
|
|
129
|
+
"(?i)^\\s*[a-z-]+-by:[^<>]+<noreply@(anthropic|openai)\\.com>\\s*$"
|
|
130
130
|
]
|
|
131
131
|
},
|
|
132
132
|
"actions": {
|
package/rules/markers.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
3
|
"id": "marker.corner-cut",
|
|
4
|
-
"version":
|
|
4
|
+
"version": 3,
|
|
5
5
|
"provider": "generic",
|
|
6
6
|
"category": "ai-marker",
|
|
7
7
|
"confidence": "medium",
|
|
@@ -14,19 +14,32 @@
|
|
|
14
14
|
"**/docs/**",
|
|
15
15
|
"**/test/**",
|
|
16
16
|
"**/tests/**",
|
|
17
|
+
"**/__tests__/**",
|
|
18
|
+
"**/testdata/**",
|
|
17
19
|
"**/fixtures/**",
|
|
18
20
|
"**/__fixtures__/**",
|
|
19
21
|
"**/vendor/**",
|
|
22
|
+
"**/vendored/**",
|
|
20
23
|
"**/third_party/**",
|
|
21
24
|
"**/node_modules/**",
|
|
22
25
|
"**/dist/**",
|
|
23
26
|
"**/build/**",
|
|
27
|
+
"out/**",
|
|
28
|
+
"target/**",
|
|
29
|
+
"**/.next/**",
|
|
24
30
|
"**/coverage/**",
|
|
25
31
|
"**/generated/**",
|
|
26
32
|
"**/*.generated.*",
|
|
27
33
|
"**/*.min.js",
|
|
28
34
|
"**/*.map",
|
|
29
|
-
"**/*.md"
|
|
35
|
+
"**/*.md",
|
|
36
|
+
"**/*.mdx",
|
|
37
|
+
"**/*.markdown",
|
|
38
|
+
"**/*.rst",
|
|
39
|
+
"**/*.test.*",
|
|
40
|
+
"**/*.spec.*",
|
|
41
|
+
"**/*_test.*",
|
|
42
|
+
"**/*_spec.*"
|
|
30
43
|
],
|
|
31
44
|
"content": [
|
|
32
45
|
"(?i)\\b(ponytail|caveman|yagni-oneliner):"
|
|
@@ -44,7 +57,7 @@
|
|
|
44
57
|
},
|
|
45
58
|
{
|
|
46
59
|
"id": "marker.ai-authored",
|
|
47
|
-
"version":
|
|
60
|
+
"version": 2,
|
|
48
61
|
"provider": "generic",
|
|
49
62
|
"category": "ai-marker",
|
|
50
63
|
"confidence": "low",
|
|
@@ -57,22 +70,35 @@
|
|
|
57
70
|
"**/docs/**",
|
|
58
71
|
"**/test/**",
|
|
59
72
|
"**/tests/**",
|
|
73
|
+
"**/__tests__/**",
|
|
74
|
+
"**/testdata/**",
|
|
60
75
|
"**/fixtures/**",
|
|
61
76
|
"**/__fixtures__/**",
|
|
62
77
|
"**/vendor/**",
|
|
78
|
+
"**/vendored/**",
|
|
63
79
|
"**/third_party/**",
|
|
64
80
|
"**/node_modules/**",
|
|
65
81
|
"**/dist/**",
|
|
66
82
|
"**/build/**",
|
|
83
|
+
"out/**",
|
|
84
|
+
"target/**",
|
|
85
|
+
"**/.next/**",
|
|
67
86
|
"**/coverage/**",
|
|
68
87
|
"**/generated/**",
|
|
69
88
|
"**/*.generated.*",
|
|
70
89
|
"**/*.min.js",
|
|
71
90
|
"**/*.map",
|
|
72
|
-
"**/*.md"
|
|
91
|
+
"**/*.md",
|
|
92
|
+
"**/*.mdx",
|
|
93
|
+
"**/*.markdown",
|
|
94
|
+
"**/*.rst",
|
|
95
|
+
"**/*.test.*",
|
|
96
|
+
"**/*.spec.*",
|
|
97
|
+
"**/*_test.*",
|
|
98
|
+
"**/*_spec.*"
|
|
73
99
|
],
|
|
74
100
|
"content": [
|
|
75
|
-
"(?i)\\b(
|
|
101
|
+
"(?i)\\b(generated by (ai|copilot|claude|chatgpt|codex))\\b"
|
|
76
102
|
]
|
|
77
103
|
},
|
|
78
104
|
"actions": {
|