@rmyndharis/aimhooman 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/rules/aimhooman.md +5 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/aimhooman.md +5 -3
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +5 -3
- package/.github/copilot-instructions.md +5 -3
- package/.kiro/steering/aimhooman.md +5 -3
- package/.windsurf/rules/aimhooman.md +5 -3
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +177 -1
- package/CONTRIBUTING.md +36 -3
- package/GEMINI.md +5 -3
- package/README.md +18 -8
- package/bin/aimhooman.mjs +163 -49
- package/docs/design/frictionless-enforcement.md +28 -0
- package/package.json +1 -1
- package/rules/attribution.json +8 -8
- package/rules/markers.json +31 -5
- package/rules/paths.json +60 -33
- package/rules/secrets.json +2 -1
- package/skills/aimhooman/SKILL.md +5 -3
- package/src/atomic-write.mjs +18 -1
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +113 -19
- package/src/gitx.mjs +65 -270
- package/src/history-scan.mjs +8 -1
- package/src/hook.mjs +424 -47
- package/src/scan-session.mjs +11 -4
- package/src/scan-target.mjs +29 -3
package/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
|
-
|
|
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
|
|
406
|
+
const commitPrefixRisk = parsed.commands.some((candidate) => (
|
|
370
407
|
(candidate.verb === 'commit' || candidate.verb === 'unknown')
|
|
371
408
|
&& candidate.prefixRisk
|
|
372
|
-
))
|
|
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 &&
|
|
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 &&
|
|
522
|
+
if (profile !== 'strict' && potentialCommit && prefixHookBypass) {
|
|
463
523
|
return emitDecision(
|
|
464
524
|
'deny',
|
|
465
525
|
unmodelledPrefixReason,
|
|
466
526
|
);
|
|
467
527
|
}
|
|
468
|
-
|
|
469
|
-
|
|
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 &&
|
|
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
|
-
|
|
499
|
-
|
|
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
|
-
|
|
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', '
|
|
1199
|
-
'rebase', 'remote', 'replace', 'reset', 'revert', 'stash', 'switch',
|
|
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
|
-
{
|
|
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', '
|
|
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',
|
|
1277
|
-
'
|
|
1278
|
-
'
|
|
1279
|
-
'
|
|
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,
|
|
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
|
-
//
|
|
1839
|
-
//
|
|
1840
|
-
//
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|