@marcomigozzi/trailhead 0.13.0 → 0.15.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 (120) hide show
  1. package/README.it.md +142 -29
  2. package/README.md +141 -28
  3. package/bin/base-preflight-docs.test.js +65 -0
  4. package/bin/execute-split-docs.test.js +146 -0
  5. package/bin/isolation-answer-docs.test.js +41 -0
  6. package/bin/isolation-copy-docs.test.js +28 -0
  7. package/bin/lib/codex-projection.js +17 -10
  8. package/bin/lib/codex-projection.test.js +80 -11
  9. package/bin/lib/install-verify.js +10 -5
  10. package/bin/lib/install-verify.test.js +51 -0
  11. package/bin/report-status-docs.test.js +195 -0
  12. package/bin/resolve-helpers-docs.test.js +36 -0
  13. package/bin/scope-confirm-skip-docs.test.js +70 -0
  14. package/bin/trailhead.js +40 -28
  15. package/bin/trailhead.test.js +503 -6
  16. package/bin/uat-setup-docs.test.js +65 -0
  17. package/package.json +4 -1
  18. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  19. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  20. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  21. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  22. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  23. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  24. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  25. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  26. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  27. package/plugins/trailhead/commands/panel.md +10 -0
  28. package/plugins/trailhead/hooks/hooks.json +3 -18
  29. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  30. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  31. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  32. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  33. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  34. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  35. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  36. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  37. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  38. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  39. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  40. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  41. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  42. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  43. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  44. package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
  45. package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
  46. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  47. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  48. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  49. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  50. package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
  51. package/plugins/trailhead/hooks/mod/README.md +219 -0
  52. package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
  53. package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
  54. package/plugins/trailhead/hooks/mod/register.tsx +293 -0
  55. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  56. package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
  57. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  58. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  59. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  60. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  61. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  62. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  63. package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
  64. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
  65. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  66. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  67. package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
  68. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
  69. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  70. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  71. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  72. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  73. package/plugins/trailhead/skills/_shared/choices.md +7 -3
  74. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  75. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  76. package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
  77. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  78. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  79. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  80. package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
  81. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  82. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  83. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  84. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  85. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  86. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  87. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  88. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  89. package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
  90. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  91. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  92. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  93. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  94. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  95. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  96. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  97. package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
  98. package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
  99. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  100. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
  101. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  102. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  103. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  104. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  105. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  106. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  107. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  108. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  109. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  110. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  111. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  112. package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
  113. package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
  114. package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
  115. package/plugins/trailhead/tests/mod.test.ts +14 -0
  116. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  117. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  118. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  119. package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
  120. package/plugins/trailhead/types/index.d.ts +103 -0
@@ -8,13 +8,27 @@
8
8
  // package age, download counts, repo metadata) and checkpoint the human on a
9
9
  // suspicious result (block-and-ask). A vetted install proceeds by re-running the
10
10
  // command prefixed with the sentinel `TRAILHEAD_VERIFIED_INSTALL=1`.
11
- // Only a NAMED install is gated: lockfile/manifest installs (bare `npm install`,
12
- // `npm ci`, `pip install -r`, `cargo build`, `go mod download`, `bundle install`)
13
- // and local paths / URLs / VCS refs carry no typed name and pass through.
11
+ // Only a NAMED install is gated: installs with no typed name (bare `npm install`,
12
+ // `npm ci`, `install --frozen-lockfile` / `--immutable` with no package, `pip install -r`,
13
+ // `cargo build`, `go mod download`, `bundle install`) and local paths / URLs /
14
+ // VCS refs carry no typed name and pass through. A file-like name (`foo.txt`,
15
+ // `x.tgz`) passes only when it is a path (`./x`, `sub/x`, `/x`, `~/x`) or exists
16
+ // on disk (relative to the command's cwd); otherwise it is a typed name and is gated.
17
+ // The on-disk check resolves against the hook's cwd; it does not follow a
18
+ // preceding `cd` in the same command. Command wrappers (`sudo`, `time`, `env`,
19
+ // `nohup`, `xargs`, `timeout`, `nice`, `stdbuf`, `setsid`, `ionice`, `doas`,
20
+ // `command`, `exec`) are peeled, with their own flags, so `sudo npm install foo`
21
+ // is gated like the bare form. Parsing is quote-aware (shared
22
+ // lib/shell-scan.js): text inside quotes or a heredoc (`echo "x; npm i foo"`) is
23
+ // never a command, and redirections (`2>&1`, `> log`) are never package names.
14
24
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
15
25
  // Crash-safe: any error → exit 0 (never wedge the user's workflow).
