@rmyndharis/aimhooman 0.1.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.
Files changed (54) hide show
  1. package/.agents/rules/aimhooman.md +53 -0
  2. package/.claude-plugin/marketplace.json +23 -0
  3. package/.claude-plugin/plugin.json +9 -0
  4. package/.clinerules/aimhooman.md +53 -0
  5. package/.codex-plugin/plugin.json +37 -0
  6. package/.cursor/rules/aimhooman.mdc +59 -0
  7. package/.gemini/settings.json +7 -0
  8. package/.github/copilot-instructions.md +53 -0
  9. package/.github/hooks/aimhooman.json +22 -0
  10. package/.kiro/steering/aimhooman.md +53 -0
  11. package/.windsurf/rules/aimhooman.md +57 -0
  12. package/AGENTS.md +53 -0
  13. package/CHANGELOG.md +153 -0
  14. package/CODE_OF_CONDUCT.md +128 -0
  15. package/CONTRIBUTING.md +144 -0
  16. package/GEMINI.md +53 -0
  17. package/LICENSE +21 -0
  18. package/README.md +472 -0
  19. package/SECURITY.md +74 -0
  20. package/bin/aimhooman.mjs +1589 -0
  21. package/docs/design/agent-portability.md +73 -0
  22. package/docs/design/frictionless-enforcement.md +135 -0
  23. package/docs/hosts.json +164 -0
  24. package/docs/logo/aimhooman-logo.png +0 -0
  25. package/docs/logo/aimhooman.png +0 -0
  26. package/hooks/hooks.json +29 -0
  27. package/package.json +77 -0
  28. package/rules/attribution.json +142 -0
  29. package/rules/markers.json +88 -0
  30. package/rules/paths.json +407 -0
  31. package/rules/secrets.json +90 -0
  32. package/schemas/overrides.schema.json +134 -0
  33. package/schemas/project-policy.schema.json +12 -0
  34. package/schemas/rule-pack.schema.json +126 -0
  35. package/schemas/scan-report.schema.json +165 -0
  36. package/skills/aimhooman/SKILL.md +65 -0
  37. package/src/args.mjs +72 -0
  38. package/src/atomic-write.mjs +285 -0
  39. package/src/codex-manifest.mjs +65 -0
  40. package/src/exclude.mjs +125 -0
  41. package/src/git-environment.mjs +5 -0
  42. package/src/git-path.mjs +5 -0
  43. package/src/githooks.mjs +813 -0
  44. package/src/gitx.mjs +721 -0
  45. package/src/history-scan.mjs +295 -0
  46. package/src/hook.mjs +2602 -0
  47. package/src/policy-resolver.mjs +200 -0
  48. package/src/report.mjs +63 -0
  49. package/src/rules.mjs +509 -0
  50. package/src/ruleset-text.mjs +29 -0
  51. package/src/scan-session.mjs +152 -0
  52. package/src/scan-target.mjs +697 -0
  53. package/src/scan.mjs +325 -0
  54. package/src/state.mjs +360 -0
