@marcomigozzi/trailhead 0.14.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 (92) hide show
  1. package/README.it.md +118 -30
  2. package/README.md +107 -29
  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 +1 -1
  8. package/bin/lib/codex-projection.test.js +41 -0
  9. package/bin/report-status-docs.test.js +195 -0
  10. package/bin/resolve-helpers-docs.test.js +36 -0
  11. package/bin/scope-confirm-skip-docs.test.js +70 -0
  12. package/bin/trailhead.js +3 -3
  13. package/bin/trailhead.test.js +341 -1
  14. package/bin/uat-setup-docs.test.js +65 -0
  15. package/package.json +1 -1
  16. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  17. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  18. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  19. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  20. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  21. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  22. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  23. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  24. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  25. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  26. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  27. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  28. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  29. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  30. package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
  31. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  32. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  33. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  34. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  35. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  36. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  37. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  38. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  39. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  40. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  41. package/plugins/trailhead/hooks/mod/README.md +38 -6
  42. package/plugins/trailhead/hooks/mod/map-pane-view.ts +168 -15
  43. package/plugins/trailhead/hooks/mod/map-pane.tsx +275 -106
  44. package/plugins/trailhead/hooks/mod/register.tsx +15 -2
  45. package/plugins/trailhead/hooks/mod/work-state-view.ts +17 -0
  46. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  47. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  48. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  49. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  50. package/plugins/trailhead/skills/_shared/choices.md +4 -2
  51. package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
  52. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  53. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  54. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  55. package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
  56. package/plugins/trailhead/skills/_shared/substrate.md +6 -4
  57. package/plugins/trailhead/skills/_shared/teamwork.md +16 -7
  58. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
  59. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  60. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  61. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  62. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  63. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  64. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  65. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  66. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  67. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
  68. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
  69. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
  70. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  71. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  72. package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
  73. package/plugins/trailhead/skills/trailhead-work/SKILL.md +14 -12
  74. package/plugins/trailhead/skills/trailhead-work/references/auto.md +4 -4
  75. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  76. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +26 -10
  77. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  78. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  79. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  80. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  81. package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
  82. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  83. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  84. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  85. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  86. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  87. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  88. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  89. package/plugins/trailhead/tests/map-pane-ui.test.ts +566 -19
  90. package/plugins/trailhead/tests/map-pane-view.test.ts +304 -16
  91. package/plugins/trailhead/tests/work-state-view.test.ts +25 -1
  92. package/plugins/trailhead/types/index.d.ts +37 -3
