@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
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-bash-guards.js. Run: node trailhead-bash-guards.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { spawnSync } = require('child_process');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+ const os = require('os');
9
+
10
+ const HOOK = path.join(__dirname, 'trailhead-bash-guards.js');
11
+ const {
12
+ parseModGuards, parseGuardsArg, runGuards, MOD_GUARDS_FRESH_MS, GUARD_ORDER,
13
+ } = require('./trailhead-bash-guards.js');
14
+
15
+ let passed = 0;
16
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
17
+ const eq = (name, actual, expected) => { assert.deepStrictEqual(actual, expected, name); passed++; };
18
+
19
+ // Run the merged hook end-to-end: tool_input JSON on stdin, capture {code, out, err}.
20
+ function runHook(command, { env = {}, args = [], hook = HOOK } = {}) {
21
+ const input = JSON.stringify({ tool_input: { command } });
22
+ const r = spawnSync('node', [hook, ...args], { input, env: { ...process.env, ...env } });
23
+ return { code: r.status, out: r.stdout.toString(), err: r.stderr.toString() };
24
+ }
25
+ const codeOf = (r) => (r.out ? JSON.parse(r.out).code : undefined);
26
+
27
+ // One command per guard that it (and only it) blocks.
28
+ const BAD_COMMIT = 'git commit -m "not conventional"';
29
+ const BAD_SECRET = 'gh issue comment 5 --body "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789xx"';
30
+ const BAD_INSTALL = 'npm install left-pad';
31
+ const BAD_SEARCH = 'cd x && grep y z';
32
+ const BAD_BODY = 'gh issue edit 5 --body ""';
33
+
34
+ // --- unit: constants ---
35
+ eq('guard order is fixed', GUARD_ORDER, ['commit', 'secret', 'install', 'search', 'body']);
36
+ ok('freshness window is 5s', MOD_GUARDS_FRESH_MS === 5000);
37
+
38
+ // --- unit: parseModGuards ---
39
+ const NOW = 1_700_000_000_000;
40
+ eq('fresh stamp lists the guards', [...parseModGuards(`commit,search@${NOW - 100}`, NOW)].sort(), ['commit', 'search']);
41
+ eq('age 0 is fresh', [...parseModGuards(`commit@${NOW}`, NOW)], ['commit']);
42
+ eq('age exactly the window is fresh', [...parseModGuards(`commit@${NOW - MOD_GUARDS_FRESH_MS}`, NOW)], ['commit']);
43
+ eq('age window+1 is stale', parseModGuards(`commit@${NOW - MOD_GUARDS_FRESH_MS - 1}`, NOW).size, 0);
44
+ eq('future-dated stamp is ignored', parseModGuards(`commit@${NOW + 1}`, NOW).size, 0);
45
+ for (const bad of ['commit', '@123', 'commit@abc', ',@1', '', undefined, 'commit,@1', 'commit@1@2', 'commit@-5']) {
46
+ eq(`malformed ${JSON.stringify(bad)} skips nothing`, parseModGuards(bad, 1).size, 0);
47
+ }
48
+ eq('unknown names are ignored', [...parseModGuards(`commit,bogus@${NOW}`, NOW)], ['commit']);
49
+
50
+ // --- unit: parseGuardsArg ---
51
+ eq('no --guards -> all five', parseGuardsArg([]), GUARD_ORDER);
52
+ eq('valid subset keeps canonical order', parseGuardsArg(['--guards=body,commit']), ['commit', 'body']);
53
+ eq('empty --guards -> all five', parseGuardsArg(['--guards=']), GUARD_ORDER);
54
+ eq('malformed --guards -> all five', parseGuardsArg(['--guards=commit,,body']), GUARD_ORDER);
55
+ eq('any unknown name invalidates the whole list', parseGuardsArg(['--guards=commit,secert']), GUARD_ORDER);
56
+
57
+ // --- unit: runGuards with fake evaluators ---
58
+ const allow = () => ({ kind: 'allow' });
59
+ const blocker = (code) => () => ({ kind: 'block', payload: { decision: 'block', code, reason: 'r' } });
60
+ {
61
+ const calls = [];
62
+ const spy = (n, f) => (c, o) => { calls.push(n); return f(c, o); };
63
+ const r = runGuards('x', {
64
+ now: NOW, env: {},
65
+ evaluators: {
66
+ commit: spy('commit', () => { throw new Error('boom'); }),
67
+ secret: spy('secret', allow), install: spy('install', blocker('I')),
68
+ search: spy('search', blocker('S')), body: spy('body', allow),
69
+ },
70
+ });
71
+ eq('a throwing guard counts as allow and the next one still blocks', r.code, 2);
72
+ eq('the first block wins', JSON.parse(r.stdout).code, 'I');
73
+ eq('later guards are not run after a block', calls, ['commit', 'secret', 'install']);
74
+ }
75
+ {
76
+ const r = runGuards('x', {
77
+ now: NOW, env: { TRAILHEAD_MOD_GUARDS: `commit@${NOW}` },
78
+ evaluators: { commit: blocker('C'), secret: allow, install: allow, search: allow, body: allow },
79
+ });
80
+ eq('a fresh stamp skips the listed guard', r.code, 0);
81
+ }
82
+ {
83
+ const adv = (t) => () => ({ kind: 'advise', stderr: `${t}\n`, payload: { hookSpecificOutput: { additionalContext: t } } });
84
+ const r = runGuards('x', {
85
+ now: NOW, env: {},
86
+ evaluators: { commit: adv('a'), secret: adv('b'), install: allow, search: allow, body: allow },
87
+ });
88
+ eq('advisories exit 0', r.code, 0);
89
+ eq('advisory stderr lines are all written', r.stderr, 'a\nb\n');
90
+ eq('advisories are joined in one context', JSON.parse(r.stdout).hookSpecificOutput.additionalContext, 'a\nb');
91
+ }
92
+
93
+ // --- end to end: each guard's block, exact payload, exit 2 ---
94
+ const { evaluate: evCommit } = require('./trailhead-commit-guard.js');
95
+ const { evaluate: evSecret } = require('./trailhead-secret-guard.js');
96
+ const { evaluate: evInstall } = require('./trailhead-install-guard.js');
97
+ const { evaluate: evSearch } = require('./trailhead-search-guard.js');
98
+ const { evaluate: evBody } = require('./trailhead-body-guard.js');
99
+ for (const [name, cmd, ev] of [
100
+ ['commit', BAD_COMMIT, evCommit], ['secret', BAD_SECRET, evSecret], ['install', BAD_INSTALL, evInstall],
101
+ ['search', BAD_SEARCH, evSearch], ['body', BAD_BODY, evBody],
102
+ ]) {
103
+ const r = runHook(cmd);
104
+ const want = ev(cmd);
105
+ ok(`${name}: evaluator blocks the probe`, want.kind === 'block');
106
+ eq(`${name}: exit 2`, r.code, 2);
107
+ eq(`${name}: exact payload on stdout`, r.out, JSON.stringify(want.payload));
108
+ }
109
+
110
+ // --- end to end: clean, unparseable, stdin shapes ---
111
+ {
112
+ const r = runHook('git status');
113
+ eq('clean command: exit 0', r.code, 0);
114
+ eq('clean command: empty stdout', r.out, '');
115
+ eq('clean command: empty stderr', r.err, '');
116
+ const bad = spawnSync('node', [HOOK], { input: 'not json {{' });
117
+ eq('unparseable input: exit 0', bad.status, 0);
118
+ const nul = spawnSync('node', [HOOK], { input: 'null' });
119
+ eq('null input: exit 0', nul.status, 0);
120
+ }
121
+
122
+ // --- end to end: first block wins ---
123
+ {
124
+ const r = runHook(`${BAD_COMMIT} && ${BAD_SEARCH}`);
125
+ ok('the probe trips both commit and search', evSearch(`${BAD_COMMIT} && ${BAD_SEARCH}`).kind === 'block');
126
+ eq('first block wins: exit 2', r.code, 2);
127
+ eq('first block wins: commit payload', codeOf(r) !== 'SEARCH_HYGIENE_VIOLATION', true);
128
+ eq('first block wins: single payload', r.out, JSON.stringify(evCommit(`${BAD_COMMIT} && ${BAD_SEARCH}`).payload));
129
+ }
130
+
131
+ // --- end to end: TRAILHEAD_MOD_GUARDS ---
132
+ {
133
+ const fresh = runHook(BAD_COMMIT, { env: { TRAILHEAD_MOD_GUARDS: `commit@${Date.now()}` } });
134
+ eq('fresh stamp lets the skipped guard through', fresh.code, 0);
135
+ const stale = runHook(BAD_COMMIT, { env: { TRAILHEAD_MOD_GUARDS: `commit@${Date.now() - 60000}` } });
136
+ eq('stale stamp blocks', stale.code, 2);
137
+ const junk = runHook(BAD_COMMIT, { env: { TRAILHEAD_MOD_GUARDS: 'commit' } });
138
+ eq('malformed stamp blocks', junk.code, 2);
139
+ const multi = `${BAD_INSTALL} && ${BAD_SEARCH}`;
140
+ const part = runHook(multi, { env: { TRAILHEAD_MOD_GUARDS: `commit,search@${Date.now()}` } });
141
+ eq('a fresh list skips exactly the listed guards: install still blocks', codeOf(part), 'UNVETTED_PACKAGE_INSTALL');
142
+ const all = runHook(multi, { env: { TRAILHEAD_MOD_GUARDS: `install,search@${Date.now()}` } });
143
+ eq('both listed guards skipped: passes', all.code, 0);
144
+ }
145
+
146
+ // --- end to end: failure posture ---
147
+ {
148
+ const env = { TRAILHEAD_SECRETGUARD_THROW: '1' };
149
+ const alone = runHook('gh issue comment 5 --body "hello"', { env });
150
+ eq('secret advisory alone: exit 0', alone.code, 0);
151
+ ok('secret advisory alone: stderr names the failed scan', /could not scan this issue write/.test(alone.err));
152
+ ok('secret advisory alone: additionalContext on stdout',
153
+ /could NOT complete its scan/.test(JSON.parse(alone.out).hookSpecificOutput.additionalContext));
154
+ const both = runHook('gh issue edit 5 --body ""', { env });
155
+ eq('a faulted guard does not stop the next from blocking', both.code, 2);
156
+ eq('later block payload on stdout', codeOf(both), 'EMPTY_ISSUE_BODY_WRITE');
157
+ ok('earlier advisory stderr survives the later block', /could not scan this issue write/.test(both.err));
158
+ ok('advisory stdout is dropped on block', !/hookSpecificOutput/.test(both.out));
159
+ }
160
+
161
+ // --- end to end: --guards selection and module loading, on a throwaway copy ---
162
+ {
163
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tb-guards-'));
164
+ try {
165
+ fs.cpSync(__dirname, tmp, {
166
+ recursive: true,
167
+ filter: (src) => path.basename(src) !== 'mod' && !src.endsWith('.test.js'),
168
+ });
169
+ const hook = path.join(tmp, 'trailhead-bash-guards.js');
170
+ fs.rmSync(path.join(tmp, 'trailhead-search-guard.js'));
171
+
172
+ const codex = runHook(BAD_SEARCH, { hook, args: ['--guards=commit,secret,install,body'] });
173
+ eq('--guards without search never loads search-guard', codex.code, 0);
174
+ eq('--guards without search: no warning', codex.err, '');
175
+ const still = runHook(BAD_INSTALL, { hook, args: ['--guards=commit,secret,install,body'] });
176
+ eq('--guards subset still blocks with its guards', still.code, 2);
177
+ const only = runHook(BAD_INSTALL, { hook, args: ['--guards=commit'] });
178
+ eq('unselected guards do not run', only.code, 0);
179
+
180
+ for (const bad of ['--guards=', '--guards=commit,secert', '--guards=commit,,body']) {
181
+ const r = runHook(BAD_INSTALL, { hook, args: [bad] });
182
+ eq(`${bad}: falls back to all five (install blocks)`, r.code, 2);
183
+ const clean = runHook('git status', { hook, args: [bad] });
184
+ ok(`${bad}: all five are selected (the missing search module is reported)`, /search/.test(clean.err));
185
+ }
186
+
187
+ const missing = runHook(`${BAD_BODY} && ${BAD_SEARCH}`, { hook });
188
+ eq('a module that fails to load is skipped, the others still block', codeOf(missing), 'EMPTY_ISSUE_BODY_WRITE');
189
+ ok('a load failure is a one-line stderr warning naming the guard',
190
+ missing.err.trim().split('\n').length === 1 && /search/.test(missing.err));
191
+ const clean = runHook('git status', { hook });
192
+ eq('a load failure alone still exits 0', clean.code, 0);
193
+ ok('a load failure reaches the session as an advisory naming the guard',
194
+ /could NOT load the search guard/.test(JSON.parse(clean.out).hookSpecificOutput.additionalContext));
195
+ } finally {
196
+ fs.rmSync(tmp, { recursive: true, force: true });
197
+ }
198
+ }
199
+
200
+ // --- #227: unmodelled constructs through the merged runner ---
201
+ {
202
+ const r1 = runHook('alias g=gh; g issue comment 1 --body x');
203
+ eq('secret-guard blocks a hidden gh write behind an alias', [r1.code, codeOf(r1)], [2, 'SECRET_GUARD_UNMODELLED_CONSTRUCT']);
204
+ const r2 = runHook('echo ${ gh issue comment 1 --body x; }');
205
+ eq('secret-guard blocks a write inside a function substitution', [r2.code, codeOf(r2)], [2, 'SECRET_GUARD_UNMODELLED_CONSTRUCT']);
206
+ const r3 = runHook("gh api -X PATCH repos/o/r/issues/1 -f 'body'=\"\"");
207
+ eq('body-guard blocks a quoted api body key with an empty body', [r3.code, codeOf(r3)], [2, 'BODY_GUARD_UNMODELLED_CONSTRUCT']);
208
+ const r4 = runHook('printf -v B %s x; gh issue edit 1 --body-file ~/nonexistent-227.md');
209
+ eq('a state construct before a tilde body file blocks', r4.code, 2);
210
+ eq('a plain alias passes', runHook("alias ll='ls -l'").code, 0);
211
+ eq('a literal write after source passes', runHook(". venv/bin/activate && gh issue edit 1 --body 'hello'").code, 0);
212
+ }
213
+
214
+ // --- every existing guard suite, re-run through the merged hook ---
215
+ for (const name of ['commit', 'secret', 'install', 'search', 'body']) {
216
+ const file = path.join(__dirname, `trailhead-${name}-guard.test.js`);
217
+ const r = spawnSync('node', [file], { env: { ...process.env, TRAILHEAD_GUARD_HOOK: HOOK } });
218
+ if (r.status !== 0) process.stderr.write(r.stdout.toString() + r.stderr.toString());
219
+ eq(`${name}-guard suite passes through the merged hook`, r.status, 0);
220
+ }
221
+
222
+ // --- registration: one Bash entry, one command (the merged hook) ---
223
+ {
224
+ const reg = JSON.parse(fs.readFileSync(path.join(__dirname, 'hooks.json'), 'utf8'));
225
+ const bashGroups = reg.hooks.PreToolUse.filter((g) => g.matcher === 'Bash');
226
+ eq('hooks.json has exactly one PreToolUse entry with matcher Bash', bashGroups.length, 1);
227
+ eq('that entry holds exactly one command', bashGroups[0].hooks.length, 1);
228
+ ok('and it is the merged hook', bashGroups[0].hooks[0].command.includes('trailhead-bash-guards.js'));
229
+ ok('no legacy guard script is registered anywhere in hooks.json',
230
+ !/trailhead-(commit|secret|install|search|body)-guard\.js/.test(JSON.stringify(reg)));
231
+ }
232
+
233
+ console.log(`trailhead-bash-guards.test.js: ${passed} checks passed`);
@@ -9,6 +9,17 @@
9
9
  // backstop: it hard-blocks a `gh issue edit` / `gh pr edit` / `gh api` write
