@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
@@ -6,15 +6,63 @@
6
6
  // post to an issue/PR/comment and BLOCKS if it looks like a secret is in it, so
7
7
  // nothing sensitive lands on the tracker.
8
8
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
9
- // Fail-OPEN on error to keep the workflow moving, but the failure is made
10
- // OBSERVABLE (an advisory), never byte-identical to a clean pass: once a write is
11
- // identified, a scan error emits a "could-not-scan" advisory instead of silently
12
- // allowing. (Anti-pattern: a guard whose failure looks exactly like a success.)
13
- // Self-contained. Pure functions are exported for tests; runs as a hook when
14
- // executed directly.
9
+ // Every gh write in a compound command is checked (`D=/x && gh issue comment ...`,
10
+ // `cd d && gh pr comment ...`), not just a command that starts with gh. A
11
+ // --body-file path is resolved statically ($NAME, ${NAME}, leading ~, variables set
12
+ // earlier in the command); the guard never runs anything. Bodies read from stdin
13
+ // (< file, cat file | gh), $(cat file), $VAR, gh api body=@file / --input are
14
+ // resolved and scanned too, and a body file the same command writes first (heredoc
15
+ // redirect, tee, cat, cp) is traced to its source. Expansions inside inline content
16
+ // (echo/printf arguments, unquoted heredocs, herestrings, --body) are resolved and
17
+ // scanned. Writes inside $(...) or backticks, function bodies, case arms and behind
18
+ // wrappers (env, sudo, nice, timeout, ...) or a variable command word are analysed
19
+ // like any other write.
20
+ // Known limits, accepted (fail closed or best-effort, never silent):
21
+ // - a gh write run through bash -c / sh -c / zsh -c / eval / xargs is not analysed:
22
+ // it blocks as SECRET_GUARD_OPAQUE_WRITE (run gh directly instead);
23
+ // - a construct the guard cannot interpret with certainty (eval, source / `.`,
24
+ // a bash 5.3 `${ cmd; }`, printf -v, a function definition, an alias, a quoted or
25
+ // split `gh api` body key, a backslash-newline inside a word) is not emulated: as a
26
+ // FINAL gate, after every verdict above, it blocks with
27
+ // SECRET_GUARD_UNMODELLED_CONSTRUCT when it can matter (the code it hides or runs
28
+ // later is a gh write, a write follows it, or its own statement is one). The
29
+ // message asks for the simple form (lib/unmodelled.js is the one closed list; #227);
30
+ // - a body file produced by an unrecognised writer (curl -o, a script, ...) in the
31
+ // same command is not traced: the file must already exist, or the write blocks.
32
+ // Fail-CLOSED on a body file it cannot vouch for: a path that cannot be resolved
33
+ // statically (unknown variable, $(...), backticks, a relative path after a cd)
34
+ // blocks with SECRET_GUARD_UNRESOLVED_BODY_FILE, and a resolved file that cannot
35
+ // be read blocks with SECRET_GUARD_UNREADABLE_BODY_FILE. Otherwise the write would
36
+ // post an unscanned body.
37
+ // Fail-OPEN only on an internal scanner fault, to keep the workflow moving, but the
38
+ // failure is made OBSERVABLE (an advisory), never byte-identical to a clean pass:
39
+ // once a write is identified, a scan error emits a "could-not-scan" advisory
40
+ // instead of silently allowing. (Anti-pattern: a guard whose failure looks exactly
41
+ // like a success.)
42
+ // Self-contained. Pure functions (incl. evaluate) are exported for tests and for
43
+ // the merged trailhead-bash-guards.js; runs as a hook when executed directly.
15
44
 
16
45
  const fs = require('fs');
46
+ const path = require('path');
17
47
  const { parseGhSubcommand } = require('./lib/gh-subcommand.js');
48
+ const { splitStatementsWithSeparators, heredocBodies, countHeredocs, tokenize, stripQuotes } = require('./lib/shell-scan.js');
49
+ const {
50
+ ASSIGN_TOKEN_RE,
51
+ STDIN_PATHS,
52
+ words,
53
+ expandWord,
54
+ braceExpand,
55
+ braceFlatten,
56
+ parseRedirects,
57
+ flagValues,
58
+ resolveWord,
59
+ substitutionSpans,
60
+ joinedText,
61
+ resolvedWords,
62
+ dequote,
63
+ bodySources,
64
+ readBodyFile,
65
+ } = require('./lib/body-sources.js');
18
66
 
19
67
  // High-confidence secret formats: a match here is almost never a false positive.