@@ -66,6 +66,23 @@ const stableJson = (value: unknown): string => {
66
66
  return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`
67
67
  }
68
68
 
69
+ // The state belongs to this session (a state with no session counts as its own, like the orphan rule).
70
+ const isOwn = (state: WorkState | null, session: string): boolean =>
71
+ state !== null && (state.session === null || state.session === session)
72
+
73
+ // The ticket this session owns across a work-state file shared with other sessions. A state it wrote
74
+ // sets it; one another session wrote leaves it; a missing file clears it only when the last state read
75
+ // was this session's (its own Resolve), so another session's cleanup never drops the mark.
76
+ export const nextOwnTicket = (
77
+ prev: number | null,
78
+ lastState: WorkState | null,
79
+ nextState: WorkState | null,
80
+ session: string,
81
+ ): number | null => {
82
+ if (nextState !== null) return isOwn(nextState, session) ? nextState.ticket : prev
83
+ return isOwn(lastState, session) ? null : prev
84
+ }
85
+
69
86
  export const sameValue = (a: unknown, b: unknown): boolean => stableJson(a) === stableJson(b)
70
87
 
71
88
  export const statusLine = (state: WorkState | null, isOrphaned: boolean): string | undefined => {
@@ -8,14 +8,28 @@
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 (incl. evaluate) exported for tests
26
+ // Self-contained (its only dependency is the sibling lib/ module). Pure functions (incl. evaluate) exported for tests
17
27
  // and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
18
28
 
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { basename, splitStatements, tokenize, stripQuotes, stripRedirections } = require('./lib/shell-scan.js');
32
+
19
33
  const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
20
34
 
21
35
  // Per-ecosystem: the binaries that own it, and the install verbs that fetch a
@@ -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;
@@ -147,10 +279,10 @@ function blockVerdict(hit) {
147
279
 
148
280
  // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
149
281
  // exits or writes; any internal error falls through to allow.
150
- function evaluate(cmd) {
282
+ function evaluate(cmd, opts) {
151
283
  try {
152
284
  if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
153
- const hit = detectInstall(cmd);
285
+ const hit = detectInstall(cmd, { cwd: opts && opts.cwd });
154
286
  if (hit) return blockVerdict(hit);
155
287
  } catch {
156
288
  // fall through to allow: a guard must never wedge the workflow
@@ -160,12 +292,15 @@ function evaluate(cmd) {
160
292
 
161
293
  function run(data) {
162
294
  let cmd = '';
295
+ let cwd;
163
296
  try {
164
- 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;
165
300
  } catch {
166
301
  process.exit(0); // unparseable input: nothing to gate
167
302
  }
168
- const v = evaluate(cmd);
303
+ const v = evaluate(cmd, { cwd });
169
304
  if (v.kind === 'block') {
170
305
  process.stdout.write(JSON.stringify(v.payload));
171
306
  process.exit(2);
@@ -4,10 +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
10
  // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
9
11
  const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
10
- const { detectInstall, isVerified } = require('./trailhead-install-guard.js');
12
+ const { detectInstall, isVerified, evaluate } = require('./trailhead-install-guard.js');
11
13
 
12
14
  let passed = 0;
13
15
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
@@ -86,6 +88,176 @@ ok('detects install after ; chain', (() => { const d = detectInstall('echo hi; p
86
88
  ok('detects install in a piped tail', (() => { const d = detectInstall('true | cargo add serde'); return d && d.packages.includes('serde'); })());
87
89
  ok('compound with no install stays null', detectInstall('cd app && npm run build && ls') === null);
88
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
+
89
261
  // --- unit: isVerified ---
90
262
  ok('sentinel recognised', isVerified('TRAILHEAD_VERIFIED_INSTALL=1 npm install lodash'));
91
263
  ok('no sentinel', !isVerified('npm install lodash'));
@@ -107,6 +279,13 @@ ok('non-install allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
107
279
  const a4 = runHook('pip install -r requirements.txt');
108
280
  ok('requirements-file install allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
109
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
+
110
289
  // --- end-to-end: unparseable stdin allowed ---
111
290
  const u = (() => {
112
291
  try {
@@ -24,6 +24,21 @@
24
24
  // cannot see. Blocking a lone `cd` would be far too aggressive (a legitimate,
25
25
  // common shape) so it is deliberately left to the prose directive.
26
26
  //
27
+ // Redirections are not reads of a relative path: before a verb's args are
28
+ // walked, output redirections (`>`, `>>`, `2>`, `&>`, glued or with a separate
29
+ // target), fd duplications (`2>&1`) and heredoc/herestring introducers are
30
+ // dropped together with their target, so `cat /abs/a > out.txt` or
31
+ // `tail /abs/log 2>/dev/null` are not reported. An input redirection `< f` IS a
32
+ // read: only the operator is dropped and `f` stays in the arg list, so
33
+ // `cd app && cat < rel.txt` still blocks while `wc -l < /abs/a` passes.
34
+ //
35
+ // cwd exemption: when the hook payload carries the session `cwd` (absolute), a
36
+ // joined `cd X`/`pushd X` whose literal target resolves to exactly that cwd is
37
+ // a no-op, so it does not make later relative reads non-static. Dynamic or
38
+ // special targets (`$VAR`, backticks, `~`, `-`, a bare `cd`) always count as a
39
+ // directory change, and an unknown cwd behaves as before. The exemption only
40
+ // avoids ARMING: once a real cd has armed the guard, nothing disarms it.
41
+ //
27
42
  // Blocks with exit 2 + {"decision":"block","code":...,"reason":...}; allows
28
43
  // everything else. Crash-safe: any error -> exit 0 (never wedge the user's
29
44
  // workflow). Self-contained (its own dependency is the sibling lib/ module,
@@ -36,12 +51,14 @@
36
51
  // machinery (SEARCH_VERB_CONFIG, scanArgsSkippingPattern) are shared with
37
52
  // trailhead-secret-read-guard.js via lib/shell-scan.js (#135 follow-up), so
38
53
  // the two guards can never drift into two divergent tokenizers.
54
+ const path = require('path');
39
55
  const {
40
56
  basename,
41
57
  stripQuotes,
42
58
  splitStatements,
43
59
  tokenize,
44
60
  skipEnvPrefix,
61
+ stripRedirections,
45
62
  SEARCH_VERB_CONFIG,
46
63
  scanArgsSkippingPattern,
47
64
  } = require('./lib/shell-scan.js');
@@ -114,7 +131,7 @@ const VERB_CONFIG = {
114
131
  // Walk a verb's argument tokens (verb itself already stripped) for the first
115
132
  // relative FILE argument found, via the shared pattern/flag-skipping walker.
116
133
  function scanArgsForRelativeFile(args, config) {
117
- return scanArgsSkippingPattern(args, config, isRelativeFileArg);
134
+ return scanArgsSkippingPattern(stripRedirections(args), config, isRelativeFileArg);
118
135
  }
119
136
 
120
137
  // `git grep`/`git log`/`git show`/`git diff` WITHOUT a `-C` (or --work-tree):
@@ -177,6 +194,22 @@ function isCdOrPushdStatement(stmt) {
177
194
  return bin === 'cd' || bin === 'pushd';
178
195
  }
179
196
 
197
+ // Does this cd/pushd statement provably stay in the session cwd? Only with a
198
+ // known absolute cwd and exactly one static literal target resolving to it.
199
+ function cdTargetsSessionCwd(stmt, cwd) {
200
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
201
+ const tokens = normalizeStatementTokens(tokenize(stmt));
202
+ let i = skipEnvPrefix(tokens) + 1; // past cd/pushd
203
+ while (i < tokens.length && tokens[i].startsWith('-') && tokens[i] !== '-') {
204
+ if (tokens[i++] === '--') break; // option flags (-P/-L/--) before the target
205
+ }
206
+ if (i !== tokens.length - 1) return false; // bare cd goes to $HOME; extra targets are unsure
207
+ const target = stripQuotes(tokens[i]);
208
+ if (!target || target === '-' || target.startsWith('~')) return false;
209
+ if (target.includes('$') || target.includes('`')) return false;
210
+ return path.resolve(cwd, target) === path.resolve(cwd);
211
+ }
212
+
180
213
  // `env -C <dir> <cmd> …`: the directory change and the read live in the SAME
