@marcomigozzi/trailhead 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.it.md +52 -14
  2. package/README.md +53 -15
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/config-dir-templates-docs.test.js +52 -0
  12. package/bin/decision-builds-docs.test.js +75 -0
  13. package/bin/fog-vs-ticket-docs.test.js +56 -0
  14. package/bin/grilling-holistic-review-docs.test.js +90 -0
  15. package/bin/lib/codex-projection.js +13 -6
  16. package/bin/lib/codex-projection.test.js +25 -2
  17. package/bin/lib/host-descriptor.js +14 -1
  18. package/bin/lib/host-descriptor.test.js +18 -0
  19. package/bin/lib/runtime-command-literals.test.js +184 -0
  20. package/bin/needs-you-docs.test.js +41 -0
  21. package/bin/pick-stress-test-docs.test.js +105 -0
  22. package/bin/report-status-docs.test.js +1 -1
  23. package/bin/trailhead.js +3 -3
  24. package/bin/trailhead.test.js +17 -18
  25. package/package.json +1 -1
  26. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  27. package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
  28. package/plugins/trailhead/agents/trailhead-verify.md +2 -2
  29. package/plugins/trailhead/commands/auto.md +2 -2
  30. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  31. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  32. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  33. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  34. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
  36. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
  38. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
  39. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  40. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  41. package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
  42. package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
  43. package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
  44. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  45. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  46. package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
  47. package/plugins/trailhead/hooks/mod/README.md +185 -39
  48. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  49. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  50. package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
  51. package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
  52. package/plugins/trailhead/hooks/mod/register.tsx +298 -39
  53. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  54. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  55. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  56. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  57. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  58. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  59. package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
  60. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  61. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  62. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  63. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  64. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  65. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
  66. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
  67. package/plugins/trailhead/skills/_shared/choices.md +7 -2
  68. package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
  69. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  70. package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
  71. package/plugins/trailhead/skills/_shared/substrate.md +4 -1
  72. package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
  73. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
  74. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  75. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  76. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  77. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  78. package/plugins/trailhead/skills/_shared/techniques.md +2 -2
  79. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  80. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  81. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  82. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  83. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
  84. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  85. package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
  86. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  87. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
  88. package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
  89. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  90. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  91. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  92. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  93. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  94. package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
  95. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  96. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  97. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  98. package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
  99. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  100. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  101. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  102. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  103. package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
  104. package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
  105. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  106. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  107. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  108. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  109. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  110. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  111. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  112. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  113. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  114. package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
  115. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  116. package/plugins/trailhead/types/index.d.ts +26 -1
@@ -86,6 +86,20 @@
86
86
  // a link prepared under a non-secret name onto `.env`
87
87
  // (`ln -s .env.example l && mv l .env`).
88
88
  //
89
+ // #273: a command carried in a git config value (`git -c alias.x="!cat .env" x`,
90
+ // `-c core.pager=...`, `--config-env=core.pager=VAR` with VAR assigned in the same
91
+ // statement) is scanned as a nested command, like `bash -c`; `--config-env` on a
92
+ // command key whose variable is not assigned there denies as unmodelled.
93
+ //
94
+ // The same scan covers the statement's own leading env assignments that inject config or
95
+ // commands: GIT_CONFIG_KEY_<n> / GIT_CONFIG_VALUE_<n> pairs (read whatever GIT_CONFIG_COUNT
96
+ // says), GIT_CONFIG_PARAMETERS (an unparsable one on a command key denies as unmodelled), and
97
+ // the command-valued variables GIT_PAGER, PAGER, GIT_SSH_COMMAND, GIT_SSH, GIT_EXTERNAL_DIFF,
98
+ // GIT_EDITOR, GIT_SEQUENCE_EDITOR, EDITOR, VISUAL, GIT_ASKPASS, SSH_ASKPASS. Only for a git
99
+ // statement; variables exported in an earlier statement are not visible. Backslash escapes in a
100
+ // quoted value follow the shell (shellUnquote). Global options git does not list before -c are
101
+ // walked as flags (walkGitGlobals), never a reason to stop scanning.
102
+ //
89
103
  // #227: a construct the guard cannot interpret with certainty (eval, source / `.`, a
