@rmyndharis/aimhooman 0.1.2 → 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
@@ -237,6 +237,14 @@ function hookPreToolUse(input) {
237
237
  }
238
238
  targetRepo = openRepo(gitCommand.cwd);
239
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;
240
248
  // An unresolved subcommand/alias may itself move a ref. When its
241
249
  // hook path or execution context is altered, there is no safe
242
250
  // content snapshot to fall back to, so treat it like a direct ref
@@ -285,6 +293,18 @@ function hookPreToolUse(input) {
285
293
  "Run 'aimhooman init' and retry."
286
294
  );
287
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
+ }
288
308
  if (targetProfile !== 'strict') continue;
289
309
  if (parsed.uncertainShell || gitCommand.classification === 'uncertain') {
290
310
  return emitDecision(
@@ -331,9 +351,9 @@ function hookPreToolUse(input) {
331
351
  }
332
352
  }
333
353
  let eng;
334
- // diagnosticWarning is about the rule pack and drives the messages below;
354
+ // diagnosticWarning is about the rule pack and can stop a strict command;
335
355
  // hygieneWarning is housekeeping that never touches the decision. Kept apart
336
- // so the "invalid local pack skipped" wording stays true.
356
+ // because only one of the two is ever grounds to deny.
337
357
  let diagnosticWarning = '';
338
358
  let hygieneWarning = '';
339
359
  try {
@@ -354,7 +374,9 @@ function hookPreToolUse(input) {
354
374
  : `aimhooman could not load policy rules: ${e.message}`;
355
375
  if (e?.name === 'LocalOverridesError') return emitDecision('deny', reason);
356
376
  if (profile === 'strict') return emitDecision('deny', reason);
357
- 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;
358
380
  }
359
381
  // Refreshing the excludes is gitignore hygiene, not part of the verdict:
360
382
  // pre-commit never writes them and still answers. Kept out of the block
@@ -381,16 +403,39 @@ function hookPreToolUse(input) {
381
403
  // also a potential pre-commit bypass. Strict rejects it above; clean and
382
404
  // compliance still need the staged-content backstop so a hook mutation
383
405
  // cannot expose a secret that was already in the index.
384
- const prefixBypass = parsed.commands.some((candidate) => (
406
+ const commitPrefixRisk = parsed.commands.some((candidate) => (
385
407
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
386
408
  && candidate.prefixRisk
387
- )) || (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;
388
417
  const aliasBypass = parsed.commands.some((candidate) => (
389
418
  (candidate.verb === 'commit' || candidate.verb === 'unknown')
390
419
  && candidate.inlineAliasRisk
391
420
  ));
392
421
  const hiddenBypass = noVerify || bypassHooks || parsed.uncertainShell
393
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;
394
439
  const bypassContext = aliasBypass
395
440
  ? 'an inline Git alias cannot be proved to preserve the pre-commit guard'
396
441
  : prefixBypass
@@ -457,7 +502,7 @@ function hookPreToolUse(input) {
457
502
  const indexReplacement = parsed.commands.some((candidate) => (
458
503
  candidate.verb === 'commit' && candidate.indexMutationRisk
459
504
  ));
460
- if (profile !== 'strict' && commit && hiddenBypass
505
+ if (profile !== 'strict' && commit && guardBypass
461
506
  && parsed.commands.some((c) => c.verb === 'commit' && c.futureIndex)) {
462
507
  if (indexReplacement) {
463
508
  return emitDecision(
@@ -474,18 +519,17 @@ function hookPreToolUse(input) {
474
519
  );
475
520
  }
476
521
  if (blocks.length === 0) {
477
- if (profile !== 'strict' && potentialCommit && prefixBypass) {
522
+ if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
478
523
  return emitDecision(
479
524
  'deny',
480
525
  unmodelledPrefixReason,
481
526
  );
482
527
  }
483
- if (diagnosticWarning) {
484
- return emitDecision('allow', `aimhooman warning: ${diagnosticWarning}; invalid local pack skipped`);
485
- }
486
- if (hygieneWarning) {
487
- return emitDecision('allow', `aimhooman warning: ${hygieneWarning}`);
488
- }
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.
489
533
  return 0;
490
534
  }
491
535
 
@@ -505,7 +549,7 @@ function hookPreToolUse(input) {
505
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.`,
506
550
  );
507
551
  }
508
- if (profile !== 'strict' && potentialCommit && prefixBypass) {
552
+ if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
509
553
  return emitDecision(
510
554
  'deny',
511
555
  unmodelledPrefixReason,
@@ -641,6 +685,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
641
685
  classification: 'none',
642
686
  environmentRisk: [],
643
687
  prefixRisk: false,
688
+ prefixHooksRisk: false,
644
689
  addPaths,
645
690
  commands,
646
691
  };
@@ -656,6 +701,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
656
701
  const persistentEnvironmentRisk = new Set();
657
702
  let prefixRisk = false;
658
703
  let prefixIndexMutationRisk = false;
704
+ let prefixHooksRisk = false;
659
705
  let policyTransitionRisk = false;
660
706
  for (const unit of shellUnits(cmd)) {
661
707
  const precedingOperator = previousOperator;
@@ -792,6 +838,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
792
838
  if (toks.length < 2) {
793
839
  if (toks.length && !READ_ONLY_SHELL_COMMANDS.has(basename(toks[0]))) {
794
840
  prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
841
+ prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
795
842
  prefixRisk = true;
796
843
  }
797
844
  continue;
@@ -838,6 +885,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
838
885
  }
839
886
  if (!READ_ONLY_SHELL_COMMANDS.has(basename(g0))) {
840
887
  prefixIndexMutationRisk ||= commandMayReplaceIndex(toks, unit.text);
888
+ prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
841
889
  prefixRisk = true;
842
890
  if (CURRENT_SHELL_MUTATORS.has(shellExecutable(g0)) || definesShellFunction) {
843
891
  shellTargetUncertain = true;
@@ -848,6 +896,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
848
896
  let i = 1;
849
897
  let cwd = unwrapped.cwd;
850
898
  let commandBypass = environmentRisk.length > 0;
899
+ let commandHooksPathOverride = environmentRisk.some(gitConfigEnvName);
851
900
  let aliasResolutionRisk = !canResolveGitAlias(g0);
852
901
  let inlineAliasRisk = false;
853
902
  while (i < toks.length && toks[i].startsWith('-')) {
@@ -867,6 +916,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
867
916
  i += 1;
868
917
  } else if (option === '-c' && toks[i + 1]) {
869
918
  if (hookAffectingConfig(toks[i + 1])) commandBypass = true;
919
+ if (hooksPathOverrideConfig(toks[i + 1])) commandHooksPathOverride = true;
870
920
  if (aliasAffectingConfig(toks[i + 1])) {
871
921
  aliasResolutionRisk = true;
872
922
  inlineAliasRisk = true;
@@ -874,6 +924,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
874
924
  i += 2;
875
925
  } else if (option.startsWith('-c') && option.length > 2) {
876
926
  if (hookAffectingConfig(option.slice(2))) commandBypass = true;
927
+ if (hooksPathOverrideConfig(option.slice(2))) commandHooksPathOverride = true;
877
928
  if (aliasAffectingConfig(option.slice(2))) {
878
929
  aliasResolutionRisk = true;
879
930
  inlineAliasRisk = true;
@@ -881,6 +932,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
881
932
  i += 1;
882
933
  } else if (option === '--config-env' && toks[i + 1]) {
883
934
  if (hookAffectingConfig(toks[i + 1])) commandBypass = true;
935
+ if (hooksPathOverrideConfig(toks[i + 1])) commandHooksPathOverride = true;
884
936
  if (aliasAffectingConfig(toks[i + 1])) {
885
937
  aliasResolutionRisk = true;
886
938
  inlineAliasRisk = true;
@@ -945,6 +997,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
945
997
  cwd,
946
998
  noVerify: false,
947
999
  bypassHooks: commandBypass,
1000
+ hooksPathOverride: commandHooksPathOverride,
948
1001
  futureIndex: true,
949
1002
  classification: 'uncertain',
950
1003
  environmentRisk,
@@ -970,6 +1023,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
970
1023
  cwd,
971
1024
  noVerify: commandNoVerify,
972
1025
  bypassHooks: commandBypass,
1026
+ hooksPathOverride: commandHooksPathOverride,
973
1027
  futureIndex,
974
1028
  editorRisk: commitOptions.editorRisk,
975
1029
  classification: uncertainShell
@@ -1005,9 +1059,16 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
1005
1059
  });
1006
1060
  sawAdd = true;
1007
1061
  } else if (verb === 'config') {
1008
- prefixRisk ||= gitConfigMayMutate(toks.slice(i + 1));
1062
+ if (gitConfigMayMutate(toks.slice(i + 1))) {
1063
+ prefixRisk = true;
1064
+ prefixHooksRisk ||= commandMayTouchHooks(toks, unit.text);
1065
+ }
1009
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.
1010
1070
  prefixRisk = true;
1071
+ prefixHooksRisk = true;
1011
1072
  } else if (!SAFE_NON_COMMIT_GIT.has(verb)) {
1012
1073
  commands.push({
1013
1074
  verb: 'unknown',
@@ -1096,6 +1157,7 @@ export function parseGit(cmd, initialCwd = process.cwd()) {
1096
1157
  classification,
1097
1158
  environmentRisk: unique(commands.flatMap((candidate) => candidate.environmentRisk || [])),
1098
1159
  prefixRisk,
1160
+ prefixHooksRisk,
1099
1161
  addPaths,
1100
1162
  commands,
1101
1163
  };
@@ -1201,6 +1263,22 @@ function commandMayReplaceIndex(tokens, source) {
1201
1263
  || /\bgit\s+rev-parse\b[^;&|\n]*\b--git-path(?:=|\s+)index\b/i.test(text);
1202
1264
  }
1203
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
+
1204
1282
  const GIT_POLICY_TRANSITION_COMMANDS = new Set([
1205
1283
  'am', 'branch', 'checkout', 'cherry-pick', 'commit', 'merge', 'pull', 'read-tree', 'rebase',
1206
1284
  'replace', 'reset', 'restore', 'revert', 'rm', 'switch', 'symbolic-ref', 'update-index',
@@ -1211,9 +1289,94 @@ const GIT_POLICY_TRANSITION_COMMANDS = new Set([
1211
1289
  // safety boundary is the managed reference-transaction hook, so PreToolUse
1212
1290
  // must not let config/environment/prefix indirection disable that boundary.
1213
1291
  const GIT_REF_MUTATION_COMMANDS = new Set([
1214
- 'am', 'bisect', 'branch', 'checkout', 'cherry-pick', 'fetch', 'merge', 'pull', 'push',
1215
- 'rebase', 'remote', 'replace', 'reset', 'revert', 'stash', 'switch', 'symbolic-ref',
1216
- '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',
1217
1380
  ]);
1218
1381
 
1219
1382
  function gitCommandMayBypassRefGuard(verb, args = []) {
@@ -1289,17 +1452,25 @@ function gitConfigMayMutate(args) {
1289
1452
  return positionals.length > 1;
1290
1453
  }
1291
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.
1292
1458
  const SAFE_NON_COMMIT_GIT = new Set([
1293
1459
  'add', 'am', 'annotate', 'apply', 'archive', 'bisect', 'blame', 'branch', 'bundle', 'cat-file',
1294
1460
  'check-attr', 'check-ignore', 'check-mailmap', 'check-ref-format', 'checkout',
1295
- 'cherry-pick', 'clean', 'clone', 'column', 'config', 'count-objects', 'credential', 'describe',
1461
+ 'cherry-pick', 'clean', 'clone', 'column', 'commit-graph', 'config', 'count-objects',
1462
+ 'credential', 'describe',
1296
1463
  'diff', 'diff-files', 'diff-index', 'diff-tree', 'difftool', 'fetch', 'for-each-ref',
1297
- 'fsck', 'gc', 'grep', 'hash-object', 'help', 'index-pack', 'init', 'interpret-trailers',
1298
- 'log', 'ls-files', 'ls-remote', 'ls-tree', 'merge', 'merge-base', 'mktag', 'mktree', 'mv',
1299
- 'name-rev', 'pack-objects', 'prune', 'pull', 'push', 'range-diff', 'read-tree', 'rebase',
1300
- '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',
1301
1472
  'rm', 'shortlog', 'show', 'show-branch', 'show-ref', 'sparse-checkout', 'stash', 'status',
1302
- 'submodule', 'switch', 'symbolic-ref', 'tag', 'unpack-objects', 'update-index',
1473
+ 'stripspace', 'submodule', 'switch', 'symbolic-ref', 'tag', 'unpack-objects', 'update-index',
1303
1474
  'update-ref', 'verify-commit', 'verify-pack', 'verify-tag', 'version', 'whatchanged',
1304
1475
  'worktree', 'write-tree',
1305
1476
  ]);
@@ -1310,6 +1481,11 @@ function resolveGitAliases(parsed) {
1310
1481
  let aliasPolicyTransitionRisk = false;
1311
1482
  let aliasIndexMutationRisk = false;
1312
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;
1313
1489
  for (const candidate of parsed.commands) {
1314
1490
  let resolved = candidate;
1315
1491
  if (candidate.verb === 'unknown') {
@@ -1344,6 +1520,8 @@ function resolveGitAliases(parsed) {
1344
1520
  || effective.indexMutationRisk
1345
1521
  || gitIndexMutationRisk(effective.verb, effective.args);
1346
1522
  aliasPrefixRisk ||= effective.verb === 'unknown' || effective.prefixMutationRisk;
1523
+ aliasHooksRisk ||= Boolean(effective.prefixMutationRisk)
1524
+ && commandMayTouchHooks(effective.args || [], effective.aliasExpansion || '');
1347
1525
  }
1348
1526
  const commitCommands = commands.filter((candidate) => (
1349
1527
  candidate.verb === 'commit' || candidate.verb === 'unknown'
@@ -1357,6 +1535,7 @@ function resolveGitAliases(parsed) {
1357
1535
  commit: commitCommands.length > 0,
1358
1536
  noVerify,
1359
1537
  bypassHooks,
1538
+ prefixHooksRisk: Boolean(parsed.prefixHooksRisk || aliasHooksRisk),
1360
1539
  addPaths: [...parsed.addPaths, ...aliasAddPaths],
1361
1540
  commands,
1362
1541
  environmentRisk: unique(commitCommands.flatMap((candidate) => candidate.environmentRisk || [])),
@@ -1490,6 +1669,15 @@ function hookAffectingConfig(value) {
1490
1669
  return /^(?:core\.(?:editor|hookspath)|sequence\.editor|include\.path|includeif\..+\.path|remote\..+\.receivepack)$/i.test(key);
1491
1670
  }
1492
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
+
1493
1681
  function aliasAffectingConfig(value) {
1494
1682
  const key = String(value).split('=', 1)[0];
1495
1683
  return /^alias\./i.test(key);
@@ -1781,8 +1969,12 @@ function shellUnits(s) {
1781
1969
  // never write files, and cannot bypass Git hooks. Anything not listed is treated
1782
1970
  // as potentially able to hide or feed a commit (fail-closed). Deliberately
1783
1971
  // excluded: shells and interpreters, awk/sed/ed/vim, sqlite3/psql/dc/bc/octave
1784
- // and other stdin-as-program readers, git, tee (writes files), env/xargs/find
1785
- // (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.
1786
1978
  const SAFE_PIPE_EXECUTABLES = new Set([
1787
1979
  'cat', 'tac', 'head', 'tail', 'tr', 'cut', 'paste', 'fold', 'fmt', 'expand',
1788
1980
  'unexpand', 'rev', 'nl', 'pr', 'column', 'sort', 'uniq', 'comm', 'tsort',
@@ -1795,6 +1987,100 @@ const SAFE_PIPE_EXECUTABLES = new Set([
1795
1987
  'gh', '[',
1796
1988
  ]);
1797
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
+
1798
2084
  // containsOpaquePipeLexeme is a quote-aware scan for shell constructs that can
1799
2085
  // hide or feed a commit or subcommand: command substitution, subshells, brace
1800
2086
  // expansion/groups, script-feed redirects, and command separators. Output
@@ -1857,22 +2143,85 @@ function pipelineSegmentExecutable(segment) {
1857
2143
  return null;
1858
2144
  }
1859
2145
 
1860
- // benignReadOnlyPipeline reports whether a command is a bare pipeline whose
1861
- // every segment is a known non-code-executing, non-mutating command with no
1862
- // opaque shell syntax. Such a pipe cannot hide or feed a commit, so it is not a
1863
- // "potential commit" for the unmodelled-prefix guard. Fail-closed: any opaque
1864
- // lexeme, unlisted executable, missing executable, or non-pipe returns false.
1865
- function benignReadOnlyPipeline(command) {
1866
- if (!command.includes('|')) return false;
1867
- if (containsOpaquePipeLexeme(command)) return false;
1868
- const segments = splitPipeSegments(command);
1869
- if (segments.length < 2) return false;
1870
- 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) => {
1871
2195
  const executable = pipelineSegmentExecutable(segment);
1872
- 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;
1873
2205
  });
1874
2206
  }
1875
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
+
1876
2225
  function pipedShellCommitInvocations(source, initialCwd) {
1877
2226
  const units = shellUnits(source);
1878
2227
  const commits = [];
@@ -2175,7 +2524,7 @@ function unwrapCommand(input, initialCwd) {
2175
2524
  while (isAssignment(toks[0])) assignments.push(toks.shift());
2176
2525
  };
2177
2526
  const stripPrecommands = () => {
2178
- for (;;) {
2527
+ for (; ;) {
2179
2528
  if (toks[0] === '-' || toks[0] === 'nocorrect' || toks[0] === 'noglob') {
2180
2529
  toks.shift();
2181
2530
  } else if (toks[0] === 'coproc') {
@@ -2566,16 +2915,22 @@ function updatePersistentEnvironment(words, risks) {
2566
2915
  return false;
2567
2916
  }
2568
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
+
2569
2930
  function gitConfigAssignmentsBypass(assignments) {
2570
2931
  return assignments.some((assignment) => {
2571
2932
  const separator = assignment.indexOf('=');
2572
- const key = assignment.slice(0, separator).toUpperCase();
2573
- if (key === 'GIT_CONFIG_PARAMETERS' || key === 'GIT_CONFIG_COUNT') return true;
2574
- if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key)) return true;
2575
- if ([
2576
- 'GIT_CONFIG', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_NOSYSTEM',
2577
- ].includes(key)) return true;
2578
- return ['HOME', 'XDG_CONFIG_HOME'].includes(key);
2933
+ return gitConfigEnvName(assignment.slice(0, separator).toUpperCase());
2579
2934
  });
2580
2935
  }
2581
2936