@marcomigozzi/trailhead 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.it.md +28 -3
- package/README.md +38 -3
- package/bin/lib/codex-projection.js +16 -9
- package/bin/lib/codex-projection.test.js +39 -11
- package/bin/lib/install-verify.js +10 -5
- package/bin/lib/install-verify.test.js +51 -0
- package/bin/trailhead.js +37 -25
- package/bin/trailhead.test.js +162 -5
- package/package.json +4 -1
- package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
- package/plugins/trailhead/commands/panel.md +10 -0
- package/plugins/trailhead/hooks/hooks.json +3 -18
- package/plugins/trailhead/hooks/lib/body-sources.js +795 -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/mockup-link.js +21 -5
- package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
- package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -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 +252 -0
- package/plugins/trailhead/hooks/mod/README.md +187 -0
- package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
- package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
- package/plugins/trailhead/hooks/mod/register.tsx +280 -0
- package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
- package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -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 +22 -12
- package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
- 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 +19 -9
- package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
- 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 +3 -1
- package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
- package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
- package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
- package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
- package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
- package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
- package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
- package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
- package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
- package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
- package/plugins/trailhead/tests/map-pane-view.test.ts +565 -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 +86 -0
- package/plugins/trailhead/types/index.d.ts +69 -0
|
@@ -7,8 +7,9 @@ const path = require('path');
|
|
|
7
7
|
const fs = require('fs');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
const
|
|
10
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
11
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-body-guard.js');
|
|
12
|
+
const { ghBodyWrite, isEmptyBody, evaluate } = require('./trailhead-body-guard.js');
|
|
12
13
|
|
|
13
14
|
let passed = 0;
|
|
14
15
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
@@ -87,6 +88,10 @@ ok("isEmptyBody('x') is false", isEmptyBody('x') === false);
|
|
|
87
88
|
ok("isEmptyBody(' a ') is false", isEmptyBody(' a ') === false);
|
|
88
89
|
|
|
89
90
|
// --- end-to-end: block ---
|
|
91
|
+
// #219: a quoted command word or subcommand is the same word for bash.
|
|
92
|
+
const bQuoted = runHook('gh "issue" edit 5 --body ""');
|
|
93
|
+
ok('blocks an empty --body behind a quoted subcommand (#219)', bQuoted.code === 2 && /EMPTY_ISSUE_BODY_WRITE/.test(bQuoted.out));
|
|
94
|
+
|
|
90
95
|
const b1 = runHook('gh issue edit 5 --body ""');
|
|
91
96
|
ok('blocks an empty inline --body (exit 2)', b1.code === 2 && /"decision":"block"/.test(b1.out) && /EMPTY_ISSUE_BODY_WRITE/.test(b1.out));
|
|
92
97
|
|
|
@@ -206,9 +211,144 @@ const a2 = runHook('gh issue view 5 --json body'); // read, not a write
|
|
|
206
211
|
ok('allows a read (exit 0)', a2.code === 0 && a2.out.trim() === '');
|
|
207
212
|
const a3 = runHook('gh issue comment 5 --body ""'); // excluded verb, even though empty
|
|
208
213
|
ok('allows an empty gh issue comment (excluded verb, exit 0)', a3.code === 0 && a3.out.trim() === '');
|
|
209
|
-
|
|
214
|
+
// The merged runner also runs the secret-guard, which resolves `$(cat <path>)`: point it at a real clean file.
|
|
215
|
+
const a4File = path.join(os.tmpdir(), `bg-subst-${process.pid}.md`);
|
|
216
|
+
fs.writeFileSync(a4File, 'not empty\n');
|
|
217
|
+
const a4 = runHook(`gh issue edit 5 --body "$(cat ${a4File})"`); // unknowable, can't prove empty
|
|
218
|
+
fs.unlinkSync(a4File);
|
|
210
219
|
ok('allows an unknowable (shell-expansion) body (exit 0)', a4.code === 0 && a4.out.trim() === '');
|
|
211
220
|
|
|
221
|
+
// --- regression (#211): an empty-body write chained after another command ---
|
|
222
|
+
const isBlock = (r) => r.code === 2 && /"decision":"block"/.test(r.out) && /EMPTY_ISSUE_BODY_WRITE/.test(r.out);
|
|
223
|
+
const isAllow = (r) => r.code === 0 && r.out.trim() === '';
|
|
224
|
+
ok('blocks an empty --body chained after cd (exit 2)', isBlock(runHook('cd /tmp && gh issue edit 5 --body ""')));
|
|
225
|
+
ok('blocks an empty --body after a variable assignment (exit 2)', isBlock(runHook('D=x && gh issue edit 5 --body ""')));
|
|
226
|
+
ok('blocks an empty --body inside a subshell (exit 2)', isBlock(runHook('( gh issue edit 5 --body "" )')));
|
|
227
|
+
ok('blocks an empty --body inside if/then (exit 2)', isBlock(runHook('if true; then gh issue edit 5 --body ""; fi')));
|
|
228
|
+
ok('blocks an empty write that is a LATER statement after a non-empty one (exit 2)',
|
|
229
|
+
isBlock(runHook('gh issue edit 5 --body "x"; gh issue edit 6 --body ""')));
|
|
230
|
+
ok('blocks a chained gh api -f body="" after cd (exit 2)',
|
|
231
|
+
isBlock(runHook('cd /tmp && gh api --method PATCH repos/o/r/issues/5 -f body=""')));
|
|
232
|
+
ok('allows a chained non-empty body (exit 0)', isAllow(runHook('cd /tmp && gh issue edit 5 --body "x"')));
|
|
233
|
+
ok('allows a chained empty gh issue comment (out of scope, exit 0)',
|
|
234
|
+
isAllow(runHook('cd /tmp && gh issue comment 5 --body ""')));
|
|
235
|
+
ok('allows a chained empty gh issue create (out of scope, exit 0)',
|
|
236
|
+
isAllow(runHook('cd /tmp && gh issue create --title t --body ""')));
|
|
237
|
+
{
|
|
238
|
+
// After a cd the working directory is unknown: a relative path cannot be proven empty.
|
|
239
|
+
const relName = `bg-rel-${process.pid}.md`;
|
|
240
|
+
fs.writeFileSync(path.join(process.cwd(), relName), '');
|
|
241
|
+
// evaluate() directly: the merged runner's secret-guard has its own (separate) block for this shape.
|
|
242
|
+
ok('allows a RELATIVE --body-file after cd (unknowable)',
|
|
243
|
+
evaluate(`cd /tmp && gh issue edit 5 --body-file ${relName}`).kind === 'allow');
|
|
244
|
+
ok('allows a RELATIVE api body=@path after cd (unknowable)',
|
|
245
|
+
evaluate(`cd /tmp && gh api --method PATCH repos/o/r/issues/5 -F body=@${relName}`).kind === 'allow');
|
|
246
|
+
ok('blocks the same RELATIVE --body-file when no cd precedes it',
|
|
247
|
+
evaluate(`gh issue edit 5 --body-file ${relName}`).kind === 'block');
|
|
248
|
+
fs.unlinkSync(path.join(process.cwd(), relName));
|
|
249
|
+
const absEmpty = path.join(os.tmpdir(), `bg-abs-empty-${process.pid}.md`);
|
|
250
|
+
fs.writeFileSync(absEmpty, '');
|
|
251
|
+
ok('blocks an ABSOLUTE empty --body-file after cd (exit 2)',
|
|
252
|
+
isBlock(runHook(`cd /tmp && gh issue edit 5 --body-file ${absEmpty}`)));
|
|
253
|
+
fs.unlinkSync(absEmpty);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
{
|
|
257
|
+
// Partial install (no secret-guard next to the hook): the leading write still blocks.
|
|
258
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-partial-'));
|
|
259
|
+
fs.mkdirSync(path.join(dir, 'lib'));
|
|
260
|
+
fs.copyFileSync(path.join(__dirname, 'trailhead-body-guard.js'), path.join(dir, 'trailhead-body-guard.js'));
|
|
261
|
+
fs.copyFileSync(path.join(__dirname, 'lib', 'gh-subcommand.js'), path.join(dir, 'lib', 'gh-subcommand.js'));
|
|
262
|
+
fs.copyFileSync(path.join(__dirname, 'lib', 'body-sources.js'), path.join(dir, 'lib', 'body-sources.js'));
|
|
263
|
+
const partial = require(path.join(dir, 'trailhead-body-guard.js'));
|
|
264
|
+
ok('without secret-guard, a leading empty --body still blocks',
|
|
265
|
+
partial.evaluate('gh issue edit 5 --body ""').kind === 'block');
|
|
266
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- regression (#213): repeated / mixed body flags resolve like gh (pflag keeps the LAST) ---
|
|
270
|
+
{
|
|
271
|
+
const blk = (c) => evaluate(c).kind === 'block';
|
|
272
|
+
const alw = (c) => evaluate(c).kind === 'allow';
|
|
273
|
+
for (const sub of ['issue', 'pr']) {
|
|
274
|
+
const g = `gh ${sub} edit 5`;
|
|
275
|
+
ok(`#213 ${sub}: blocks --body "x" --body ""`, blk(`${g} --body "x" --body ""`));
|
|
276
|
+
ok(`#213 ${sub}: blocks --body=x --body=`, blk(`${g} --body=x --body=`));
|
|
277
|
+
ok(`#213 ${sub}: blocks -b "x" --body ""`, blk(`${g} -b "x" --body ""`));
|
|
278
|
+
ok(`#213 ${sub}: blocks -b "x" -b ""`, blk(`${g} -b "x" -b ""`));
|
|
279
|
+
ok(`#213 ${sub}: allows --body "" --body "x"`, alw(`${g} --body "" --body "x"`));
|
|
280
|
+
ok(`#213 ${sub}: allows --body= --body=x`, alw(`${g} --body= --body=x`));
|
|
281
|
+
ok(`#213 ${sub}: allows --body "" -b "x"`, alw(`${g} --body "" -b "x"`));
|
|
282
|
+
ok(`#213 ${sub}: allows --body '' --body 'x'`, alw(`${g} --body '' --body 'x'`));
|
|
283
|
+
}
|
|
284
|
+
const g = 'gh issue edit 5';
|
|
285
|
+
ok('#213 blocks --body "x" --body " "', blk(`${g} --body "x" --body " "`));
|
|
286
|
+
ok('#213 blocks an attached -b""', blk(`${g} -b""`));
|
|
287
|
+
ok('#213 allows --body "" --body "$(cat f)" (unknowable last)', alw(`${g} --body "" --body "$(cat f)"`));
|
|
288
|
+
ok('#213 allows --body "" --body $B (unknowable last)', alw(`${g} --body "" --body $B`));
|
|
289
|
+
ok('#213 allows --body "" --body-file /dev/null (gh mutual-exclusion error)', alw(`${g} --body "" --body-file /dev/null`));
|
|
290
|
+
ok('#213 allows --body "x" --body-file /dev/null (gh mutual-exclusion error)', alw(`${g} --body "x" --body-file /dev/null`));
|
|
291
|
+
ok('#213 allows --body "x" --body-file "" (empty file flag = absent)', alw(`${g} --body "x" --body-file ""`));
|
|
292
|
+
ok('#213 blocks --body "" --body-file "" (empty file flag = absent)', blk(`${g} --body "" --body-file ""`));
|
|
293
|
+
const full = path.join(os.tmpdir(), `bg-213-full-${process.pid}.md`);
|
|
294
|
+
fs.writeFileSync(full, 'real content\n');
|
|
295
|
+
ok('#213 blocks --body-file <full> --body-file /dev/null (last wins)', blk(`${g} --body-file ${full} --body-file /dev/null`));
|
|
296
|
+
ok('#213 allows --body-file /dev/null --body-file <full> (last wins)', alw(`${g} --body-file /dev/null --body-file ${full}`));
|
|
297
|
+
fs.unlinkSync(full);
|
|
298
|
+
ok('#213 per statement: blocks a later repeated-flag empty write',
|
|
299
|
+
blk('gh issue edit 5 --body "x" --body ""; gh issue edit 6 --body "x"'));
|
|
300
|
+
ok('#213 per statement: allows when the last --body of each statement is non-empty',
|
|
301
|
+
alw('gh issue edit 5 --body "" --body "x"; gh issue edit 6 --body "ok"'));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- regression (#213 review): backslash-newline continuations, stdin-like body files ---
|
|
305
|
+
{
|
|
306
|
+
const blk = (c) => evaluate(c).kind === 'block';
|
|
307
|
+
const alw = (c) => evaluate(c).kind === 'allow';
|
|
308
|
+
const { words } = require('./lib/body-sources.js');
|
|
309
|
+
ok('words: unindented continuation joins (a\\<nl>b -> ab)', JSON.stringify(words('a\\\nb')) === '["ab"]');
|
|
310
|
+
ok('words: spaced continuation keeps two words', JSON.stringify(words('a \\\nb')) === '["a","b"]');
|
|
311
|
+
ok('words: continuation inside double quotes is removed', JSON.stringify(words('"a\\\nb"')) === JSON.stringify(['"ab"']));
|
|
312
|
+
ok('words: continuation inside single quotes is literal', JSON.stringify(words("'a\\\nb'")) === JSON.stringify(["'a\\\nb'"]));
|
|
313
|
+
ok('#213 blocks --title t \\<nl>--body ""', blk('gh issue edit 5 --title t \\\n--body ""'));
|
|
314
|
+
ok('#213 blocks edit 5 \\<nl>--body ""', blk('gh issue edit 5 \\\n--body ""'));
|
|
315
|
+
ok('#213 blocks --body "x" \\<nl>--body ""', blk('gh issue edit 5 --body "x" \\\n--body ""'));
|
|
316
|
+
ok('#213 allows --body "" \\<nl>--body "x"', alw('gh issue edit 5 --body "" \\\n--body "x"'));
|
|
317
|
+
for (const f of ['/dev/stdin', '/dev/fd/0', '/proc/self/fd/0', '-']) {
|
|
318
|
+
const r = ghBodyWrite(`gh issue edit 5 --body-file ${f}`);
|
|
319
|
+
ok(`#213 --body-file ${f} is unknowable`, r && r.body === null);
|
|
320
|
+
}
|
|
321
|
+
// Run only this guard as a real hook process behind a shell pipe (stdin is a consumed pipe, like in production).
|
|
322
|
+
const bodyGuardOnly = (c) => {
|
|
323
|
+
try {
|
|
324
|
+
execFileSync('sh', ['-c', 'printf %s "$TH_IN" | "$TH_NODE" "$TH_HOOK"'], {
|
|
325
|
+
env: { ...process.env, TH_IN: JSON.stringify({ tool_input: { command: c } }), TH_NODE: process.execPath, TH_HOOK: path.join(__dirname, 'trailhead-body-guard.js') },
|
|
326
|
+
});
|
|
327
|
+
return true;
|
|
328
|
+
} catch { return false; }
|
|
329
|
+
};
|
|
330
|
+
ok('#213 negative control: the bodyGuardOnly harness sees a block', !bodyGuardOnly('gh issue edit 5 --body ""'));
|
|
331
|
+
{
|
|
332
|
+
// `..` after a symlink resolves in the kernel, not lexically: lnk/s -> real/sub, so lnk/s/../empty.md is real/empty.md.
|
|
333
|
+
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'bg-213-lnk-'));
|
|
334
|
+
fs.mkdirSync(path.join(d, 'real', 'sub'), { recursive: true });
|
|
335
|
+
fs.mkdirSync(path.join(d, 'lnk'));
|
|
336
|
+
fs.writeFileSync(path.join(d, 'real', 'empty.md'), '');
|
|
337
|
+
fs.writeFileSync(path.join(d, 'lnk', 'empty.md'), 'not empty\n');
|
|
338
|
+
fs.symlinkSync(path.join(d, 'real', 'sub'), path.join(d, 'lnk', 's'));
|
|
339
|
+
ok('#213 blocks a --body-file whose .. follows a symlink (read the raw path)',
|
|
340
|
+
blk(`gh issue edit 5 --body-file ${d}/lnk/s/../empty.md`));
|
|
341
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
for (const f of ['/dev//stdin', '/dev/fd//0', '/dev/./stdin']) {
|
|
344
|
+
ok(`#213 allows --body-file ${f} (non-canonical stdin spelling)`, bodyGuardOnly(`gh issue edit 5 --body-file ${f}`)); // the secret-guard judges these spellings on its own
|
|
345
|
+
}
|
|
346
|
+
ok('#213 pins: an escaped backslash before the newline is not a continuation (allows)',
|
|
347
|
+
alw('gh issue edit 5 --title t \\\\\n--body ""'));
|
|
348
|
+
ok('#213 pins: backslash CR LF is not a continuation (allows)',
|
|
349
|
+
alw('gh issue edit 5 --title t \\\r\n--body ""'));
|
|
350
|
+
}
|
|
351
|
+
|
|
212
352
|
// --- crash-safety ---
|
|
213
353
|
try {
|
|
214
354
|
execFileSync('node', [HOOK], { input: 'not json at all {{{' });
|
|
@@ -219,4 +359,85 @@ try {
|
|
|
219
359
|
const c2 = runHook('gh issue edit 5 --add-label x'); // non-body gh command
|
|
220
360
|
ok('a non-body gh command exits 0', c2.code === 0 && c2.out.trim() === '');
|
|
221
361
|
|
|
362
|
+
// #225: a flag between the subcommand and the verb must not hide the write.
|
|
363
|
+
const bFlagVerb = runHook('gh issue -R o/r edit 5 --body ""');
|
|
364
|
+
ok('blocks an empty --body with a flag before the verb (exit 2)', bFlagVerb.code === 2 && /EMPTY_ISSUE_BODY_WRITE/.test(bFlagVerb.out));
|
|
365
|
+
|
|
366
|
+
// #227: constructs the guard cannot interpret are blocked, not emulated.
|
|
367
|
+
{
|
|
368
|
+
const CODE = 'BODY_GUARD_UNMODELLED_CONSTRUCT';
|
|
369
|
+
const clean = path.join(os.tmpdir(), `bg-um-clean-${process.pid}.md`);
|
|
370
|
+
fs.writeFileSync(clean, 'A real body.\n');
|
|
371
|
+
const code = (c) => { const r = runHook(c); return r.code === 2 ? (JSON.parse(r.out).code || '?') : null; };
|
|
372
|
+
|
|
373
|
+
// Gaps that used to pass.
|
|
374
|
+
for (const c of [
|
|
375
|
+
`gh api -X PATCH repos/o/r/issues/1 -f 'body'=""`,
|
|
376
|
+
'gh api -X PATCH repos/o/r/issues/1 -f bo""dy=""',
|
|
377
|
+
'printf -v B %s x; gh issue edit 1 --body "$B"',
|
|
378
|
+
'source ./x.sh; gh issue edit 1 --body "$B"',
|
|
379
|
+
'source ./x.sh; gh issue edit 1 --body-file rel.md',
|
|
380
|
+
'. ./x.sh && gh pr edit 1 --body "$B"',
|
|
381
|
+
`eval 'gh issue edit 1 --body real'`,
|
|
382
|
+
'echo ${ gh issue edit 1 --body ""; }',
|
|
383
|
+
'alias g=gh; g issue edit 1 --body ""',
|
|
384
|
+
'f() { gh issue edit 1 --body real; }; f',
|
|
385
|
+
`gh issue ed\\\nit 1 --body real`,
|
|
386
|
+
// Through the merged runner the secret-guard speaks first on some of these (its own gate); still a block.
|
|
387
|
+
]) ok(`blocks an unmodelled construct: ${c.slice(0, 50)}`, code(c) === CODE || (!!process.env.TRAILHEAD_GUARD_HOOK && code(c) !== null));
|
|
388
|
+
|
|
389
|
+
// An unknowable body behind a quoted key blocks too (through the merged runner the secret-guard may speak first).
|
|
390
|
+
ok('a quoted api key with an unknowable body blocks', code('gh api -X PATCH repos/o/r/issues/1 -f b\\ody="$B"') !== null);
|
|
391
|
+
ok('a quoted api key with an unreadable file blocks', code("gh api -X PATCH repos/o/r/issues/1 -f 'body'=@/nonexistent/bg-um.md") !== null);
|
|
392
|
+
for (const c of [
|
|
393
|
+
`printf -v B %s ''; gh issue edit 1 --body "it's $B and 'x'"`,
|
|
394
|
+
`source x.sh; gh issue edit 1 --body "it's $B and 'x'"`,
|
|
395
|
+
`printf -v B %s ''; printf %s "$B" | gh issue edit 1 --body-file -`,
|
|
396
|
+
`printf -v B %s ''; gh issue edit 1 --body-file - <<EOF\n$B\nEOF`,
|
|
397
|
+
]) ok(`W2: a state-dependent write after the construct still blocks: ${c.slice(0, 40).replace(/\n/g, ' ')}`, code(c) !== null);
|
|
398
|
+
for (const c of ["source x; gh issue edit 1 --body 'lit'", 'source x; gh issue edit 1 --body lit', `source x; gh issue edit 1 --body "it's fine"`]) {
|
|
399
|
+
ok(`W2: a literal write after source passes: ${c}`, runHook(c).code === 0);
|
|
400
|
+
}
|
|
401
|
+
ok('the existing empty-body verdict keeps priority', code('source ./x.sh; gh issue edit 1 --body ""') === 'EMPTY_ISSUE_BODY_WRITE' ||
|
|
402
|
+
(!!process.env.TRAILHEAD_GUARD_HOOK && code('source ./x.sh; gh issue edit 1 --body ""') !== null));
|
|
403
|
+
ok('an empty body before the construct keeps its verdict', code('gh issue edit 1 --body ""; alias g=gh') === 'EMPTY_ISSUE_BODY_WRITE');
|
|
404
|
+
|
|
405
|
+
// Plain controls pass.
|
|
406
|
+
for (const c of [
|
|
407
|
+
"alias ll='ls -l'",
|
|
408
|
+
'alias g=gh',
|
|
409
|
+
'source ~/.venv/bin/activate && pytest',
|
|
410
|
+
'eval "$(ssh-agent)"',
|
|
411
|
+
'eval "$(ssh-agent)"; gh issue edit 1 --body-file ' + clean,
|
|
412
|
+
'printf -v x %s y; echo $x',
|
|
413
|
+
`gh issue edit 1 --body-file ${clean}; source ./x.sh`,
|
|
414
|
+
`. venv/bin/activate && gh issue edit 1 --body 'hello'`,
|
|
415
|
+
`source venv/bin/activate && gh issue edit 1 --body-file ${clean}`,
|
|
416
|
+
'f() { gh issue edit 1 --body x; }',
|
|
417
|
+
`gh issue edit 1 --body 'use eval, source and alias here'`,
|
|
418
|
+
`gh issue edit 1 --body-file ${clean} # eval x`,
|
|
419
|
+
`gh issue edit 1 \\\n --body-file ${clean}`,
|
|
420
|
+
'gh api -X PATCH repos/o/r/issues/1 -f body=x',
|
|
421
|
+
`gh api -X PATCH repos/o/r/issues/1 -f 'body'=real`, // a known, non-empty body is read like the plain form
|
|
422
|
+
`gh api -X PATCH repos/o/r/issues/1 -f 'body'=@${clean}`,
|
|
423
|
+
'gh api -X PATCH repos/o/r/issues/1 -f "body=x"',
|
|
424
|
+
`gh api -X PATCH repos/o/r/issues/1 -F title=x`,
|
|
425
|
+
]) ok(`passes a plain form: ${c.slice(0, 50)}`, runHook(c).code === 0);
|
|
426
|
+
|
|
427
|
+
// Not a body-guard concern (a comment adds text, it overwrites nothing); the merged runner's
|
|
428
|
+
// secret-guard may block these for its own reasons, so only the standalone guard is checked.
|
|
429
|
+
if (!process.env.TRAILHEAD_GUARD_HOOK) {
|
|
430
|
+
for (const c of [
|
|
431
|
+
'alias g=gh; g issue comment 1 --body x',
|
|
432
|
+
'printf -v B %s x; gh issue comment 1 --body "$B"',
|
|
433
|
+
`source ./x.sh; gh issue comment 1 --body-file ${clean}`,
|
|
434
|
+
]) ok(`passes a non-overwriting write: ${c.slice(0, 50)}`, runHook(c).code === 0);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const r = runHook(`gh api -X PATCH repos/o/r/issues/1 -f 'body'=""`);
|
|
438
|
+
const reason = JSON.parse(r.out).reason;
|
|
439
|
+
ok('the message names the guard and asks for the simple form', /^trailhead body-guard: /.test(reason) && /Rewrite it in a simple form/.test(reason));
|
|
440
|
+
fs.rmSync(clean);
|
|
441
|
+
}
|
|
442
|
+
|
|
222
443
|
console.log(`✓ body-guard: ${passed} assertions passed`);
|
|
@@ -5,15 +5,17 @@
|
|
|
5
5
|
// 2. Conventional Commits subject: <type>(<scope>)?: <subject>, <=72 chars.
|
|
6
6
|
// Blocks with exit 2 + {"decision":"block","reason":...}; allows everything else.
|
|
7
7
|
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
8
|
-
// Self-contained, no dependencies.
|
|
8
|
+
// Self-contained, no dependencies. Exports a pure evaluate(cmd) so the merged
|
|
9
|
+
// trailhead-bash-guards.js can run it in-process; run directly it is unchanged.
|
|
9
10
|
|
|
10
11
|
const { checkCommitMessage } = require('./lib/commit-message-check.js');
|
|
11
12
|
|
|
12
13
|
const CO_AUTHORED = /co-authored-by\s*:/i;
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const ALLOW = { kind: 'allow' };
|
|
16
|
+
|
|
17
|
+
function blockVerdict(code, reason) {
|
|
18
|
+
return { kind: 'block', payload: { decision: 'block', code, reason } };
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
// Token-walk: is this shell string a `git commit …`? Handles env-var prefixes
|
|
@@ -50,18 +52,15 @@ function extractMessage(cmd) {
|
|
|
50
52
|
return null;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
process.stdin.on('end', () => {
|
|
57
|
-
clearTimeout(timer);
|
|
55
|
+
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
56
|
+
// exits or writes; any internal error falls through to allow (crash-safe).
|
|
57
|
+
function evaluate(cmd) {
|
|
58
58
|
try {
|
|
59
|
-
|
|
60
|
-
if (!isGitCommit(cmd)) process.exit(0);
|
|
59
|
+
if (!isGitCommit(cmd)) return ALLOW;
|
|
61
60
|
|
|
62
61
|
// Co-Authored-By anywhere in the command (covers -m, -F, heredocs).
|
|
63
62
|
if (CO_AUTHORED.test(cmd)) {
|
|
64
|
-
|
|
63
|
+
return blockVerdict('CO_AUTHORED_BY_FORBIDDEN',
|
|
65
64
|
'trailhead: do not add a Co-Authored-By trailer to commits. Remove it and commit again.');
|
|
66
65
|
}
|
|
67
66
|
|
|
@@ -71,10 +70,38 @@ process.stdin.on('end', () => {
|
|
|
71
70
|
// `#` lines as message text (cleanup=whitespace), so a `#…` first line is
|
|
72
71
|
// a real subject to validate, not a comment to skip.
|
|
73
72
|
const verdict = checkCommitMessage(msg, { stripComments: false });
|
|
74
|
-
if (!verdict.ok)
|
|
73
|
+
if (!verdict.ok) return blockVerdict(verdict.code, verdict.reason);
|
|
75
74
|
}
|
|
76
75
|
} catch {
|
|
77
76
|
// fall through to allow
|
|
78
77
|
}
|
|
78
|
+
return ALLOW;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Standalone CLI: write the payload and exit exactly as the guard always did.
|
|
82
|
+
function run(data) {
|
|
83
|
+
let cmd = '';
|
|
84
|
+
try {
|
|
85
|
+
cmd = (JSON.parse(data).tool_input || {}).command || '';
|
|
86
|
+
} catch {
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
const v = evaluate(cmd);
|
|
90
|
+
if (v.kind === 'block') {
|
|
91
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
92
|
+
process.exit(2);
|
|
93
|
+
}
|
|
79
94
|
process.exit(0);
|
|
80
|
-
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (require.main === module) {
|
|
98
|
+
let data = '';
|
|
99
|
+
const timer = setTimeout(() => process.exit(0), 5000);
|
|
100
|
+
process.stdin.on('data', (c) => (data += c));
|
|
101
|
+
process.stdin.on('end', () => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
run(data);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { evaluate };
|
|
@@ -5,7 +5,8 @@ const assert = require('assert');
|
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
9
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-commit-guard.js');
|
|
9
10
|
|
|
10
11
|
let passed = 0;
|
|
11
12
|
const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// and local paths / URLs / VCS refs carry no typed name and pass through.
|
|
14
14
|
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
15
15
|
// Crash-safe: any error → exit 0 (never wedge the user's workflow).
|
|
16
|
-
// Self-contained, no dependencies. Pure functions exported for tests
|
|
17
|
-
// hook when executed directly.
|
|
16
|
+
// Self-contained, no dependencies. Pure functions (incl. evaluate) exported for tests
|
|
17
|
+
// and the merged trailhead-bash-guards.js; runs as a hook when executed directly.
|
|
18
18
|
|
|
19
19
|
const SENTINEL = /(?:^|\s)TRAILHEAD_VERIFIED_INSTALL=1(?:\s|$)/;
|
|
20
20
|
|
|
@@ -133,7 +133,7 @@ function isVerified(cmd) {
|
|
|
133
133
|
return SENTINEL.test(String(cmd));
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
function
|
|
136
|
+
function blockVerdict(hit) {
|
|
137
137
|
const list = hit.packages.join(', ');
|
|
138
138
|
const reason =
|
|
139
139
|
`trailhead: this ${hit.ecosystem} install names package(s) not yet vetted (${list}). ` +
|
|
@@ -142,8 +142,20 @@ function block(hit) {
|
|
|
142
142
|
'and repository metadata (present and matching). ' +
|
|
143
143
|
'STOP and ask the human on anything suspicious (block-and-ask): do not install on your own judgement. ' +
|
|
144
144
|
'Once vetted, re-run the exact command prefixed with `TRAILHEAD_VERIFIED_INSTALL=1` to proceed.';
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
return { kind: 'block', payload: { decision: 'block', code: 'UNVETTED_PACKAGE_INSTALL', reason } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
|
|
149
|
+
// exits or writes; any internal error falls through to allow.
|
|
150
|
+
function evaluate(cmd) {
|
|
151
|
+
try {
|
|
152
|
+
if (isVerified(cmd)) return { kind: 'allow' }; // agent has vetted these
|
|
153
|
+
const hit = detectInstall(cmd);
|
|
154
|
+
if (hit) return blockVerdict(hit);
|
|
155
|
+
} catch {
|
|
156
|
+
// fall through to allow: a guard must never wedge the workflow
|
|
157
|
+
}
|
|
158
|
+
return { kind: 'allow' };
|
|
147
159
|
}
|
|
148
160
|
|
|
149
161
|
function run(data) {
|
|
@@ -153,12 +165,10 @@ function run(data) {
|
|
|
153
165
|
} catch {
|
|
154
166
|
process.exit(0); // unparseable input: nothing to gate
|
|
155
167
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
} catch {
|
|
161
|
-
// fall through to allow: a guard must never wedge the workflow
|
|
168
|
+
const v = evaluate(cmd);
|
|
169
|
+
if (v.kind === 'block') {
|
|
170
|
+
process.stdout.write(JSON.stringify(v.payload));
|
|
171
|
+
process.exit(2);
|
|
162
172
|
}
|
|
163
173
|
process.exit(0);
|
|
164
174
|
}
|
|
@@ -173,4 +183,4 @@ if (require.main === module) {
|
|
|
173
183
|
});
|
|
174
184
|
}
|
|
175
185
|
|
|
176
|
-
module.exports = { detectInstall, isVerified };
|
|
186
|
+
module.exports = { detectInstall, isVerified, evaluate };
|
|
@@ -5,7 +5,8 @@ const assert = require('assert');
|
|
|
5
5
|
const { execFileSync } = require('child_process');
|
|
6
6
|
const path = require('path');
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
// TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
|
|
9
|
+
const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-install-guard.js');
|
|
9
10
|
const { detectInstall, isVerified } = require('./trailhead-install-guard.js');
|
|
10
11
|
|
|
11
12
|
let passed = 0;
|
|
@@ -7,7 +7,14 @@
|
|
|
7
7
|
// practice (an approval asked with only the project's name, no URL), so this
|
|
8
8
|
// hook blocks an AskUserQuestion that is a mockup-approval ask when the
|
|
9
9
|
// assistant prose immediately before it (no tool call in between) holds no
|
|
10
|
-
// URL or file path.
|
|
10
|
+
// URL or file path. A link carried by the approval question itself (question,
|
|
11
|
+
// header, option label or description) is accepted too.
|
|
12
|
+
// Transcript timing: Claude Code writes the CURRENT assistant message (its
|
|
13
|
+
// text and tool_use blocks) to the transcript only after the tool call
|
|
14
|
+
// completes, so at PreToolUse the prose carrying the link is usually not
|
|
15
|
+
// there yet. The prose check therefore runs only when the pending ask is
|
|
16
|
+
// found in the transcript (by tool_use_id, or as the identical tail ask);
|
|
17
|
+
// otherwise the guard cannot judge and allows.
|
|
11
18
|
// Blocks with exit 2 + {"decision":"block",...}; allows everything else.
|
|
12
19
|
// Fail-OPEN on any error (exit 0): a missing/unreadable transcript or an
|
|
13
20
|
// unexpected shape must never wedge the workflow.
|
|
@@ -16,7 +23,7 @@
|
|
|
16
23
|
// Pure functions are exported for tests; runs as a hook when executed directly.
|
|
17
24
|
|
|
18
25
|
const fs = require('fs');
|
|
19
|
-
const { hasLink, isMockupApprovalAsk, BLOCK_REASON } = require('./lib/mockup-link.js');
|
|
26
|
+
const { hasLink, isMockupApprovalAsk, askHasLink, BLOCK_REASON } = require('./lib/mockup-link.js');
|
|
20
27
|
|
|
21
28
|
// Only the transcript tail matters: the prose sits right before the ask.
|
|
22
29
|
const TAIL_BYTES = 512 * 1024;
|
|
@@ -25,14 +32,41 @@ const blocksOf = (e) => (Array.isArray(e.message && e.message.content) ? e.messa
|
|
|
25
32
|
const isPendingAsk = (e) => e.type === 'assistant'
|
|
26
33
|
&& blocksOf(e).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion');
|
|
27
34
|
|
|
35
|
+
const mainChain = (entries) => entries.filter((e) => e && (e.type === 'assistant' || e.type === 'user') && !e.isSidechain);
|
|
36
|
+
|
|
37
|
+
// Index (in the main chain) of the entry holding the pending ask being
|
|
38
|
+
// judged, or -1 when it is not in the transcript yet. With a tool_use id:
|
|
39
|
+
// the assistant entry carrying that id. Without: the last entry, only if it
|
|
40
|
+
// is a pending AskUserQuestion whose input equals toolInput.
|
|
41
|
+
function pendingAskIndex(entries, toolUseId, toolInput) {
|
|
42
|
+
const main = mainChain(entries);
|
|
43
|
+
if (toolUseId) {
|
|
44
|
+
return main.findIndex((e) => e.type === 'assistant'
|
|
45
|
+
&& blocksOf(e).some((b) => b.type === 'tool_use' && b.id === toolUseId));
|
|
46
|
+
}
|
|
47
|
+
const i = main.length - 1;
|
|
48
|
+
if (i < 0 || !isPendingAsk(main[i])) return -1;
|
|
49
|
+
const same = blocksOf(main[i]).some((b) => b.type === 'tool_use' && b.name === 'AskUserQuestion'
|
|
50
|
+
&& JSON.stringify(b.input) === JSON.stringify(toolInput));
|
|
51
|
+
return same ? i : -1;
|
|
52
|
+
}
|
|
53
|
+
|
|
28
54
|
// The assistant prose right before the ask: walk the main-chain entries back
|
|
29
|
-
// from the end, skip the pending AskUserQuestion
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
55
|
+
// from the anchor entry (default: the end), skip the pending AskUserQuestion
|
|
56
|
+
// entries (keeping the text that precedes the tool_use inside the anchor
|
|
57
|
+
// entry itself), and collect text blocks until anything else (a user turn, a tool
|
|
58
|
+
// result, another tool call).
|
|
59
|
+
function proseBeforeAsk(entries, anchor) {
|
|
60
|
+
const main = mainChain(entries);
|
|
61
|
+
let i = anchor === undefined ? main.length - 1 : anchor;
|
|
35
62
|
const texts = [];
|
|
63
|
+
// Text blocks in the same entry, before its tool_use, are prose too.
|
|
64
|
+
if (i >= 0 && main[i] && isPendingAsk(main[i])) {
|
|
65
|
+
const blocks = blocksOf(main[i]);
|
|
66
|
+
const firstTool = blocks.findIndex((b) => b.type === 'tool_use');
|
|
67
|
+
blocks.slice(0, firstTool).filter((b) => b.type === 'text').forEach((b) => texts.push(b.text || ''));
|
|
68
|
+
}
|
|
69
|
+
while (i >= 0 && isPendingAsk(main[i])) i--;
|
|
36
70
|
for (; i >= 0; i--) {
|
|
37
71
|
const e = main[i];
|
|
38
72
|
if (e.type !== 'assistant') break;
|
|
@@ -73,9 +107,12 @@ function run(data) {
|
|
|
73
107
|
const payload = JSON.parse(data);
|
|
74
108
|
if (payload.tool_name && payload.tool_name !== 'AskUserQuestion') process.exit(0);
|
|
75
109
|
if (!isMockupApprovalAsk(payload.tool_input)) process.exit(0);
|
|
110
|
+
if (askHasLink(payload.tool_input)) process.exit(0);
|
|
76
111
|
if (!payload.transcript_path) process.exit(0);
|
|
77
|
-
const
|
|
78
|
-
|
|
112
|
+
const entries = parseEntries(readTail(payload.transcript_path));
|
|
113
|
+
const idx = pendingAskIndex(entries, payload.tool_use_id, payload.tool_input);
|
|
114
|
+
if (idx < 0) process.exit(0); // current message not flushed yet: cannot judge
|
|
115
|
+
if (!hasLink(proseBeforeAsk(entries, idx))) block();
|
|
79
116
|
} catch {
|
|
80
117
|
// fall through to allow: a guard must never wedge the workflow
|
|
81
118
|
}
|
|
@@ -92,4 +129,4 @@ if (require.main === module) {
|
|
|
92
129
|
});
|
|
93
130
|
}
|
|
94
131
|
|
|
95
|
-
module.exports = { isMockupApprovalAsk, hasLink, proseBeforeAsk };
|
|
132
|
+
module.exports = { isMockupApprovalAsk, hasLink, askHasLink, proseBeforeAsk, pendingAskIndex };
|
|
@@ -10,6 +10,8 @@ const {
|
|
|
10
10
|
isMockupApprovalAsk,
|
|
11
11
|
hasLink,
|
|
12
12
|
proseBeforeAsk,
|
|
13
|
+
pendingAskIndex,
|
|
14
|
+
askHasLink,
|
|
13
15
|
} = require('./trailhead-mockup-link-guard.js');
|
|
14
16
|
|
|
15
17
|
const HOOK = path.join(__dirname, 'trailhead-mockup-link-guard.js');
|
|
@@ -20,7 +22,7 @@ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
|
|
|
20
22
|
// Costruttori di righe transcript (stesso shape JSONL di Claude Code)
|
|
21
23
|
const text = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }] } });
|
|
22
24
|
const thinking = () => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'thinking', thinking: '' }] } });
|
|
23
|
-
const toolUse = (name, input = {}) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id
|
|
25
|
+
const toolUse = (name, input = {}, id = 'x') => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'tool_use', id, name, input }] } });
|
|
24
26
|
const toolResult = (c) => ({ type: 'user', isSidechain: false, message: { content: [{ type: 'tool_result', tool_use_id: 'x', content: c }] } });
|
|
25
27
|
const userPrompt = (c) => ({ type: 'user', isSidechain: false, message: { content: c } });
|
|
26
28
|
|
|
@@ -103,8 +105,45 @@ const sidechain = [
|
|
|
103
105
|
];
|
|
104
106
|
ok('sidechain text is ignored', !hasLink(proseBeforeAsk(sidechain)));
|
|
105
107
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
+
// Senza ancora: comportamento storico, si parte dalla coda del transcript
|
|
109
|
+
const noAnchor = [userPrompt('x'), text('Ecco https://a.b/c')];
|
|
110
|
+
ok('without an anchor the prose is read from the transcript tail', hasLink(proseBeforeAsk(noAnchor)));
|
|
111
|
+
|
|
112
|
+
// Con ancora: si parte dall'ask trovato, non dalla coda
|
|
113
|
+
const anchored = [
|
|
114
|
+
userPrompt('x'),
|
|
115
|
+
text('Mockup: https://a.b/c'),
|
|
116
|
+
toolUse('AskUserQuestion', approvalInput, 'ask1'),
|
|
117
|
+
toolResult('Approvo'),
|
|
118
|
+
text('Altro testo senza link'),
|
|
119
|
+
];
|
|
120
|
+
ok('with an anchor the prose is read back from the anchored ask', hasLink(proseBeforeAsk(anchored, 2)));
|
|
121
|
+
|
|
122
|
+
// --- askHasLink ---
|
|
123
|
+
const askWith = (over) => ({ questions: [{ ...approvalInput.questions[0], ...over }] });
|
|
124
|
+
ok('link in the approval question counts', askHasLink(askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' })));
|
|
125
|
+
ok('link in the approval header counts', askHasLink(askWith({ header: 'https://claude.ai/artifact/AbC123' })));
|
|
126
|
+
ok('link in an approval option label counts', askHasLink(askWith({ options: [{ label: 'Apri https://a.b/c' }, { label: 'No' }] })));
|
|
127
|
+
ok('link in an approval option description counts', askHasLink(askWith({ options: [{ label: 'Approvo', description: 'vedi mockups/faq.html' }] })));
|
|
128
|
+
ok('approval ask without any link has none', !askHasLink(approvalInput));
|
|
129
|
+
ok('link only in a different non-approval question does not count', !askHasLink({
|
|
130
|
+
questions: [
|
|
131
|
+
{ question: 'Dove sta il piano?', options: [{ label: 'https://a.b/piano' }] },
|
|
132
|
+
approvalInput.questions[0],
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
ok('malformed input has no ask link', !askHasLink({}) && !askHasLink(null));
|
|
136
|
+
|
|
137
|
+
// --- pendingAskIndex ---
|
|
138
|
+
const withIds = [userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok'), text('Prosa'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
139
|
+
ok('pendingAskIndex finds the entry by tool_use id', pendingAskIndex(withIds, 'ask1', approvalInput) === 4);
|
|
140
|
+
ok('pendingAskIndex by id ignores a missing id', pendingAskIndex(withIds, 'nope', approvalInput) === -1);
|
|
141
|
+
ok('pendingAskIndex without id falls back to input equality on the tail', pendingAskIndex(withIds, undefined, approvalInput) === 4);
|
|
142
|
+
ok('pendingAskIndex without id is -1 when the tail ask has a different input',
|
|
143
|
+
pendingAskIndex(withIds, undefined, askWith({ question: 'Approvi il mockup B?' })) === -1);
|
|
144
|
+
ok('pendingAskIndex is -1 when the tail is the previous tool_result',
|
|
145
|
+
pendingAskIndex([userPrompt('x'), toolUse('Bash', {}, 'b1'), toolResult('ok')], undefined, approvalInput) === -1);
|
|
146
|
+
ok('pendingAskIndex ignores sidechain entries', pendingAskIndex([{ ...toolUse('AskUserQuestion', approvalInput, 'ask1'), isSidechain: true }], 'ask1', approvalInput) === -1);
|
|
108
147
|
|
|
109
148
|
// --- end-to-end ---
|
|
110
149
|
function writeTranscript(entries) {
|
|
@@ -138,6 +177,39 @@ ok('other tools are ignored', r4.code === 0);
|
|
|
138
177
|
const r5 = runHook({ tool_name: 'AskUserQuestion', tool_input: { questions: [{ question: 'Approvi il piano?', options: [] }] }, transcript_path: writeTranscript(failing) });
|
|
139
178
|
ok('non-mockup ask is allowed', r5.code === 0);
|
|
140
179
|
|
|
180
|
+
// Shape reale di Claude Code 2.1.295: il messaggio corrente non e' ancora nel transcript
|
|
181
|
+
const unflushed = [userPrompt('lavora #12'), toolUse('Bash', {}, 'b1'), toolResult('ok')];
|
|
182
|
+
const ra = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(unflushed) });
|
|
183
|
+
ok('(a) transcript ending at a previous tool_result fails open', ra.code === 0 && ra.out.trim() === '');
|
|
184
|
+
|
|
185
|
+
const flushedNoLink = [...failing.slice(0, 5), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
186
|
+
const rb = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
|
|
187
|
+
ok('(b) tool_use_id with the ask entry present and no link in the prose is blocked', rb.code === 2 && /MOCKUP_LINK_MISSING/.test(rb.out));
|
|
188
|
+
|
|
189
|
+
const linkedAsk = askWith({ question: 'Approvi il mockup https://claude.ai/artifact/AbC123?' });
|
|
190
|
+
const rc = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: linkedAsk, transcript_path: writeTranscript(failing) });
|
|
191
|
+
ok('(c) link carried by the ask question is allowed', rc.code === 0 && rc.out.trim() === '');
|
|
192
|
+
|
|
193
|
+
const otherPending = [userPrompt('x'), text('Niente link'), toolUse('AskUserQuestion', { questions: [{ question: 'Altro?', options: [] }] }, 'other')];
|
|
194
|
+
const rd = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript(otherPending) });
|
|
195
|
+
ok('(d) transcript ending in a different pending ask fails open', rd.code === 0 && rd.out.trim() === '');
|
|
196
|
+
|
|
197
|
+
// Testo e tool_use nella STESSA riga di transcript
|
|
198
|
+
const sameEntry = (t) => ({ type: 'assistant', isSidechain: false, message: { content: [{ type: 'text', text: t }, { type: 'tool_use', id: 'ask1', name: 'AskUserQuestion', input: approvalInput }] } });
|
|
199
|
+
ok('text before the tool_use in the same entry counts as prose', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')], 1)));
|
|
200
|
+
ok('same-entry text is also read from the tail', hasLink(proseBeforeAsk([userPrompt('x'), sameEntry('Vedi https://a.b/c')])));
|
|
201
|
+
const rs1 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Vedi https://a.b/c')]) });
|
|
202
|
+
ok('same-entry text with a link is allowed', rs1.code === 0 && rs1.out.trim() === '');
|
|
203
|
+
const rs2 = runHook({ tool_name: 'AskUserQuestion', tool_use_id: 'ask1', tool_input: approvalInput, transcript_path: writeTranscript([userPrompt('x'), sameEntry('Mockup pronto su MyApp')]) });
|
|
204
|
+
ok('same-entry text without a link is blocked', rs2.code === 2 && /MOCKUP_LINK_MISSING/.test(rs2.out));
|
|
205
|
+
|
|
206
|
+
// Senza tool_use_id: ask in coda identico a tool_input (fallback per uguaglianza)
|
|
207
|
+
const ri1 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedNoLink) });
|
|
208
|
+
ok('no id, identical tail ask, no link in the prose is blocked', ri1.code === 2 && /MOCKUP_LINK_MISSING/.test(ri1.out));
|
|
209
|
+
const flushedLink = [...failing.slice(0, 4), text('Mockup: https://claude.ai/artifact/AbC123xyz'), toolUse('AskUserQuestion', approvalInput, 'ask1')];
|
|
210
|
+
const ri2 = runHook({ tool_name: 'AskUserQuestion', tool_input: approvalInput, transcript_path: writeTranscript(flushedLink) });
|
|
211
|
+
ok('no id, identical tail ask, link in the prose is allowed', ri2.code === 0 && ri2.out.trim() === '');
|
|
212
|
+
|
|
141
213
|
let r6;
|
|
142
214
|
try { execFileSync('node', [HOOK], { input: 'not json' }); r6 = 0; } catch (e) { r6 = e.status; }
|
|
143
215
|
ok('unparseable input fails open', r6 === 0);
|