@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/src/hook.mjs CHANGED
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process';
3
3
  import { homedir } from 'node:os';
4
4
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { GIT_TIMEOUT_MS } from './git-environment.mjs';
6
7
  import { newEngineWithDiagnostics } from './scan.mjs';
7
8
  import { openRepo, stagedEntries } from './gitx.mjs';
8
9
  import { applyExclude, patternsForRules } from './exclude.mjs';
@@ -236,6 +237,14 @@ function hookPreToolUse(input) {
236
237
  }
237
238
  targetRepo = openRepo(gitCommand.cwd);
238
239
  const targetProfile = enforcementPolicy(targetRepo).profile;
240
+ // Some ref-mutation verbs have read-only listing forms that move no
241
+ // ref and so cannot bypass the reference-transaction guard. Reading
242
+ // a repository (`git branch | grep`, `git remote -v | grep origin`,
243
+ // `git stash list | head`) is everyday work; refusing it behind a
244
+ // pipeline forced developers out of their normal workflow. A real
245
+ // mutation still carries a mutating flag or positional and stays
246
+ // subject to every check below.
247
+ if (gitReadOnlyRefCommand(gitCommand.verb, gitCommand.args || [])) continue;
239
248
  // An unresolved subcommand/alias may itself move a ref. When its
240
249
  // hook path or execution context is altered, there is no safe
241
250
  // content snapshot to fall back to, so treat it like a direct ref
@@ -284,6 +293,18 @@ function hookPreToolUse(input) {
284
293
  "Run 'aimhooman init' and retry."
285
294
  );
286
295
  }
296
+ // The hooks are installed but this command routes around them. Unlike
297
+ // --no-verify, which leaves reference-transaction to catch the ref
298
+ // update, an overridden hooks path removes every managed guard at
299
+ // once, so there is nothing downstream to delegate to. Persisting the
300
+ // same override already denies, so this keeps the two forms agreeing.
301
+ if (gitCommand.verb === 'commit' && gitCommand.hooksPathOverride) {
302
+ return emitDecision(
303
+ 'deny',
304
+ 'aimhooman cannot verify the managed pre-commit and reference-transaction guards for '
305
+ + 'git commit when the command overrides the hooks path; run the commit without the override.'
306
+ );
307
+ }
287
308
  if (targetProfile !== 'strict') continue;
288
309
  if (parsed.uncertainShell || gitCommand.classification === 'uncertain') {
289
310
  return emitDecision(
@@ -330,7 +351,11 @@ function hookPreToolUse(input) {
330
351
  }
331
352
  }
332
353
  let eng;
354
+ // diagnosticWarning is about the rule pack and can stop a strict command;
355
+ // hygieneWarning is housekeeping that never touches the decision. Kept apart
356
+ // because only one of the two is ever grounds to deny.
333
357
  let diagnosticWarning = '';
358
+ let hygieneWarning = '';
334
359
  try {
335
360
  const loaded = repo
336
361
  ? engineForPolicy(repo, policy, policy.head)
@@ -343,14 +368,26 @@ function hookPreToolUse(input) {
343
368
  return emitDecision('deny', `aimhooman strict policy could not load local rules: ${diagnosticWarning}`);
344
369
  }
345
370
  }
346
- if (repo) applyExclude(repo.excludeFile, patternsForRules(eng.rules));
347
371
  } catch (e) {
348
372
  const reason = e?.name === 'LocalOverridesError'
349
373
  ? `aimhooman cannot load local overrides: ${e.message}`
350
374
  : `aimhooman could not load policy rules: ${e.message}`;
351
375
  if (e?.name === 'LocalOverridesError') return emitDecision('deny', reason);
352
376
  if (profile === 'strict') return emitDecision('deny', reason);
353
- return emitDecision('allow', `${reason}; continuing because profile=${profile}`);
377
+ // Rules that will not load are the least basis there is for granting an
378
+ // allow, and clean/compliance do not deny on them. Say nothing instead.
379
+ return 0;
380
+ }
381
+ // Refreshing the excludes is gitignore hygiene, not part of the verdict:
382
+ // pre-commit never writes them and still answers. Kept out of the block
383
+ // above so a read-only .git/info (CI checkout, read-only volume, a
384
+ // repository owned by another user) cannot decide what is allowed.
385
+ if (repo) {
386
+ try {
387
+ applyExclude(repo.excludeFile, patternsForRules(eng.rules));
388
+ } catch (e) {
389
+ hygieneWarning = `could not refresh ${repo.excludeFile}: ${e.message}`;
390
+ }
354
391
  }
355
392
  // A strict policy cannot make a meaningful guarantee if Git's own guards
356
393
  // are explicitly bypassed. Deny before the shell can stage-and-commit in a
