@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.
- package/README.it.md +52 -14
- package/README.md +53 -15
- package/bin/auto-intro-docs.test.js +72 -0
- package/bin/auto-merge-docs.test.js +33 -0
- package/bin/auto-merge-ticket-docs.test.js +32 -0
- package/bin/auto-review-rounds-docs.test.js +45 -0
- package/bin/auto-run-docs.test.js +60 -0
- package/bin/auto-scope-docs.test.js +33 -0
- package/bin/auto-slots-docs.test.js +45 -0
- package/bin/auto-uat-docs.test.js +47 -0
- package/bin/config-dir-templates-docs.test.js +52 -0
- package/bin/decision-builds-docs.test.js +75 -0
- package/bin/fog-vs-ticket-docs.test.js +56 -0
- package/bin/grilling-holistic-review-docs.test.js +90 -0
- package/bin/lib/codex-projection.js +13 -6
- package/bin/lib/codex-projection.test.js +25 -2
- package/bin/lib/host-descriptor.js +14 -1
- package/bin/lib/host-descriptor.test.js +18 -0
- package/bin/lib/runtime-command-literals.test.js +184 -0
- package/bin/needs-you-docs.test.js +41 -0
- package/bin/pick-stress-test-docs.test.js +105 -0
- package/bin/report-status-docs.test.js +1 -1
- package/bin/trailhead.js +3 -3
- package/bin/trailhead.test.js +17 -18
- package/package.json +1 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
- package/plugins/trailhead/agents/trailhead-code-review.md +2 -2
- package/plugins/trailhead/agents/trailhead-verify.md +2 -2
- package/plugins/trailhead/commands/auto.md +2 -2
- package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
- package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
- package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
- package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
- package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
- package/plugins/trailhead/hooks/lib/dashboard.test.js +66 -3
- package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
- package/plugins/trailhead/hooks/lib/model-defaults-review.js +50 -21
- package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +50 -0
- package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
- package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
- package/plugins/trailhead/hooks/lib/report-status.test.js +68 -1
- package/plugins/trailhead/hooks/lib/shell-scan.js +25 -0
- package/plugins/trailhead/hooks/lib/statusline-context.test.js +161 -0
- package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
- package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +6 -1
- package/plugins/trailhead/hooks/mod/README.md +185 -39
- package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
- package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +150 -25
- package/plugins/trailhead/hooks/mod/map-pane.tsx +557 -99
- package/plugins/trailhead/hooks/mod/register.tsx +298 -39
- package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
- package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
- package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
- package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
- package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
- package/plugins/trailhead/hooks/mod/work-state-view.ts +21 -5
- package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
- package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
- package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +336 -23
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +321 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -2
- package/plugins/trailhead/skills/_shared/configuration-reference.md +5 -5
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +26 -4
- package/plugins/trailhead/skills/_shared/substrate.md +4 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +5 -5
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +2 -2
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +2 -2
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
- package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +4 -3
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +23 -12
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +7 -6
- package/plugins/trailhead/templates/statusline-trailhead.sh +80 -15
- package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
- package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
- package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
- package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
- package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +32 -12
- package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
- package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
- package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +67 -3
- package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
- package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
- package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
- package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1839 -72
- package/plugins/trailhead/tests/map-pane-view.test.ts +308 -14
- package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
- package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
- package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
- package/plugins/trailhead/tests/session-registry.test.ts +50 -0
- package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
- package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
- package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
- package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
- package/plugins/trailhead/tests/work-state-view.test.ts +19 -0
- package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
- 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 =
|
|
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
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
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
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
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('-'))
|
|
327
|
-
if (
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
}
|