@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/.agents/rules/aimhooman.md +5 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/aimhooman.md +5 -3
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +5 -3
- package/.github/copilot-instructions.md +5 -3
- package/.kiro/steering/aimhooman.md +5 -3
- package/.windsurf/rules/aimhooman.md +5 -3
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +30 -0
- package/GEMINI.md +5 -3
- package/README.md +15 -7
- package/bin/aimhooman.mjs +123 -29
- package/docs/design/frictionless-enforcement.md +28 -0
- package/package.json +1 -1
- package/rules/attribution.json +3 -3
- package/rules/markers.json +31 -5
- package/rules/paths.json +60 -33
- package/rules/secrets.json +2 -1
- package/skills/aimhooman/SKILL.md +5 -3
- package/src/atomic-write.mjs +11 -1
- package/src/gitx.mjs +17 -5
- package/src/history-scan.mjs +4 -0
- package/src/hook.mjs +401 -46
- package/src/scan-session.mjs +9 -3
- package/src/scan-target.mjs +10 -1
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
406
|
+
const commitPrefixRisk = parsed.commands.some((candidate) => (
|
|
385
407
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
386
408
|
&& candidate.prefixRisk
|
|
387
|
-
))
|
|
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 &&
|
|
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 &&
|
|
522
|
+
if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
|
|
478
523
|
return emitDecision(
|
|
479
524
|
'deny',
|
|
480
525
|
unmodelledPrefixReason,
|
|
481
526
|
);
|
|
482
527
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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 &&
|
|
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
|
-
|
|
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', '
|
|
1215
|
-
'rebase', 'remote', 'replace', 'reset', 'revert', 'stash', 'switch',
|
|
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', '
|
|
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',
|
|
1298
|
-
'
|
|
1299
|
-
'
|
|
1300
|
-
'
|
|
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,
|
|
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
|
-
//
|
|
1861
|
-
//
|
|
1862
|
-
//
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|