16
- // Self-contained, no dependencies. Pure functions exported for tests; runs as a
17
- // hook when executed directly.
26
+ // Self-contained (its only dependency is the sibling lib/ module). Pure functions (incl. evaluate) exported for tests
27
+ // and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { basename, splitStatements, tokenize, stripQuotes, stripRedirections } = require('./lib/shell-scan.js');
18
32
 
19
33
  const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
20
34
 
@@ -37,22 +51,129 @@ const VALUE_FLAGS = new Set([
37
51
  '-t', '--target', '--prefix', '--root', '--python', '-d', '--dest',
38
52
  '-w', '--workspace', '--registry', '-C', '--directory', '--chdir',
39
53
  '--no-binary', '--only-binary',
54
+ '--filter', '-F', // pnpm workspace selector: its value is a project, not a package
40
55
  ]);
41
56
 
42
- function basename(p) {
43
- const s = String(p).split('/');
44
- return s[s.length - 1];
57
+ // Command wrappers that run the rest of the line as a command, mapped to the
58
+ // flags that consume a SEPARATE-TOKEN value (so it is not read as the command).
59
+ // Attached forms (`-uroot`, `--user=root`, GNU xargs `-i`/`-e`/`-l`) need no entry.
60
+ const WRAPPERS = {
61
+ sudo: new Set([
62
+ '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from',
63
+ '-U', '--other-user', '-r', '--role', '-t', '--type', '-D', '--chdir', '-R', '--chroot',
64
+ '-T', '--command-timeout',
65
+ ]),
66
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0']), // -S handled apart
67
+ time: new Set(['-f', '--format', '-o', '--output']),
68
+ nohup: new Set(),
69
+ xargs: new Set([
70
+ '-n', '--max-args', '-I', '-a', '--arg-file', '-d', '--delimiter', '-E', '-L', '--max-lines',
71
+ '-P', '--max-procs', '-s', '--max-chars',
72
+ ]),
73
+ timeout: new Set(['-s', '--signal', '-k', '--kill-after']), // + one DURATION operand
74
+ nice: new Set(['-n', '--adjustment']),
75
+ stdbuf: new Set(['-i', '-o', '-e', '--input', '--output', '--error']),
76
+ setsid: new Set(),
77
+ ionice: new Set(['-c', '--class', '-n', '--classdata']),
78
+ doas: new Set(['-a', '-C', '-u']),
79
+ command: new Set(), // -v / -V only look a name up: see isLookupFlag
80
+ exec: new Set(['-a']),
81
+ };
82
+ const ENV_ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
83
+
84
+ // How a wrapper flag consumes its value. Returns { next } (value is the NEXT
85
+ // token), { attached } (value is inside this token) or {} (flagless). A short
86
+ // cluster is read like getopt: the first value-taking letter ends it, so `-nu root`
87
+ // takes the next token and `-uroot` carries its value attached.
88
+ function flagValue(f, valueFlags) {
89
+ if (f.startsWith('--')) {
90
+ const eq = f.indexOf('=');
91
+ if (eq !== -1) return { attached: f.slice(eq + 1) };
92
+ return valueFlags.has(f) ? { next: true } : {};
93
+ }
94
+ for (let k = 1; k < f.length; k++) {
95
+ if (!valueFlags.has('-' + f[k])) continue;
96
+ return k === f.length - 1 ? { next: true } : { attached: f.slice(k + 1) };
97
+ }
98
+ return {};
99
+ }
100
+
101
+ // `env -S` / `--split-string`: its payload IS the command line, not a flag value.
102
+ function isSplitFlag(f) {
103
+ if (f.startsWith('--')) return f === '--split-string' || f.startsWith('--split-string=');
104
+ for (let k = 1; k < f.length; k++) {
105
+ if (f[k] === 'S') return true;
106
+ if ('uCa'.includes(f[k])) return false; // value letter first: S is part of its value
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // `command -v` / `-V` (also inside a cluster like `-pv`) only describe a name and
112
+ // execute nothing, so nothing follows that can be an install.
113
+ function isLookupFlag(f) {
114
+ return !f.startsWith('--') && /[vV]/.test(f);
115
+ }
116
+
117
+ // The text of an `env -S` payload token. A double-quoted payload (outer quotes
118
+ // stripped) also loses its bash escapes (`\$`, `\``, `\"`, `\\`); single-quoted
119
+ // text is literal. Local on purpose: the shared stripQuotes is left as is.
120
+ function payloadText(payload) {
121
+ const p = payload || '';
122
+ const stripped = stripQuotes(p);
123
+ const dq = p.length >= 2 && p.startsWith('"') && p.endsWith('"');
124
+ return dq ? stripped.replace(/\\([$`"\\])/g, '$1') : stripped;
125
+ }
126
+
127
+ // The real command, past leading `NAME=value` assignments and any (nested) wrapper
128
+ // with its flags. Returns { toks, i }: toks may be rewritten when an `env -S`
129
+ // payload is re-tokenized; i is toks.length when nothing is left.
130
+ function skipWrappers(input) {
131
+ let toks = input;
132
+ let i = 0;
133
+ for (;;) {
134
+ while (i < toks.length && ENV_ASSIGN.test(toks[i])) i++;
135
+ const wrapper = basename(toks[i] || '');
136
+ const valueFlags = WRAPPERS[wrapper];
137
+ if (!valueFlags) return { toks, i };
138
+ i++; // past the wrapper
139
+ while (i < toks.length && toks[i].startsWith('-')) {
140
+ const f = toks[i++];
141
+ if (f === '--') break; // end of the wrapper's options
142
+ if (wrapper === 'env' && isSplitFlag(f)) {
143
+ const eq = f.indexOf('=');
144
+ const sIdx = f.indexOf('S');
145
+ let payload;
146
+ if (f.startsWith('--')) payload = eq !== -1 ? f.slice(eq + 1) : toks[i++];
147
+ else payload = sIdx === f.length - 1 ? toks[i++] : f.slice(sIdx + 1);
148
+ toks = [...tokenize(payloadText(payload)), ...toks.slice(i)];
149
+ i = 0; // the payload and the rest now form the command line
150
+ break;
151
+ }
152
+ if (wrapper === 'command' && isLookupFlag(f)) return { toks, i: toks.length };
153
+ if (flagValue(f, valueFlags).next) i++; // consume its separate-token value
154
+ }
155
+ if (wrapper === 'timeout' && i < toks.length) i++; // the DURATION operand
156
+ }
157
+ }
158
+
159
+ // A file-like name is local only when it is a path or exists on disk. A leading
160
+ // `@` is a scoped npm name, never a path.
161
+ function isLocalFile(t, opts) {
162
+ if (t.startsWith('@')) return false;
163
+ if (t.includes('/')) return true;
164
+ const exists = (opts && opts.exists) || fs.existsSync;
165
+ return exists(path.resolve((opts && opts.cwd) || process.cwd(), t));
45
166
  }
46
167
 
47
168
  // A positional that is NOT a public-registry name: local path, url, vcs ref, or a
48
169
  // file artifact. Go module paths (example.com/foo) look url-ish but ARE the named
49
170
  // module, so for go only a leading . / ~ disqualifies.
50
- function isLocalOrUrl(t, ecosystem) {
171
+ function isLocalOrUrl(t, ecosystem, opts) {
51
172
  if (t.startsWith('.') || t.startsWith('/') || t.startsWith('~')) return true;
52
173
  if (ecosystem === 'go') return false; // a bare module path is the name; gate it
53
174
  if (t.includes('://')) return true;
54
175
  if (/^(?:git\+|file:|https?:|hg\+|svn\+|bzr\+)/.test(t)) return true;
55
- if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return true;
176
+ if (/\.(?:tgz|tar\.gz|whl|gz|zip|txt|toml|lock|cfg)$/.test(t)) return isLocalFile(t, opts);
56
177
  return false;
57
178
  }
58
179
 
@@ -70,12 +191,29 @@ function bareName(t) {
70
191
  return n;
71
192
  }
72
193
 
194
+ // Positional package names among the arguments after the verb (redirections
195
+ // already removed). Quotes are stripped so `"lodash"` is the name `lodash`.
196
+ function collectPackages(args, ecosystem, opts) {
197
+ const packages = [];
198
+ for (let j = 0; j < args.length; j++) {
199
+ const t = args[j];
200
+ if (t.startsWith('-')) {
201
+ if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
202
+ continue;
203
+ }
204
+ const name = stripQuotes(t);
205
+ if (!name || name === '&' || isLocalOrUrl(name, ecosystem, opts)) continue;
206
+ const bare = bareName(name);
207
+ if (bare) packages.push(bare);
208
+ }
209
+ return packages;
210
+ }
211
+
73
212
  // Detect a public-registry install that NAMES at least one package, in a single
74
- // shell segment. Returns { ecosystem, packages: [names] } or null.
75
- function detectSegment(cmd) {
76
- const toks = String(cmd).trim().split(/\s+/).filter(Boolean);
77
- let i = 0;
78
- while (i < toks.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(toks[i])) i++; // env prefix
213
+ // shell statement. Returns { ecosystem, packages: [names] } or null.
214
+ function detectSegment(stmt, opts) {
215
+ const { toks, i: start } = skipWrappers(tokenize(stmt));
216
+ let i = start; // env prefix + sudo/time/env/nohup/xargs
79
217
  if (i >= toks.length) return null;
80
218
 
81
219
  const bin = basename(toks[i]);
@@ -102,27 +240,21 @@ function detectSegment(cmd) {
102
240
  if (!ECOSYSTEMS[ecosystem].verbs.includes(verb)) return null;
103
241
  i++;
104
242
 
105
- const packages = [];
106
- for (let j = i; j < toks.length; j++) {
107
- const t = toks[j];
108
- if (t.startsWith('-')) {
109
- if (VALUE_FLAGS.has(t)) j++; // consume its separate-token value
110
- continue;
111
- }
112
- if (isLocalOrUrl(t, ecosystem)) continue;
113
- packages.push(bareName(t));
114
- }
243
+ const args = stripRedirections(toks.slice(i));
244
+ // A lockfile install (`--frozen-lockfile`, `--immutable`) carries no positional,
245
+ // so it yields no package here; a flag never excuses a name typed beside it.
246
+ const packages = collectPackages(args, ecosystem, opts);
115
247
  if (packages.length === 0) return null; // lockfile/manifest install: nothing typed
116
248
  return { ecosystem, packages };
117
249
  }
118
250
 
119
251
  // Agents chain commands (`cd app && npm install foo`), so check every shell
120
- // segment, not just the first. Split on the common separators (&& || ; | and
121
- // newlines); a registry name never contains one, so a stray split is harmless.
122
- // Returns the first segment's hit, or null.
123
- function detectInstall(cmd) {
124
- for (const seg of String(cmd).split(/&&|\|\||;|\n|\|/)) {
125
- const hit = detectSegment(seg);
252
+ // statement, not just the first. splitStatements is quote- and heredoc-aware, so
253
+ // a separator or install text inside quotes / a heredoc body is not a statement.
254
+ // Returns the first statement's hit, or null.
255
+ function detectInstall(cmd, opts) {
256
+ for (const stmt of splitStatements(cmd)) {
257
+ const hit = detectSegment(stmt, opts);
126
258
  if (hit) return hit;
127
259
  }
128
260
  return null;
@@ -133,7 +265,7 @@ function isVerified(cmd) {
133
265
  return SENTINEL.test(String(cmd));
134
266
  }
135
267
 
136
- function block(hit) {
268
+ function blockVerdict(hit) {
137
269
  const list = hit.packages.join(', ');
138
270
  const reason =
139
271
  `trailhead: this ${hit.ecosystem} install names package(s) not yet vetted (${list}). ` +
@@ -142,23 +274,36 @@ function block(hit) {
142
274
  'and repository metadata (present and matching). ' +
143
275
  'STOP and ask the human on anything suspicious (block-and-ask): do not install on your own judgement. ' +
144
276
  'Once vetted, re-run the exact command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1` to proceed.';
145
- process.stdout.write(JSON.stringify({ decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason }));
146
- process.exit(2);
277
+ return { kind: 'block', payload: { decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason } };
278
+ }
279
+
280
+ // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
281
+ // exits or writes; any internal error falls through to allow.
282
+ function evaluate(cmd, opts) {
283
+ try {
284
+ if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
285
+ const hit = detectInstall(cmd, { cwd: opts && opts.cwd });
286
+ if (hit) return blockVerdict(hit);
287
+ } catch {
288
+ // fall through to allow: a guard must never wedge the workflow
289
+ }
290
+ return { kind: 'allow' };
147
291
  }
148
292
 
149
293
  function run(data) {
150
294
  let cmd = '';
295
+ let cwd;
151
296
  try {
152
- cmd = (JSON.parse(data).tool_input || {}).command || '';
297
+ const input = JSON.parse(data);
298
+ cmd = (input.tool_input || {}).command || '';
299
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
153
300
  } catch {
154
301
  process.exit(0); // unparseable input: nothing to gate
155
302
  }
156
- try {
157
- if (isVerified(cmd)) process.exit(0); // agent has vetted these
158
- const hit = detectInstall(cmd);
159
- if (hit) block(hit);
160
- } catch {
161
- // fall through to allow: a guard must never wedge the workflow
303
+ const v = evaluate(cmd, { cwd });
304
+ if (v.kind === 'block') {
305
+ process.stdout.write(JSON.stringify(v.payload));
306
+ process.exit(2);
162
307
  }
163
308
  process.exit(0);
164
309
  }
@@ -173,4 +318,4 @@ if (require.main === module) {
173
318
  });
174
319
  }
175
320
 
176
- module.exports = { detectInstall, isVerified };
321
+ module.exports = { detectInstall, isVerified, evaluate };
@@ -4,9 +4,12 @@
4
4
  const assert = require('assert');
5
5
  const { execFileSync } = require('child_process');
6
6
  const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
7
9
 
8
- const HOOK = path.join(__dirname, 'trailhead-install-guard.js');
9
- const { detectInstall, isVerified } = require('./trailhead-install-guard.js');
10
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
11
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
12
+ const { detectInstall, isVerified, evaluate } = require('./trailhead-install-guard.js');
10
13
 
11
14
  let passed = 0;
12
15
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
@@ -85,6 +88,176 @@ ok('detects install after ; chain', (() => { const d = detectInstall('echo hi; p
85
88
  ok('detects install in a piped tail', (() => { const d = detectInstall('true | cargo add serde'); return d && d.packages.includes('serde'); })());
86
89
  ok('compound with no install stays null', detectInstall('cd app && npm run build && ls') === null);
87
90
 
91
+ // --- unit: lockfile installs, redirections, quoted text (#239) ---
92
+ const heredocDoc = ['cat > /tmp/x.md <<\'EOF\'', '| `pnpm install a 2>&1` | x |', 'EOF'].join('\n');
93
+ const neg239 = [
94
+ 'pnpm install --frozen-lockfile --offline 2>&1',
95
+ 'cd app && pnpm install --frozen-lockfile --offline 2>&1 | tail -5',
96
+ 'pnpm install --frozen-lockfile > /tmp/log',
97
+ 'pnpm install --frozen-lockfile --filter web',
98
+ 'pnpm install --frozen-lockfile',
99
+ 'yarn install --immutable',
100
+ 'bun install --frozen-lockfile',
101
+ 'yarn install --frozen-lockfile',
102
+ 'npm install 2>&1',
103
+ 'echo "done; pnpm install lodash"',
104
+ 'echo "a && npm install foo"',
105
+ 'echo "next: pnpm install then build"',
106
+ 'git commit -m "fix: pnpm install guard | npm i x"',
107
+ heredocDoc,
108
+ ];
109
+ for (const cmd of neg239) {
110
+ ok(`ignores: ${cmd.split('\n')[0]}`, detectInstall(cmd) === null);
111
+ }
112
+
113
+ // Redirections and quoting must not hide a real named install, nor leak into the name.
114
+ const pos239 = [
115
+ ['npm install lodash 2>&1', 'npm', ['lodash']],
116
+ ['pnpm add vite > /tmp/log', 'npm', ['vite']],
117
+ ['npm install "lodash"', 'npm', ['lodash']],
118
+ ['echo ok && npm install lodash', 'npm', ['lodash']],
119
+ ['echo ok; pip install requests', 'pip', ['requests']],
120
+ ['pnpm add --filter web vite', 'npm', ['vite']],
121
+ ];
122
+ for (const [cmd, eco, names] of pos239) {
123
+ const d = detectInstall(cmd);
124
+ ok(`detects exactly: ${cmd}`, d && d.ecosystem === eco && JSON.stringify(d.packages) === JSON.stringify(names));
125
+ }
126
+
127
+ // A lockfile flag never excuses a typed name: the named package is still gated.
128
+ const posLock = [
129
+ ['npm i --frozen-lockfile evilpkg', 'evilpkg'],
130
+ ['pnpm install --frozen-lockfile evilpkg', 'evilpkg'],
131
+ ['yarn install --immutable evilpkg', 'evilpkg'],
132
+ ['npm install evilpkg --frozen', 'evilpkg'],
133
+ ];
134
+ for (const [cmd, name] of posLock) {
135
+ const d = detectInstall(cmd);
136
+ ok(`detects despite lockfile flag: ${cmd}`, d && d.packages.includes(name));
137
+ const r = runHook(cmd);
138
+ ok(`blocks despite lockfile flag: ${cmd}`, r.code === 2 && new RegExp(name).test(r.out));
139
+ }
140
+
141
+ // A bare `&` (background) is never listed as a package name.
142
+ ok('background & is not a package', JSON.stringify((detectInstall('npm install lodash &') || {}).packages) === JSON.stringify(['lodash']));
143
+
144
+ // --- unit: wrapped installs and file-like names (#255) ---
145
+ const posWrap = [
146
+ ['sudo npm install lodash', 'lodash'],
147
+ ['time pip install requests', 'requests'],
148
+ ['env FOO=1 npm i lodash', 'lodash'],
149
+ ['nohup npm install lodash', 'lodash'],
150
+ ['xargs npm install lodash', 'lodash'],
151
+ ['sudo -u bob npm install lodash', 'lodash'], // flag with a separate-token value
152
+ ['sudo -- npm i lodash', 'lodash'],
153
+ ['sudo -E npm i lodash', 'lodash'],
154
+ ['env -u HOME npm i lodash', 'lodash'],
155
+ ['env -C /tmp FOO=1 npm i lodash', 'lodash'],
156
+ ['time -p pip install requests', 'requests'],
157
+ ['time -o /tmp/t pip install requests', 'requests'],
158
+ ['xargs -n 1 npm install lodash', 'lodash'],
159
+ ['xargs -I {} pip install requests', 'requests'],
160
+ ['xargs --max-args=1 npm i lodash', 'lodash'],
161
+ ['sudo env FOO=1 nohup npm i lodash', 'lodash'], // chain
162
+ ['FOO=1 sudo -u root time -p pip install requests', 'requests'],
163
+ ['cd app && sudo npm install lodash', 'lodash'],
164
+ ['/usr/bin/sudo npm install lodash', 'lodash'],
165
+ ['sudo -nu root npm i lodash', 'lodash'], // bundled short flags, value flag last
166
+ ['sudo -Eu bob npm i lodash', 'lodash'],
167
+ ['sudo -iu root npm i lodash', 'lodash'],
168
+ ['sudo -uroot npm i lodash', 'lodash'], // attached value
169
+ ['sudo -nE npm i lodash', 'lodash'], // cluster with no value flag
170
+ ['env -S "npm i lodash"', 'lodash'], // split-string payload is the command
171
+ ['env -S npm i lodash', 'lodash'],
172
+ ["env -S 'npm i lodash'", 'lodash'],
173
+ ['env --split-string="npm i lodash"', 'lodash'],
174
+ ['env --split-string npm i lodash', 'lodash'],
175
+ ['env -a name npm i lodash', 'lodash'], // argv0 value, command follows
176
+ ['env -S "FOO=1 sudo npm i lodash"', 'lodash'],
177
+ ];
178
+ for (const [cmd, name] of posWrap) {
179
+ const d = detectInstall(cmd);
180
+ ok(`detects wrapped: ${cmd}`, d && d.packages.includes(name));
181
+ }
182
+ const negWrap = [
183
+ 'sudo npm install',
184
+ 'sudo npm ci',
185
+ 'time npm install --frozen-lockfile',
186
+ 'env FOO=1 npm install',
187
+ 'xargs echo npm install foo', // xargs runs echo
188
+ 'sudo -u bob ls',
189
+ 'sudo',
190
+ 'env',
191
+ 'sudo -u',
192
+ 'sudo -nu root ls',
193
+ 'env -S "ls -la"',
194
+ 'env -S "npm install"',
195
+ ];
196
+ for (const cmd of negWrap) {
197
+ ok(`ignores wrapped: ${cmd}`, detectInstall(cmd) === null);
198
+ }
199
+
200
+ // A file-like suffix is local only for a path or an existing file.
201
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'install-guard-'));
202
+ fs.writeFileSync(path.join(tmp, 'foo.tgz'), '');
203
+ const inTmp = { cwd: tmp };
204
+ const posFile = ['pip install foo.txt', 'npm install left-pad.toml', 'npm install nope.tgz', 'pip install pkg.whl'];
205
+ for (const cmd of posFile) {
206
+ const d = detectInstall(cmd, inTmp);
207
+ ok(`gates absent file-like name: ${cmd}`, d && d.packages.length === 1);
208
+ }
209
+ const negFile = [
210
+ 'pip install ./foo.txt',
211
+ 'pip install sub/req.txt',
212
+ 'pip install /abs/req.txt',
213
+ 'pip install ~/req.txt',
214
+ 'npm install foo.tgz', // exists in cwd
215
+ ];
216
+ for (const cmd of negFile) {
217
+ ok(`allows path or existing file: ${cmd}`, detectInstall(cmd, inTmp) === null);
218
+ }
219
+ ok('existing file resolved against cwd, not process cwd', detectInstall('npm install foo.tgz', { cwd: os.tmpdir() }) !== null);
220
+ ok('injectable exists check', detectInstall('npm install x.tgz', { exists: () => true }) === null);
221
+ ok('scoped name is never a path', (() => { const d = detectInstall('npm install @scope/pkg.zip', inTmp); return d && d.packages[0] === '@scope/pkg.zip'; })());
222
+ ok('evaluate tolerates null opts', evaluate('npm install lodash', null).kind === 'block');
223
+ const wf = evaluate('npm install foo.tgz', { cwd: tmp });
224
+ ok('evaluate threads cwd (existing file allowed)', wf.kind === 'allow');
225
+ ok('evaluate blocks absent file-like name', evaluate('pip install foo.txt', { cwd: tmp }).kind === 'block');
226
+ const wrapRun = runHook('sudo npm install lodash');
227
+ ok('blocks a sudo-wrapped install end-to-end (exit 2)', wrapRun.code === 2 && /lodash/.test(wrapRun.out));
228
+ const cwdRun = (() => {
229
+ const input = JSON.stringify({ cwd: tmp, tool_input: { command: 'npm install foo.tgz' } });
230
+ try { return { code: 0, out: execFileSync('node', [HOOK], { input }).toString() }; } catch (e) { return { code: e.status }; }
231
+ })();
232
+ ok('hook honours input.cwd for the on-disk check (exit 0)', cwdRun.code === 0);
233
+ fs.rmSync(tmp, { recursive: true, force: true });
234
+
235
+ // Wrappers peeled with their flags: timeout (+ DURATION), nice, stdbuf, setsid, ionice, doas, command, exec.
236
+ const wrapHits = [
237
+ 'timeout 10 npm i x', 'timeout -s KILL 10 npm i x', 'timeout --signal=KILL 5s npm i x',
238
+ 'timeout -k 1 10 npm i x', 'nice npm i x', 'nice -n 5 npm i x', 'nice -n5 npm i x', 'nice -5 npm i x',
239
+ 'command npm i x', 'command -p npm i x', 'exec npm i x', 'exec -a foo npm i x',
240
+ 'doas npm i x', 'doas -u root npm i x', 'setsid npm i x', 'setsid -w npm i x',
241
+ 'ionice -c 3 npm i x', 'stdbuf -oL npm i x', 'stdbuf -o L npm i x',
242
+ 'sudo timeout 10 nice npm i x', 'xargs timeout 10 npm i x', 'timeout -- 10 npm i x',
243
+ 'env -S "env -S \\"npm i lodash\\""',
244
+ 'env -S "env -S \\"env -S \\\\\\"npm i lodash\\\\\\"\\""',
245
+ "env -S 'npm i lodash'",
246
+ ];
247
+ for (const c of wrapHits) ok(`wrapper hit: ${c}`, detectInstall(c) && detectInstall(c).packages.length > 0);
248
+ const wrapMisses = [
249
+ 'timeout 10 ls', 'nice ls', 'command -v npm', 'command -v npm i x', 'command -V npm i x',
250
+ 'command -pv npm i x', 'exec 3>&1', 'exec -a npm ls', 'doas ls',
251
+ "env -S 'echo \"npm i lodash\"'", 'env -S "echo \\"npm i lodash\\""',
252
+ ];
253
+ for (const c of wrapMisses) ok(`wrapper miss: ${c}`, detectInstall(c) === null);
254
+
255
+ // Known unmodelled gaps (documented, NOT asserted): this guard reads one plain
256
+ // command per statement plus the wrappers it peels, so an install behind
257
+ // `bash -c "..."` / `sh -c "..."`, `$( )`, `eval`, other wrappers (`chrt`,
258
+ // `taskset`, `flock`, `su -c`), or a GNU long-option prefix abbreviation
259
+ // taking a separate value (`timeout --sig KILL 5 ...`) is not seen.
260
+
88
261
  // --- unit: isVerified ---
89
262
  ok('sentinel recognised', isVerified('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash'));
90
263
  ok('no sentinel', !isVerified('npm install lodash'));
@@ -106,6 +279,13 @@ ok('non-install allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
106
279
  const a4 = runHook('pip install -r requirements.txt');
107
280
  ok('requirements-file install allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
108
281
 
282
+ const a5 = runHook('cd app && pnpm install --frozen-lockfile --offline 2>&1 | tail -5');
283
+ ok('lockfile install with redirection allowed (exit 0)', a5.code === 0 && a5.out.trim() === '');
284
+ const a6 = runHook('echo "done; pnpm install lodash"');
285
+ ok('install text inside echo allowed (exit 0)', a6.code === 0 && a6.out.trim() === '');
286
+ const b3 = runHook('npm install lodash 2>&1');
287
+ ok('named install with redirection still blocks (exit 2)', b3.code === 2 && /lodash/.test(b3.out));
288
+
109
289
  // --- end-to-end: unparseable stdin allowed ---
110
290
  const u = (() => {
111
291
  try {
@@ -7,7 +7,14 @@
7
7
  // practice (an approval asked with only the project's name, no URL), so this
8
8
  // hook blocks an AskUserQuestion that is a mockup-approval ask when the
9
9
  // assistant prose immediately before it (no tool call in between) holds no
10
- // URL or file path.
10
+ // URL or file path. A link carried by the approval question itself (question,
11
+ // header, option label or description) is accepted too.
12
+ // Transcript timing: Claude Code writes the CURRENT assistant message (its
13
+ // text and tool_use blocks) to the transcript only after the tool call
14
+ // completes, so at PreToolUse the prose carrying the link is usually not
15
+ // there yet. The prose check therefore runs only when the pending ask is
16
+ // found in the transcript (by tool_use_id, or as the identical tail ask);
17
+ // otherwise the guard cannot judge and allows.
11
18
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
12
19
  // Fail-OPEN on any error (exit 0): a missing/unreadable transcript or an
13
20
  // unexpected shape must never wedge the workflow.
@@ -16,7 +23,7 @@
16
23
  // Pure functions are exported for tests; runs as a hook when executed directly.
17
24
 
18
25
  const fs = require('fs');
19
- const { hasLink, isMockupApprovalAsk, BLOCK_REASON } = require('./lib/mockup-link.js');
26
+ const { hasLink, isMockupApprovalAsk, askHasLink, BLOCK_REASON } = require('./lib/mockup-link.js');
20
27
 
21
28
  // Only the transcript tail matters: the prose sits right before the ask.
22
29
  const TAIL_BYTES = 512 * 1024;
@@ -25,14 +32,41 @@ const blocksOf = (e) => (Array.isArray(e.message && e.message.content) ? e.messa
25
32
  const isPendingAsk = (e) => e.type === 'assistant'
26
33
  && blocksOf(e).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion');
27
34
 
35
+ const mainChain = (entries) => entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
36
+
37
+ // Index (in the main chain) of the entry holding the pending ask being
38
+ // judged, or -1 when it is not in the transcript yet. With a tool_use id:
39
+ // the assistant entry carrying that id. Without: the last entry, only if it
40
+ // is a pending AskUserQuestion whose input equals toolInput.
41
+ function pendingAskIndex(entries, toolUseId, toolInput) {
42
+ const main = mainChain(entries);
43
+ if (toolUseId) {
44
+ return main.findIndex((e) => e.type === 'assistant'
45
+ && blocksOf(e).some((b) => b.type === 'tool_use' && b.id === toolUseId));
46
+ }
47
+ const i = main.length - 1;
48
+ if (i < 0 || !isPendingAsk(main[i])) return -1;
49
+ const same = blocksOf(main[i]).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion'
50
+ && JSON.stringify(b.input) === JSON.stringify(toolInput));
51
+ return same ? i : -1;
52
+ }
53
+
28
54
  // The assistant prose right before the ask: walk the main-chain entries back
29
- // from the end, skip the pending AskUserQuestion entries, and collect text
30
- // blocks until anything else (a user turn, a tool result, another tool call).
31
- function proseBeforeAsk(entries) {
32
- const main = entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
33
- let i = main.length - 1;
34
- while (i >= 0 && isPendingAsk(main[i])) i--;
55
+ // from the anchor entry (default: the end), skip the pending AskUserQuestion
56
+ // entries (keeping the text that precedes the tool_use inside the anchor
57
+ // entry itself), and collect text blocks until anything else (a user turn, a tool
58
+ // result, another tool call).
59
+ function proseBeforeAsk(entries, anchor) {
60
+ const main = mainChain(entries);
61
+ let i = anchor === undefined ? main.length - 1 : anchor;
35
62
  const texts = [];
63
+ // Text blocks in the same entry, before its tool_use, are prose too.
64
+ if (i >= 0 && main[i] && isPendingAsk(main[i])) {
65
+ const blocks = blocksOf(main[i]);
66
+ const firstTool = blocks.findIndex((b) => b.type === 'tool_use');
67
+ blocks.slice(0, firstTool).filter((b) => b.type === 'text').forEach((b) => texts.push(b.text || ''));
68
+ }
69
+ while (i >= 0 && isPendingAsk(main[i])) i--;
36
70
  for (; i >= 0; i--) {
37
71
  const e = main[i];
38
72
  if (e.type !== 'assistant') break;
@@ -73,9 +107,12 @@ function run(data) {
73
107
  const payload = JSON.parse(data);
74
108
  if (payload.tool_name && payload.tool_name !== 'AskUserQuestion') process.exit(0);
75
109
  if (!isMockupApprovalAsk(payload.tool_input)) process.exit(0);
110
+ if (askHasLink(payload.tool_input)) process.exit(0);
76
111
  if (!payload.transcript_path) process.exit(0);
77
- const prose = proseBeforeAsk(parseEntries(readTail(payload.transcript_path)));
78
- if (!hasLink(prose)) block();
112
+ const entries = parseEntries(readTail(payload.transcript_path));
113
+ const idx = pendingAskIndex(entries, payload.tool_use_id, payload.tool_input);
114
+ if (idx < 0) process.exit(0); // current message not flushed yet: cannot judge
115
+ if (!hasLink(proseBeforeAsk(entries, idx))) block();
79
116
  } catch {
80
117
  // fall through to allow: a guard must never wedge the workflow
81
118
  }
@@ -92,4 +129,4 @@ if (require.main === module) {
92
129
  });
93
130
  }
94
131
 
95
- module.exports = { isMockupApprovalAsk, hasLink, proseBeforeAsk };
132
+ module.exports = { isMockupApprovalAsk, hasLink, askHasLink, proseBeforeAsk, pendingAskIndex };