@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.
- package/README.it.md +142 -29
- package/README.md +141 -28
- package/bin/base-preflight-docs.test.js +65 -0
- package/bin/execute-split-docs.test.js +146 -0
- package/bin/isolation-answer-docs.test.js +41 -0
- package/bin/isolation-copy-docs.test.js +28 -0
- package/bin/lib/codex-projection.js +17 -10
- package/bin/lib/codex-projection.test.js +80 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/report-status-docs.test.js +195 -0
- package/bin/resolve-helpers-docs.test.js +36 -0
- package/bin/scope-confirm-skip-docs.test.js +70 -0
- package/bin/trailhead.js +40 -28
- package/bin/trailhead.test.js +503 -6
- package/bin/uat-setup-docs.test.js +65 -0
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
- package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
- package/plugins/trailhead/agents/trailhead-debug.md +20 -1
- package/plugins/trailhead/agents/trailhead-executor.md +29 -2
- package/plugins/trailhead/agents/trailhead-fix.md +29 -2
- package/plugins/trailhead/agents/trailhead-plan.md +20 -1
- package/plugins/trailhead/agents/trailhead-research.md +22 -3
- package/plugins/trailhead/agents/trailhead-verify.md +15 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
- package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
- package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
- package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
- package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
- package/plugins/trailhead/hooks/lib/dashboard.test.js +409 -0
- package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
- package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
- package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
- package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
- package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
- package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
- package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
- package/plugins/trailhead/hooks/lib/shell-scan.js +532 -65
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +434 -0
- package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
- package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
- package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
- package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
- package/plugins/trailhead/hooks/lib/work-state.test.js +297 -0
- package/plugins/trailhead/hooks/mod/README.md +219 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +606 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +832 -0
- package/plugins/trailhead/hooks/mod/register.tsx +293 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +110 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
- package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
- package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
- package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
- package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
- package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
- package/plugins/trailhead/hooks/trailhead-install-guard.js +187 -42
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +182 -2
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
- package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
- package/plugins/trailhead/hooks/trailhead-search-guard.js +59 -13
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +68 -3
- package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
- package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
- package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
- package/plugins/trailhead/skills/_shared/choices.md +7 -3
- package/plugins/trailhead/skills/_shared/configuration-reference.md +15 -3
- package/plugins/trailhead/skills/_shared/load-first.md +3 -3
- package/plugins/trailhead/skills/_shared/multi-map.md +3 -3
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/substrate-commands.md +22 -11
- package/plugins/trailhead/skills/_shared/substrate.md +6 -4
- package/plugins/trailhead/skills/_shared/teamwork.md +17 -6
- package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +17 -9
- package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
- package/plugins/trailhead/skills/_shared/techniques.md +12 -2
- package/plugins/trailhead/skills/trailhead/SKILL.md +3 -3
- package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-capture/references/capture.md +2 -2
- package/plugins/trailhead/skills/trailhead-chart/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-chart/references/charting.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
- package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
- package/plugins/trailhead/skills/trailhead-view/SKILL.md +8 -6
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +16 -13
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +6 -4
- package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +28 -10
- package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
- package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
- package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
- package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
- package/plugins/trailhead/templates/trailhead-dashboard.js +340 -0
- package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
- package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
- package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
- package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
- package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
- package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
- package/plugins/trailhead/templates/trailhead-work-state.js +217 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1921 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +853 -0
- package/plugins/trailhead/tests/mod.test.ts +14 -0
- package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
- package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
- package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
- package/plugins/trailhead/tests/work-state-view.test.ts +110 -0
- 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
|
-
|
|
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
|
|
68
|
-
//
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
272
|
+
const gate = unmodelledGate(cmd);
|
|
273
|
+
if (gate) return gate;
|
|
206
274
|
} catch {
|
|
207
|
-
|
|
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 };
|