90
104
  // bash 5.3 `${ cmd; }`, printf -v, a function definition, an alias, a quoted gh api body
91
105
  // key, a backslash-newline inside a word) is not emulated. As a FINAL gate, after the
@@ -142,10 +156,17 @@ function isSecretBasename(name) {
142
156
  return !NON_SECRET_ENV_SUFFIXES.has(lower.slice(lower.lastIndexOf('.') + 1));
143
157
  }
144
158
 
159
+ // #276: il token grezzo tiene `\`+char verbatim. Con un backslash la parola si legge come
160
+ // la shell (`\.env` è `.env`); senza, resta il solo strip della coppia di apici esterna
161
+ // (le forme con apici nel mezzo, `.e""nv`, restano al gate dei costrutti non modellati).
162
+ function unquoteWord(t) {
163
+ return String(t).includes('\\') ? shellUnquote(t) : stripQuotes(t);
164
+ }
165
+
145
166
  // Anche la forma `<rev>:<path>` (`git show HEAD:.env`, `scp host:.env .`):
146
167
  // il path dopo il primo `:` è letto quanto un operando nudo.
147
168
  function isSecretPathArg(t) {
148
- const bare = stripQuotes(t);
169
+ const bare = unquoteWord(t);
149
170
  if (!bare) return false;
150
171
  if (isSecretBasename(basename(bare))) return true;
151
172
  const colon = bare.indexOf(':');
@@ -201,14 +222,18 @@ const GIT_PATHSPEC_SUBCOMMANDS = new Map([
201
222
 
202
223
  // Opzioni globali di git (usage line, git 2.49), per trovare il sottocomando.
203
224
  // Un'opzione con valore consuma la parola successiva salvo forma
204
- // `--opt=value`; un'opzione in nessuno dei due set fa fail closed, così un
205
- // valore non è mai letto come sottocomando (`git -C ls-files show HEAD:.env`
206
- // esegue `show`).
207
- const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
225
+ // `--opt=value`. Un'opzione in nessuno dei due set NON ferma la camminata
226
+ // (vedi walkGitGlobals): per il sottocomando esenta nulla (fail closed, un
227
+ // valore non è mai letto come sottocomando: `git -C ls-files show HEAD:.env`
228
+ // esegue `show`), per gli `-c` successivi li lascia comunque controllati.
229
+ const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source', '--super-prefix']);
208
230
  const GIT_GLOBAL_FLAGS = new Set([
209
231
  '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch',
210
- '--no-optional-locks', '--no-advice', '--bare',
232
+ '--no-optional-locks', '--no-advice', '--bare', '--literal-pathspecs', '--glob-pathspecs',
233
+ '--noglob-pathspecs', '--icase-pathspecs', '--exec-path', '--html-path', '--man-path', '--info-path',
211
234
  ]);
235
+ // Flag che accetta anche la forma `--opt=value` ma mai un valore separato.
236
+ const GIT_GLOBAL_EQ_FLAGS = new Set(['--exec-path', '--list-cmds']);
212
237
 
213
238
  // `cp`/`mv` scrivono la destinazione e non la stampano: un nome segreto che è
214
239
  // SOLO la destinazione di una copia/spostamento a sorgente singola è un nome
@@ -318,19 +343,52 @@ function isListedFlag(text, flags) {
318
343
  return /^-[A-Za-z]+$/.test(text) && [...text.slice(1)].every((ch) => flags.has(`-${ch}`));
319
344
  }
320
345
 
321
- // Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
322
- // un'opzione fuori da GIT_GLOBAL_VALUE_OPTIONS / GIT_GLOBAL_FLAGS.
323
- function gitSubcommandIndex(operands) {
346
+ // UNICA camminata sulle opzioni globali di git, condivisa da gitSubcommandIndex
347
+ // e da extractGitConfigCommands così le due non divergono. Restituisce
348
+ // { sub, known, configs }: `sub` = indice del primo operando non-opzione (-1 se
349
+ // assente), `known` = false se la camminata ha incontrato un'opzione fuori dai
350
+ // set (allora nessuna esenzione per il sottocomando), `configs` = i valori
351
+ // `-c` / `-ckey=value` / `--config-env` visti prima del sottocomando.
352
+ // Un'opzione sconosciuta non interrompe la camminata: si tratta come flag fino al
353
+ // primo operando non-opzione (fail toward scanning).
354
+ function walkGitGlobals(operands) {
355
+ const out = { sub: -1, known: true, configs: [] };
324
356
  for (let k = 0; k < operands.length; k++) {
325
357
  const t = operands[k];
326
- if (!t.startsWith('-')) return k;
327
- if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) { k++; continue; }
328
- if (GIT_GLOBAL_FLAGS.has(t)) continue;
329
- const eq = t.indexOf('=');
330
- const isLongValueForm = t.startsWith('--') && eq !== -1 && GIT_GLOBAL_VALUE_OPTIONS.has(t.slice(0, eq));
331
- if (!isLongValueForm) return -1;
358
+ if (!t.startsWith('-') || t === '-') { out.sub = k; return out; }
359
+ if (t === '-c' || t === '--config-env') {
360
+ if (k + 1 >= operands.length) { out.known = false; return out; }
361
+ out.configs.push({ kind: t === '-c' ? 'c' : 'env', value: operands[++k], sample: `${t} ${operands[k]}` });
362
+ } else if (t.startsWith('--config-env=')) {
363
+ out.configs.push({ kind: 'env', value: t.slice('--config-env='.length), sample: t });
364
+ } else if (isGluedGitConfig(t)) {
365
+ out.configs.push({ kind: 'c', value: t.slice(2), sample: t }); // #273: `-ckey=value`
366
+ } else if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) {
367
+ if (k + 1 >= operands.length) { out.known = false; return out; }
368
+ k++; // `-C <path>` and friends: the value is not a config
369
+ } else if (GIT_GLOBAL_FLAGS.has(t)) {
370
+ // known flag
371
+ } else {
372
+ const eq = t.indexOf('=');
373
+ const name = eq === -1 ? t : t.slice(0, eq);
374
+ const longForm = t.startsWith('--') && eq !== -1 && (GIT_GLOBAL_VALUE_OPTIONS.has(name) || GIT_GLOBAL_EQ_FLAGS.has(name));
375
+ if (!longForm) out.known = false; // unknown option: treated as a flag, no exemption
376
+ }
332
377
  }
333
- return -1;
378
+ return out;
379
+ }
380
+
381
+ // Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
382
+ // un'opzione sconosciuta o manca.
383
+ function gitSubcommandIndex(operands) {
384
+ const w = walkGitGlobals(operands);
385
+ return w.known ? w.sub : -1;
386
+ }
387
+
388
+ // #275: come gitSubcommandIndex ma anche dopo un'opzione globale sconosciuta
389
+ // (fail toward scanning: l'esenzione no, la scansione sì).
390
+ function gitSubcommandIndexLoose(operands) {
391
+ return walkGitGlobals(operands).sub;
334
392
  }
335
393
 
336
394
  // Indici dei pathspec di un sottocomando in GIT_PATHSPEC_SUBCOMMANDS. git
@@ -374,7 +432,7 @@ function copyDestinationIndices(operands) {
374
432
  // controllo; ogni altro token resta controllato, quindi l'esenzione non può
375
433
  // riciclare una lettura altrove nello statement.
376
434
  function nameOnlyTokenIndices(bin, tokens) {
377
- const operands = tokens.slice(1).map((t) => stripQuotes(stripTrailingPunct(t)));
435
+ const operands = tokens.slice(1).map((t) => unquoteWord(stripTrailingPunct(t)));
378
436
  let indices = NO_SKIP;
379
437
  if (bin === 'git') indices = gitPathspecIndices(operands);
380
438
  else if (bin === 'cp' || bin === 'mv') indices = copyDestinationIndices(operands);
@@ -386,22 +444,259 @@ function nameOnlyTokenIndices(bin, tokens) {
386
444
  // yet re-tokenized) so the caller can recurse into it as a nested command.
387
445
  // Best-effort: only the common single-quoted-argument idiom, not every way a
388
446
  // shell can be told to execute a string.
447
+ // #276: ogni token si legge come la shell esterna (`\\` dentro apici doppi diventa `\`)
448
+ // prima del join, così la shell interna vede lo stesso testo.
389
449
  function extractNestedCommand(stmtTokens) {
390
450
  if (!stmtTokens.length) return null;
391
451
  const bin = basename(stmtTokens[0]);
392
452
  if (bin === 'eval') {
393
453
  const rest = stmtTokens.slice(1);
394
454
  if (!rest.length) return null;
395
- return stripQuotes(rest.join(' '));
455
+ return rest.map(unquoteWord).join(' ');
396
456
  }
397
457
  if (SHELL_DASH_C_VERBS.has(bin)) {
398
458
  const cIdx = stmtTokens.indexOf('-c');
399
459
  if (cIdx === -1 || cIdx + 1 >= stmtTokens.length) return null;
400
- return stripQuotes(stmtTokens.slice(cIdx + 1).join(' '));
460
+ return stmtTokens.slice(cIdx + 1).map(unquoteWord).join(' ');
401
461
  }
402
462
  return null;
403
463
  }
404
464
 
465
+ // #273: `git -c key=value` / `--config-env=key=ENV` can name a COMMAND git runs
466
+ // (an alias with `!`, a pager, an editor, an ssh command, ...), which the plain
467
+ // token walk only tests as a file name. The key families git runs as a command,
468
+ // matched on the lowercased key (section and variable are case-insensitive; a
469
+ // lowercased middle segment only widens a wildcard). `bang` = the value is a
470
+ // command only when it starts with `!` (the `!` is not part of the command).
471
+ const GIT_COMMAND_KEYS = [
472
+ { re: /^alias\..+$/, bang: true },
473
+ { re: /^core\.(pager|editor|sshcommand|fsmonitor)$/ },
474
+ { re: /^pager\..+$/ },
475
+ { re: /^sequence\.editor$/ },
476
+ { re: /^diff\.external$/ },
477
+ { re: /^diff\..+\.textconv$/ },
478
+ { re: /^filter\..+\.(clean|smudge|process)$/ },
479
+ { re: /^credential\.(.+\.)?helper$/, bang: true },
480
+ { re: /^gpg\.(.+\.)?program$/ },
481
+ // #275: more keys git runs as a command.
482
+ { re: /^core\.(askpass|gitproxy)$/ },
483
+ { re: /^(mergetool|difftool|browser|man)\..+\.cmd$/ },
484
+ { re: /^merge\..+\.driver$/ },
485
+ { re: /^uploadpack\.packobjectshook$/ },
486
+ { re: /^diff\..+\.command$/ },
487
+ ];
488
+
489
+ // `-ckey=value` (value glued on, no space).
490
+ function isGluedGitConfig(t) {
491
+ return t.length > 2 && t.startsWith('-c') && !t.startsWith('--');
492
+ }
493
+
494
+ // The word as the shell hands it over: quote pairs removed, their content kept
495
+ // (`alias.x="!cat .env"` -> `alias.x=!cat .env`). Backslashes follow the shell:
496
+ // outside quotes one escapes the next char (backslash-newline vanishes), inside
497
+ // double quotes only before `"`, `\`, `$`, a backtick or a newline, and inside
498
+ // single quotes nothing is special. No expansions: best-effort like the rest.
499
+ function shellUnquote(word) {
500
+ const chars = [...String(word)];
501
+ let out = '';
502
+ let q = null;
503
+ for (let i = 0; i < chars.length; i++) {
504
+ const ch = chars[i];
505
+ if (q === "'") { if (ch === "'") q = null; else out += ch; continue; }
506
+ if (ch === '\\' && i + 1 < chars.length) {
507
+ const nx = chars[i + 1];
508
+ if (q === null || '"\\$`\n'.includes(nx)) {
509
+ i++;
510
+ if (nx !== '\n') out += nx;
511
+ continue;
512
+ }
513
+ }
514
+ if (q === '"') { if (ch === '"') q = null; else out += ch; }
515
+ else if (ch === '"' || ch === "'") q = ch;
516
+ else out += ch;
517
+ }
518
+ return out;
519
+ }
520
+
521
+ // The command text a git config value carries for `key`, or null when the key
522
+ // is not command-valued (or `!`-less where the `!` is required).
523
+ function isGitCommandKey(key) {
524
+ return GIT_COMMAND_KEYS.some((k) => k.re.test(key.toLowerCase()));
525
+ }
526
+
527
+ function gitConfigCommand(key, value) {
528
+ const spec = GIT_COMMAND_KEYS.find((k) => k.re.test(key.toLowerCase()));
529
+ if (!spec) return null;
530
+ if (spec.bang) return value.startsWith('!') && value.length > 1 ? value.slice(1) : null;
531
+ return value ? value : null;
532
+ }
533
+
534
+ // #273: environment variables whose value git (or ssh under it) runs as a command.
535
+ const GIT_COMMAND_ENV_VARS = [
536
+ 'GIT_PAGER', 'PAGER', 'GIT_SSH_COMMAND', 'GIT_SSH', 'GIT_EXTERNAL_DIFF', 'GIT_EDITOR',
537
+ 'GIT_SEQUENCE_EDITOR', 'EDITOR', 'VISUAL', 'GIT_ASKPASS', 'SSH_ASKPASS',
538
+ ];
539
+
540
+ // Entries of a GIT_CONFIG_PARAMETERS value (space-separated `'key=value'` or
541
+ // `'key'='value'`) as [key, value] pairs, or null when the quoting is unbalanced.
542
+ function parseGitConfigParameters(text) {
543
+ const entries = [];
544
+ for (const word of tokenize(text)) {
545
+ const kv = splitAssignment(shellUnquote(word));
546
+ if (kv) entries.push(kv);
547
+ }
548
+ const quotes = (text.match(/'/g) || []).length;
549
+ return quotes % 2 === 0 ? entries : null;
550
+ }
551
+
552
+ // Splits `NAME=value` at the first `=` (null when there is none or the name is empty).
553
+ function splitAssignment(kv) {
554
+ const eq = kv.indexOf('=');
555
+ return eq <= 0 ? null : [kv.slice(0, eq), kv.slice(eq + 1)];
556
+ }
557
+
558
+ // #275: `git config` options that take a separate value, and the ones that only read.
559
+ const GIT_CONFIG_VALUE_OPTIONS = new Set(['-f', '--file', '--blob', '-t', '--type', '--default', '--comment', '--value']);
560
+ const GIT_CONFIG_FLAGS = new Set([
561
+ '--global', '--system', '--local', '--worktree', '--add', '--replace-all', '--append', '--all',
562
+ '--bool', '--int', '--bool-or-int', '--path', '--expiry-date', '--includes', '--no-includes',
563
+ '--null', '-z', '--fixed-value', '--show-origin', '--show-scope', '--name-only', '--no-type',
564
+ ]);
565
+ const GIT_CONFIG_READ_OPTIONS = new Set([
566
+ '--get', '--get-all', '--get-regexp', '--get-urlmatch', '--get-color', '--get-colorbool', '-l',
567
+ '--list', '--unset', '--unset-all', '-e', '--edit', '--rename-section', '--remove-section',
568
+ ]);
569
+ const GIT_CONFIG_READ_WORDS = new Set([
570
+ 'get', 'list', 'unset', 'edit', 'rename-section', 'remove-section',
571
+ ]);
572
+
573
+ // #275: the [key, value] pairs a `git config` invocation writes. `args` = the words after
574
+ // `config`. The first positional is the key and the second the value (a leading `set`
575
+ // is the mode word); read verbs write nothing. An option the walk does not know could
576
+ // hide which word is the value, so each positional after a command-valued key is also
577
+ // taken as its value (fail toward scanning). A single `key=value` token is not a write.
578
+ function gitConfigWritePairs(args) {
579
+ const positional = [];
580
+ let unknown = false;
581
+ for (let k = 0; k < args.length; k++) {
582
+ const t = args[k];
583
+ // #275: `--` ends the options, every later word is a positional (`-n; cat .env` too).
584
+ if (t === '--') { positional.push(...args.slice(k + 1)); break; }
585
+ if (GIT_CONFIG_READ_OPTIONS.has(t)) return [];
586
+ if (!t.startsWith('-') || t === '-') {
587
+ if (positional.length === 0 && GIT_CONFIG_READ_WORDS.has(t)) return [];
588
+ if (positional.length === 0 && t === 'set') continue;
589
+ positional.push(t);
590
+ } else if (GIT_CONFIG_VALUE_OPTIONS.has(t)) k++;
591
+ else if (!GIT_CONFIG_FLAGS.has(t) && !/^--(file|blob|type|default|comment|value)=/.test(t)) unknown = true;
592
+ }
593
+ const pairs = positional.length >= 2 ? [[positional[0], positional[1]]] : [];
594
+ if (unknown) {
595
+ positional.forEach((key, i) => {
596
+ if (i + 1 < positional.length && isGitCommandKey(key)) pairs.push([key, positional[i + 1]]);
597
+ });
598
+ }
599
+ return pairs;
600
+ }
601
+
602
+ // Every command text carried in the git global options of a statement, from
603
+ // `-c key=value`, `-ckey=value` and `--config-env[=| ]key=ENV`, plus the
604
+ // `--config-env` forms on a command-valued key whose ENV the statement does not
605
+ // assign (`unresolved`: the guard cannot see the value, so it fails closed).
606
+ // ENV is resolved from the statement's own leading NAME=value assignments.
607
+ function extractGitConfigCommands(tokens) {
608
+ const found = { commands: [], unresolved: [], unparsed: [] };
609
+ const first = skipEnvPrefix(tokens);
610
+ if (first >= tokens.length || basename(shellUnquote(tokens[first])) !== 'git') return found;
611
+ const env = new Map();
612
+ for (const a of tokens.slice(0, first)) {
613
+ const kv = splitAssignment(shellUnquote(a));
614
+ if (kv) env.set(kv[0], kv[1]);
615
+ }
616
+ const add = (key, value) => {
617
+ const cmd = gitConfigCommand(key, value);
618
+ if (cmd) found.commands.push(cmd);
619
+ };
620
+ // #273: config injected through the statement's own leading assignments. Pairs
621
+ // are read regardless of GIT_CONFIG_COUNT (fail toward scanning).
622
+ for (const [name, value] of env) {
623
+ const m = /^GIT_CONFIG_KEY_(\d+)$/.exec(name);
624
+ if (m && env.has(`GIT_CONFIG_VALUE_${m[1]}`)) add(value, env.get(`GIT_CONFIG_VALUE_${m[1]}`));
625
+ if (GIT_COMMAND_ENV_VARS.includes(name) && value) found.commands.push(value);
626
+ }
627
+ if (env.has('GIT_CONFIG_PARAMETERS')) {
628
+ const text = env.get('GIT_CONFIG_PARAMETERS');
629
+ const entries = parseGitConfigParameters(text);
630
+ if (entries) entries.forEach(([k, v]) => add(k, v));
631
+ else if ((text.match(/[A-Za-z0-9._-]+(?==)/g) || []).some(isGitCommandKey)) found.unparsed.push(`GIT_CONFIG_PARAMETERS=${text}`);
632
+ }
633
+ const operands = tokens.slice(first + 1).map(shellUnquote);
634
+ for (const cfg of walkGitGlobals(operands).configs) {
635
+ const kv = splitAssignment(cfg.value);
636
+ if (!kv) continue;
637
+ if (cfg.kind === 'c') { add(kv[0], kv[1]); continue; }
638
+ if (!isGitCommandKey(kv[0])) continue;
639
+ if (env.has(kv[1])) add(kv[0], env.get(kv[1])); else found.unresolved.push(cfg.sample);
640
+ }
641
+ // #275: `git config <key> <value>` writes a command into the config.
642
+ const sub = gitSubcommandIndexLoose(operands);
643
+ if (sub !== -1 && operands[sub] === 'config') {
644
+ gitConfigWritePairs(operands.slice(sub + 1)).forEach(([k, v]) => add(k, v));
645
+ }
646
+ return found;
647
+ }
648
+
649
+ // The unmodelled-hit shape (lib/unmodelled.js) for a `--config-env` on a command key whose
650
+ // variable the statement does not assign.
651
+ const GIT_CONFIG_ENV_UNMODELLED = (sample) => ({
652
+ id: 'git-config-env',
653
+ label: 'a git --config-env on a command-valued key whose variable is not assigned in the same statement',
654
+ hint: 'assign it in the same statement (`VAR="cmd" git --config-env=key=VAR ...`) or pass the command with `git -c key=cmd`',
655
+ sample,
656
+ });
657
+
658
+ // The unmodelled-hit shape for a GIT_CONFIG_PARAMETERS value the guard cannot parse.
659
+ const GIT_CONFIG_PARAMETERS_UNMODELLED = (sample) => ({
660
+ id: 'git-config-parameters',
661
+ label: 'a GIT_CONFIG_PARAMETERS value with unbalanced quoting that names a command-valued key',
662
+ hint: 'pass the command with `git -c key=cmd` or a well-quoted GIT_CONFIG_PARAMETERS',
663
+ sample,
664
+ });
665
+
666
+ // #316: the unmodelled-hit shape for a nested shell / eval behind a command wrapper
667
+ // (`nice bash -c '...'`), whose inner text the guard does not follow.
668
+ const WRAPPED_SHELL_UNMODELLED = (sample) => ({
669
+ id: 'wrapped-nested-shell',
670
+ label: 'a command wrapper in front of a nested shell (`bash -c`) or eval',
671
+ hint: 'run the inner command directly, without the wrapper and the nested shell',
672
+ sample,
673
+ });
674
+
675
+ // #316: wrappers this gate also knows that lib/unmodelled.js does not skip. A word list
676
+ // only: the gate never walks a wrapper's options, so no option values are needed here.
677
+ const EXTRA_WRAPPER_WORDS = new Set(['stdbuf', 'time', 'setsid', 'doas']);
678
+
679
+ // #316: a statement whose command word (after NAME=value prefixes) is a known wrapper
680
+ // (lib/unmodelled.js) and that carries a nested shell word or `eval` later on. The
681
+ // wrapper's own options are not walked: any such word fails closed. Null when not.
682
+ function wrappedNestedShell(tokens) {
683
+ let lib;
684
+ try {
685
+ lib = require('./lib/unmodelled.js');
686
+ } catch {
687
+ return null; // a missing lib only switches this gate off, like unmodelledSecretPiece
688
+ }
689
+ const first = skipEnvPrefix(tokens);
690
+ if (first >= tokens.length) return null;
691
+ const word = unquoteWord(tokens[first]);
692
+ if (!lib.isWrapperWord(word) && !EXTRA_WRAPPER_WORDS.has(word)) return null;
693
+ const nested = tokens.slice(first + 1).some((t) => {
694
+ const w = basename(unquoteWord(t));
695
+ return w === 'eval' || SHELL_DASH_C_VERBS.has(w);
696
+ });
697
+ return nested ? tokens.join(' ') : null;
698
+ }
699
+
405
700
  // Scan a single top-level statement for a secret path. Recurses into a
406
701
  // bash -c/eval quoted sub-command first (finding #1), routes grep/rg/ag/sed/
407
702
  // awk through the shared pattern-skipping walk (finding #4), and falls back
@@ -419,6 +714,19 @@ function scanStatementForSecretPath(stmt) {
419
714
  // stray `-f .env` on the wrapper itself) for defence in depth.
420
715
  }
421
716
 
717
+ // #273: a command carried in a git config value (`-c core.pager="cat .env"`).
718
+ const git = extractGitConfigCommands(tokens);
719
+ for (const text of git.commands) {
720
+ const hit = scanBashForSecretPath(text);
721
+ if (hit) return hit;
722
+ }
723
+ if (git.unresolved.length) {
724
+ return { path: git.unresolved[0], unmodelled: GIT_CONFIG_ENV_UNMODELLED(git.unresolved[0]) };
725
+ }
726
+ if (git.unparsed.length) {
727
+ return { path: git.unparsed[0], unmodelled: GIT_CONFIG_PARAMETERS_UNMODELLED(git.unparsed[0]) };
728
+ }
729
+
422
730
  const bin = basename(tokens[0]);
423
731
  if (SEARCH_VERBS.has(bin)) {
424
732
  return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
@@ -427,7 +735,12 @@ function scanStatementForSecretPath(stmt) {
427
735
  // standalone docker-compose/podman-compose binaries) get --env-file's value
428
736
  // exempted; every other verb keeps today's behaviour unchanged.
429
737
  const exempt = ENV_FILE_EXEMPT_VERBS.has(bin) ? ENV_FILE_EXEMPT_FLAGS : NO_EXEMPT_FLAGS;
430
- return scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
738
+ const plain = scanTokensForSecretPath(tokens, exempt, nameOnlyTokenIndices(bin, tokens));
739
+ if (plain) return plain;
740
+ // #316: only when a secret-looking piece sits in the statement (`timeout 5 bash -c 'echo hi'` stays allowed).
741
+ const wrapped = wrappedNestedShell(tokens);
742
+ const piece = wrapped && crudeSecretPiece(wrapped);
743
+ return piece ? { path: piece, unmodelled: WRAPPED_SHELL_UNMODELLED(wrapped) } : null;
431
744
  }
432
745
 
433
746
  // #214: substitution bodies are scanned off an explicit work-list (no
@@ -462,13 +775,13 @@ function invokesInterpreter(stmt) {
462
775
  let i = 0;
463
776
  while (i < tokens.length && LEADING_KEYWORDS.has(tokens[i])) i++;
464
777
  i += skipEnvPrefix(tokens.slice(i));
465
- if (i < tokens.length && basename(stripQuotes(tokens[i])) === 'env') {
778
+ if (i < tokens.length && basename(unquoteWord(tokens[i])) === 'env') {
466
779
  i++;
467
780
  while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || tokens[i].startsWith('-'))) {
468
781
  i += ENV_VALUE_OPTIONS.has(tokens[i]) ? 2 : 1;
469
782
  }
470
783
  }
471
- return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(stripQuotes(tokens[i])));
784
+ return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(unquoteWord(tokens[i])));
472
785
  }
473
786
 
474
787
  // Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
@@ -570,7 +883,7 @@ function detectSecretRead(toolName, toolInput) {
570
883
  if (toolName === 'Bash') {
571
884
  const cmd = input.command || '';
572
885
  const hit = scanBashForSecretPath(cmd);
573
- if (hit) return { path: hit };
886
+ if (hit) return typeof hit === 'object' ? hit : { path: hit }; // object: an unmodelled hit (#273)
574
887
  const um = unmodelledSecretPiece(cmd); // a final gate: the precise scan keeps priority
575
888
  return um ? { path: um.piece, unmodelled: um.hit } : null;
576
889
  }