10
10
  // that would set a body to empty or whitespace-only, protecting the existing
11
11
  // body from a degraded read.
12
+ // The command is not required to START with `gh`: the shared statement walk
13
+ // (ghWrites in secret-guard: `;` `&&` `||` `|` newline, subshells, if/then,
14
+ // leading NAME=v) finds every `gh` write, and each one is checked on its own;
15
+ // ANY provably empty body blocks. Opaque writes (eval, sh -c, ...) are ignored
16
+ // (fail-open). After a cd the working directory is unknown, so a relative
17
+ // --body-file / api `@path` is unknowable and never blocks.
18
+ // A construct it cannot interpret with certainty (eval, source / `.`, a bash 5.3
19
+ // `${ cmd; }`, printf -v, a function definition, an alias, a quoted or split `gh api`
20
+ // body key, a backslash-newline inside a word) is not emulated: as a FINAL gate, after
21
+ // the empty-body verdict, it blocks with BODY_GUARD_UNMODELLED_CONSTRUCT when a body
22
+ // write hides behind it or follows it (lib/unmodelled.js is the one closed list; #227).
12
23
  // Blocks with exit 2 + {"decision":"block",...}; allows everything else.
13
24
  // Fail-OPEN on any error (exit 0): this is a cheap backstop, not a hard