181
214
  // statement (env execs its argument as a single command with that cwd), so
182
215
  // this is checked independently of the cross-statement `cwdChanged` flag.
@@ -215,7 +248,7 @@ function envDashCRelativeRead(stmt) {
215
248
  // with no path argument at all implicitly searches the (now-relative) cwd,
216
249
  // but adding that case risks false-positiving on legitimate stdin-reading
217
250
  // commands, so it is left to the prose directive.
218
- function detectSearchHygieneViolation(cmd) {
251
+ function detectSearchHygieneViolation(cmd, { cwd } = {}) {
219
252
  const statements = splitStatements(cmd);
220
253
  let cwdChanged = false;
221
254
  for (let idx = 0; idx < statements.length; idx++) {
@@ -233,7 +266,7 @@ function detectSearchHygieneViolation(cmd) {
233
266
  // the sole statement) - a lone standalone `cd` is the known limitation
234
267
  // above and must never be blocked here.
235
268
  if (isCdOrPushdStatement(stmt) && idx < statements.length - 1) {
236
- cwdChanged = true;
269
+ if (!cdTargetsSessionCwd(stmt, cwd)) cwdChanged = true;
237
270
  }
238
271
  }
239
272
  return null;
@@ -251,9 +284,9 @@ function blockVerdict(hit) {
251
284
 
252
285
  // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
253
286
  // exits or writes; any internal error falls through to allow.
254
- function evaluate(cmd) {
287
+ function evaluate(cmd, { cwd } = {}) {
255
288
  try {
256
- const hit = detectSearchHygieneViolation(cmd);
289
+ const hit = detectSearchHygieneViolation(cmd, { cwd });
257
290
  if (hit) return blockVerdict(hit);
258
291
  } catch {
259
292
  // fall through to allow: a guard must never wedge the workflow
@@ -263,12 +296,15 @@ function evaluate(cmd) {
263
296
 
264
297
  function run(data) {
265
298
  let cmd = '';
299
+ let cwd;
266
300
  try {
267
- cmd = (JSON.parse(data).tool_input || {}).command || '';
301
+ const input = JSON.parse(data);
302
+ cmd = (input.tool_input || {}).command || '';
303
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
268
304
  } catch {
269
305
  process.exit(0); // unparseable input: nothing to gate
270
306
  }
271
- const v = evaluate(cmd);
307
+ const v = evaluate(cmd, { cwd });
272
308
  if (v.kind === 'block') {
273
309
  process.stdout.write(JSON.stringify(v.payload));
274
310
  process.exit(2);