20
68
  const SECRET_PATTERNS = [
@@ -38,35 +86,319 @@ const ASSIGN_RE =
38
86
  const SAFE_VALUE =
39
87
  /^(?:\$|\$\{|<|\*{2,}|x{3,}|redacted|your[-_]|example|placeholder|dummy|test|todo|none|null|true|false|process\.env|import\.meta|os\.environ)/i;
40
88
 
41
- // Is this a `gh` command that WRITES issue/PR/comment text? Returns the kind or null.
42
- function ghIssueWrite(cmd) {
43
- const parsed = parseGhSubcommand(cmd);
89
+ // Kind of tracker write ('issue' | 'pr' | 'api') one statement performs, or null.
90
+ function writeKind(stmt) {
91
+ const parsed = parseGhSubcommand(stmt);
44
92
  if (!parsed) return null;
45
93
  const { sub, verb } = parsed;
46
94
  if ((sub === 'issue' || sub === 'pr') && ['create', 'comment', 'edit', 'review'].includes(verb)) {
47
95
  return sub;
48
96
  }
49
- if (sub === 'api' && /(?:^|\s)(?:-f|-F|--field|--raw-field)\s+['"]?body=/.test(cmd)) return 'api';
97
+ if (sub === 'api' && /(?:^|\s)(?:-f|-F|--field|--raw-field)(?:\s+|=)?body=/.test(dequote(stmt))) return 'api';
98
+ if (sub === 'api' && /(?:^|\s)--input(?:=|\s|$)/.test(dequote(stmt))) return 'api'; // --input sends a request body from a file
50
99
  return null;
51
100
  }
52
101
 
53
- // For gh issue/pr, the body may live in a --body-file <path>; read it too.
54
- // The path is quoted ("..."/'...') or bare. A bare path stops at whitespace AND
55
- // at shell metacharacters (; | & ( ) < > `): in a compound command like
56
- // `gh ... --body-file /tmp/x.md; echo done` the `;` abuts the path with no
57
- // space, so a naive [^\s]+ would swallow it into the filename and the read would
58
- // ENOENT, turning a clean write into a spurious could-not-scan (and, worse, a
59
- // real body left unscanned). Quoted paths keep those characters verbatim.
60
- function bodyFileText(cmd) {
61
- const m = cmd.match(/(?:--body-file|(?<![-\w])-F)(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"';|&()<>`]+))/);
62
- if (!m) return '';
63
- const path = m[1] || m[2] || m[3];
64
- if (path === '-' || path.startsWith('<')) return ''; // stdin/heredoc already in the command string
65
- return fs.readFileSync(path, 'utf8').slice(0, 1_000_000);
102
+ // Is some word of the statement `gh` (or a path ending in /gh) once quotes and brace
103
+ // groups are resolved (`gh`, `{gh,gh}`, `g{h,h}`)? A word too large to expand never
104
+ // counts: it cannot be told apart from any other big group.
105
+ function hasGhWord(text) {
106
+ return words(text).some((t) => {
107
+ const b = braceExpand(t);
108
+ return !b.overflow && b.words.some((w) => /(^|\/)gh$/.test(dequote(w)));
109
+ });
110
+ }
111
+
112
+ // Could this statement, with a brace group in its words, be a gh write (#220)? The
113
+ // statement's argv is already brace-expanded in place (see unwrap), which is what bash
114
+ // runs; this also tries each alternative of each group on its own (`gh {issue,issue}
115
+ // comment` runs `gh issue issue comment`, but a one-alternative reading is a write), so
116
+ // a braced command, global flag, subcommand or verb word never hides a write. Fail
117
+ // closed: a statement too large to enumerate counts when it reads like a write.
118
+ function braceBuiltWrite(text, overflow) {
119
+ if (!hasGhWord(text)) return false;
120
+ const alts = words(text).map((t) => braceExpand(t));
121
+ if (overflow || alts.some((a) => a.overflow)) return WRITE_WORDS_RE.test(dequote(text));
122
+ if (alts.every((a) => a.words.length === 1)) return false;
123
+ if (alts.reduce((n, a) => n * a.words.length, 1) > BRACE_COMBO_LIMIT) return WRITE_WORDS_RE.test(dequote(text));
124
+ const combos = alts.reduce((acc, a) => acc.flatMap((c) => a.words.map((w) => [...c, w])), [[]]);
125
+ return combos.some((c) => writeKind(c.join(' ')) !== null);
126
+ }
127
+
128
+ // --- statement normalisation ---------------------------------------------------
129
+
130
+ // Reserved words and prefixes that can precede a command inside a compound construct.
131
+ const PREFIX_WORDS = new Set(['do', 'then', 'else', 'elif', '{', '!', 'time', 'if', 'while', 'until']);
132
+ // Wrappers that run the rest of the line as a command, with the flags of each that
133
+ // take a separate value (skipped together with it).
134
+ const WRAPPER_VALUE_FLAGS = {
135
+ env: ['-u', '-C', '-S'],
136
+ sudo: ['-u', '-g', '-h', '-p', '-C', '-r', '-t', '-U', '-D', '-R'],
137
+ nice: ['-n'],
138
+ ionice: ['-c', '-n', '-p'],
139
+ timeout: ['-k', '-s'],
140
+ stdbuf: ['-i', '-o', '-e'],
141
+ exec: ['-a'],
142
+ command: [],
143
+ builtin: [],
144
+ nohup: [],
145
+ };
146
+ // Commands that run text we cannot analyse statically.
147
+ const WRAPPERS = new Set(Object.keys(WRAPPER_VALUE_FLAGS));
148
+ const OPAQUE_RUNNERS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'eval', 'xargs']);
149
+ // A gh write's words after a command word we could not resolve (`$CMD issue comment ...`).
150
+ const BRACE_COMBO_LIMIT = 256;
151
+ const WRITE_WORDS_RE = /\b(?:issue|pr)\b[\s\S]*?\b(?:create|comment|edit|review)\b|\bapi\b[\s\S]*?(?:\bbody=|--input)/;
152
+
153
+ // Index just past a wrapper command's own flags (and values), starting at the
154
+ // wrapper token toks[k]. `timeout` also takes a DURATION positional.
155
+ function skipWrapperArgs(toks, k) {
156
+ const w = dequote(toks[k]);
157
+ const valueFlags = WRAPPER_VALUE_FLAGS[w];
158
+ let j = k + 1;
159
+ while (j < toks.length) {
160
+ const t = dequote(toks[j]); // a quoted flag is the same flag (#219)
161
+ if (valueFlags.includes(t)) j += 2;
162
+ else if (t.startsWith('-')) j++;
163
+ else if ((w === 'env' || w === 'sudo') && ASSIGN_TOKEN_RE.test(t)) j++;
164
+ else break;
165
+ }
166
+ if (w === 'timeout' && j < toks.length) j++;
167
+ return j;
168
+ }
169
+
170
+ // One leading construct stripped from `toks`, or null: a subshell opener, a reserved
171
+ // word, a function definition head, a case head or arm pattern, or a wrapper command.
172
+ function stripLeader(toks) {
173
+ const t0 = toks[0];
174
+ const lead = /^\(+/.exec(t0);
175
+ if (lead) return { toks: [t0.slice(lead[0].length), ...toks.slice(1)].filter(Boolean), opens: lead[0].length };
176
+ if (PREFIX_WORDS.has(t0)) return { toks: toks.slice(1) };
177
+ if (t0 === 'function') return { toks: toks.slice(toks[2] === '()' ? 3 : 2) }; // function NAME [()]
178
+ if (/^[A-Za-z_][\w.:-]*\(\)\{?$/.test(t0)) return { toks: toks.slice(1) }; // name() / name(){
179
+ if (/^[A-Za-z_][\w.:-]*$/.test(t0) && toks[1] === '()') return { toks: toks.slice(2) }; // name ()
180
+ if (t0 === 'case' && toks.includes('in')) return { toks: toks.slice(toks.indexOf('in') + 1) };
181
+ if (/^[^\s()$"'`]*\)$/.test(t0)) return { toks: toks.slice(1) }; // case arm pattern)
182
+ let k = 0;
183
+ while (k < toks.length && ASSIGN_TOKEN_RE.test(toks[k])) k++;
184
+ if (k < toks.length && WRAPPERS.has(dequote(toks[k]))) { // a quoted `"env"` runs the same command (#219)
185
+ return { toks: [...toks.slice(0, k), ...toks.slice(skipWrapperArgs(toks, k))] };
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // xargs flags that take a separate value (a long flag only when written without `=`).
191
+ const XARGS_VALUE_FLAGS = new Set([
192
+ '-I', '-n', '-P', '-d', '-E', '-L', '-s', '-a', '-R', '-S', '-J',
193
+ '--arg-file', '--delimiter', '--eof', '--max-lines', '--max-args', '--max-procs', '--max-chars', '--process-slot-var',
194
+ ]);
195
+ // xargs long flags that take no separate value (`--replace` and `--eof` also accept `=`).
196
+ const XARGS_BOOLEAN_LONG = new Set([
197
+ '--null', '--no-run-if-empty', '--verbose', '--interactive', '--open-tty', '--exit', '--show-limits',
198
+ '--version', '--help', '--replace', '--',
199
+ ]);
200
+
201
+ // The script text a shell/eval/xargs statement runs, as a list of strings to analyse
202
+ // (the `-c` string, eval's arguments, the heredoc bodies fed to a shell, the command
203
+ // xargs runs). Only the runner's own arguments: never the rest of the command.
204
+ function runnerScripts(text, heredocs) {
205
+ const { args } = parseRedirects(words(text));
206
+ let k = 0;
207
+ while (k < args.length && ASSIGN_TOKEN_RE.test(args[k])) k++;
208
+ const name = dequote(args[k]).replace(/^.*\//, '');
209
+ const rest = args.slice(k + 1);
210
+ if (name === 'eval') return [rest.map(stripQuotes).join(' ')];
211
+ if (name === 'xargs') {
212
+ let j = 0;
213
+ let unknownLong = false; // a long flag we cannot size: its value might be taken for the command
214
+ while (j < rest.length && rest[j].startsWith('-')) {
215
+ const f = rest[j];
216
+ if (f.startsWith('--') && !f.includes('=') && !XARGS_VALUE_FLAGS.has(f) && !XARGS_BOOLEAN_LONG.has(f)) unknownLong = true;
217
+ j += XARGS_VALUE_FLAGS.has(f) ? 2 : 1;
218
+ }
219
+ if (j >= rest.length) return [];
220
+ // The command runs directly: quotes stay for the recursion. After an unknown flag
221
+ // fail closed: also try each later word as the command.
222
+ const from = unknownLong ? rest.map((_, i) => i).filter((i) => i >= j) : [j];
223
+ return from.map((i) => rest.slice(i).join(' '));
224
+ }
225
+ const c = rest.findIndex((a) => /^-[A-Za-z]*c$/.test(a));
226
+ if (c >= 0) return rest[c + 1] === undefined ? [] : [stripQuotes(rest[c + 1])];
227
+ return heredocs.map((h) => h.body); // a script fed on stdin
228
+ }
229
+
230
+ // Strip what stands between a statement and the command it runs: reserved words
231
+ // (do, then, else, !, time, ...), subshell/group openers, function and case heads,
232
+ // and wrapper commands (env, sudo, nice, timeout, ...). A variable command word
233
+ // (`$G issue ...`) is resolved with the variables known so far. Returns
234
+ // { text, runner, unresolvedCmd }
235
+ // `runner` is true for bash/sh/zsh/eval/xargs (text we cannot analyse) and
236
+ // `unresolvedCmd` for a command word that is a variable we cannot resolve.
237
+ function unwrap(text, vars = {}, env = {}, expand = true) {
238
+ let toks = words(text);
239
+ // bash expands brace groups word by word before running anything (#220).
240
+ const flat = expand ? braceFlatten(toks) : { toks, overflow: false };
241
+ toks = flat.toks;
242
+ let opens = 0;
243
+ for (let step = toks.length ? stripLeader(toks) : null; step; step = toks.length ? stripLeader(toks) : null) {
244
+ toks = step.toks;
245
+ opens += step.opens || 0;
246
+ }
247
+ if (opens > 0 && toks.length) {
248
+ const last = toks[toks.length - 1];
249
+ const stripped = last.replace(new RegExp(`\\){1,${opens}}$`), '');
250
+ toks = stripped ? [...toks.slice(0, -1), stripped] : toks.slice(0, -1);
251
+ }
252
+ let k = 0;
253
+ while (k < toks.length && ASSIGN_TOKEN_RE.test(toks[k])) k++;
254
+ let unresolvedCmd = false;
255
+ if (toks[k] && /^"?\$\{?[A-Za-z_]/.test(toks[k])) {
256
+ const r = expandWord(toks[k], vars, env);
257
+ if (r.unresolved) unresolvedCmd = true;
258
+ else toks = [...toks.slice(0, k), r.value, ...toks.slice(k + 1)];
259
+ }
260
+ const cmd0 = toks[k] ? dequote(toks[k]).replace(/^.*\//, '') : '';
261
+ return { text: toks.join(' '), runner: OPAQUE_RUNNERS.has(cmd0), unresolvedCmd, braceOverflow: flat.overflow };
262
+ }
263
+
264
+ // The --body-file path of one gh statement, resolved WITHOUT running anything.
265
+ // { path } an absolute path to read
266
+ // { unresolved } the path cannot be known statically (reason given): fail closed
267
+ // null no --body-file/-F, or it reads stdin
268
+ // `vars` are the variables set earlier in the command (name -> string, or null when
269
+ // unresolvable), `env` the hook's environment. A relative path resolves against
270
+ // `cwd` (process.cwd() when undefined); cwd === null means the working directory is
271
+ // no longer known (an earlier cd), so a relative path is unresolved. The last
272
+ // --body-file/-F wins, as in gh.
273
+ function resolveBodyFile(stmt, vars, env, cwd) {
274
+ const { args } = parseRedirects(words(stmt));
275
+ const vals = flagValues(args, '--body-file', '-F');
276
+ if (vals.length === 0) return null;
277
+ const raw = vals[vals.length - 1];
278
+ if (STDIN_PATHS.has(raw)) return null; // stdin: its content is the command string or a producer
279
+ return resolveWord(raw, vars, env, cwd);
280
+ }
281
+
282
+ // --- variable tracking across a compound command -------------------------------
283
+
284
+ const DECL_KEYWORDS = new Set(['export', 'declare', 'local', 'readonly', 'typeset']);
285
+
286
+ // [[name, rawValue], ...] when the statement is only assignments (optionally behind
287
+ // export/declare/...), else null. `D=v cmd ...` (an inline prefix) is not one.
288
+ function parseAssignments(text) {
289
+ const toks = tokenize(text);
290
+ const decl = DECL_KEYWORDS.has(toks[0]);
291
+ const out = [];
292
+ for (let i = decl ? 1 : 0; i < toks.length; i++) {
293
+ const m = ASSIGN_TOKEN_RE.exec(toks[i]);
294
+ if (m) out.push([m[1], m[2]]);
295
+ else if (!(decl && (toks[i].startsWith('-') || /^[A-Za-z_][A-Za-z0-9_]*$/.test(toks[i])))) return null;
296
+ }
297
+ return decl || out.length ? out : null;
298
+ }
299
+
300
+ function changesDir(text) {
301
+ const toks = tokenize(text);
302
+ let i = 0;
303
+ while (i < toks.length && ASSIGN_TOKEN_RE.test(toks[i])) i++;
304
+ return ['cd', 'pushd', 'popd'].includes(toks[i]);
305
+ }
306
+
307
+ // Every `gh` tracker write in a (possibly compound) command, in order:
308
+ // { kind, text, vars, cwdKnown }. The command is split into statements (`;`, `&&`,
309
+ // `||`, `|`, `&`, newline) and each is checked on its own: `D=/x && gh issue comment
310
+ // ...` or `cd d && gh pr comment ...` is a write even though the string does not
311
+ // START with gh. `vars` are the variables the write can see: a pure `NAME=v`
312
+ // statement propagates, except in a pipeline or a backgrounded statement (a
313
+ // subshell); an inline `NAME=v gh ...` prefix is the child's environment only.
314
+ // `cwdKnown` turns false after a cd/pushd/popd.
315
+ function ghWrites(cmd, env = process.env) {
316
+ return collectWrites(cmd, env, { vars: {}, cwdKnown: true });
317
+ }
318
+
319
+ // ghWrites from a given starting state (a command substitution starts with the
320
+ // variables and working directory of the statement that holds it).
321
+ function collectWrites(cmd, env, start) {
322
+ const writes = [];
323
+ const history = []; // every statement: { text, sep, vars, cwdKnown, index, heredocs }
324
+ const bodies = heredocBodies(cmd);
325
+ let nextBody = 0;
326
+ let vars = start.vars;
327
+ let cwdKnown = start.cwdKnown;
328
+ let prevSep = null;
329
+ for (const entry of splitStatementsWithSeparators(cmd)) {
330
+ const { text: raw, sep } = entry;
331
+ const subshell = sep === '|' || sep === '&' || prevSep === '|' || prevSep === '&';
332
+ prevSep = sep;
333
+ const { text, runner, unresolvedCmd, braceOverflow } = unwrap(raw, vars, env);
334
+ const index = history.length;
335
+ const heredocs = bodies.slice(nextBody, nextBody + countHeredocs(entry));
336
+ nextBody += heredocs.length;
337
+ history.push({ text, sep, vars, cwdKnown, index, heredocs });
338
+ const kind = writeKind(text);
339
+ // `{gh,gh} issue comment`, `gh {issue,issue} comment`, an unexpandable brace word (#220)
340
+ if (!kind && braceBuiltWrite(unwrap(raw, vars, env, false).text, braceOverflow)) {
341
+ writes.push({ kind: 'opaque', text, vars, cwdKnown, index, history });
342
+ continue;
343
+ }
344
+ if (kind) {
345
+ writes.push({ kind, text, vars, cwdKnown, index, history });
346
+ continue;
347
+ }
348
+ if (unresolvedCmd && WRITE_WORDS_RE.test(dequote(text))) {
349
+ writes.push({ kind: 'opaque', text, vars, cwdKnown, index, history });
350
+ continue;
351
+ }
352
+ // A shell/eval/xargs line that holds a gh write cannot be analysed (opaque). A
353
+ // command substitution is analysed like any other command, from this state.
354
+ if (runner) {
355
+ const state = { vars, cwdKnown };
356
+ if (runnerScripts(text, heredocs).some((script) => collectWrites(script, env, state).length > 0)) {
357
+ writes.push({ kind: 'opaque', text, vars, cwdKnown, index, history });
358
+ continue;
359
+ }
360
+ } else {
361
+ for (const sp of substitutionSpans(text)) writes.push(...collectWrites(sp.inner, env, { vars, cwdKnown }));
362
+ }
363
+ const assigns = parseAssignments(text);
364
+ if (assigns) {
365
+ if (!subshell) {
366
+ const next = { ...vars };
367
+ for (const [name, rawValue] of assigns) {
368
+ const r = expandWord(rawValue, next, env);
369
+ next[name] = r.unresolved ? null : r.value;
370
+ }
371
+ vars = next;
372
+ }
373
+ continue;
374
+ }
375
+ if (changesDir(text)) cwdKnown = false;
376
+ }
377
+ return writes;
378
+ }
379
+
380
+ // Is this command a `gh` write of issue/PR/comment text? Returns the kind of the
381
+ // first write, or null.
382
+ function ghIssueWrite(cmd, env = process.env) {
383
+ const first = ghWrites(cmd, env)[0];
384
+ return first ? first.kind : null;
385
+ }
386
+
387
+ // The text of the --body-file a gh issue/pr write in `cmd` reads ('' when none).
388
+ // A thin wrapper over the resolver: it throws when the path cannot be resolved
389
+ // statically or read, rather than guessing. Kept for callers and tests; evaluate()
390
+ // does its own, fuller, body-source analysis.
391
+ function bodyFileText(cmd, env = process.env, cwd) {
392
+ const w = ghWrites(cmd, env).find((x) => x.kind === 'issue' || x.kind === 'pr');
393
+ if (!w) return '';
394
+ const ref = resolveBodyFile(w.text, w.vars, env, w.cwdKnown ? cwd : null);
395
+ if (!ref) return '';
396
+ if (ref.unresolved) throw new Error(`body file path not resolvable (${ref.unresolved})`);
397
+ return fs.readFileSync(ref.path, 'utf8').slice(0, 1_000_000);
66
398
  }
67
399
 
68
- function scan(text) {
69
- if (process.env.TRAILHEAD_SECRETGUARD_THROW === '1') throw new Error('forced fault (test seam)');
400
+ function scan(text, env = process.env) {
401
+ if (env.TRAILHEAD_SECRETGUARD_THROW === '1') throw new Error('forced fault (test seam)');
70
402
  for (const { name, re } of SECRET_PATTERNS) {
71
403
  if (re.test(text)) return name;
72
404
  }
@@ -75,49 +407,201 @@ function scan(text) {
75
407
  return null;
76
408
  }
77
409
 
78
- function block(reason) {
79
- process.stdout.write(JSON.stringify({ decision: 'block', code: 'SECRET_IN_ISSUE_WRITE', reason }));
80
- process.exit(2);
81
- }
82
-
83
410
  // A scan we could NOT complete on a write we DID identify: surface it, don't hide it.
84
- function adviseUnscanned(kind, err) {
411
+ function unscannedVerdict(kind, err) {
85
412
  const detail = String((err && err.message) || err || 'unknown').slice(0, 120);
86
- process.stderr.write(`trailhead secret-guard: could not scan this ${kind} write (${detail}): NOT verified secret-free\n`);
87
- process.stdout.write(JSON.stringify({
88
- hookSpecificOutput: {
89
- hookEventName: 'PreToolUse',
90
- additionalContext:
91
- `⚠️ trailhead secret-guard could NOT complete its scan of this ${kind} write (${detail}). ` +
92
- 'It did NOT verify the content is secret-free. Check the body by hand before it lands, or re-run. ' +
93
- 'Do not treat this as a clean pass.',
413
+ return {
414
+ kind: 'advise',
415
+ stderr: `trailhead secret-guard: could not scan this ${kind} write (${detail}): NOT verified secret-free\n`,
416
+ payload: {
417
+ hookSpecificOutput: {
418
+ hookEventName: 'PreToolUse',
419
+ additionalContext:
420
+ `⚠️ trailhead secret-guard could NOT complete its scan of this ${kind} write (${detail}). ` +
421
+ 'It did NOT verify the content is secret-free. Check the body by hand before it lands, or re-run. ' +
422
+ 'Do not treat this as a clean pass.',
423
+ },
94
424
  },
95
- }));
425
+ };
96
426
  }
97
427
 
98
- function run(data) {
99
- let cmd = '';
428
+ // A block verdict for a body file the guard cannot vouch for.
429
+ function bodyFileBlock(code, kind, detail) {
430
+ return {
431
+ kind: 'block',
432
+ payload: {
433
+ decision: 'block',
434
+ code,
435
+ reason:
436
+ `trailhead: this gh ${kind} write reads its body from a source the secret-guard ${detail}, ` +
437
+ 'so it cannot verify the body is secret-free. Write the body file in a separate Bash call first ' +
438
+ "(or with a heredoc redirect in the same command: `cat > /tmp/body.md <<'EOF' ... EOF`), " +
439
+ 'then pass its literal absolute path (`--body-file /tmp/body.md`). Paths built with `$(mktemp)` ' +
440
+ 'or any other command substitution cannot be resolved.',
441
+ },
442
+ };
443
+ }
444
+
445
+ // A block verdict for inline body text (--body, echo, heredoc) with an expansion the
446
+ // guard cannot resolve.
447
+ function inlineBodyBlock(kind, detail) {
448
+ return blockVerdict(
449
+ 'SECRET_GUARD_UNRESOLVED_BODY_FILE',
450
+ `trailhead: this gh ${kind} write has body text the secret-guard ${detail}, so it cannot verify the body is ` +
451
+ "secret-free. To post literal text, put it in single quotes ('...') so the shell does not expand it, " +
452
+ 'or write it to a file and pass its literal absolute path (`--body-file /tmp/body.md`).',
453
+ );
454
+ }
455
+
456
+ function blockVerdict(code, reason) {
457
+ return { kind: 'block', payload: { decision: 'block', code, reason } };
458
+ }
459
+
460
+ function opaqueBlock() {
461
+ return blockVerdict(
462
+ 'SECRET_GUARD_OPAQUE_WRITE',
463
+ 'trailhead: a gh issue/PR write runs inside bash -c / sh -c / eval / xargs, so the secret-guard cannot analyse ' +
464
+ 'the body it posts. Run the gh command directly, with the body in a file passed by its literal absolute path ' +
465
+ '(--body-file /tmp/body.md), and post again.',
466
+ );
467
+ }
468
+
469
+ function secretBlock(kind, hit) {
470
+ return blockVerdict(
471
+ 'SECRET_IN_ISSUE_WRITE',
472
+ `trailhead: this ${kind === 'api' ? 'GitHub API write' : `gh ${kind}`} looks like it contains a secret (${hit}). ` +
473
+ 'Nothing sensitive may land on the tracker. Redact it: replace the value with `<REDACTED>` or an env-var reference ' +
474
+ '(e.g. `$SUPABASE_KEY`), and post again. If this is a false positive (not a real secret), reword so it no longer matches a credential pattern.',
475
+ );
476
+ }
477
+
478
+ // The text to scan for a set of writes: the command itself plus every body source
479
+ // (expanded values, files read). Returns { text } or { block } when a source cannot
480
+ // be resolved or read (fail closed).
481
+ function gatherText(cmd, writes, env, cwd) {
482
+ let text = cmd;
483
+ // The raw command keeps a token broken by a backslash-newline; scan it joined as well.
484
+ const histories = new Set(writes.map((w) => w.history));
485
+ if (cmd.includes('\\\n')) {
486
+ for (const history of histories) {
487
+ for (const part of joinedText(history)) text += '\n' + part;
488
+ }
489
+ }
490
+ // Adjacent quoted parts (`"a""b"`) are one word for bash; scan the quote-resolved words (#217).
491
+ // Brace groups are expanded first (#220); a word too large to expand, or a fault, fails closed.
492
+ for (const history of histories) {
493
+ const kind = (writes.find((w) => w.history === history) || writes[0]).kind;
494
+ const stuck = (fault) => ({
495
+ block: inlineBodyBlock(kind, `cannot resolve statically (brace expansion ${fault ? 'failed' : 'too large'})`),
496
+ });
497
+ let r;
498
+ try {
499
+ r = resolvedWords(history);
500
+ } catch {
501
+ return stuck(true); // an unexpected fault must not skip the scan: fail closed
502
+ }
503
+ if (r.overflow) return stuck(r.fault);
504
+ for (const word of r.words) text += '\n' + word;
505
+ }
506
+ for (const w of writes) {
507
+ for (const item of bodySources(w, { env, cwd, history: w.history })) {
508
+ if (item.unresolved) {
509
+ const detail = `cannot resolve statically (${item.unresolved})`;
510
+ return { block: item.inline ? inlineBodyBlock(w.kind, detail) : bodyFileBlock('SECRET_GUARD_UNRESOLVED_BODY_FILE', w.kind, detail) };
511
+ }
512
+ if (item.text !== undefined) { text += '\n' + item.text; continue; }
513
+ try {
514
+ text += '\n' + readBodyFile(item.path, item.optional);
515
+ } catch (e) {
516
+ const why = (e && e.code) || 'read error';
517
+ return { block: bodyFileBlock('SECRET_GUARD_UNREADABLE_BODY_FILE', w.kind, `could not read (${why}: ${item.path})`) };
518
+ }
519
+ }
520
+ }
521
+ return { text };
522
+ }
523
+
524
+ // The text with every secret-looking piece replaced, for a message that quotes the command.
525
+ function redact(text) {
526
+ let out = String(text);
527
+ for (const { re } of SECRET_PATTERNS) out = out.replace(new RegExp(re.source, 'g'), '<REDACTED>');
528
+ return out.replace(new RegExp(ASSIGN_RE.source, 'gi'), (m, key) => `${key}=<REDACTED>`);
529
+ }
530
+
531
+ // A quoted or split gh api body key (`-f 'body'=`, `bo""dy=`) is followed exactly since
532
+ // #219 (writeKind and the body sources read the quote-removed key), so it is no gap here.
533
+ const MODELLED_HERE = ['api-field'];
534
+
535
+ // The final gate (#227): a construct the guard cannot interpret (eval, source, a
536
+ // function substitution, printf -v, a function definition, an alias, a quoted gh api
537
+ // body key, a backslash-newline inside a word) is blocked, never emulated, when it can
538
+ // matter here: its payload, its later calls, a write after it or its own statement is a
539
+ // gh write by ghWrites. Returns a block verdict or null.
540
+ function unmodelledGate(cmd, env) {
541
+ let lib; // loaded lazily: a missing or broken lib only switches this gate off
100
542
  try {
101
- cmd = (JSON.parse(data).tool_input || {}).command || '';
543
+ lib = require('./lib/unmodelled.js');
102
544
  } catch {
103
- process.exit(0); // unparseable input: nothing meaningful to say
545
+ return null;
104
546
  }
105
- const kind = ghIssueWrite(cmd);
106
- if (!kind) process.exit(0); // not a tracker write: out of scope
547
+ const { findUnmodelled, firstRelevant, unmodelledReason } = lib;
548
+ const hit = firstRelevant(findUnmodelled(cmd), (text) => ghWrites(text, env).length > 0, MODELLED_HERE);
549
+ if (!hit) return null;
550
+ return blockVerdict(
551
+ 'SECRET_GUARD_UNMODELLED_CONSTRUCT',
552
+ unmodelledReason('secret-guard', 'that the body it posts is secret-free', hit, redact),
553
+ );
554
+ }
555
+
556
+ // Pure verdict for one command: allow | block {payload} | advise {payload, stderr}.
557
+ // Never exits or writes. `env` carries the test seam so the merged runner's
558
+ // environment is the one honoured; `cwd` is the hook input's working directory
559
+ // (relative body-file paths resolve against it, process.cwd() when absent).
560
+ // Fail closed on a body source: if its path cannot be resolved statically, or the
561
+ // resolved file cannot be read, the write is blocked (it would otherwise post an
562
+ // unscanned body). Only an internal scanner fault is an advisory.
563
+ function evaluate(cmd, { env = process.env, cwd } = {}) {
564
+ const verdict = evaluateWrites(cmd, env, cwd);
565
+ if (verdict.kind === 'block') return verdict; // every earlier verdict keeps priority
566
+ return unmodelledGate(cmd, env) || verdict;
567
+ }
568
+
569
+ function evaluateWrites(cmd, env, cwd) {
570
+ const writes = ghWrites(cmd, env);
571
+ if (writes.length === 0) return { kind: 'allow' }; // not a tracker write: out of scope
572
+ if (writes.some((w) => w.kind === 'opaque')) return opaqueBlock();
573
+
574
+ const gathered = gatherText(cmd, writes, env, cwd);
575
+ if (gathered.block) return gathered.block;
107
576
 
108
577
  // From here we KNOW it's a tracker write, so a scan failure must be OBSERVABLE.
578
+ const kind = writes[0].kind;
109
579
  try {
110
- const text = cmd + '\n' + (kind !== 'api' ? bodyFileText(cmd) : '');
111
- const hit = scan(text);
112
- if (hit) {
113
- block(
114
- `trailhead: this ${kind === 'api' ? 'GitHub API write' : `gh ${kind}`} looks like it contains a secret (${hit}). ` +
115
- 'Nothing sensitive may land on the tracker. Redact it: replace the value with `<REDACTED>` or an env-var reference ' +
116
- '(e.g. `$SUPABASE_KEY`), and post again. If this is a false positive (not a real secret), reword so it no longer matches a credential pattern.'
117
- );
118
- }
580
+ const hit = scan(gathered.text, env);
581
+ return hit ? secretBlock(kind, hit) : { kind: 'allow' };
119
582
  } catch (e) {
120
- adviseUnscanned(kind, e); // fail open, but loudly: never a silent clean-looking pass
583
+ return unscannedVerdict(kind, e); // internal fault: fail open, but loudly, never a silent clean-looking pass
584
+ }
585
+ }
586
+
587
+ function run(data) {
588
+ let cmd = '';
589
+ let cwd;
590
+ try {
591
+ const input = JSON.parse(data);
592
+ cmd = (input.tool_input || {}).command || '';
593
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
594
+ } catch {
595
+ process.exit(0); // unparseable input: nothing meaningful to say
596
+ }
597
+ const v = evaluate(cmd, { cwd });
598
+ if (v.kind === 'block') {
599
+ process.stdout.write(JSON.stringify(v.payload));
600
+ process.exit(2);
601
+ }
602
+ if (v.kind === 'advise') {
603
+ process.stderr.write(v.stderr);
604
+ process.stdout.write(JSON.stringify(v.payload));
121
605
  }
122
606
  process.exit(0);
123
607
  }
@@ -132,4 +616,4 @@ if (require.main === module) {
132
616
  });
133
617
  }
134
618
 
135
- module.exports = { ghIssueWrite, bodyFileText, scan, SECRET_PATTERNS };
619
+ module.exports = { ghIssueWrite, ghWrites, resolveBodyFile, bodyFileText, scan, evaluate, redact, SECRET_PATTERNS };