@@ -366,16 +403,39 @@ function hookPreToolUse(input) {
366
403
  // also a potential pre-commit bypass. Strict rejects it above; clean and
367
404
  // compliance still need the staged-content backstop so a hook mutation
368
405
  // cannot expose a secret that was already in the index.
369
- const prefixBypass = parsed.commands.some((candidate) => (
406
+ const commitPrefixRisk = parsed.commands.some((candidate) => (
370
407
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
371
408
  && candidate.prefixRisk
372
- )) || (parsed.opaqueCommitHiding && parsed.commands.length === 0 && parsed.prefixRisk);
409
+ ));
410
+ // Nothing here was modelled, yet the shape can still hide a commit — a pipe
411
+ // into a shell, a fed script. There is no argv to read a --no-verify out of,
412
+ // which is the reason to refuse rather than a reason to let it past.
413
+ const opaqueCommitRisk = parsed.opaqueCommitHiding
414
+ && parsed.commands.length === 0
415
+ && parsed.prefixRisk;
416
+ const prefixBypass = commitPrefixRisk || opaqueCommitRisk;
373
417
  const aliasBypass = parsed.commands.some((candidate) => (
374
418
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
375
419
  && candidate.inlineAliasRisk
376
420
  ));
377
421
  const hiddenBypass = noVerify || bypassHooks || parsed.uncertainShell
378
422
  || prefixBypass || aliasBypass;
423
+ // Being unmodelled is not the same as being a bypass. Once a commit has been
424
+ // read out of the command, a prefix earns the refusal below only when it can
425
+ // take the guard away: it names the hooks, or the commit already bypasses
426
+ // them. A build, a test run, ls — those leave pre-commit to answer, and
427
+ // refusing them taught agents to drop the `&&` gate rather than to run the
428
+ // command separately. hiddenBypass stays wider on purpose; it is what keeps
429
+ // the staged-content backstop reading the blobs, so a secret already in the
430
+ // index still stops the commit here.
431
+ const prefixHookBypass = opaqueCommitRisk
432
+ || (commitPrefixRisk && (noVerify || bypassHooks || parsed.prefixHooksRisk));
433
+ // The same distinction, for the deny paths that ask "will anything scan this
434
+ // commit". hiddenBypass answers a different question — "should the backstop
435
+ // read the blobs" — and answering the first with the second refuses
436
+ // `build && git add . && git commit`, which is how most commits get made.
437
+ const guardBypass = noVerify || bypassHooks || parsed.uncertainShell
438
+ || prefixHookBypass || aliasBypass;
379
439
  const bypassContext = aliasBypass
380
440
  ? 'an inline Git alias cannot be proved to preserve the pre-commit guard'
381
441
  : prefixBypass
@@ -442,7 +502,7 @@ function hookPreToolUse(input) {
442
502
  const indexReplacement = parsed.commands.some((candidate) => (
443
503
  candidate.verb === 'commit' && candidate.indexMutationRisk
444
504
  ));
445
- if (profile !== 'strict' && commit && hiddenBypass
505
+ if (profile !== 'strict' && commit && guardBypass
446
506
  && parsed.commands.some((c) => c.verb === 'commit' && c.futureIndex)) {
447
507
  if (indexReplacement) {
448
508
  return emitDecision(
@@ -459,15 +519,17 @@ function hookPreToolUse(input) {
459
519
  );
460
520
  }
461
521
  if (blocks.length === 0) {
462
- if (profile !== 'strict' && potentialCommit && prefixBypass) {
522
+ if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
463
523
  return emitDecision(
464
524
  'deny',
465
525
  unmodelledPrefixReason,
466
526
  );
467
527
  }
468
- if (diagnosticWarning) {
469
- return emitDecision('allow', `aimhooman warning: ${diagnosticWarning}; invalid local pack skipped`);
470
- }
528
+ // Nothing found and nothing to object to, so emit nothing and leave the
529
+ // host's permission rules in charge. An allow auto-approves the call and
530
+ // skips them, and neither warning is an opinion about this command: one
531
+ // is about the rule pack, the other about a housekeeping write. Both
532
+ // still reach the notes on the findings path below.
471
533
  return 0;
472
534
  }
473
535
 
@@ -487,7 +549,7 @@ function hookPreToolUse(input) {
487
549
  `aimhooman blocked this: ${bypassContext}, so ${reasonParts(blocks).join('; ')} would be committed. Run the commit separately without the bypass, or unstage it. AI works, hoomans ship.`,
488
550
  );
489
551
  }
490
- if (profile !== 'strict' && potentialCommit && prefixBypass) {
552
+ if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
491
553
  return emitDecision(
492
554
  'deny',
493
555
  unmodelledPrefixReason,
@@ -495,10 +557,8 @@ function hookPreToolUse(input) {
495
557
  }
496
558
  const guarded = repo && !bypassed && installedHooks(repo).includes('pre-commit');
497
559
  const advisory = advisoryReason(blocks, guarded, bypassed ? bypassContext : '');
498
- return emitDecision(
499
- 'allow',
500
- diagnosticWarning ? `${advisory} Warning: ${diagnosticWarning}.` : advisory
501
- );
560
+ const notes = [diagnosticWarning, hygieneWarning].filter(Boolean).join('; ');
561
+ return emitDecision('allow', notes ? `${advisory} Warning: ${notes}.` : advisory);
502
562
  }
503
563
 
504
564
  function toolName(i) {
@@ -625,6 +685,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
625
685
  classification: 'none',
626
686
  environmentRisk: [],
627
687
  prefixRisk: false,
688
+ prefixHooksRisk: false,
628
689
  addPaths,
629
690
  commands,
630
691
  };
@@ -640,6 +701,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
640
701
  const persistentEnvironmentRisk = new Set();
641
702
  let prefixRisk = false;
642
703
  let prefixIndexMutationRisk = false;
704
+ let prefixHooksRisk = false;
643
705
  let policyTransitionRisk = false;
644
706
  for (const unit of shellUnits(cmd)) {
645
707
  const precedingOperator = previousOperator;
@@ -776,6 +838,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
776
838
  if (toks.length < 2) {
777
839
  if (toks.length && !READ_ONLY_SHELL_COMMANDS.has(basename(toks[0]))) {
778
840
  prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
841
+ prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
779
842
  prefixRisk = true;
780
843
  }
781
844
  continue;
@@ -822,6 +885,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
822
885
  }
823
886
  if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))) {
824
887
  prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
888
+ prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
825
889
  prefixRisk = true;
826
890
  if (CURRENT_SHELL_MUTATORS.has(shellExecutable(g0)) || definesShellFunction) {
827
891
  shellTargetUncertain = true;
@@ -832,6 +896,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
832
896
  let i = 1;
833
897
  let cwd = unwrapped.cwd;
834
898
  let commandBypass = environmentRisk.length > 0;
899
+ let commandHooksPathOverride = environmentRisk.some(gitConfigEnvName);
835
900
  let aliasResolutionRisk = !canResolveGitAlias(g0);
836
901
  let inlineAliasRisk = false;
837
902
  while (i < toks.length && toks[i].startsWith('-')) {
@@ -851,6 +916,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
851
916
  i += 1;
852
917
  } else if (option === '-c' && toks[i + 1]) {
853
918
  if (hookAffectingConfig(toks[i + 1])) commandBypass = true;
919
+ if (hooksPathOverrideConfig(toks[i + 1])) commandHooksPathOverride = true;
854
920
  if (aliasAffectingConfig(toks[i + 1])) {
855
921
  aliasResolutionRisk = true;
856
922
  inlineAliasRisk = true;
@@ -858,6 +924,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
858
924
  i += 2;
859
925
  } else if (option.startsWith('-c') && option.length > 2) {
860
926
  if (hookAffectingConfig(option.slice(2))) commandBypass = true;
927
+ if (hooksPathOverrideConfig(option.slice(2))) commandHooksPathOverride = true;
861
928
  if (aliasAffectingConfig(option.slice(2))) {
862
929
  aliasResolutionRisk = true;
863
930
  inlineAliasRisk = true;
@@ -865,6 +932,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
865
932
  i += 1;
866
933
  } else if (option === '--config-env' && toks[i + 1]) {
867
934
  if (hookAffectingConfig(toks[i + 1])) commandBypass = true;
935
+ if (hooksPathOverrideConfig(toks[i + 1])) commandHooksPathOverride = true;
868
936
  if (aliasAffectingConfig(toks[i + 1])) {
869
937
  aliasResolutionRisk = true;
870
938
  inlineAliasRisk = true;
@@ -929,6 +997,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
929
997
  cwd,
930
998
  noVerify: false,
931
999
  bypassHooks: commandBypass,
1000
+ hooksPathOverride: commandHooksPathOverride,
932
1001
  futureIndex: true,
933
1002
  classification: 'uncertain',
934
1003
  environmentRisk,
@@ -954,6 +1023,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
954
1023
  cwd,
955
1024
  noVerify: commandNoVerify,
956
1025
  bypassHooks: commandBypass,
1026
+ hooksPathOverride: commandHooksPathOverride,
957
1027
  futureIndex,
958
1028
  editorRisk: commitOptions.editorRisk,
959
1029
  classification: uncertainShell
@@ -989,9 +1059,16 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
989
1059
  });
990
1060
  sawAdd = true;
991
1061
  } else if (verb === 'config') {
992
- prefixRisk ||= gitConfigMayMutate(toks.slice(i + 1));
1062
+ if (gitConfigMayMutate(toks.slice(i + 1))) {
1063
+ prefixRisk = true;
1064
+ prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
1065
+ }
993
1066
  } else if (verb === 'init') {
1067
+ // init copies the hook templates into the hooks directory, and
1068
+ // --separate-git-dir moves the directory itself, so the hooks read
1069
+ // here are not necessarily the ones the commit runs.
994
1070
  prefixRisk = true;
1071
+ prefixHooksRisk = true;
995
1072
  } else if (!SAFE_NON_COMMIT_GIT.has(verb)) {
996
1073
  commands.push({
997
1074
  verb: 'unknown',
@@ -1080,6 +1157,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
1080
1157
  classification,
1081
1158
  environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
1082
1159
  prefixRisk,
1160
+ prefixHooksRisk,
1083
1161
  addPaths,
1084
1162
  commands,
1085
1163
  };
@@ -1185,6 +1263,22 @@ function commandMayReplaceIndex(tokens, source) {
1185
1263
  || /\bgit\s+rev-parse\b[^;&|\n]*\b--git-path(?:=|\s+)index\b/i.test(text);
1186
1264
  }
1187
1265
 
1266
+ // The same shape, asking the other question a prefix raises: it runs in the repo
1267
+ // before Git starts, with write access to the hooks. A build or a test suite
1268
+ // cannot take pre-commit away; writing into the hooks directory or repointing
1269
+ // core.hooksPath can. Literal, like the index check above, so an ordinary
1270
+ // command does not read as a bypass merely for being unmodelled.
1271
+ // An include never names the hooks; it carries a core.hooksPath of its own and
1272
+ // the guard is gone all the same. hooksPathOverrideConfig already counts it for
1273
+ // the -c spelling, so leaving it out here would let the two spellings of one
1274
+ // bypass disagree.
1275
+ function commandMayTouchHooks(tokens, source) {
1276
+ const text = `${tokens.join(' ')} ${source}`.replace(/\\/g, '/');
1277
+ return /(?:^|[\s"'=/])\.git\/hooks(?:$|[\s"'/])/i.test(text)
1278
+ || /\bhookspath\b/i.test(text)
1279
+ || /\binclude(?:if\.[^\s"'=]*)?\.path\b/i.test(text);
1280
+ }
1281
+
1188
1282
  const GIT_POLICY_TRANSITION_COMMANDS = new Set([
1189
1283
  'am', 'branch', 'checkout', 'cherry-pick', 'commit', 'merge', 'pull', 'read-tree', 'rebase',
1190
1284
  'replace', 'reset', 'restore', 'revert', 'rm', 'switch', 'symbolic-ref', 'update-index',
@@ -1195,9 +1289,94 @@ const GIT_POLICY_TRANSITION_COMMANDS = new Set([
1195
1289
  // safety boundary is the managed reference-transaction hook, so PreToolUse
1196
1290
  // must not let config/environment/prefix indirection disable that boundary.
1197
1291
  const GIT_REF_MUTATION_COMMANDS = new Set([
1198
- 'am', 'bisect', 'branch', 'checkout', 'cherry-pick', 'fetch', 'merge', 'pull', 'push',
1199
- 'rebase', 'remote', 'replace', 'reset', 'revert', 'stash', 'switch', 'symbolic-ref',
1200
- 'update-ref', 'worktree',
1292
+ 'am', 'bisect', 'branch', 'checkout', 'cherry-pick', 'fetch', 'maintenance', 'merge', 'notes',
1293
+ 'pull', 'push', 'rebase', 'remote', 'replace', 'reset', 'revert', 'stash', 'switch',
1294
+ 'symbolic-ref', 'tag', 'update-ref', 'worktree',
1295
+ ]);
1296
+
1297
+ // gitReadOnlyRefCommand reports whether a ref-mutation verb is used in a
1298
+ // read-only listing form that cannot move a ref or mutate state, so the
1299
+ // reference-transaction guard has nothing to enforce. branch/remote/stash/notes
1300
+ // each have listing subcommands developers pipe every day (`git branch | grep`,
1301
+ // `git remote -v | grep`, `git stash list | head`); the mutating forms keep a
1302
+ // mutating flag or positional and fall through to the full guard. Conservative:
1303
+ // any unknown flag combination is treated as a mutation (fail-closed).
1304
+ const GIT_BRANCH_MUTATING_FLAGS = new Set([
1305
+ '-d', '--delete', '-D', '-m', '--move', '-M', '-c', '--copy', '-C',
1306
+ '-u', '--set-upstream-to', '--unset-upstream', '--set-upstream', '-t',
1307
+ '--track', '--unset-track', '-f', '--force',
1308
+ ]);
1309
+ const GIT_REMOTE_MUTATING_SUBCOMMANDS = new Set([
1310
+ 'add', 'rename', 'rm', 'remove', 'set-url', 'set-head', 'prune', 'update',
1311
+ 'get-url', 'set-urladd',
1312
+ ]);
1313
+ const GIT_STASH_MUTATING_SUBCOMMANDS = new Set([
1314
+ 'push', 'pop', 'apply', 'drop', 'clear', 'save', 'create', 'store', 'branch',
1315
+ ]);
1316
+ const GIT_NOTES_MUTATING_SUBCOMMANDS = new Set([
1317
+ 'add', 'append', 'copy', 'remove', 'rm', 'edit', 'prune',
1318
+ ]);
1319
+
1320
+ function gitReadOnlyRefCommand(verb, args = []) {
1321
+ const flags = args.filter((arg) => arg.startsWith('-'));
1322
+ const positionals = args.filter((arg) => !arg.startsWith('-'));
1323
+ if (verb === 'branch') {
1324
+ // `git branch`, `git branch -a/-r/-l/--list/-v/--verbose/--all/--remotes`
1325
+ // only list. A name, a start-point, or any create/delete/move/copy flag
1326
+ // is a mutation.
1327
+ if (flags.some((flag) => GIT_BRANCH_MUTATING_FLAGS.has(flag))) return false;
1328
+ const listingOnly = flags.every((flag) => (
1329
+ GIT_BRANCH_READONLY_FLAGS.has(flag)
1330
+ ));
1331
+ if (!listingOnly) return false;
1332
+ // A positional without --list/-l creates or moves a branch.
1333
+ if (positionals.length && !flags.some((flag) => flag === '-l' || flag === '--list')) {
1334
+ return false;
1335
+ }
1336
+ return true;
1337
+ }
1338
+ if (verb === 'remote') {
1339
+ // `git remote` and `git remote -v/--verbose` list; `git remote show <n>`
1340
+ // is read-only; everything else mutates.
1341
+ if (positionals.length === 0) return flags.every((flag) => flag === '-v' || flag === '--verbose');
1342
+ const sub = positionals[0];
1343
+ if (GIT_REMOTE_MUTATING_SUBCOMMANDS.has(sub)) return false;
1344
+ return sub === 'show';
1345
+ }
1346
+ if (verb === 'stash') {
1347
+ // No subcommand defaults to `push` (a mutation). Only list/show are read.
1348
+ const sub = positionals[0] || 'push';
1349
+ if (GIT_STASH_MUTATING_SUBCOMMANDS.has(sub)) return false;
1350
+ return sub === 'list' || sub === 'show';
1351
+ }
1352
+ if (verb === 'notes') {
1353
+ // No subcommand defaults to `list` (read). list/show are read-only.
1354
+ const sub = positionals[0] || 'list';
1355
+ if (GIT_NOTES_MUTATING_SUBCOMMANDS.has(sub)) return false;
1356
+ return sub === 'list' || sub === 'show';
1357
+ }
1358
+ if (verb === 'tag') {
1359
+ // `git tag` and `git tag -l/--list` only list. A name, -d/--delete, -f/--force,
1360
+ // -a/--annotate, -s, -m, or -u creates/moves/deletes a tag.
1361
+ if (flags.some((flag) => GIT_TAG_MUTATING_FLAGS.has(flag))) return false;
1362
+ if (positionals.length && !flags.some((flag) => flag === '-l' || flag === '--list')) {
1363
+ return false;
1364
+ }
1365
+ return true;
1366
+ }
1367
+ return false;
1368
+ }
1369
+
1370
+ const GIT_TAG_MUTATING_FLAGS = new Set([
1371
+ '-d', '--delete', '-f', '--force', '-a', '--annotate', '-s', '--sign',
1372
+ '-m', '-u', '--local-user', '-v', '--verify', '-n',
1373
+ ]);
1374
+
1375
+ const GIT_BRANCH_READONLY_FLAGS = new Set([
1376
+ '-a', '--all', '-r', '--remotes', '-l', '--list', '-v', '--verbose', '-vv',
1377
+ '-q', '--quiet', '--no-color', '--color',
1378
+ '--sort', '--format', '--contains', '--no-contains', '--merged', '--no-merged',
1379
+ '--points-at',
1201
1380
  ]);
1202
1381
 
1203
1382
  function gitCommandMayBypassRefGuard(verb, args = []) {
@@ -1213,7 +1392,12 @@ function configuredPushReceiver(repo) {
1213
1392
  return execFileSync(
1214
1393
  'git',
1215
1394
  ['config', '--get-regexp', '^remote\\..*\\.receivepack$'],
1216
- { cwd: repo.root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
1395
+ {
1396
+ cwd: repo.root,
1397
+ encoding: 'utf8',
1398
+ stdio: ['ignore', 'pipe', 'ignore'],
1399
+ timeout: GIT_TIMEOUT_MS,
1400
+ },
1217
1401
  ).trim().length > 0;
1218
1402
  } catch (error) {
1219
1403
  if (error?.status === 1) return false;
@@ -1268,17 +1452,25 @@ function gitConfigMayMutate(args) {
1268
1452
  return positionals.length > 1;
1269
1453
  }
1270
1454
 
1455
+ // A verb missing from this list is captured as 'unknown' before the ref-mutation
1456
+ // branch below is ever reached, so membership here is what lets a verb be
1457
+ // modelled at all. Ref-moving verbs need both lists, never this one alone.
1271
1458
  const SAFE_NON_COMMIT_GIT = new Set([
1272
1459
  'add', 'am', 'annotate', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle', 'cat-file',
1273
1460
  'check-attr', 'check-ignore', 'check-mailmap', 'check-ref-format', 'checkout',
1274
- 'cherry-pick', 'clean', 'clone', 'column', 'config', 'count-objects', 'credential', 'describe',
1461
+ 'cherry-pick', 'clean', 'clone', 'column', 'commit-graph', 'config', 'count-objects',
1462
+ 'credential', 'describe',
1275
1463
  'diff', 'diff-files', 'diff-index', 'diff-tree', 'difftool', 'fetch', 'for-each-ref',
1276
- 'fsck', 'gc', 'grep', 'hash-object', 'help', 'index-pack', 'init', 'interpret-trailers',
1277
- 'log', 'ls-files', 'ls-remote', 'ls-tree', 'merge', 'merge-base', 'mktag', 'mktree', 'mv',
1278
- 'name-rev', 'pack-objects', 'prune', 'pull', 'push', 'range-diff', 'read-tree', 'rebase',
1279
- 'reflog', 'remote', 'repack', 'replace', 'reset', 'restore', 'rev-list', 'rev-parse', 'revert',
1464
+ 'format-patch', 'fsck', 'gc', 'grep', 'hash-object', 'help', 'index-pack', 'init',
1465
+ 'interpret-trailers',
1466
+ 'log', 'ls-files', 'ls-remote', 'ls-tree', 'maintenance', 'merge', 'merge-base', 'merge-tree',
1467
+ 'mktag', 'mktree', 'mv',
1468
+ 'name-rev', 'notes', 'pack-objects', 'prune', 'pull', 'push', 'range-diff', 'read-tree',
1469
+ 'rebase',
1470
+ 'reflog', 'remote', 'repack', 'replace', 'rerere', 'reset', 'restore', 'rev-list', 'rev-parse',
1471
+ 'revert',
1280
1472
  'rm', 'shortlog', 'show', 'show-branch', 'show-ref', 'sparse-checkout', 'stash', 'status',
1281
- 'submodule', 'switch', 'symbolic-ref', 'tag', 'unpack-objects', 'update-index',
1473
+ 'stripspace', 'submodule', 'switch', 'symbolic-ref', 'tag', 'unpack-objects', 'update-index',
1282
1474
  'update-ref', 'verify-commit', 'verify-pack', 'verify-tag', 'version', 'whatchanged',
1283
1475
  'worktree', 'write-tree',
1284
1476
  ]);
@@ -1289,6 +1481,11 @@ function resolveGitAliases(parsed) {
1289
1481
  let aliasPolicyTransitionRisk = false;
1290
1482
  let aliasIndexMutationRisk = false;
1291
1483
  let aliasPrefixRisk = false;
1484
+ // parseGit derives prefixHooksRisk from the literal command text, and an
1485
+ // alias hides its expansion from that text entirely. Without this the alias
1486
+ // channel raises prefixRisk but never the hooks half, so a hooks-removing
1487
+ // prefix reached through an alias reads as an ordinary build step.
1488
+ let aliasHooksRisk = false;
1292
1489
  for (const candidate of parsed.commands) {
1293
1490
  let resolved = candidate;
1294
1491
  if (candidate.verb === 'unknown') {
@@ -1323,6 +1520,8 @@ function resolveGitAliases(parsed) {
1323
1520
  || effective.indexMutationRisk
1324
1521
  || gitIndexMutationRisk(effective.verb, effective.args);
1325
1522
  aliasPrefixRisk ||= effective.verb === 'unknown' || effective.prefixMutationRisk;
1523
+ aliasHooksRisk ||= Boolean(effective.prefixMutationRisk)
1524
+ && commandMayTouchHooks(effective.args || [], effective.aliasExpansion || '');
1326
1525
  }
1327
1526
  const commitCommands = commands.filter((candidate) => (
1328
1527
  candidate.verb === 'commit' || candidate.verb === 'unknown'
@@ -1336,6 +1535,7 @@ function resolveGitAliases(parsed) {
1336
1535
  commit: commitCommands.length > 0,
1337
1536
  noVerify,
1338
1537
  bypassHooks,
1538
+ prefixHooksRisk: Boolean(parsed.prefixHooksRisk || aliasHooksRisk),
1339
1539
  addPaths: [...parsed.addPaths, ...aliasAddPaths],
1340
1540
  commands,
1341
1541
  environmentRisk: unique(commitCommands.flatMap((candidate) => candidate.environmentRisk || [])),
@@ -1448,6 +1648,7 @@ function readGitAlias(cwd, name) {
1448
1648
  cwd,
1449
1649
  encoding: 'utf8',
1450
1650
  stdio: ['ignore', 'pipe', 'ignore'],
1651
+ timeout: GIT_TIMEOUT_MS,
1451
1652
  }).trim();
1452
1653
  } catch {
1453
1654
  return null;
@@ -1468,6 +1669,15 @@ function hookAffectingConfig(value) {
1468
1669
  return /^(?:core\.(?:editor|hookspath)|sequence\.editor|include\.path|includeif\..+\.path|remote\..+\.receivepack)$/i.test(key);
1469
1670
  }
1470
1671
 
1672
+ // A narrower question than hookAffectingConfig: not "can this change how a hook
1673
+ // behaves" but "does this take the managed hooks away". core.editor and
1674
+ // sequence.editor leave pre-commit, commit-msg and reference-transaction
1675
+ // running; these do not.
1676
+ function hooksPathOverrideConfig(value) {
1677
+ const key = String(value).split('=', 1)[0];
1678
+ return /^(?:core\.hookspath|include\.path|includeif\..+\.path)$/i.test(key);
1679
+ }
1680
+
1471
1681
  function aliasAffectingConfig(value) {
1472
1682
  const key = String(value).split('=', 1)[0];
1473
1683
  return /^alias\./i.test(key);
@@ -1759,8 +1969,12 @@ function shellUnits(s) {
1759
1969
  // never write files, and cannot bypass Git hooks. Anything not listed is treated
1760
1970
  // as potentially able to hide or feed a commit (fail-closed). Deliberately
1761
1971
  // excluded: shells and interpreters, awk/sed/ed/vim, sqlite3/psql/dc/bc/octave
1762
- // and other stdin-as-program readers, git, tee (writes files), env/xargs/find
1763
- // (run sub-commands), and curl/wget (fetch arbitrary content to a pipe).
1972
+ // and other stdin-as-program readers, tee (writes files), env/xargs/find
1973
+ // (run sub-commands), and curl/wget (fetch arbitrary content to a pipe). Git is
1974
+ // handled separately: a read-only Git subcommand is an allowed pipe SOURCE (see
1975
+ // GIT_READONLY_PIPE_SUBCOMMANDS), but Git is never a safe pipe SINK, because a
1976
+ // mutating subcommand (apply, am, hash-object --stdin, fast-import) reads its
1977
+ // program or patch from stdin.
1764
1978
  const SAFE_PIPE_EXECUTABLES = new Set([
1765
1979
  'cat', 'tac', 'head', 'tail', 'tr', 'cut', 'paste', 'fold', 'fmt', 'expand',
1766
1980
  'unexpand', 'rev', 'nl', 'pr', 'column', 'sort', 'uniq', 'comm', 'tsort',
@@ -1773,6 +1987,100 @@ const SAFE_PIPE_EXECUTABLES = new Set([
1773
1987
  'gh', '[',
1774
1988
  ]);
1775
1989
 
1990
+ // SAFE_BUILD_PIPE_EXECUTABLES are build, test, lint and language-toolchain
1991
+ // commands permitted as a benign pipe SOURCE. They take data/flag arguments,
1992
+ // report to stdout/stderr, and cannot bypass Git hooks or hide a `git commit`
1993
+ // invocation from the parser — `npm test | tail`, `cargo build 2>&1 | grep`,
1994
+ // `jest | head`, `eslint . | head` are how a developer runs a project every
1995
+ // day. A package script could in principle shell out to git, but that is a
1996
+ // prefix/indirection risk handled by the same guard that covers `a && b`, not a
1997
+ // pipe that feeds code into a sink, so it does not make the pipeline opaque.
1998
+ // Deliberately excluded: shells and interpreters reachable with a program on
1999
+ // stdin (sh, bash, zsh, python -c, node -e, perl), and anything that runs an
2000
+ // arbitrary sub-command (xargs, find -exec, env, make with a generated recipe
2001
+ // is kept because its recipe is repository content, not pipe input).
2002
+ const SAFE_BUILD_PIPE_EXECUTABLES = new Set([
2003
+ // package managers & task runners
2004
+ 'npm', 'npx', 'yarn', 'pnpm', 'pnpx', 'bun', 'bunx', 'deno', 'cargo',
2005
+ 'rustc', 'go', 'make', 'cmake', 'ninja', 'bazel', 'buck', 'pants', 'mill',
2006
+ 'sbt', 'mvn', 'mvnw', 'gradle', 'gradlew', 'rake', 'bundle', 'gem', 'rake',
2007
+ 'pip', 'pip3', 'pipx', 'poetry', 'pdm', 'uv', 'rye', 'conda', 'mamba',
2008
+ // test runners / formatters / linters / type-checkers
2009
+ 'jest', 'vitest', 'mocha', 'karma', 'ava', 'tape', 'tap', 'pytest',
2010
+ 'py.test', 'tox', 'nox', 'go-test', 'rspec', 'minitest', 'test', 'ctest',
2011
+ 'xcodebuild', 'swift', 'swiftc', 'dotnet', 'msbuild', 'tsc', 'tsdx',
2012
+ 'eslint', 'biome', 'biome-check', 'standard', 'ts-standard', 'prettier',
2013
+ 'stylelint', 'ruff', 'flake8', 'pylint', 'mypy', 'black', 'isort', 'rubocop',
2014
+ 'golangci-lint', 'staticcheck', 'revive', 'clippy', 'shellcheck',
2015
+ 'hadolint', 'actionlint', 'markdownlint', 'remark', 'knip',
2016
+ // language runtimes used as direct tools
2017
+ 'node', 'python', 'python3', 'ruby', 'php', 'java', 'javac', 'dotnet',
2018
+ ]);
2019
+
2020
+ // GIT_READONLY_PIPE_SUBCOMMANDS are Git subcommands safe as the SOURCE of a
2021
+ // benign pipeline: they only report (stdout), do not move refs or mutate the
2022
+ // index/worktree, do not read a program or patch from stdin, and cannot bypass
2023
+ // hooks. `git log | head`, `git status | grep`, `git diff | cat` are how a
2024
+ // developer reads a repository every day, and treating them as opaque commit
2025
+ // hiding blocked the normal workflow. Anything that can write, move a ref, or
2026
+ // consume stdin as input (add, commit, apply, am, hash-object, reset, checkout,
2027
+ // push, fetch, merge, rebase, cherry-pick, read-tree, update-index, update-ref,
2028
+ // clean, init, clone, bundle, archive, ...) is excluded and stays fail-closed.
2029
+ // Dual-mode subcommands (branch, tag, remote, config, stash, notes) are kept:
2030
+ // piped use is almost always the read form, their listing forms cannot hide or
2031
+ // feed a commit, and a real ref/index mutation is still caught by the managed
2032
+ // reference-transaction and pre-commit hooks at commit time.
2033
+ const GIT_READONLY_PIPE_SUBCOMMANDS = new Set([
2034
+ 'annotate', 'blame', 'branch', 'cat-file', 'check-attr', 'check-ignore',
2035
+ 'check-mailmap', 'check-ref-format', 'cherry', 'config', 'count-objects',
2036
+ 'describe', 'diff', 'diff-files', 'diff-index', 'diff-tree', 'for-each-ref',
2037
+ 'fsck', 'grep', 'help', 'log', 'ls-files', 'ls-remote', 'ls-tree', 'merge-base',
2038
+ 'name-rev', 'notes', 'reflog', 'remote', 'rev-list', 'rev-parse', 'shortlog',
2039
+ 'show', 'show-branch', 'show-ref', 'stash', 'status', 'var',
2040
+ 'verify-commit', 'verify-pack', 'verify-tag', 'version', 'whatchanged',
2041
+ ]);
2042
+
2043
+ // gitPipeSourceSubcommand returns the Git subcommand of a pipe segment that is a
2044
+ // read-only Git source (e.g. `git -C repo log --oneline`), or null when the
2045
+ // segment is not a Git command or the subcommand is not read-only. It reuses the
2046
+ // same global-option skipping the verb parser does, so `-C path`, `-c k=v`,
2047
+ // `--git-dir`, `--work-tree`, `--namespace` and `--no-pager`/`-p` style flags do
2048
+ // not hide a mutating subcommand.
2049
+ function gitPipeSourceSubcommand(segment) {
2050
+ const words = shellWords(segment.trim());
2051
+ let i = 0;
2052
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i])) i += 1;
2053
+ if (i >= words.length || !isGitExecutable(words[i])) return null;
2054
+ i += 1;
2055
+ while (i < words.length && words[i].startsWith('-') && words[i] !== '--') {
2056
+ const option = words[i];
2057
+ // Global options that take the next token as their value.
2058
+ if (['-C', '-c', '--config-env', '--git-dir', '--work-tree', '--namespace',
2059
+ '--upload-pack', '--receive-pack'].includes(option) && words[i + 1]) {
2060
+ i += 2;
2061
+ } else if (
2062
+ option.startsWith('-C')
2063
+ || option.startsWith('-c')
2064
+ || option.startsWith('--config-env=')
2065
+ || option.startsWith('--git-dir=')
2066
+ || option.startsWith('--work-tree=')
2067
+ || option.startsWith('--namespace=')
2068
+ || option.startsWith('--upload-pack=')
2069
+ || option.startsWith('--receive-pack=')
2070
+ ) {
2071
+ i += 1;
2072
+ } else {
2073
+ // Flags that take no value (--no-pager, -p, --no-replace-objects,
2074
+ // --literal-pathspecs, --bare, --exec-path, ...). `--` ends options.
2075
+ i += 1;
2076
+ }
2077
+ }
2078
+ if (i < words.length && words[i] === '--') i += 1;
2079
+ const subcommand = words[i];
2080
+ if (!subcommand) return null;
2081
+ return GIT_READONLY_PIPE_SUBCOMMANDS.has(subcommand) ? subcommand : null;
2082
+ }
2083
+
1776
2084
  // containsOpaquePipeLexeme is a quote-aware scan for shell constructs that can
1777
2085
  // hide or feed a commit or subcommand: command substitution, subshells, brace
1778
2086
  // expansion/groups, script-feed redirects, and command separators. Output
@@ -1835,22 +2143,85 @@ function pipelineSegmentExecutable(segment) {
1835
2143
  return null;
1836
2144
  }
1837
2145
 
1838
- // benignReadOnlyPipeline reports whether a command is a bare pipeline whose
1839
- // every segment is a known non-code-executing, non-mutating command with no
1840
- // opaque shell syntax. Such a pipe cannot hide or feed a commit, so it is not a
1841
- // "potential commit" for the unmodelled-prefix guard. Fail-closed: any opaque
1842
- // lexeme, unlisted executable, missing executable, or non-pipe returns false.
1843
- function benignReadOnlyPipeline(command) {
1844
- if (!command.includes('|')) return false;
1845
- if (containsOpaquePipeLexeme(command)) return false;
1846
- const segments = splitPipeSegments(command);
1847
- if (segments.length < 2) return false;
1848
- return segments.every((segment) => {
2146
+ // splitLogicalUnits splits a command line on unquoted command separators that
2147
+ // do not move data into a pipe sink: &&, ||, and ;. A pipeline between two
2148
+ // separators stays one unit (its internal | is handled by splitPipeSegments).
2149
+ function splitLogicalUnits(command) {
2150
+ const units = [];
2151
+ let unit = '';
2152
+ let quote = '';
2153
+ let escaped = false;
2154
+ for (let i = 0; i < command.length; i += 1) {
2155
+ const char = command[i];
2156
+ const next = command[i + 1];
2157
+ if (escaped) { unit += char; escaped = false; continue; }
2158
+ if (char === '\\' && quote !== "'") { unit += char; escaped = true; continue; }
2159
+ if (quote) { unit += char; if (char === quote) quote = ''; continue; }
2160
+ if (char === "'" || char === '"') { unit += char; quote = char; continue; }
2161
+ if ((char === '&' && next === '&') || (char === '|' && next === '|')) {
2162
+ units.push(unit); unit = ''; i += 1; continue;
2163
+ }
2164
+ if (char === ';') { units.push(unit); unit = ''; continue; }
2165
+ unit += char;
2166
+ }
2167
+ units.push(unit);
2168
+ return units;
2169
+ }
2170
+
2171
+ // benignReadOnlyUnit reports whether a single shell unit (no &&/||/;) is a
2172
+ // read-only pipeline or a single read-only command. It is the same fail-closed
2173
+ // contract as benignReadOnlyPipeline, applied to one piece of a compound line:
2174
+ // the unit cannot hide or feed a commit when every pipe segment is a known safe
2175
+ // filter, a read-only Git source, or a build/test toolchain command as source.
2176
+ function benignReadOnlyUnit(unit) {
2177
+ const text = unit.trim();
2178
+ if (!text) return true; // an empty piece (e.g. trailing ;) is harmless
2179
+ if (containsOpaquePipeLexeme(text)) return false;
2180
+ const segments = splitPipeSegments(text);
2181
+ if (segments.length < 2) {
2182
+ // A single command (no pipe). Benign when it is a known safe command, a
2183
+ // build/test toolchain command, a read-only Git command, or a directory
2184
+ // change.
2185
+ const words = shellWords(text);
2186
+ while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) words.shift();
2187
+ if (!words.length) return true;
2188
+ const executable = shellExecutable(words[0]);
2189
+ if (SAFE_PIPE_EXECUTABLES.has(executable)) return true;
2190
+ if (SAFE_BUILD_PIPE_EXECUTABLES.has(executable)) return true;
2191
+ if (executable === 'cd' || executable === 'pushd' || executable === 'popd') return true;
2192
+ return gitPipeSourceSubcommand(text) !== null;
2193
+ }
2194
+ return segments.every((segment, index) => {
1849
2195
  const executable = pipelineSegmentExecutable(segment);
1850
- return executable !== null && SAFE_PIPE_EXECUTABLES.has(executable);
2196
+ if (executable !== null && SAFE_PIPE_EXECUTABLES.has(executable)) return true;
2197
+ if (index === 0) {
2198
+ // Source-only segments: a read-only Git command, or a build/test
2199
+ // toolchain command. Build tools and interpreters must never be a
2200
+ // later (sink) segment, where stdin could feed a program or patch.
2201
+ if (executable !== null && SAFE_BUILD_PIPE_EXECUTABLES.has(executable)) return true;
2202
+ if (gitPipeSourceSubcommand(segment) !== null) return true;
2203
+ }
2204
+ return false;
1851
2205
  });
1852
2206
  }
1853
2207
 
2208
+ // benignReadOnlyPipeline reports whether a command line is composed entirely of
2209
+ // read-only units (joined by &&, ||, or ;) where every piece is a known
2210
+ // non-code-executing, non-mutating command or a read-only Git source, with no
2211
+ // opaque shell syntax. Such a line cannot hide or feed a commit, so it is not a
2212
+ // "potential commit" for the unmodelled-prefix guard. This is how a developer
2213
+ // reads a repository every day: `cd repo && git log | head`, `git status | grep`,
2214
+ // `git diff | cat`. Fail-closed: any opaque lexeme, unlisted executable, missing
2215
+ // executable, mutating Git subcommand, or a non-read-only command returns false.
2216
+ // A read-only Git command may appear only as the source (first segment) of a
2217
+ // pipeline, never as a later sink where stdin could drive a mutation.
2218
+ function benignReadOnlyPipeline(command) {
2219
+ if (!command.includes('|') && !/[;&]/.test(command)) return false;
2220
+ if (containsOpaquePipeLexeme(command)) return false;
2221
+ const units = splitLogicalUnits(command);
2222
+ return units.every(benignReadOnlyUnit);
2223
+ }
2224
+
1854
2225
  function pipedShellCommitInvocations(source, initialCwd) {
1855
2226
  const units = shellUnits(source);
1856
2227
  const commits = [];
@@ -2153,7 +2524,7 @@ function unwrapCommand(input, initialCwd) {
2153
2524
  while (isAssignment(toks[0])) assignments.push(toks.shift());
2154
2525
  };
2155
2526
  const stripPrecommands = () => {
2156
- for (;;) {
2527
+ for (; ;) {
2157
2528
  if (toks[0] === '-' || toks[0] === 'nocorrect' || toks[0] === 'noglob') {
2158
2529
  toks.shift();
2159
2530
  } else if (toks[0] === 'coproc') {
@@ -2544,16 +2915,22 @@ function updatePersistentEnvironment(words, risks) {
2544
2915
  return false;
2545
2916
  }
2546
2917
 
2918
+ // An assignment that can inject arbitrary Git config can set core.hooksPath, so
2919
+ // it can remove every managed hook. Named at the key level so both the
2920
+ // assignment list and the already-computed risk names can ask the same question.
2921
+ function gitConfigEnvName(key) {
2922
+ if (key === 'GIT_CONFIG_PARAMETERS' || key === 'GIT_CONFIG_COUNT') return true;
2923
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) return true;
2924
+ if ([
2925
+ 'GIT_CONFIG', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM',
2926
+ ].includes(key)) return true;
2927
+ return ['HOME', 'XDG_CONFIG_HOME'].includes(key);
2928
+ }
2929
+
2547
2930
  function gitConfigAssignmentsBypass(assignments) {
2548
2931
  return assignments.some((assignment) => {
2549
2932
  const separator = assignment.indexOf('=');
2550
- const key = assignment.slice(0, separator).toUpperCase();
2551
- if (key === 'GIT_CONFIG_PARAMETERS' || key === 'GIT_CONFIG_COUNT') return true;
2552
- if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) return true;
2553
- if ([
2554
- 'GIT_CONFIG', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM',
2555
- ].includes(key)) return true;
2556
- return ['HOME', 'XDG_CONFIG_HOME'].includes(key);
2933
+ return gitConfigEnvName(assignment.slice(0, separator).toUpperCase());
2557
2934
  });
2558
2935
  }
2559
2936