@@ -0,0 +1,813 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ accessSync,
5
+ chmodSync,
6
+ constants,
7
+ lstatSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ statSync,
13
+ unlinkSync,
14
+ } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
17
+ import { gitConfig } from './gitx.mjs';
18
+ import { atomicWrite, withLock } from './atomic-write.mjs';
19
+
20
+ const MARKER = '# aimhooman-managed hook';
21
+ const HOOK_FORMAT_VERSION = 2;
22
+ const FINGERPRINT_PLACEHOLDER = '0'.repeat(64);
23
+ const MANAGED = {
24
+ 'pre-commit': 'precommit',
25
+ 'pre-merge-commit': 'precommit',
26
+ 'commit-msg': 'commitmsg "$1"',
27
+ 'reference-transaction': 'refcheck "$1"',
28
+ };
29
+ const LEGACY_MANAGED = {
30
+ 'pre-commit': 'precommit',
31
+ 'commit-msg': 'commitmsg "$1"',
32
+ };
33
+
34
+ function hooksDir(repo) {
35
+ const hp = gitConfig(repo.root, 'core.hooksPath');
36
+ const dir = effectiveHooksDir(repo);
37
+ if (!hp) return { dir, shared: false, warnings: [] };
38
+
39
+ const scope = configScope(repo, 'core.hooksPath');
40
+ const localScope = ['local', 'worktree'].includes(scope);
41
+ const repositoryOwned = localScope && repositoryOwnsPath(repo, dir);
42
+ const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
43
+ if (shared) {
44
+ const where = scope ? `${scope} scope` : 'a non-local scope';
45
+ const reason = localScope && !repositoryOwned
46
+ ? `${where}, outside this repository`
47
+ : where;
48
+ return {
49
+ dir,
50
+ shared: true,
51
+ warnings: [
52
+ `core.hooksPath is set to "${hp}" at ${reason}; local hooks were not modified`,
53
+ ],
54
+ };
55
+ }
56
+ return {
57
+ dir,
58
+ shared: false,
59
+ warnings: [`core.hooksPath is set to "${hp}"; hooks installed there`],
60
+ };
61
+ }
62
+
63
+ function repositoryOwnsPath(repo, path) {
64
+ const candidate = canonicalPath(path);
65
+ return [repo.root, repo.commonDir, repo.gitDir]
66
+ .filter(Boolean)
67
+ .map(canonicalPath)
68
+ .some((root) => {
69
+ const rel = relative(root, candidate);
70
+ return rel === '' || (!rel.startsWith(`..${sep}`)
71
+ && rel !== '..' && !isAbsolute(rel));
72
+ });
73
+ }
74
+
75
+ function canonicalPath(path) {
76
+ let current = resolve(path);
77
+ const tail = [];
78
+ for (;;) {
79
+ try {
80
+ return resolve(realpathSync(current), ...tail);
81
+ } catch (error) {
82
+ if (error?.code !== 'ENOENT') throw error;
83
+ const parent = dirname(current);
84
+ if (parent === current) return resolve(path);
85
+ tail.unshift(basename(current));
86
+ current = parent;
87
+ }
88
+ }
89
+ }
90
+
91
+ // Ask Git for the path it actually uses. In a linked worktree this resolves to
92
+ // the common repository hooks directory, not <gitDir>/hooks.
93
+ function effectiveHooksDir(repo) {
94
+ try {
95
+ return execFileSync(
96
+ 'git',
97
+ ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
98
+ { cwd: repo.root, encoding: 'utf8' }
99
+ ).trim();
100
+ } catch {
101
+ try {
102
+ const path = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
103
+ cwd: repo.root,
104
+ encoding: 'utf8',
105
+ }).trim();
106
+ return isAbsolute(path) ? path : resolve(repo.root, path);
107
+ } catch {
108
+ return join(repo.commonDir || repo.gitDir, 'hooks');
109
+ }
110
+ }
111
+ }
112
+
113
+ function configScope(repo, key) {
114
+ try {
115
+ const line = execFileSync('git', ['config', '--show-scope', '--get', key], {
116
+ cwd: repo.root,
117
+ encoding: 'utf8',
118
+ }).trim();
119
+ return line.split(/\s/, 1)[0];
120
+ } catch {
121
+ // Older Git versions do not support --show-scope. Only classify a
122
+ // value as local when we can prove it; the conservative fallback keeps
123
+ // a global dispatcher from being overwritten.
124
+ try {
125
+ execFileSync('git', ['config', '--local', '--get', key], {
126
+ cwd: repo.root,
127
+ stdio: ['ignore', 'ignore', 'ignore'],
128
+ });
129
+ return 'local';
130
+ } catch {
131
+ return '';
132
+ }
133
+ }
134
+ }
135
+
136
+ function entry(path) {
137
+ try {
138
+ return lstatSync(path);
139
+ } catch (error) {
140
+ if (error?.code === 'ENOENT') return null;
141
+ throw error;
142
+ }
143
+ }
144
+
145
+ function snapshotFile(path) {
146
+ const stat = entry(path);
147
+ if (!stat) return { path, existed: false };
148
+ if (!stat.isFile()) throw new Error(`cannot snapshot non-file hook path "${path}"`);
149
+ return {
150
+ path,
151
+ existed: true,
152
+ content: readFileSync(path),
153
+ mode: stat.mode & 0o777,
154
+ };
155
+ }
156
+
157
+ function rollbackFiles(snapshots, originalError) {
158
+ const failures = [];
159
+ for (const snapshot of [...snapshots].reverse()) {
160
+ try {
161
+ const current = entry(snapshot.path);
162
+ if (current?.isSymbolicLink()) {
163
+ throw new Error('path became a symlink during installation');
164
+ }
165
+ if (snapshot.existed) {
166
+ atomicWrite(snapshot.path, snapshot.content, { mode: snapshot.mode });
167
+ chmodSync(snapshot.path, snapshot.mode);
168
+ } else if (current) {
169
+ if (!current.isFile()) throw new Error('path became a non-file during installation');
170
+ unlinkSync(snapshot.path);
171
+ }
172
+ } catch (error) {
173
+ failures.push(`${snapshot.path}: ${error.message}`);
174
+ }
175
+ }
176
+ if (failures.length) {
177
+ throw new Error(
178
+ `${originalError.message}; hook rollback also failed: ${failures.join('; ')}`,
179
+ { cause: originalError },
180
+ );
181
+ }
182
+ throw originalError;
183
+ }
184
+
185
+ function symlinkWarning(name) {
186
+ return `${name} is a symlink; refusing to follow or overwrite it`;
187
+ }
188
+
189
+ function chainDir(repo) {
190
+ // Chained predecessors live in common repository state (shared by linked
191
+ // worktrees). repo.stateDir is always join(commonDir, 'aimhooman'), so this
192
+ // resolves to <commonDir>/aimhooman/chained regardless of the hooks dir.
193
+ // Global hooks (installGlobalHooks) keep their chained backups in the hooks
194
+ // dir itself; hookDiagnostics special-cases that path.
195
+ return join(repo.stateDir, 'chained');
196
+ }
197
+
198
+ // unrestoredChainedBackups lists predecessor hook backups still on disk after
199
+ // an uninstall attempt. A non-empty result means restore did not finish for
200
+ // those hooks (a per-hook failure left the user's original hook existing only
201
+ // in the backup), so the caller must NOT purge stateDir or the originals would
202
+ // be destroyed. Only managed hook names count; other files are ignored.
203
+ export function unrestoredChainedBackups(repo) {
204
+ let entries;
205
+ try {
206
+ entries = readdirSync(chainDir(repo));
207
+ } catch (error) {
208
+ if (error?.code === 'ENOENT') return [];
209
+ throw error;
210
+ }
211
+ return entries.filter((name) => Object.prototype.hasOwnProperty.call(MANAGED, name)).sort();
212
+ }
213
+
214
+ // globalHooksDir is the aimhooman global hooks path (~/.aimhooman/hooks).
215
+ export function globalHooksDir() {
216
+ return join(homedir(), '.aimhooman', 'hooks');
217
+ }
218
+
219
+ // installGlobalHooks writes aimhooman dispatchers to ~/.aimhooman/hooks for global use.
220
+ export function installGlobalHooks(cliPath, options = {}) {
221
+ const dir = options.dir || globalHooksDir();
222
+ mkdirSync(dir, { recursive: true });
223
+ const installed = [];
224
+ const skipped = [];
225
+ const warnings = [];
226
+ for (const name of Object.keys(MANAGED)) {
227
+ const dest = join(dir, name);
228
+ const current = entry(dest);
229
+ if (current?.isSymbolicLink()) {
230
+ skipped.push(name);
231
+ warnings.push(symlinkWarning(name));
232
+ } else if (current && !ownedHook(readFileSync(dest, 'utf8'), name)) {
233
+ skipped.push(name);
234
+ warnings.push(`${name} already exists and is not managed by aimhooman; refusing to overwrite it`);
235
+ }
236
+ }
237
+ // Preflight every destination so installation is all-or-nothing.
238
+ if (skipped.length) return { dir, installed, skipped: skipped.sort(), warnings };
239
+ const destinations = Object.keys(MANAGED).map((name) => join(dir, name));
240
+ const snapshots = destinations.map(snapshotFile);
241
+ const writeHook = options.writeHook || atomicWrite;
242
+ try {
243
+ for (const [name, cmd] of Object.entries(MANAGED)) {
244
+ const dest = join(dir, name);
245
+ writeHook(dest, hookScript(name, cmd, cliPath, join(dir, 'chained', name)), { mode: 0o755 });
246
+ chmodSync(dest, 0o755);
247
+ installed.push(name);
248
+ }
249
+ } catch (error) {
250
+ rollbackFiles(snapshots, error);
251
+ }
252
+ return { dir, installed: installed.sort(), skipped: skipped.sort(), warnings };
253
+ }
254
+
255
+ // uninstallGlobalHooks removes aimhooman-managed dispatchers from the global
256
+ // hooks dir. Filesystem errors propagate so callers cannot report a partial
257
+ // removal as success; the directory itself is left in place.
258
+ export function uninstallGlobalHooks(options = {}) {
259
+ const dir = options.dir || globalHooksDir();
260
+ const readHook = options.readHook || readFileSync;
261
+ const unlinkHook = options.unlinkHook || unlinkSync;
262
+ const removed = [];
263
+ const warnings = [];
264
+ for (const name of Object.keys(MANAGED)) {
265
+ const dest = join(dir, name);
266
+ if (entry(dest)?.isSymbolicLink()) {
267
+ warnings.push(symlinkWarning(name));
268
+ continue;
269
+ }
270
+ const current = entry(dest);
271
+ if (!current) continue;
272
+ if (ownedHook(readHook(dest, 'utf8'), name)) {
273
+ unlinkHook(dest);
274
+ removed.push(name);
275
+ }
276
+ }
277
+ return { dir, removed: removed.sort(), warnings };
278
+ }
279
+
280
+ // installHooks writes aimhooman dispatchers, chaining any existing foreign hooks.
281
+ export function installHooks(repo, cliPath, options = {}) {
282
+ const { dir, shared, warnings } = hooksDir(repo);
283
+ if (shared) return { installed: [], chained: [], warnings, shared: true };
284
+ return withLock(join(dir, '.aimhooman-hooks.lock'), () => (
285
+ installHooksLocked(repo, cliPath, options, dir, warnings)
286
+ ));
287
+ }
288
+
289
+ function installHooksLocked(repo, cliPath, options, dir, warnings) {
290
+ mkdirSync(dir, { recursive: true });
291
+ const chainedDir = chainDir(repo);
292
+ const installed = [];
293
+ const chained = [];
294
+ let unsafe = false;
295
+ for (const name of Object.keys(MANAGED)) {
296
+ const dest = join(dir, name);
297
+ const current = entry(dest);
298
+ const chainedPath = join(chainedDir, name);
299
+ if (current?.isSymbolicLink()) {
300
+ warnings.push(symlinkWarning(name));
301
+ unsafe = true;
302
+ continue;
303
+ }
304
+ if (entry(chainedPath)?.isSymbolicLink()) {
305
+ warnings.push(`${name} chained backup is a symlink; refusing to use it`);
306
+ unsafe = true;
307
+ continue;
308
+ }
309
+ if (current && ownedHook(readFileSync(dest, 'utf8'), name)
310
+ && !ownedForChain(readFileSync(dest, 'utf8'), name, chainedPath)) {
311
+ warnings.push(`${name} is managed for another repository; refusing to overwrite it`);
312
+ unsafe = true;
313
+ }
314
+ }
315
+ // Do not leave a repository with only part of the policy hook set.
316
+ if (unsafe) return { installed, chained, warnings, shared: false };
317
+
318
+ const paths = Object.keys(MANAGED).flatMap((name) => [
319
+ join(dir, name),
320
+ join(chainedDir, name),
321
+ ]);
322
+ const snapshots = paths.map(snapshotFile);
323
+ const writeHook = options.writeHook || atomicWrite;
324
+ try {
325
+ for (const [name, cmd] of Object.entries(MANAGED)) {
326
+ const dest = join(dir, name);
327
+ const current = entry(dest);
328
+ if (current) {
329
+ const cur = readFileSync(dest);
330
+ if (!ownedHook(cur, name)) {
331
+ mkdirSync(chainedDir, { recursive: true });
332
+ const chainedPath = join(chainedDir, name);
333
+ const predecessorMode = current.mode & 0o777;
334
+ writeHook(chainedPath, cur, { mode: predecessorMode });
335
+ chmodSync(chainedPath, predecessorMode);
336
+ chained.push(name);
337
+ }
338
+ }
339
+ writeHook(dest, hookScript(name, cmd, cliPath, join(chainedDir, name)), { mode: 0o755 });
340
+ chmodSync(dest, 0o755);
341
+ installed.push(name);
342
+ }
343
+ } catch (error) {
344
+ rollbackFiles(snapshots, error);
345
+ }
346
+ return { installed: installed.sort(), chained: chained.sort(), warnings, shared: false };
347
+ }
348
+
349
+ // uninstallHooks removes aimhooman hooks and restores any chained originals.
350
+ // Best-effort and atomic across the hook set: a filesystem error on one hook
351
+ // is recorded as a failure and the remaining hooks are still processed, so a
352
+ // re-run self-heals (already-processed hooks are skipped via ownedHook).
353
+ export function uninstallHooks(repo) {
354
+ const { dir, shared, warnings } = hooksDir(repo);
355
+ if (shared) return { removed: [], restored: [], warnings, failures: [] };
356
+ return withLock(join(dir, '.aimhooman-hooks.lock'), () => (
357
+ uninstallHooksLocked(repo, dir, warnings)
358
+ ));
359
+ }
360
+
361
+ function uninstallHooksLocked(repo, dir, warnings) {
362
+ const chainedDir = chainDir(repo);
363
+ const removed = [];
364
+ const restored = [];
365
+ const failures = [];
366
+ for (const name of Object.keys(MANAGED)) {
367
+ const dest = join(dir, name);
368
+ let current;
369
+ try {
370
+ current = entry(dest);
371
+ } catch (error) {
372
+ failures.push(`${name}: cannot inspect hook: ${error.message}`);
373
+ continue;
374
+ }
375
+ if (!current) continue;
376
+ if (current.isSymbolicLink()) {
377
+ warnings.push(symlinkWarning(name));
378
+ continue;
379
+ }
380
+ try {
381
+ const chained = join(chainedDir, name);
382
+ const content = readFileSync(dest, 'utf8');
383
+ if (!ownedHook(content, name)) continue;
384
+ if (!ownedForChain(content, name, chained)) {
385
+ warnings.push(`${name} is managed for another repository; refusing to remove it`);
386
+ continue;
387
+ }
388
+ const predecessor = entry(chained);
389
+ if (predecessor?.isSymbolicLink()) {
390
+ warnings.push(`${name} chained backup is a symlink; refusing to restore it`);
391
+ continue;
392
+ }
393
+ if (predecessor) {
394
+ const predecessorMode = predecessor.mode & 0o777;
395
+ atomicWrite(dest, readFileSync(chained), { mode: predecessorMode });
396
+ chmodSync(dest, predecessorMode);
397
+ unlinkSync(chained);
398
+ restored.push(name);
399
+ } else {
400
+ unlinkSync(dest);
401
+ }
402
+ removed.push(name);
403
+ } catch (error) {
404
+ failures.push(`${name}: ${error.message}`);
405
+ }
406
+ }
407
+ return { removed: removed.sort(), restored: restored.sort(), warnings, failures };
408
+ }
409
+
410
+ // hookDiagnostics reports both dispatcher integrity and whether its configured
411
+ // command can be reached from the current environment.
412
+ export function hookDiagnostics(repo) {
413
+ const { dir, shared } = hooksDir(repo);
414
+ // Global hooks (installGlobalHooks) keep chained backups in the hooks dir
415
+ // itself; local hooks keep them in repo state via chainDir. Diagnose the
416
+ // path the dispatcher actually embeds, not the repo-relative default.
417
+ const globalDir = globalHooksDir();
418
+ const chained = resolve(dir) === resolve(globalDir)
419
+ ? join(globalDir, 'chained')
420
+ : chainDir(repo);
421
+ return Object.keys(MANAGED).sort().map((name) => {
422
+ const path = join(dir, name);
423
+ const base = { name, path, chainedPath: join(chained, name), shared };
424
+ let stat;
425
+ try {
426
+ stat = entry(path);
427
+ } catch (error) {
428
+ return {
429
+ ...base,
430
+ managed: false,
431
+ reachable: false,
432
+ reason: `cannot inspect hook: ${error.message}`,
433
+ };
434
+ }
435
+ if (!stat) return { ...base, managed: false, reachable: false, reason: 'missing' };
436
+ if (stat.isSymbolicLink()) {
437
+ return { ...base, managed: false, reachable: false, reason: 'symlink' };
438
+ }
439
+ let content;
440
+ try {
441
+ content = readFileSync(path, 'utf8');
442
+ } catch (error) {
443
+ return {
444
+ ...base,
445
+ managed: false,
446
+ reachable: false,
447
+ reason: `unreadable: ${error.message}`,
448
+ };
449
+ }
450
+ const inspected = inspectHook(content, name);
451
+ const executable = hookFileExecutable(stat);
452
+ if (!inspected.valid) {
453
+ return {
454
+ ...base,
455
+ managed: false,
456
+ reachable: false,
457
+ executable,
458
+ reason: inspected.reason,
459
+ };
460
+ }
461
+ if (!ownedForChain(content, name, base.chainedPath)) {
462
+ return {
463
+ ...base,
464
+ managed: false,
465
+ reachable: false,
466
+ executable,
467
+ reason: 'managed for another repository',
468
+ };
469
+ }
470
+ const nodeReachable = regularExecutableFile(inspected.nodePath);
471
+ const embeddedReachable = regularReadableFile(inspected.cliPath);
472
+ const gitReachable = pathCommandReachable('git', inspected.pathValue);
473
+ let chainedSafe = true;
474
+ try {
475
+ chainedSafe = !entry(base.chainedPath)?.isSymbolicLink();
476
+ } catch {
477
+ chainedSafe = false;
478
+ }
479
+ const reachable = nodeReachable
480
+ && embeddedReachable
481
+ && gitReachable
482
+ && chainedSafe
483
+ && inspected.shellPathCompatible;
484
+ return {
485
+ ...base,
486
+ managed: true,
487
+ reachable,
488
+ executable,
489
+ version: inspected.version,
490
+ fingerprint: inspected.fingerprint,
491
+ cliPath: inspected.cliPath,
492
+ nodePath: inspected.nodePath,
493
+ embeddedReachable,
494
+ nodeReachable,
495
+ gitReachable,
496
+ chainedSafe,
497
+ shellPathCompatible: inspected.shellPathCompatible,
498
+ reason: !executable
499
+ ? 'not executable'
500
+ : reachable
501
+ ? ''
502
+ : !chainedSafe
503
+ ? 'chained predecessor is a symlink'
504
+ : !inspected.shellPathCompatible
505
+ ? 'dispatcher PATH is incompatible with the Git shell; run aimhooman init again'
506
+ : 'aimhooman command is not reachable',
507
+ };
508
+ });
509
+ }
510
+
511
+ // installedHooks lists current, executable dispatchers whose fingerprint and
512
+ // command reachability have both been verified.
513
+ export function installedHooks(repo) {
514
+ return hookDiagnostics(repo)
515
+ .filter((hook) => hook.managed && hook.executable && hook.reachable)
516
+ .map((hook) => hook.name)
517
+ .sort();
518
+ }
519
+
520
+ // activeGitHook reports whether Git can execute an unmanaged hook that runs
521
+ // outside aimhooman's dispatcher chain.
522
+ export function activeGitHook(repo, name) {
523
+ if (!/^[a-z][a-z0-9-]*$/.test(String(name))) throw new Error('invalid Git hook name');
524
+ const path = join(effectiveHooksDir(repo), name);
525
+ try {
526
+ const stat = statSync(path);
527
+ return { name, path, active: hookFileExecutable(stat) };
528
+ } catch (error) {
529
+ if (error?.code === 'ENOENT') return { name, path, active: false };
530
+ throw error;
531
+ }
532
+ }
533
+
534
+ function hookScript(name, cmd, cliPath, chainedPath) {
535
+ const resolvedCliPath = resolve(String(cliPath));
536
+ const cliMetadata = Buffer.from(resolvedCliPath, 'utf8').toString('base64url');
537
+ const nodePath = process.execPath;
538
+ const pathValue = String(process.env.PATH || '')
539
+ .split(delimiter)
540
+ .filter((directory) => directory && isAbsolute(directory))
541
+ .join(delimiter);
542
+ const shellPathValue = hookPathForShell(pathValue);
543
+ const nodeMetadata = Buffer.from(nodePath, 'utf8').toString('base64url');
544
+ const pathMetadata = Buffer.from(pathValue, 'utf8').toString('base64url');
545
+ const captureTree = name === 'commit-msg'
546
+ ? `AIMHOOMAN_COMMIT_TREE=$(PATH="$AIMHOOMAN_PATH" git write-tree) || {
547
+ echo "aimhooman: cannot snapshot the would-be commit tree" >&2
548
+ exit 30
549
+ }
550
+ `
551
+ : '';
552
+ const captureTransaction = name === 'reference-transaction'
553
+ ? `AIMHOOMAN_REF_UPDATES=$(
554
+ while IFS= read -r AIMHOOMAN_REF_UPDATE || [ -n "$AIMHOOMAN_REF_UPDATE" ]; do
555
+ printf '%s\\n' "$AIMHOOMAN_REF_UPDATE" || exit $?
556
+ done
557
+ ) || exit $?
558
+ `
559
+ : '';
560
+ const chainedInvocation = name === 'reference-transaction'
561
+ ? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | "$CHAINED" "$@" || exit $?`
562
+ : ` "$CHAINED" "$@" || exit $?`;
563
+ const aimCommand = name === 'commit-msg'
564
+ ? 'commitmsg "$1" --tree "$AIMHOOMAN_COMMIT_TREE"'
565
+ : name === 'reference-transaction'
566
+ ? 'refcheck "$1"'
567
+ : cmd;
568
+ const aimInvocation = name === 'reference-transaction'
569
+ ? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
570
+ : `run_aimhooman ${aimCommand} || exit $?`;
571
+ const template = `#!/bin/sh -p
572
+ ${MARKER} (${name})
573
+ # aimhooman-hook-version: ${HOOK_FORMAT_VERSION}
574
+ # aimhooman-hook-fingerprint: ${FINGERPRINT_PLACEHOLDER}
575
+ # aimhooman-cli-base64url: ${cliMetadata}
576
+ # aimhooman-node-base64url: ${nodeMetadata}
577
+ # aimhooman-path-base64url: ${pathMetadata}
578
+ # Managed by aimhooman. Remove with: aimhooman uninstall
579
+ AIMHOOMAN_CLI=${shq(resolvedCliPath)}
580
+ AIMHOOMAN_NODE=${shq(nodePath)}
581
+ AIMHOOMAN_PATH=${shq(shellPathValue)}
582
+ ${captureTree}${captureTransaction}run_aimhooman() {
583
+ if [ ! -f "$AIMHOOMAN_CLI" ] || [ ! -f "$AIMHOOMAN_NODE" ]; then
584
+ echo "aimhooman: guard unavailable; install it or run 'aimhooman init' again" >&2
585
+ return 127
586
+ fi
587
+ (
588
+ unset NODE_OPTIONS NODE_PATH NODE_REPL_EXTERNAL_MODULE NODE_EXTRA_CA_CERTS
589
+ unset NODE_CHANNEL_FD NODE_CHANNEL_SERIALIZATION_MODE NODE_V8_COVERAGE NODE_DEBUG
590
+ unset LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES DYLD_LIBRARY_PATH
591
+ unset DYLD_FRAMEWORK_PATH DYLD_FALLBACK_LIBRARY_PATH DYLD_FALLBACK_FRAMEWORK_PATH
592
+ unset BASH_ENV ENV CDPATH
593
+ PATH=$AIMHOOMAN_PATH
594
+ AIMHOOMAN_ACTIVE_HOOK=${shq(name)}
595
+ export PATH
596
+ export AIMHOOMAN_ACTIVE_HOOK
597
+ "$AIMHOOMAN_NODE" "$AIMHOOMAN_CLI" "$@"
598
+ )
599
+ }
600
+ CHAINED=${shq(chainedPath)}
601
+ if [ -L "$CHAINED" ]; then
602
+ echo "aimhooman: chained hook is a symlink; run 'aimhooman init' after restoring the original hook" >&2
603
+ exit 126
604
+ fi
605
+ if [ -x "$CHAINED" ]; then
606
+ ${chainedInvocation}
607
+ fi
608
+ ${aimInvocation}
609
+ `;
610
+ const fingerprint = hookFingerprint(template);
611
+ return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);
612
+ }
613
+
614
+ function inspectHook(content, expectedName) {
615
+ const text = String(content);
616
+ const header = new RegExp(
617
+ `^#!\\/bin\\/sh(?: -p)?\\n${escapeRegExp(MARKER)} \\(${escapeRegExp(expectedName)}\\)\\n` +
618
+ `# aimhooman-hook-version: (\\d+)\\n` +
619
+ '# aimhooman-hook-fingerprint: ([a-f0-9]{64})\\n' +
620
+ '# aimhooman-cli-base64url: ([A-Za-z0-9_-]*)\\n' +
621
+ '(?:# aimhooman-node-base64url: ([A-Za-z0-9_-]*)\\n)?' +
622
+ '(?:# aimhooman-path-base64url: ([A-Za-z0-9_-]*)\\n)?'
623
+ );
624
+ const match = text.match(header);
625
+ if (!match) return { valid: false, reason: 'managed header is missing or malformed' };
626
+ const version = Number(match[1]);
627
+ const fingerprint = match[2];
628
+ const normalized = text.replace(
629
+ `# aimhooman-hook-fingerprint: ${fingerprint}`,
630
+ `# aimhooman-hook-fingerprint: ${FINGERPRINT_PLACEHOLDER}`
631
+ );
632
+ if (hookFingerprint(normalized) !== fingerprint) {
633
+ return { valid: false, reason: 'managed hook fingerprint does not match' };
634
+ }
635
+ if (version !== HOOK_FORMAT_VERSION) {
636
+ return {
637
+ valid: false,
638
+ version,
639
+ fingerprint,
640
+ reason: `unsupported managed hook version ${match[1]}`,
641
+ };
642
+ }
643
+ const cliPath = decodeMetadata(match[3]);
644
+ const nodePath = decodeMetadata(match[4]);
645
+ const pathValue = decodeMetadata(match[5]);
646
+ if (cliPath === null || nodePath === null || pathValue === null) {
647
+ return { valid: false, reason: 'managed CLI metadata is invalid' };
648
+ }
649
+ const nativePathAssignment = `AIMHOOMAN_PATH=${shq(pathValue)}\n`;
650
+ const shellPathAssignment = `AIMHOOMAN_PATH=${shq(hookPathForShell(pathValue))}\n`;
651
+ const shellPathCompatible = text.includes(shellPathAssignment);
652
+ if (
653
+ !isAbsolute(cliPath)
654
+ || !isAbsolute(nodePath)
655
+ || !text.includes(`AIMHOOMAN_CLI=${shq(cliPath)}\n`)
656
+ || !text.includes(`AIMHOOMAN_NODE=${shq(nodePath)}\n`)
657
+ || (!shellPathCompatible && !text.includes(nativePathAssignment))
658
+ ) {
659
+ return { valid: false, reason: 'managed CLI metadata does not match the dispatcher' };
660
+ }
661
+ return {
662
+ valid: true,
663
+ owned: true,
664
+ version,
665
+ fingerprint,
666
+ cliPath,
667
+ nodePath,
668
+ pathValue,
669
+ shellPathCompatible,
670
+ };
671
+ }
672
+
673
+ // Git executes hooks with a POSIX shell even on Windows. Keep the native PATH
674
+ // in authenticated metadata for diagnostics, but render drive and UNC entries
675
+ // in the form Git for Windows' shell accepts. MSYS converts the exported value
676
+ // back to a native PATH when the dispatcher starts the embedded Node binary.
677
+ export function hookPathForShell(pathValue, platform = process.platform) {
678
+ const value = String(pathValue || '');
679
+ if (platform !== 'win32') return value;
680
+ return value.split(';').filter(Boolean).map((directory) => {
681
+ const normalized = directory.replace(/\\/g, '/');
682
+ const extendedUnc = normalized.match(/^\/\/\?\/UNC\/(.+)$/i);
683
+ if (extendedUnc) return `//${extendedUnc[1]}`;
684
+ const drive = normalized.match(/^(?:\/\/\?\/)?([A-Za-z]):(?:\/(.*))?$/);
685
+ if (drive) {
686
+ return `/${drive[1].toLowerCase()}${drive[2] === undefined ? '' : `/${drive[2]}`}`;
687
+ }
688
+ return normalized.startsWith('/') ? normalized : '';
689
+ }).filter(Boolean).join(':');
690
+ }
691
+
692
+ function hookFingerprint(content) {
693
+ return createHash('sha256').update(content, 'utf8').digest('hex');
694
+ }
695
+
696
+ function ownedHook(content, name) {
697
+ const inspected = inspectHook(content, name);
698
+ return inspected.valid || legacyHook(content, name);
699
+ }
700
+
701
+ function ownedForChain(content, name, chainedPath) {
702
+ if (!ownedHook(content, name)) return false;
703
+ return assignmentValue(content, 'CHAINED') === String(chainedPath);
704
+ }
705
+
706
+ function decodeMetadata(value) {
707
+ if (typeof value !== 'string') return null;
708
+ const decoded = Buffer.from(value, 'base64url').toString('utf8');
709
+ return Buffer.from(decoded, 'utf8').toString('base64url') === value ? decoded : null;
710
+ }
711
+
712
+ // Recognize only the exact pre-fingerprint dispatcher so an existing install
713
+ // can be upgraded without treating an arbitrary copied marker as ownership.
714
+ function legacyHook(content, name) {
715
+ if (!Object.hasOwn(LEGACY_MANAGED, name)) return false;
716
+ const cli = assignmentValue(content, 'AIMHOOMAN_CLI');
717
+ const chained = assignmentValue(content, 'CHAINED');
718
+ if (cli === null || chained === null) return false;
719
+ return content === legacyHookScript(name, LEGACY_MANAGED[name], cli, chained);
720
+ }
721
+
722
+ function assignmentValue(content, key) {
723
+ const match = String(content).match(new RegExp(`^${key}=(.*)$`, 'm'));
724
+ if (!match || !match[1].startsWith("'") || !match[1].endsWith("'")) return null;
725
+ return match[1].slice(1, -1).split("'\\''").join("'");
726
+ }
727
+
728
+ function legacyHookScript(name, cmd, cliPath, chainedPath) {
729
+ return `#!/bin/sh
730
+ ${MARKER} (${name})
731
+ # Managed by aimhooman. Remove with: aimhooman uninstall
732
+ AIMHOOMAN_CLI=${shq(cliPath)}
733
+ run_aimhooman() {
734
+ if [ -f "$AIMHOOMAN_CLI" ] && command -v node >/dev/null 2>&1; then
735
+ node "$AIMHOOMAN_CLI" "$@"
736
+ elif command -v aimhooman >/dev/null 2>&1; then
737
+ aimhooman "$@"
738
+ else
739
+ echo "aimhooman: not found; skipping guard (npm i -g @rmyndharis/aimhooman)" >&2
740
+ return 0
741
+ fi
742
+ }
743
+ run_aimhooman ${cmd} || exit $?
744
+ CHAINED=${shq(chainedPath)}
745
+ if [ -x "$CHAINED" ]; then
746
+ "$CHAINED" "$@" || exit $?
747
+ fi
748
+ `;
749
+ }
750
+
751
+ function regularReadableFile(path) {
752
+ try {
753
+ if (!statSync(path).isFile()) return false;
754
+ accessSync(path, constants.R_OK);
755
+ return true;
756
+ } catch {
757
+ return false;
758
+ }
759
+ }
760
+
761
+ function regularExecutableFile(path) {
762
+ try {
763
+ if (!statSync(path).isFile()) return false;
764
+ accessSync(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
765
+ return true;
766
+ } catch {
767
+ return false;
768
+ }
769
+ }
770
+
771
+ export function pathCommandReachable(
772
+ command,
773
+ pathValue = process.env.PATH,
774
+ platform = process.platform,
775
+ ) {
776
+ // Node launches Git without a command shell. Windows batch files listed in
777
+ // PATHEXT are therefore not valid substitutes for git.exe, even though a
778
+ // shell lookup would find them.
779
+ const extensions = platform === 'win32' ? ['.exe'] : [''];
780
+ const pathDelimiter = platform === 'win32' ? ';' : delimiter;
781
+ for (const directory of String(pathValue || '').split(pathDelimiter).filter(Boolean)) {
782
+ for (const extension of extensions) {
783
+ const candidate = join(directory, command + extension);
784
+ try {
785
+ if (!statSync(candidate).isFile()) continue;
786
+ accessSync(candidate, platform === 'win32' ? constants.F_OK : constants.X_OK);
787
+ return true;
788
+ } catch {
789
+ /* keep looking */
790
+ }
791
+ }
792
+ }
793
+ return false;
794
+ }
795
+
796
+ export function hookFileExecutable(stat, platform = process.platform) {
797
+ if (!stat?.isFile?.()) return false;
798
+ // Bit-mask approximation (any exec bit set). This diverges from git's
799
+ // euid-aware access(X_OK) only for group/world-only exec modes (e.g. 0o770
800
+ // set by a sharing tool) in shared-ownership repos — out of scope for this
801
+ // tool, which writes 0o755 hooks for individual developers. git enforces the
802
+ // real exec check at hook time; this is a diagnostic only. regularExecutableFile
803
+ // uses accessSync(X_OK) where a live path is available.
804
+ return platform === 'win32' || Boolean(stat.mode & 0o111);
805
+ }
806
+
807
+ function escapeRegExp(value) {
808
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
809
+ }
810
+
811
+ function shq(s) {
812
+ return "'" + String(s).replace(/'/g, `'\\''`) + "'";
813
+ }