@marcomigozzi/trailhead 0.13.0 → 0.14.0

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 (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -0,0 +1,795 @@
1
+ 'use strict';
2
+ // body-sources.js: static analysis of where a `gh` issue/PR write takes its body
3
+ // from, shared by trailhead-secret-guard.js (#210). Nothing is ever executed: shell
4
+ // words are expanded the way bash would for a plain argument ($NAME, ${NAME}, a
5
+ // leading ~, variables set earlier in the command), redirects and flags are parsed,
6
+ // and a write's body is traced to files to read, text to scan, or a reason it cannot
7
+ // be known (the caller then fails closed). Best-effort, not a full shell parser.
8
+ // Self-contained: requires only node built-ins and the sibling gh-subcommand.js (dequote).
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { dequote, dequoteSpans } = require('./gh-subcommand.js');
13
+
14
+ const ASSIGN_TOKEN_RE = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
15
+
16
+ // --- static shell expansion (just enough to find a --body-file path) -----------
17
+
18
+ // Look a variable up: variables set earlier in the command first, then the hook's
19
+ // environment. null = unknown or recorded as unresolvable.
20
+ function lookupVar(name, vars, env) {
21
+ if (Object.prototype.hasOwnProperty.call(vars, name)) return vars[name];
22
+ const v = env[name];
23
+ return typeof v === 'string' ? v : null;
24
+ }
25
+
26
+ // Expand the `$...` at text[i]. Only $NAME and ${NAME} are understood; command
27
+ // substitution, parameter operators and positional/special params are unresolvable.
28
+ // Returns { value, next } or { unresolved }.
29
+ function expandDollar(text, i, vars, env) {
30
+ const rest = text.slice(i + 1);
31
+ let name;
32
+ let len;
33
+ const braced = /^\{([A-Za-z_][A-Za-z0-9_]*)\}/.exec(rest);
34
+ if (braced) {
35
+ name = braced[1];
36
+ len = braced[0].length;
37
+ } else if (rest[0] === '{') {
38
+ return { unresolved: 'parameter expansion operator' };
39
+ } else if (rest[0] === '(') {
40
+ return { unresolved: 'command substitution' };
41
+ } else if (/^[0-9@*#?$!-]/.test(rest)) {
42
+ return { unresolved: `special parameter $${rest[0]}` };
43
+ } else {
44
+ const bare = /^[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
45
+ if (!bare) return { value: '$', next: i + 1 }; // a lone $ is literal
46
+ name = bare[0];
47
+ len = name.length;
48
+ }
49
+ const v = lookupVar(name, vars, env);
50
+ if (v === null) return { unresolved: `unknown variable ${name}` };
51
+ return { value: v, next: i + 1 + len };
52
+ }
53
+
54
+ const ANSI_C_SIMPLE = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', e: '\x1b' };
55
+
56
+ // Decode the body of an ANSI-C `$'...'` span starting at word[i] (just past the
57
+ // opening quote). Common escapes are decoded (\n \t \\ \' \" \xHH \uHHHH \NNN); any
58
+ // other backslash pair keeps the character after it, so nothing is ever lost.
59
+ // Returns { value, next } or { unresolved }.
60
+ function expandAnsiC(word, i) {
61
+ let out = '';
62
+ for (;;) {
63
+ if (i >= word.length) return { unresolved: 'unterminated quote' };
64
+ const d = word[i];
65
+ if (d === "'") return { value: out, next: i + 1 };
66
+ if (d !== '\\' || i + 1 >= word.length) { out += d; i++; continue; }
67
+ const rest = word.slice(i + 1);
68
+ const n = rest[0];
69
+ let m;
70
+ if (Object.prototype.hasOwnProperty.call(ANSI_C_SIMPLE, n)) {
71
+ out += ANSI_C_SIMPLE[n]; i += 2;
72
+ } else if ((m = /^x([0-9a-fA-F]{1,2})/.exec(rest)) || (m = /^u([0-9a-fA-F]{1,4})/.exec(rest)) || (m = /^U([0-9a-fA-F]{1,8})/.exec(rest))) {
73
+ const cp = parseInt(m[1], 16);
74
+ out += cp <= 0x10ffff ? String.fromCodePoint(cp) : m[0];
75
+ i += 1 + m[0].length;
76
+ } else if ((m = /^[0-7]{1,3}/.exec(rest))) {
77
+ out += String.fromCharCode(parseInt(m[0], 8) & 0xff);
78
+ i += 1 + m[0].length;
79
+ } else {
80
+ out += n; i += 2; // \\ \' \" and any other pair: keep the character
81
+ }
82
+ }
83
+ }
84
+
85
+ // Expand one shell word the way bash would for a plain argument: single quotes are
86
+ // literal, double quotes expand $NAME/${NAME} only, a bare word also expands a
87
+ // leading `~`/`~/` to HOME. Returns { value } or { unresolved: reason }.
88
+ function expandWord(word, vars, env) {
89
+ let out = '';
90
+ let i = 0;
91
+ if (word[0] === '~') {
92
+ if (word.length > 1 && word[1] !== '/') return { unresolved: 'user home (~user)' };
93
+ const home = lookupVar('HOME', vars, env);
94
+ if (home === null) return { unresolved: 'unknown variable HOME' };
95
+ out = home;
96
+ i = 1;
97
+ }
98
+ while (i < word.length) {
99
+ const c = word[i];
100
+ if (c === "'") {
101
+ const close = word.indexOf("'", i + 1);
102
+ if (close === -1) return { unresolved: 'unterminated quote' };
103
+ out += word.slice(i + 1, close);
104
+ i = close + 1;
105
+ } else if (c === '"') {
106
+ i++;
107
+ for (;;) {
108
+ if (i >= word.length) return { unresolved: 'unterminated quote' };
109
+ const d = word[i];
110
+ if (d === '"') { i++; break; }
111
+ if (d === '`') return { unresolved: 'command substitution' };
112
+ if (d === '\\' && i + 1 < word.length) {
113
+ const n = word[i + 1];
114
+ out += '$`"\\'.includes(n) ? n : d + n;
115
+ i += 2;
116
+ } else if (d === '$') {
117
+ const r = expandDollar(word, i, vars, env);
118
+ if (r.unresolved) return r;
119
+ out += r.value;
120
+ i = r.next;
121
+ } else {
122
+ out += d;
123
+ i++;
124
+ }
125
+ }
126
+ } else if (c === '\\' && i + 1 < word.length) {
127
+ out += word[i + 1];
128
+ i += 2;
129
+ } else if (c === '$' && word[i + 1] === "'") {
130
+ // ANSI-C quoting: bash drops the `$` and decodes the span.
131
+ const r = expandAnsiC(word, i + 2);
132
+ if (r.unresolved) return r;
133
+ out += r.value;
134
+ i = r.next;
135
+ } else if (c === '$' && word[i + 1] === '"') {
136
+ i++; // locale quoting: drop the `$`, the double-quoted span follows
137
+ } else if (c === '$') {
138
+ const r = expandDollar(word, i, vars, env);
139
+ if (r.unresolved) return r;
140
+ out += r.value;
141
+ i = r.next;
142
+ } else if (c === '`') {
143
+ return { unresolved: 'command substitution' };
144
+ } else {
145
+ out += c;
146
+ i++;
147
+ }
148
+ }
149
+ return { value: out };
150
+ }
151
+
152
+ // --- brace expansion (bash expands {a,b} and {1..3} before quote removal) ---------
153
+
154
+ const BRACE_LIMIT = 256;
155
+ const BRACE_MAX_DEPTH = 64; // nesting of groups / expansions inside a word
156
+ const BRACE_MAX_STEPS = 2_000_000; // scan work per word, so a hostile word cannot stall the hook
157
+ const BRACE_OVERFLOW = Symbol('brace overflow');
158
+
159
+ // Count one unit of scan work and one level of nesting; past either cap the word is "too large".
160
+ const braceTick = (st) => { if (++st.steps > BRACE_MAX_STEPS) throw BRACE_OVERFLOW; };
161
+ const braceDeeper = (st) => { if (++st.depth > BRACE_MAX_DEPTH) throw BRACE_OVERFLOW; };
162
+
163
+ // Index just past the balanced close of a `(...)`/`${...}` opened before s[from], quote aware.
164
+ function skipBalanced(s, from, open, close, st) {
165
+ braceDeeper(st);
166
+ let depth = 1;
167
+ let j = from;
168
+ while (j < s.length && depth > 0) {
169
+ braceTick(st);
170
+ const k = skipProtected(s, j, st);
171
+ if (k >= 0) { j = k; continue; }
172
+ if (s[j] === open) depth++;
173
+ else if (s[j] === close) depth--;
174
+ j++;
175
+ }
176
+ st.depth--;
177
+ return j;
178
+ }
179
+
180
+ // If a quoted span, an escape, a `${...}`, a `$(...)`/`<(...)`/`>(...)` or a backtick span
181
+ // starts at s[i], the index just past it; otherwise -1. Braces and commas inside these
182
+ // are never part of a brace group.
183
+ function skipProtected(s, i, st) {
184
+ const c = s[i];
185
+ if (c === '\\') return Math.min(i + 2, s.length);
186
+ if (c === "'") {
187
+ const close = s.indexOf("'", i + 1);
188
+ return close === -1 ? s.length : close + 1;
189
+ }
190
+ if (c === '"') {
191
+ for (let j = i + 1; j < s.length; j++) {
192
+ if (s[j] === '\\') j++;
193
+ else if (s[j] === '"') return j + 1;
194
+ }
195
+ return s.length;
196
+ }
197
+ if (c === '`') {
198
+ for (let j = i + 1; j < s.length; j++) {
199
+ if (s[j] === '\\') j++;
200
+ else if (s[j] === '`') return j + 1;
201
+ }
202
+ return s.length;
203
+ }
204
+ if (c === '$' && s[i + 1] === "'") {
205
+ for (let j = i + 2; j < s.length; j++) {
206
+ if (s[j] === '\\') j++;
207
+ else if (s[j] === "'") return j + 1;
208
+ }
209
+ return s.length;
210
+ }
211
+ if (c === '$' && s[i + 1] === '{') return skipBalanced(s, i + 2, '{', '}', st);
212
+ if ((c === '$' || c === '<' || c === '>') && s[i + 1] === '(') return skipBalanced(s, i + 2, '(', ')', st);
213
+ return -1;
214
+ }
215
+
216
+ const SEQ_INT = '[-+]?\\d+';
217
+
218
+ // The values of a `{x..y}` / `{x..y..step}` body, or null when it is not a sequence.
219
+ function sequenceValues(body, limit) {
220
+ let m = new RegExp(`^(${SEQ_INT})\\.\\.(${SEQ_INT})(?:\\.\\.(${SEQ_INT}))?$`).exec(body);
221
+ const letters = !m;
222
+ if (!m) m = /^([A-Za-z])\.\.([A-Za-z])(?:\.\.([-+]?\d+))?$/.exec(body);
223
+ if (!m) return null;
224
+ const from = letters ? m[1].charCodeAt(0) : parseInt(m[1], 10);
225
+ const to = letters ? m[2].charCodeAt(0) : parseInt(m[2], 10);
226
+ const step = Math.abs(m[3] ? parseInt(m[3], 10) : 1) || 1;
227
+ const count = Math.floor(Math.abs(to - from) / step) + 1;
228
+ if (count > limit) throw BRACE_OVERFLOW;
229
+ // Bash pads to the longest endpoint (sign included) when one has a leading zero, like
230
+ // printf %0Nd: `{-01..2}` is `-01 000 001 002`.
231
+ const zeroLed = (e) => (e[0] === '0' && e.length > 1) || (e[0] === '-' && e[1] === '0' && e.length > 2);
232
+ const width = !letters && (zeroLed(m[1]) || zeroLed(m[2])) ? Math.max(m[1].length, m[2].length) : 0;
233
+ const fmt = (v) => (v < 0 ? '-' + String(-v).padStart(Math.max(width - 1, 0), '0') : String(v).padStart(width, '0'));
234
+ const out = [];
235
+ const dir = to >= from ? step : -step;
236
+ for (let n = 0, v = from; n < count; n++, v += dir) out.push(letters ? String.fromCharCode(v) : fmt(v));
237
+ return out;
238
+ }
239
+
240
+ // Expand the first expandable group of `s` (and, recursively, the rest). Throws
241
+ // BRACE_OVERFLOW past `limit` results, the depth cap or the scan budget.
242
+ function braceWords(s, limit, st) {
243
+ for (let i = 0; i < s.length;) {
244
+ braceTick(st);
245
+ const k = skipProtected(s, i, st);
246
+ if (k >= 0) { i = k; continue; }
247
+ if (s[i] !== '{') { i++; continue; }
248
+ let depth = 1;
249
+ let j = i + 1;
250
+ const commas = [];
251
+ while (j < s.length) {
252
+ braceTick(st);
253
+ const q = skipProtected(s, j, st);
254
+ if (q >= 0) { j = q; continue; }
255
+ if (s[j] === '{') depth++;
256
+ else if (s[j] === '}' && --depth === 0) break;
257
+ else if (s[j] === ',' && depth === 1) commas.push(j);
258
+ j++;
259
+ }
260
+ if (j >= s.length) { i++; continue; } // unmatched: literal
261
+ let alts;
262
+ if (commas.length) {
263
+ alts = [];
264
+ let from = i + 1;
265
+ for (const c of [...commas, j]) { alts.push(s.slice(from, c)); from = c + 1; }
266
+ braceDeeper(st);
267
+ alts = alts.flatMap((a) => braceWords(a, limit, st));
268
+ st.depth--;
269
+ if (alts.length > limit) throw BRACE_OVERFLOW;
270
+ } else {
271
+ alts = sequenceValues(s.slice(i + 1, j), limit);
272
+ }
273
+ if (!alts) { i++; continue; } // {} / {x}: literal, keep scanning inside
274
+ braceDeeper(st);
275
+ const post = braceWords(s.slice(j + 1), limit, st);
276
+ st.depth--;
277
+ if (alts.length * post.length > limit) throw BRACE_OVERFLOW;
278
+ const pre = s.slice(0, i);
279
+ return alts.flatMap((a) => post.map((p) => pre + a + p));
280
+ }
281
+ return [s];
282
+ }
283
+
284
+ // Bash brace expansion of a RAW shell word (quotes still in place, so they stay
285
+ // literal): { words } or { overflow: true } past `limit` results, the nesting cap, or
286
+ // on any internal fault (#220): a word it cannot expand is never passed through.
287
+ function braceExpand(word, limit = BRACE_LIMIT) {
288
+ try {
289
+ const out = braceWords(String(word), limit, { steps: 0, depth: 0 });
290
+ return out.length > limit ? { overflow: true } : { words: out };
291
+ } catch (e) {
292
+ // A size/depth overflow is expected; anything else is a fault. Both fail closed.
293
+ return e === BRACE_OVERFLOW ? { overflow: true } : { overflow: true, fault: true };
294
+ }
295
+ }
296
+
297
+ // What bash passes as argv once each word's brace groups are expanded in place
298
+ // (`-{f,f} body=x` is `-f -f body=x`): { toks, overflow, fault }. A word that cannot
299
+ // be expanded stays as written and sets `overflow` (the caller fails closed).
300
+ function braceFlatten(toks) {
301
+ let overflow = false;
302
+ let fault = false;
303
+ const out = toks.flatMap((t) => {
304
+ const b = braceExpand(t);
305
+ if (!b.overflow) return b.words;
306
+ overflow = true;
307
+ fault = fault || b.fault === true;
308
+ return [t];
309
+ });
310
+ return { toks: out, overflow, fault };
311
+ }
312
+
313
+ // Quote-aware word splitter. Like shell-scan's tokenize, but a `$(...)`, `<(...)`,
314
+ // `>(...)` or backtick span stays inside ONE word even when it holds spaces.
315
+ function words(stmt) {
316
+ const s = String(stmt).trim();
317
+ const out = [];
318
+ let cur = '';
319
+ let quote = null; // ' or "
320
+ let ansi = false; // inside a `$'...'` span
321
+ let bare = false; // the previous char is a bare `$` (not escaped, not the 2nd of `$$`)
322
+ for (let i = 0; i < s.length; i++) {
323
+ const c = s[i];
324
+ const afterBare = bare;
325
+ bare = false;
326
+ if (quote) {
327
+ // An ANSI-C `$'...'` span honours backslash escapes (`$'a\'b'`).
328
+ if (quote === "'" && ansi && c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
329
+ // Inside double quotes bash removes a backslash-newline continuation.
330
+ if (quote === '"' && c === '\\' && s[i + 1] === '\n') { i++; bare = afterBare; continue; }
331
+ cur += c;
332
+ if (quote === '"' && c === '\\' && i + 1 < s.length) cur += s[++i];
333
+ else if (c === quote) { quote = null; ansi = false; }
334
+ continue;
335
+ }
336
+ if (c === "'" || c === '"') { ansi = c === "'" && afterBare; quote = c; cur += c; continue; }
337
+ // A backslash-newline continuation vanishes (`a\<nl>b` is `ab`, `a \<nl>b` is two words).
338
+ if (c === '\\' && s[i + 1] === '\n') { i++; bare = afterBare; continue; } // the bare-dollar state carries over
339
+ if (c === '\\' && i + 1 < s.length) { cur += c + s[++i]; continue; }
340
+ if ((c === '$' || c === '<' || c === '>') && s[i + 1] === '(') {
341
+ let depth = 0;
342
+ let j = i + 1;
343
+ for (; j < s.length; j++) {
344
+ if (s[j] === '(') depth++;
345
+ else if (s[j] === ')' && --depth === 0) break;
346
+ }
347
+ cur += s.slice(i, j + 1);
348
+ i = j;
349
+ continue;
350
+ }
351
+ if (c === '`') {
352
+ const close = s.indexOf('`', i + 1);
353
+ const end = close === -1 ? s.length - 1 : close;
354
+ cur += s.slice(i, end + 1);
355
+ i = end;
356
+ continue;
357
+ }
358
+ if (/\s/.test(c)) {
359
+ if (cur) { out.push(cur); cur = ''; }
360
+ continue;
361
+ }
362
+ // A redirect operator glued to its neighbours (`cat >f<<'EOF'`, `echo x>f`)
363
+ // starts a new word; a file-descriptor digit before it stays with the operator.
364
+ if (c === '<' || c === '>' || (c === '&' && s[i + 1] === '>')) {
365
+ if (cur && !/^\d+$/.test(cur)) { out.push(cur); cur = ''; }
366
+ const op = /^(?:&>>|&>|<<<|<<-|<<|>>|>&|<&|>\||<>|>|<)/.exec(s.slice(i))[0];
367
+ cur += op;
368
+ i += op.length - 1;
369
+ continue;
370
+ }
371
+ if (c === '$') bare = !afterBare; // `$$` is the PID: its 2nd `$` opens nothing
372
+ cur += c;
373
+ }
374
+ if (cur) out.push(cur);
375
+ return out;
376
+ }
377
+
378
+ // The command/process substitutions ($(...), `...`, and outside double quotes <(...)
379
+ // and >(...)) in some text: [{ start, end, inner }] (end exclusive). Single-quoted
380
+ // text is skipped, except with `literalQuotes` (heredoc text, where quotes are
381
+ // ordinary characters). An unclosed span runs to the end of the text.
382
+ function substitutionSpans(s, literalQuotes = false) {
383
+ const out = [];
384
+ let inSingle = false;
385
+ let inDouble = false;
386
+ for (let i = 0; i < s.length; i++) {
387
+ const c = s[i];
388
+ if (inSingle) { if (c === "'") inSingle = false; continue; }
389
+ if (c === '\\') { i++; continue; }
390
+ if (!literalQuotes && c === "'") { inSingle = true; continue; }
391
+ if (!literalQuotes && c === '"') { inDouble = !inDouble; continue; }
392
+ if (s[i + 1] === '(' && (c === '$' || (!inDouble && !literalQuotes && (c === '<' || c === '>')))) {
393
+ let depth = 0;
394
+ let j = i + 1;
395
+ for (; j < s.length; j++) {
396
+ if (s[j] === '(') depth++;
397
+ else if (s[j] === ')' && --depth === 0) break;
398
+ }
399
+ out.push({ start: i, end: Math.min(j + 1, s.length), inner: s.slice(i + 2, j) });
400
+ i = j;
401
+ } else if (c === '`') {
402
+ const close = s.indexOf('`', i + 1);
403
+ const end = close === -1 ? s.length : close;
404
+ out.push({ start: i, end: Math.min(end + 1, s.length), inner: s.slice(i + 1, end) });
405
+ i = end;
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+
411
+ // Expand heredoc text the way bash does for an unquoted delimiter: $NAME and ${NAME}
412
+ // expand, `\$`, `\`` and `\\` are escapes, quotes are ordinary characters.
413
+ function expandPlain(text, vars, env) {
414
+ let out = '';
415
+ let i = 0;
416
+ while (i < text.length) {
417
+ const c = text[i];
418
+ if (c === '\\' && text[i + 1] === '\n') {
419
+ i += 2; // a backslash-newline continuation vanishes
420
+ } else if (c === '\\' && i + 1 < text.length) {
421
+ const n = text[i + 1];
422
+ out += '$`\\'.includes(n) ? n : c + n;
423
+ i += 2;
424
+ } else if (c === '$') {
425
+ const r = expandDollar(text, i, vars, env);
426
+ if (r.unresolved) return r;
427
+ out += r.value;
428
+ i = r.next;
429
+ } else if (c === '`') {
430
+ return { unresolved: 'command substitution' };
431
+ } else {
432
+ out += c;
433
+ i++;
434
+ }
435
+ }
436
+ return { value: out };
437
+ }
438
+
439
+ // --- redirects and flags ----------------------------------------------------------
440
+
441
+ const REDIRECT_RE = /^(\d*)(<<<|<<-?|&>>|&>|>>|>\||>&|<&|<>|>|<)([\s\S]*)$/;
442
+
443
+ // Split a statement's words into plain arguments and redirections.
444
+ // stdin: null | { type: 'heredoc' | 'here' } | { type: 'file', word }
445
+ // outs: [{ word, append }] (stdout redirects; fd duplications like 2>&1 are dropped)
446
+ function parseRedirects(toks) {
447
+ const args = [];
448
+ const outs = [];
449
+ let stdin = null;
450
+ for (let i = 0; i < toks.length; i++) {
451
+ const t = toks[i];
452
+ const m = t.startsWith('<(') || t.startsWith('>(') ? null : REDIRECT_RE.exec(t);
453
+ if (!m) { args.push(t); continue; }
454
+ const [, fd, op, glued] = m;
455
+ if (op === '>&' || op === '<&') { if (!glued) i++; continue; } // 2>&1, >&2: fd duplication
456
+ const word = glued || toks[++i] || '';
457
+ if (op === '<<' || op === '<<-') stdin = { type: 'heredoc' };
458
+ else if (op === '<<<') stdin = { type: 'here', word };
459
+ else if (op === '<' || op === '<>') stdin = { type: 'file', word };
460
+ else if (fd === '' || fd === '1' || op.startsWith('&')) outs.push({ word, append: op.endsWith('>>') });
461
+ }
462
+ return { args, outs, stdin };
463
+ }
464
+
465
+ // A quoted or split token that is this flag (`'-F'`, `--body-fi""le`, `"--body-file=F"`,
466
+ // `"-F/p"`); free text in the flag's own quoted span (`"-F handling"`) is not (#219).
467
+ function dequotedFlag(raw, long, short) {
468
+ const { text: d, spans } = dequoteSpans(raw);
469
+ const hit = d === long || (short && d === short) || d.startsWith(`${long}=`) || (short && d.startsWith(`${short}=`));
470
+ if (hit) return d;
471
+ if (!short || !d.startsWith(short) || d.startsWith('--') || d.length <= short.length) return null;
472
+ // The attached shorthand (`"-F/p"`, `-"F"/"p a"`, `"-F/my dir/b"`). Only a space DIRECTLY
473
+ // after the flag letter, in the letter's own quoted span, is free text (`"-F handling"`).
474
+ const next = short.length;
475
+ if (/\s/.test(d[next]) && spans[next] !== -1 && spans[next] === spans[next - 1]) return null;
476
+ return d;
477
+ }
478
+
479
+ // Values of a gh flag (long form, optional short form), in order: `--long v`,
480
+ // `--long=v`, `-s v`, `-s=v` and the attached pflag shorthand `-sv`.
481
+ function flagValues(args, long, short) {
482
+ const out = [];
483
+ for (let i = 0; i < args.length; i++) {
484
+ // A quoted or split flag name is the same flag; the raw form is tried first so an
485
+ // unquoted flag keeps its raw value (#219).
486
+ const raw = args[i];
487
+ const rawHit = raw === long || raw.startsWith(`${long}=`) || (short && raw.startsWith(short) && !raw.startsWith('--'));
488
+ const t = rawHit ? raw : dequotedFlag(raw, long, short) || raw;
489
+ if (t === long || (short && t === short)) {
490
+ if (args[i + 1] !== undefined) out.push(args[++i]);
491
+ } else if (t.startsWith(`${long}=`)) out.push(t.slice(long.length + 1));
492
+ else if (short && t.startsWith(`${short}=`)) out.push(t.slice(short.length + 1));
493
+ else if (short && t.startsWith(short) && !t.startsWith('--') && t.length > 2) out.push(t.slice(2));
494
+ }
495
+ return out;
496
+ }
497
+
498
+ // Resolve one shell word that names a file to an absolute path, without running
499
+ // anything: { path } or { unresolved }. See resolveBodyFile for the cwd contract.
500
+ function resolveWord(raw, vars, env, cwd) {
501
+ if (raw.startsWith('<(') || raw.startsWith('>(')) return { unresolved: 'process substitution' };
502
+ const r = expandWord(raw, vars, env);
503
+ if (r.unresolved) return { unresolved: r.unresolved };
504
+ return resolveExpanded(r.value, cwd);
505
+ }
506
+
507
+ // Same for an already-expanded value (e.g. the path after `body=@`).
508
+ function resolveExpanded(value, cwd) {
509
+ if (path.isAbsolute(value)) return { path: path.normalize(value) };
510
+ if (cwd === null) return { unresolved: 'relative path after cd (working directory unknown)' };
511
+ return { path: path.resolve(cwd === undefined ? process.cwd() : cwd, value) };
512
+ }
513
+
514
+ const STDIN_PATHS = new Set(['-', '/dev/stdin', '/dev/fd/0', '/proc/self/fd/0']);
515
+
516
+ // --- where a write's body comes from -----------------------------------------------
517
+
518
+ const basenameOf = (w) => String(w || '').replace(/^.*\//, '');
519
+ const INLINE_PRODUCERS = new Set(['echo', 'printf', 'true', ':']);
520
+
521
+ // Body sources are a list of items:
522
+ // { text } content to scan that is not in the command string (an expanded value)
523
+ // { path } a file to read and scan
524
+ // { unresolved } a source that cannot be known statically: block
525
+ // Content that is already inside the command string (heredoc, herestring, echo/printf
526
+ // arguments) needs no item: the whole command is scanned.
527
+
528
+ const unresolvedItem = (reason) => [{ unresolved: reason }];
529
+ // An unresolvable expansion in inline text (not a file path): the caller words its block differently.
530
+ const inlineUnresolved = (reason) => [{ unresolved: reason, inline: true }];
531
+
532
+ // Items for a file named by a shell word, as seen from statement `rec`.
533
+ function wordItems(word, rec, ctx) {
534
+ const r = resolveWord(word, rec.vars, ctx.env, rec.cwdKnown ? ctx.cwd : null);
535
+ return r.unresolved ? unresolvedItem(r.unresolved) : fileItems(r.path, rec.index, ctx);
536
+ }
537
+
538
+ // Items for reading the file `p` as of statement index `before`. A file an earlier
539
+ // statement of the same command writes (and that is recognised: a redirect fed by
540
+ // inline content or by cat, tee, cp) has the content of its source, not whatever is
541
+ // (or is not) on disk now; unrecognised writers are not counted, so the file must
542
+ // exist and is read.
543
+ function fileItems(p, before, ctx) {
544
+ if (p === '/dev/null') return []; // an empty body: nothing to scan
545
+ const writers = [];
546
+ for (let i = 0; i < before; i++) {
547
+ const rec = ctx.history[i];
548
+ if (rec.sep === '&') continue; // backgrounded: may not have run yet
549
+ for (const t of writerTargets(rec, ctx)) if (t.path === p) writers.push(t);
550
+ }
551
+ if (writers.length === 0) return [{ path: p }];
552
+ const items = writers.flatMap((t) => t.content);
553
+ if (writers.some((t) => t.append)) items.push({ path: p, optional: true }); // keeps what was there
554
+ return items;
555
+ }
556
+
557
+ // Content a `cmd > file` redirect puts in the file, or null when not recognised.
558
+ function redirectContent(cmd0, rest, rec, ctx) {
559
+ if (!cmd0) return [];
560
+ if (INLINE_PRODUCERS.has(cmd0)) return rest.flatMap((a) => valueItems(a, rec, ctx));
561
+ if (cmd0 === 'cat') {
562
+ const files = rest.filter((a) => !a.startsWith('-'));
563
+ return files.length ? files.flatMap((f) => wordItems(f, rec, ctx)) : stdinContent(rec, ctx);
564
+ }
565
+ return null;
566
+ }
567
+
568
+ // The files a statement writes and what it writes into them:
569
+ // [{ path, append, content: items }]. Only the recognised shapes: a stdout
570
+ // redirect of cat/echo/printf (or nothing), `tee [-a] path`, and `cp src dst`.
571
+ function writerTargets(rec, ctx) {
572
+ const red = parseRedirects(words(rec.text));
573
+ let k = 0;
574
+ while (k < red.args.length && ASSIGN_TOKEN_RE.test(red.args[k])) k++;
575
+ const cmd0 = basenameOf(red.args[k]);
576
+ const rest = red.args.slice(k + 1);
577
+ const cwd = rec.cwdKnown ? ctx.cwd : null;
578
+ const target = (word) => {
579
+ const r = resolveWord(word, rec.vars, ctx.env, cwd);
580
+ return r.unresolved ? null : r.path;
581
+ };
582
+ const out = [];
583
+ if (red.outs.length) {
584
+ const content = redirectContent(cmd0, rest, rec, ctx);
585
+ if (content) {
586
+ for (const o of red.outs) {
587
+ const t = target(o.word);
588
+ if (t) out.push({ path: t, append: o.append, content });
589
+ }
590
+ }
591
+ }
592
+ if (cmd0 === 'tee') {
593
+ const content = stdinContent(rec, ctx);
594
+ if (content) {
595
+ const append = rest.includes('-a') || rest.includes('--append');
596
+ for (const f of rest.filter((a) => !a.startsWith('-'))) {
597
+ const t = target(f);
598
+ if (t) out.push({ path: t, append, content });
599
+ }
600
+ }
601
+ }
602
+ if (cmd0 === 'cp') {
603
+ const operands = rest.filter((a) => !a.startsWith('-'));
604
+ const dst = operands.length === 2 ? target(operands[1]) : null;
605
+ if (dst) out.push({ path: dst, append: false, content: wordItems(operands[0], rec, ctx) });
606
+ }
607
+ return out;
608
+ }
609
+
610
+ // What a statement feeds to its stdin: items, or null when there is no stdin source.
611
+ function stdinContent(rec, ctx) {
612
+ const red = parseRedirects(words(rec.text));
613
+ if (red.stdin) {
614
+ if (red.stdin.type === 'file') return wordItems(red.stdin.word, rec, ctx);
615
+ if (red.stdin.type === 'here') return valueItems(red.stdin.word, rec, ctx);
616
+ return heredocItems(rec, ctx);
617
+ }
618
+ const prev = ctx.history[rec.index - 1];
619
+ if (prev && prev.sep === '|') return producerItems(prev, ctx);
620
+ return null;
621
+ }
622
+
623
+ // What a pipeline producer writes to stdout.
624
+ function producerItems(rec, ctx) {
625
+ const { args } = parseRedirects(words(rec.text));
626
+ let k = 0;
627
+ while (k < args.length && ASSIGN_TOKEN_RE.test(args[k])) k++;
628
+ const cmd0 = basenameOf(args[k]);
629
+ if (INLINE_PRODUCERS.has(cmd0)) return args.slice(k + 1).flatMap((a) => valueItems(a, rec, ctx));
630
+ if (cmd0 === 'cat') {
631
+ const files = args.slice(k + 1).filter((a) => !a.startsWith('-'));
632
+ if (files.length) return files.flatMap((f) => wordItems(f, rec, ctx));
633
+ return stdinContent(rec, ctx) || unresolvedItem('cat reads a stdin the guard cannot see');
634
+ }
635
+ return unresolvedItem(`the body is piped from ${cmd0 || 'a command'}`);
636
+ }
637
+
638
+ // Items for text whose `$...` and backticks the shell would expand: a --body value,
639
+ // an echo/printf argument, a herestring, an unquoted heredoc body (`literalQuotes`).
640
+ // Substitutions of `cat path`/`<path` are read, `cat <<EOF` follows the heredoc,
641
+ // $VAR is expanded, and anything else is unresolvable. Literal text needs no item:
642
+ // the whole command is scanned.
643
+ function valueItems(raw, rec, ctx, literalQuotes = false) {
644
+ if (literalQuotes) return valueItemsOf(raw, rec, ctx, true); // heredoc bodies are not brace-expanded
645
+ const b = braceExpand(raw);
646
+ if (b.overflow) return inlineUnresolved(b.fault ? 'brace expansion failed' : 'brace expansion too large');
647
+ return b.words.flatMap((w) => valueItemsOf(w, rec, ctx, false));
648
+ }
649
+
650
+ function valueItemsOf(raw, rec, ctx, literalQuotes) {
651
+ if (!/[$`]/.test(raw) || (!literalQuotes && /^'[\s\S]*'$/.test(raw))) return [];
652
+ const items = [];
653
+ let rest = '';
654
+ let at = 0;
655
+ for (const sp of substitutionSpans(raw, literalQuotes)) {
656
+ rest += raw.slice(at, sp.start);
657
+ at = sp.end;
658
+ items.push(...substitutionItems(sp.inner, rec, ctx));
659
+ }
660
+ rest += raw.slice(at);
661
+ if (/[$`]/.test(rest)) {
662
+ const r = literalQuotes ? expandPlain(rest, rec.vars, ctx.env) : expandWord(rest, rec.vars, ctx.env);
663
+ items.push(...(r.unresolved ? inlineUnresolved(r.unresolved) : [{ text: r.value }]));
664
+ }
665
+ return items;
666
+ }
667
+
668
+ // Items for one `$(inner)` / `inner` substitution.
669
+ function substitutionItems(inner, rec, ctx) {
670
+ const t = inner.trim();
671
+ const h = /^cat\s+<<-?\s*(['"]?)\w+\1$/.exec(t);
672
+ if (h) return h[1] ? [] : heredocItems(rec, ctx); // a quoted delimiter is literal text
673
+ const m = /^cat\s+(\S+)$/.exec(t) || /^<\s*(\S+)$/.exec(t);
674
+ if (m) return wordItems(m[1], rec, ctx);
675
+ return inlineUnresolved('command substitution in the body');
676
+ }
677
+
678
+ // Items for the unquoted heredoc bodies of a statement (the shell expands them).
679
+ function heredocItems(rec, ctx) {
680
+ return (rec.heredocs || []).filter((h) => !h.quoted).flatMap((h) => valueItems(h.body, rec, ctx, true));
681
+ }
682
+
683
+ // Every body source of one write: { items }.
684
+ function bodySources(w, ctx) {
685
+ const rec = w.history[w.index];
686
+ const { args } = parseRedirects(words(w.text));
687
+ const stdinItems = () => stdinContent(rec, ctx) || [];
688
+ const fileArg = (raw) => (STDIN_PATHS.has(raw) ? stdinItems() : wordItems(raw, rec, ctx));
689
+ const items = [];
690
+ if (w.kind === 'api') {
691
+ // Every `body=` field: -F/--field may read a file (`body=@path`, `body=@-`); for
692
+ // -f/--raw-field `@` is literal. Any other value is inline text, expanded and scanned.
693
+ const fields = [
694
+ ...flagValues(args, '--field', '-F').map((raw) => ({ raw, fileOk: true })),
695
+ ...flagValues(args, '--raw-field', '-f').map((raw) => ({ raw, fileOk: false })),
696
+ ];
697
+ for (const { raw, fileOk } of fields) {
698
+ const key = dequote(raw); // `'body'=x`, `bo""dy=x` name the same field (#219)
699
+ if (!key.startsWith('body=')) continue;
700
+ if (!(fileOk && key.startsWith('body=@'))) { items.push(...valueItems(raw, rec, ctx)); continue; }
701
+ const r = expandWord(raw, rec.vars, ctx.env);
702
+ if (r.unresolved) { items.push(...unresolvedItem(r.unresolved)); continue; }
703
+ const file = r.value.slice('body=@'.length);
704
+ if (STDIN_PATHS.has(file)) { items.push(...stdinItems()); continue; }
705
+ const res = resolveExpanded(file, rec.cwdKnown ? ctx.cwd : null);
706
+ items.push(...(res.unresolved ? unresolvedItem(res.unresolved) : fileItems(res.path, rec.index, ctx)));
707
+ }
708
+ for (const raw of flagValues(args, '--input', null)) items.push(...fileArg(raw));
709
+ return items;
710
+ }
711
+ const files = flagValues(args, '--body-file', '-F');
712
+ // gh keeps the last one, but scanning every occurrence is the safe direction.
713
+ for (const raw of files) items.push(...fileArg(raw));
714
+ for (const raw of flagValues(args, '--body', '-b')) items.push(...valueItems(raw, rec, ctx));
715
+ return items;
716
+ }
717
+
718
+ // What bash sees once `\<newline>` continuations are removed, for the statements of
719
+ // one command: each statement's text (already joined outside single quotes, which
720
+ // stay literal) and its unquoted heredoc bodies. The raw command still has a token
721
+ // broken by a continuation, so the caller scans this too (#215).
722
+ function joinedText(history) {
723
+ const parts = [];
724
+ for (const rec of history) {
725
+ parts.push(rec.text);
726
+ for (const h of rec.heredocs || []) {
727
+ if (!h.quoted && h.body.includes('\\\n')) parts.push(h.body.split('\\\n').join(''));
728
+ }
729
+ }
730
+ return parts;
731
+ }
732
+
733
+ // Literal quote removal for a word that cannot be expanded: drop the ' and "
734
+ // characters and keep the rest, so literal halves around an unresolvable piece join.
735
+ const stripQuoteChars = (word) => word.replace(/\$(?=['"])/g, '').replace(/['"]/g, '');
736
+
737
+
738
+ // What bash passes as each word once adjacent quoted parts are concatenated
739
+ // (`"a""b"`, `"a"b`, `a''b`), for the statements of one command. An unresolvable
740
+ // word falls back to literal quote removal; blocking on it is left to the body
741
+ // sources. Variables are deliberately not expanded (empty scope): a `$NAME` stays
742
+ // literal here, so an env value is never pulled into a field the guard does not
743
+ // scan (valueItems expands those a body takes). The raw command keeps the quotes
744
+ // between the halves, so the caller scans this too (#217). Unquoted brace groups are
745
+ // expanded first (#220); `overflow` is true when one is too large to enumerate.
746
+ function resolvedWords(history) {
747
+ const out = [];
748
+ for (const rec of history) {
749
+ for (const raw of words(rec.text)) {
750
+ const b = braceExpand(raw);
751
+ if (b.overflow) return { words: out, overflow: true, fault: b.fault === true };
752
+ for (const word of b.words) {
753
+ const r = expandWord(word, {}, {});
754
+ out.push(r.unresolved ? stripQuoteChars(word) : r.value);
755
+ }
756
+ }
757
+ }
758
+ return { words: out, overflow: false, fault: false };
759
+ }
760
+
761
+ // Read a body file (regular files only: a FIFO or device could hang the hook).
762
+ // `optional` tolerates a missing file.
763
+ function readBodyFile(p, optional) {
764
+ let st;
765
+ try {
766
+ st = fs.statSync(p);
767
+ } catch (e) {
768
+ if (optional && e.code === 'ENOENT') return '';
769
+ throw e;
770
+ }
771
+ if (!st.isFile()) throw Object.assign(new Error('not a regular file'), { code: 'ENOTFILE' });
772
+ return fs.readFileSync(p, 'utf8').slice(0, 1_000_000);
773
+ }
774
+
775
+ module.exports = {
776
+ ASSIGN_TOKEN_RE,
777
+ STDIN_PATHS,
778
+ words,
779
+ expandWord,
780
+ braceExpand,
781
+ braceFlatten,
782
+ BRACE_LIMIT,
783
+ parseRedirects,
784
+ flagValues,
785
+ resolveWord,
786
+ resolveExpanded,
787
+ substitutionSpans,
788
+ expandPlain,
789
+ joinedText,
790
+ resolvedWords,
791
+ stripQuoteChars,
792
+ dequote,
793
+ bodySources,
794
+ readBodyFile,
795
+ };