14
25
  // dependency, and a crash here must never block a legitimate write.
@@ -17,7 +28,9 @@
17
28
 
18
29
  const fs = require('fs');
19
30
  const os = require('os');
31
+ const path = require('path');
20
32
  const { parseGhSubcommand } = require('./lib/gh-subcommand.js');
33
+ const { words, parseRedirects, flagValues, expandWord, STDIN_PATHS } = require('./lib/body-sources.js');
21
34
 
22
35
  // Is the resolved body text clearly empty or whitespace-only?
23
36
  function isEmptyBody(text) {
@@ -44,8 +57,11 @@ function expandHome(p) {
44
57
  // Read a --body-file (or api `@file`) target, mirroring secret-guard's
45
58
  // bodyFileText: sliced to ~1MB. Throws (ENOENT etc.) are the caller's
46
59
  // problem to catch.
47
- function readBodyFile(filePath) {
48
- return fs.readFileSync(expandHome(filePath), 'utf8').slice(0, 1_000_000);
60
+ function readBodyFile(filePath, cwdKnown = true) {
61
+ const p = expandHome(filePath);
62
+ // Unknown cwd (after a cd): a relative path may point anywhere, so refuse to guess.
63
+ if (!cwdKnown && !path.isAbsolute(p)) throw new Error('relative path with unknown cwd');
64
+ return fs.readFileSync(p, 'utf8').slice(0, 1_000_000);
49
65
  }
50
66
 
51
67
  // Strip one matching pair of surrounding quotes (an artifact of parsing a
@@ -64,47 +80,48 @@ function stripQuotedPair(val) {
64
80
  return val;
65
81
  }
66
82
 
67
- // Resolve the body for a `gh issue edit` / `gh pr edit` command. Returns:
68
- // undefined -> no body param present at all (not a body-carrying write)
83
+ // Resolve the body for a `gh issue edit` / `gh pr edit` command, the way gh
84
+ // does (pflag keeps the LAST occurrence of a repeated flag; pkg/cmd/{issue,pr}/
85
+ // edit rejects --body together with --body-file, and --body counts as provided
86
+ // whenever it appears, while --body-file counts only if its last value is
87
+ // non-empty). Flags are parsed with the shared body-sources helpers. Returns:
88
+ // undefined -> no body param present at all, or gh would reject the command
89
+ // (both --body and a non-empty --body-file: no write)
69
90
  // { body: string } -> known body text
70
91
  // { body: null } -> a body param IS present but its text is unknowable
71
92
  // (shell expansion, stdin/process-substitution path,
72
93
  // or an unreadable --body-file): never block on this.
73
- function resolveEditBody(cmd) {
74
- // --body-file <path> / its short alias -F (quoted or bare; bare stops at
75
- // whitespace/shell metacharacters, same tokenizer approach as
76
- // secret-guard's bodyFileText). The `(?<![-\w])` guard on -F mirrors
77
- // secret-guard's bodyFileText: it stops -F matching when embedded inside
78
- // a longer token, and keeps --body-file from being double-matched.
79
- const fileMatch = cmd.match(
80
- /(?:--body-file|(?<![-\w])-F)(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s"';|&()<>`]+))/
81
- );
82
- if (fileMatch) {
83
- const p = fileMatch[1] || fileMatch[2] || fileMatch[3];
84
- if (p === '-' || p.startsWith('<')) return { body: null }; // stdin / process substitution
94
+ function resolveEditBody(cmd, cwdKnown = true) {
95
+ const { args } = parseRedirects(words(cmd));
96
+ const files = flagValues(args, '--body-file', '-F');
97
+ const bodies = flagValues(args, '--body', '-b');
98
+
99
+ // Last --body-file value; an empty expanded value means "not provided".
100
+ let filePath;
101
+ if (files.length) {
102
+ const r = expandWord(files[files.length - 1], {}, { HOME: os.homedir() });
103
+ if (r.unresolved) return { body: null }; // may or may not be empty: unknowable
104
+ if (r.value !== '') filePath = r.value;
105
+ }
106
+
107
+ if (filePath !== undefined && bodies.length) return undefined; // gh errors, nothing is written
108
+
109
+ if (filePath !== undefined) {
110
+ // Normalise only for this test (/dev//stdin, /dev/./stdin): the read below keeps the raw
111
+ // path, since the kernel resolves `..` after symlinks, not lexically.
112
+ if (STDIN_PATHS.has(path.posix.normalize(filePath)) || filePath.startsWith('<')) return { body: null }; // stdin / process substitution
85
113
  try {
86
- return { body: readBodyFile(p) };
114
+ return { body: readBodyFile(filePath, cwdKnown) };
87
115
  } catch {
88
116
  return { body: null }; // unreadable: unknowable, not provably empty
89
117
  }
90
118
  }
91
119
 
92
- // Inline --body <val> / --body=<val> / its short alias -b, quoted or
93
- // bare. The `(?<![-\w])` guard on -b keeps it from matching the "-b"
94
- // that appears inside "--body" itself.
95
- const inlineMatch = cmd.match(
96
- /(?:--body|(?<![-\w])-b)(?:=|\s+)(?:"([^"]*)"|'([^']*)'|(\S*))/
97
- );
98
- if (inlineMatch) {
99
- const val =
100
- inlineMatch[1] !== undefined
101
- ? inlineMatch[1]
102
- : inlineMatch[2] !== undefined
103
- ? inlineMatch[2]
104
- : inlineMatch[3];
105
- if (val === undefined) return { body: null };
106
- if (isShellExpansion(val)) return { body: null };
107
- return { body: val };
120
+ if (bodies.length) {
121
+ // Empty env: any $VAR, $(...) or backtick is unresolved, hence unknowable.
122
+ const r = expandWord(bodies[bodies.length - 1], {}, {});
123
+ if (r.unresolved) return { body: null };
124
+ return { body: r.value };
108
125
  }
109
126
 
110
127
  return undefined; // no body param at all
@@ -117,7 +134,7 @@ function resolveEditBody(cmd) {
117
134
  // unknowable) and a bare value carrying literal surrounding quotes (e.g.
118
135
  // `-f body=""`, where the shell hasn't run yet so the quote characters are
119
136
  // still in the raw command text).
120
- function resolveApiBody(cmd) {
137
+ function resolveApiBody(cmd, cwdKnown = true) {
121
138
  let raw;
122
139
  let m = cmd.match(/(?:-f|-F|--field|--raw-field)\s+"body=([^"]*)"/);
123
140
  if (m) raw = m[1];
@@ -137,7 +154,7 @@ function resolveApiBody(cmd) {
137
154
  const ref = val.slice(1);
138
155
  if (ref === '-') return null; // @- : stdin, unknowable
139
156
  try {
140
- return readBodyFile(ref);
157
+ return readBodyFile(ref, cwdKnown);
141
158
  } catch {
142
159
  return null; // unreadable: unknowable, not provably empty
143
160
  }
@@ -153,27 +170,51 @@ function resolveApiBody(cmd) {
153
170
  // the resolved text (string), or null when present-but-unknowable. Only
154
171
  // `gh issue edit` / `gh pr edit` / `gh api ... body=` are recognised: `create`
155
172
  // and `comment` add new content rather than overwrite an existing body, so
156
- // they are out of scope for this guard.
157
- function ghBodyWrite(cmd) {
173
+ // they are out of scope for this guard. Handles ONE statement; see
174
+ // ghBodyWrites for a whole (possibly compound) command.
175
+ function ghBodyWrite(cmd, { cwdKnown = true } = {}) {
158
176
  const parsed = parseGhSubcommand(cmd);
159
177
  if (!parsed) return null;
160
178
  const { sub, verb } = parsed;
161
179
 
162
180
  if ((sub === 'issue' || sub === 'pr') && verb === 'edit') {
163
- const resolved = resolveEditBody(cmd);
181
+ const resolved = resolveEditBody(cmd, cwdKnown);
164
182
  if (resolved === undefined) return null; // no body param: not this guard's concern
165
183
  return { kind: sub, body: resolved.body };
166
184
  }
167
185
  if (sub === 'api' && /(?:^|\s)(?:-f|-F|--field|--raw-field)\s+['"]?body=/.test(cmd)) {
168
- return { kind: 'api', body: resolveApiBody(cmd) };
186
+ return { kind: 'api', body: resolveApiBody(cmd, cwdKnown) };
169
187
  }
170
188
  return null;
171
189
  }
172
190
 
173
- function block(kind) {
191
+ // Every body-clobbering write in a whole command: the shared statement walk
192
+ // finds each `gh` write, then each statement is resolved on its own with the
193
+ // cwd state at that point. Opaque writes are skipped. If the walk cannot load
194
+ // (a partial install), fall back to the single-statement check so a leading
195
+ // empty-body write still blocks.
196
+ function ghBodyWrites(cmd) {
197
+ let ghWrites;
198
+ try {
199
+ ({ ghWrites } = require('./trailhead-secret-guard.js'));
200
+ } catch {
201
+ const hit = ghBodyWrite(cmd);
202
+ return hit ? [hit] : [];
203
+ }
204
+ const hits = [];
205
+ for (const w of ghWrites(cmd, {})) {
206
+ if (w.kind === 'opaque') continue;
207
+ const hit = ghBodyWrite(w.text, { cwdKnown: w.cwdKnown });
208
+ if (hit) hits.push(hit);
209
+ }
210
+ return hits;
211
+ }
212
+
213
+ function blockVerdict(kind) {
174
214
  const label = kind === 'api' ? 'GitHub API write' : `gh ${kind} edit`;
175
- process.stdout.write(
176
- JSON.stringify({
215
+ return {
216
+ kind: 'block',
217
+ payload: {
177
218
  decision: 'block',
178
219
  code: 'EMPTY_ISSUE_BODY_WRITE',
179
220
  reason:
@@ -182,30 +223,73 @@ function block(kind) {
182
223
  'pre-write read returned an empty or degraded body (e.g. a transient HTTP 5xx on `gh issue view --json body`). ' +
183
224
  'Aborting to protect the existing body: re-read the current body (treat a non-200 or empty read as a hard ' +
184
225
  'error, retry once with backoff), rebuild it, then write again.',
185
- })
186
- );
187
- process.exit(2);
226
+ },
227
+ };
188
228
  }
189
229
 
190
- function run(data) {
230
+ // The final gate (#227): a construct the guard cannot interpret (eval, source / `.`, a
231
+ // bash 5.3 `${ cmd; }`, printf -v, a function definition, an alias, a quoted or split
232
+ // `gh api` body key, a backslash-newline inside a word) is blocked, not emulated, when
233
+ // it can matter here: its payload, its later calls, a write after it or its own
234
+ // statement is a body write by ghBodyWrites (a quoted api key only when the body it sets is
235
+ // empty or unknowable).
236
+ // Returns a block verdict or null.
237
+ function unmodelledGate(cmd) {
238
+ const { findUnmodelled, firstRelevant, unmodelledReason } = require('./lib/unmodelled.js');
239
+ const hits = findUnmodelled(cmd);
240
+ // A quoted api key is no gap when the body it sets is known and not empty
241
+ // (`-f 'body'=@file` is read like the plain form); only an empty or unknowable one is.
242
+ const emptyOrUnknown = (h) => ghBodyWrites(h.payload).some((w) => w.body === null || w.body === undefined || isEmptyBody(w.body));
243
+ const hit = firstRelevant(hits, (text) => ghBodyWrites(text).length > 0, ['api-field']) ||
244
+ hits.find((h) => h.id === 'api-field' && emptyOrUnknown(h));
245
+ if (!hit) return null;
246
+ let redact; // the secret-guard's redactor; without it the message omits the sample
191
247
  try {
192
- let cmd = '';
193
- try {
194
- cmd = (JSON.parse(data).tool_input || {}).command || '';
195
- } catch {
196
- process.exit(0); // unparseable input: nothing meaningful to say
197
- }
198
- const hit = ghBodyWrite(cmd);
199
- if (!hit || hit.body === null || hit.body === undefined) {
200
- process.exit(0); // not a body write, or a body we cannot prove empty
201
- }
202
- if (isEmptyBody(hit.body)) {
203
- block(hit.kind);
248
+ ({ redact } = require('./trailhead-secret-guard.js'));
249
+ } catch {
250
+ redact = undefined;
251
+ }
252
+ return {
253
+ kind: 'block',
254
+ payload: {
255
+ decision: 'block',
256
+ code: 'BODY_GUARD_UNMODELLED_CONSTRUCT',
257
+ reason: unmodelledReason('body-guard', 'that it does not overwrite a body with an empty one', hit, redact),
258
+ },
259
+ };
260
+ }
261
+
262
+ // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
263
+ // exits or writes; any internal error falls through to allow (a crash here must
264
+ // never block a legitimate write). The unmodelled-construct gate runs last, so an
265
+ // empty body found above keeps its own verdict.
266
+ function evaluate(cmd) {
267
+ try {
268
+ for (const hit of ghBodyWrites(cmd)) {
269
+ if (hit.body === null || hit.body === undefined) continue; // a body we cannot prove empty
270
+ if (isEmptyBody(hit.body)) return blockVerdict(hit.kind);
204
271
  }
205
- process.exit(0);
272
+ const gate = unmodelledGate(cmd);
273
+ if (gate) return gate;
206
274
  } catch {
207
- process.exit(0); // never let a crash here block a legitimate write
275
+ // never let a crash here block a legitimate write
276
+ }
277
+ return { kind: 'allow' };
278
+ }
279
+
280
+ function run(data) {
281
+ let cmd = '';
282
+ try {
283
+ cmd = (JSON.parse(data).tool_input || {}).command || '';
284
+ } catch {
285
+ process.exit(0); // unparseable input: nothing meaningful to say
286
+ }
287
+ const v = evaluate(cmd);
288
+ if (v.kind === 'block') {
289
+ process.stdout.write(JSON.stringify(v.payload));
290
+ process.exit(2);
208
291
  }
292
+ process.exit(0);
209
293
  }
210
294
 
211
295
  if (require.main === module) {
@@ -218,4 +302,4 @@ if (require.main === module) {
218
302
  });
219
303
  }
220
304
 
221
- module.exports = { ghBodyWrite, isEmptyBody, resolveEditBody, resolveApiBody };
305
+ module.exports = { ghBodyWrite, ghBodyWrites, isEmptyBody, resolveEditBody, resolveApiBody, evaluate };