@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.
Files changed (61) hide show
  1. package/README.it.md +28 -3
  2. package/README.md +38 -3
  3. package/bin/lib/codex-projection.js +16 -9
  4. package/bin/lib/codex-projection.test.js +39 -11
  5. package/bin/lib/install-verify.js +10 -5
  6. package/bin/lib/install-verify.test.js +51 -0
  7. package/bin/trailhead.js +37 -25
  8. package/bin/trailhead.test.js +162 -5
  9. package/package.json +4 -1
  10. package/plugins/trailhead/.claude-plugin/plugin.json +3 -2
  11. package/plugins/trailhead/commands/panel.md +10 -0
  12. package/plugins/trailhead/hooks/hooks.json +3 -18
  13. package/plugins/trailhead/hooks/lib/body-sources.js +795 -0
  14. package/plugins/trailhead/hooks/lib/gh-subcommand.js +92 -10
  15. package/plugins/trailhead/hooks/lib/gh-subcommand.test.js +55 -0
  16. package/plugins/trailhead/hooks/lib/mockup-link.js +21 -5
  17. package/plugins/trailhead/hooks/lib/shell-scan.js +496 -64
  18. package/plugins/trailhead/hooks/lib/shell-scan.test.js +420 -0
  19. package/plugins/trailhead/hooks/lib/unmodelled.js +345 -0
  20. package/plugins/trailhead/hooks/lib/unmodelled.test.js +173 -0
  21. package/plugins/trailhead/hooks/lib/work-state.test.js +252 -0
  22. package/plugins/trailhead/hooks/mod/README.md +187 -0
  23. package/plugins/trailhead/hooks/mod/map-pane-view.ts +453 -0
  24. package/plugins/trailhead/hooks/mod/map-pane.tsx +663 -0
  25. package/plugins/trailhead/hooks/mod/register.tsx +280 -0
  26. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +69 -0
  27. package/plugins/trailhead/hooks/mod/work-state-view.ts +93 -0
  28. package/plugins/trailhead/hooks/trailhead-bash-guards.js +140 -0
  29. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +233 -0
  30. package/plugins/trailhead/hooks/trailhead-body-guard.js +145 -61
  31. package/plugins/trailhead/hooks/trailhead-body-guard.test.js +224 -3
  32. package/plugins/trailhead/hooks/trailhead-commit-guard.js +41 -14
  33. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +2 -1
  34. package/plugins/trailhead/hooks/trailhead-install-guard.js +22 -12
  35. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +2 -1
  36. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +48 -11
  37. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +75 -3
  38. package/plugins/trailhead/hooks/trailhead-search-guard.js +19 -9
  39. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +2 -1
  40. package/plugins/trailhead/hooks/trailhead-secret-guard.js +541 -57
  41. package/plugins/trailhead/hooks/trailhead-secret-guard.test.js +1000 -4
  42. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +171 -6
  43. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +170 -0
  44. package/plugins/trailhead/skills/_shared/choices.md +3 -1
  45. package/plugins/trailhead/skills/_shared/multi-map.md +1 -1
  46. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  47. package/plugins/trailhead/skills/_shared/teamwork.md +4 -2
  48. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  49. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  50. package/plugins/trailhead/skills/trailhead-work/SKILL.md +3 -2
  51. package/plugins/trailhead/skills/trailhead-work/references/auto.md +2 -0
  52. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +3 -1
  53. package/plugins/trailhead/templates/trailhead-work-state.js +199 -0
  54. package/plugins/trailhead/tests/map-pane-ui.test.ts +1374 -0
  55. package/plugins/trailhead/tests/map-pane-view.test.ts +565 -0
  56. package/plugins/trailhead/tests/mod.test.ts +14 -0
  57. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +207 -0
  58. package/plugins/trailhead/tests/work-state-prompt.test.ts +109 -0
  59. package/plugins/trailhead/tests/work-state-ui.test.ts +365 -0
  60. package/plugins/trailhead/tests/work-state-view.test.ts +86 -0
  61. package/plugins/trailhead/types/index.d.ts +69 -0
@@ -7,15 +7,16 @@ const path = require('path');
7
7
  const fs = require('fs');
8
8
  const os = require('os');
9
9
 
10
- const HOOK = path.join(__dirname, 'trailhead-secret-guard.js');
11
- const { scan, ghIssueWrite, bodyFileText } = require('./trailhead-secret-guard.js');
10
+ // TRAILHEAD_GUARD_HOOK re-runs this suite through the merged trailhead-bash-guards.js.
11
+ const HOOK = process.env.TRAILHEAD_GUARD_HOOK || path.join(__dirname, 'trailhead-secret-guard.js');
12
+ const { scan, ghIssueWrite, ghWrites, resolveBodyFile, bodyFileText } = require('./trailhead-secret-guard.js');
12
13
 
13
14
  let passed = 0;
14
15
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
15
16
 
16
17
  // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
17
- function runHook(command, env = {}) {
18
- const input = JSON.stringify({ tool_input: { command } });
18
+ function runHook(command, env = {}, cwd) {
19
+ const input = JSON.stringify({ tool_input: { command }, ...(cwd ? { cwd } : {}) });
19
20
  try {
20
21
  const out = execFileSync('node', [HOOK], { input, env: { ...process.env, ...env } });
21
22
  return { code: 0, out: out.toString() };
@@ -57,6 +58,130 @@ ok('ignores gh -R o/r issue view (read, even with global flag)',
57
58
  ghIssueWrite('gh -R owner/repo issue view 5 --json body') === null);
58
59
  ok('ignores non-gh commands', ghIssueWrite('echo ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789xx') === null);
59
60
 
61
+ // --- unit: ghWrites() finds every write in a compound command ---
62
+ {
63
+ const kinds = (c) => JSON.stringify(ghWrites(c).map((w) => w.kind));
64
+ ok('ghWrites sees a write after D=/x &&', kinds('D=/x && gh issue comment 1 --body-file /x/b.md') === '["issue"]');
65
+ ok('ghWrites sees a write after cd &&', kinds('cd /tmp && gh pr comment 1 --body x') === '["pr"]');
66
+ ok('ghWrites sees a write after echo ;', kinds('echo x; gh issue edit 1 --body x') === '["issue"]');
67
+ ok('ghWrites sees a write after a background &', kinds('true & gh issue comment 1 --body x') === '["issue"]');
68
+ ok('ghWrites sees a write after a newline', kinds('echo x\ngh issue create --body x') === '["issue"]');
69
+ ok('ghWrites returns both writes of one command',
70
+ kinds('gh issue comment 1 --body a && gh pr review 2 --body b') === '["issue","pr"]');
71
+ ok('ghWrites sees a gh api body= write behind &&', kinds('cd /x && gh api repos/o/r/issues/5/comments -f body=x') === '["api"]');
72
+ ok('ghWrites ignores echo gh issue create', kinds('echo gh issue create --body x') === '[]');
73
+ ok('ghWrites ignores ls && git status', kinds('ls && git status') === '[]');
74
+ ok('ghWrites ignores a read behind &&', kinds('cd /x && gh issue view 5') === '[]');
75
+ ok('ghIssueWrite returns the first write kind of a compound command',
76
+ ghIssueWrite('cd /x && gh pr comment 1 --body x && gh issue edit 2 --body y') === 'pr');
77
+ ok('ghIssueWrite still returns null for a compound with no write', ghIssueWrite('cd /x && ls') === null);
78
+ }
79
+
80
+ // --- unit: ghWrites() variable tracking (what each write can see) ---
81
+ {
82
+ const w0 = (c, env = {}) => ghWrites(c, env)[0];
83
+ ok('var set with ; reaches the write', w0('D=/x; gh issue comment 1 --body-file $D/b.md').vars.D === '/x');
84
+ ok('var set with && reaches the write', w0('D=/x && gh issue comment 1 --body-file $D/b.md').vars.D === '/x');
85
+ ok('export NAME=v sets a var', w0('export D=/x && gh issue comment 1 --body-file $D/b.md').vars.D === '/x');
86
+ ok('single-quoted value stays literal', w0("D='$HOME'; gh issue comment 1 --body-file $D/b.md").vars.D === '$HOME');
87
+ ok('double-quoted value expands known vars', w0('A=/x; D="$A/y"; gh issue comment 1 --body-file $D/b.md').vars.D === '/x/y');
88
+ ok('bare value expands env and ~', w0('D=~/y; gh issue comment 1 --body-file $D/b.md', { HOME: '/h' }).vars.D === '/h/y');
89
+ ok('an unresolvable value is recorded as null', w0('D=$(pwd); gh issue comment 1 --body-file $D/b.md').vars.D === null);
90
+ ok('assignment ending in | does not propagate', !('D' in w0('D=/x | gh issue comment 1 --body-file $D/b.md').vars));
91
+ ok('assignment ending in & does not propagate', !('D' in w0('D=/x & gh issue comment 1 --body-file $D/b.md').vars));
92
+ ok('inline prefix does not propagate to the same statement', !('D' in w0('D=/x gh issue comment 1 --body-file "$D/a.md"').vars));
93
+ ok('assignment after a pipe does not propagate', !('D' in w0('true | D=/x; gh issue comment 1 --body-file "$D/a.md"').vars));
94
+ ok('cwd is known without a cd', w0('gh issue comment 1 --body x').cwdKnown === true);
95
+ ok('cwd is unknown after cd', w0('cd /tmp && gh issue comment 1 --body x').cwdKnown === false);
96
+ ok('cwd is unknown after pushd', w0('pushd /tmp; gh issue comment 1 --body x').cwdKnown === false);
97
+ }
98
+
99
+ // --- unit: resolveBodyFile() ---
100
+ {
101
+ const r = (c, vars = {}, env = {}, cwd = '/work') => resolveBodyFile(c, vars, env, cwd);
102
+ ok('no body-file flag -> null', r('gh issue comment 1 --body hi') === null);
103
+ ok('stdin body file -> null', r('gh issue comment 1 --body-file -') === null);
104
+ ok('literal absolute path', r('gh issue comment 1 --body-file /a/b.md').path === '/a/b.md');
105
+ ok('-F short flag', r('gh issue comment 1 -F /a/b.md').path === '/a/b.md');
106
+ ok('--body-file=path form', r('gh issue comment 1 --body-file=/a/b.md').path === '/a/b.md');
107
+ ok('quoted path', r('gh issue comment 1 --body-file "/a b/c.md"').path === '/a b/c.md');
108
+ ok('$NAME from vars', r('gh issue comment 1 --body-file $D/b.md', { D: '/x' }).path === '/x/b.md');
109
+ ok('${NAME} in double quotes', r('gh issue comment 1 --body-file "${D}/b.md"', { D: '/x' }).path === '/x/b.md');
110
+ ok('$NAME falls back to env', r('gh issue comment 1 --body-file $D/b.md', {}, { D: '/e' }).path === '/e/b.md');
111
+ ok('vars win over env', r('gh issue comment 1 --body-file $D/b.md', { D: '/x' }, { D: '/e' }).path === '/x/b.md');
112
+ ok('single-quoted path is literal', r("gh issue comment 1 --body-file '/l/$D/b.md'", { D: '/x' }).path === '/l/$D/b.md');
113
+ ok('bare ~/ expands to HOME', r('gh issue comment 1 --body-file ~/b.md', {}, { HOME: '/h' }).path === '/h/b.md');
114
+ ok('double-quoted ~ stays literal (relative to cwd)', r('gh issue comment 1 --body-file "~/b.md"', {}, { HOME: '/h' }).path === '/work/~/b.md');
115
+ ok('relative path resolves against cwd', r('gh issue comment 1 --body-file b.md').path === '/work/b.md');
116
+ ok('unknown variable is unresolved', !!r('gh issue comment 1 --body-file "$NOPE/b.md"').unresolved);
117
+ ok('variable recorded as null is unresolved', !!r('gh issue comment 1 --body-file $D/b.md', { D: null }).unresolved);
118
+ ok('command substitution is unresolved', !!r('gh issue comment 1 --body-file $(pwd)/b.md').unresolved);
119
+ ok('backticks are unresolved', !!r('gh issue comment 1 --body-file `pwd`/b.md').unresolved);
120
+ ok('parameter operator is unresolved', !!r('gh issue comment 1 --body-file ${D:-/x}/b.md', { D: '/x' }).unresolved);
121
+ ok('positional/special params are unresolved', !!r('gh issue comment 1 --body-file $1').unresolved && !!r('gh issue comment 1 --body-file $@').unresolved && !!r('gh issue comment 1 --body-file $$').unresolved);
122
+ ok('~user is unresolved', !!r('gh issue comment 1 --body-file ~bob/b.md').unresolved);
123
+ ok('relative path with unknown cwd (after cd) is unresolved', !!r('gh issue comment 1 --body-file b.md', {}, {}, null).unresolved);
124
+ ok('absolute path with unknown cwd still resolves', r('gh issue comment 1 --body-file /a/b.md', {}, {}, null).path === '/a/b.md');
125
+ ok('attached -F/path (pflag shorthand) resolves like -F /path', r('gh issue comment 1 -F/a/b.md').path === '/a/b.md');
126
+ ok('attached -F with a variable', r('gh issue comment 1 -F"$D"/b.md', { D: '/x' }).path === '/x/b.md');
127
+ ok('/dev/stdin reads stdin -> null', r('gh issue comment 1 --body-file /dev/stdin') === null);
128
+ ok('process substitution as body-file is unresolved', !!r('gh issue comment 1 --body-file <(cat /a/b.md)').unresolved);
129
+ ok('a < redirect is not mistaken for the body-file', r('gh issue comment 1 -F - < /a/b.md') === null);
130
+ ok('an absolute path is normalised', r('gh issue comment 1 --body-file /a/./c/../b.md').path === '/a/b.md');
131
+ ok('adjacent quoted + bare parts join', r('gh issue comment 1 --body-file "$D"/b.md', { D: '/x' }).path === '/x/b.md');
132
+ }
133
+
134
+ // --- unit: ghWrites() sees writes inside compound constructs and wrappers (#210 review C1) ---
135
+ {
136
+ const kinds = (c) => JSON.stringify(ghWrites(c).map((w) => w.kind));
137
+ const W = 'gh issue comment 1 --body-file /x/b.md';
138
+ ok('write after do', kinds(`for f in a; do ${W}; done`) === '["issue"]');
139
+ ok('write after then', kinds(`if true; then ${W}; fi`) === '["issue"]');
140
+ ok('write after else', kinds(`if false; then true; else ${W}; fi`) === '["issue"]');
141
+ ok('write as an if condition', kinds(`if ${W}; then true; fi`) === '["issue"]');
142
+ ok('write in a { } group', kinds(`{ ${W}; }`) === '["issue"]');
143
+ ok('write in a ( ) subshell, spaced', kinds(`( ${W} )`) === '["issue"]');
144
+ ok('write in a ( ) subshell, glued', kinds(`(${W})`) === '["issue"]');
145
+ ok('write after !', kinds(`! ${W}`) === '["issue"]');
146
+ ok('write after time', kinds(`time ${W}`) === '["issue"]');
147
+ ok('write behind env', kinds(`env ${W}`) === '["issue"]');
148
+ ok('write behind env -i NAME=v', kinds(`env -i A=1 ${W}`) === '["issue"]');
149
+ ok('write behind command', kinds(`command ${W}`) === '["issue"]');
150
+ ok('write behind builtin/nohup/exec',
151
+ kinds(`nohup ${W}`) === '["issue"]' && kinds(`exec ${W}`) === '["issue"]' && kinds(`builtin ${W}`) === '["issue"]');
152
+ ok('trailing ) of a subshell does not reach the path',
153
+ ghWrites(`(${W})`)[0].text.endsWith('b.md') && ghWrites(`( ${W} )`)[0].text.endsWith('b.md'));
154
+ ok('bash -c with a write is an opaque write', kinds(`bash -c '${W}'`) === '["opaque"]');
155
+ ok('sh -c / zsh -c / eval / xargs with a write are opaque',
156
+ kinds(`sh -c "${W}"`) === '["opaque"]' && kinds(`zsh -c "${W}"`) === '["opaque"]' &&
157
+ kinds(`eval ${W}`) === '["opaque"]' && kinds(`echo 1 | xargs ${W}`) === '["opaque"]');
158
+ ok('bash -c with a gh api body write is opaque', kinds('bash -c "gh api repos/o/r/issues/1/comments -f body=x"') === '["opaque"]');
159
+ ok('a command substitution holding a write is analysed as that write', kinds(`echo $(${W})`) === '["issue"]');
160
+ ok('a backtick substitution holding a write is analysed as that write', kinds(`echo \`${W}\``) === '["issue"]');
161
+ ok('an assignment from a substitution holding a write is analysed', kinds(`URL=$(${W})`) === '["issue"]');
162
+ ok('a nested bash -c inside a substitution is still opaque', kinds(`echo $(bash -c '${W}')`) === '["opaque"]');
163
+ ok('bash -c with a read stays no write', kinds('bash -c "gh issue list"') === '[]' && kinds('bash -c "gh issue view 3 --comments"') === '[]');
164
+ ok('bash -c without gh stays no write', kinds('bash -c "ls && echo hi"') === '[]');
165
+ ok('prose about gh next to an unrelated substitution is not a write', kinds('echo "gh pr create" $(date)') === '[]');
166
+ ok('a commit message heredoc mentioning gh issue comment is not a write',
167
+ kinds("git commit -m \"$(cat <<'EOF'\nuse gh issue comment here\nEOF\n)\"") === '[]');
168
+ ok('git commit message mentioning gh stays no write', kinds('git commit -m "docs: gh issue comment helper"') === '[]');
169
+ }
170
+
171
+ // --- unit: bodyFileText() is a thin wrapper over the resolver (#210 review I1) ---
172
+ {
173
+ const leak = path.join(os.tmpdir(), `sg-i1-${process.pid}.md`);
174
+ fs.writeFileSync(leak, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
175
+ ok('bodyFileText expands a variable set earlier in the command',
176
+ /AKIAIOSFODNN7EXAMPLE/.test(bodyFileText(`D=${os.tmpdir()}; gh issue edit 5 --body-file $D/sg-i1-${process.pid}.md`)));
177
+ ok('bodyFileText reads an attached -F/path', /AKIAIOSFODNN7EXAMPLE/.test(bodyFileText(`gh issue edit 5 -F${leak}`)));
178
+ ok('bodyFileText is empty without a body file', bodyFileText('gh issue edit 5 --body x') === '');
179
+ let threw = false;
180
+ try { bodyFileText('gh issue edit 5 --body-file "$SG_UNSET_VAR_210/x.md"'); } catch { threw = true; }
181
+ ok('bodyFileText throws for an unresolvable path instead of reading it verbatim', threw);
182
+ fs.unlinkSync(leak);
183
+ }
184
+
60
185
  // --- end-to-end: block ---
61
186
  const b1 = runHook('gh issue comment 5 --body "t ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789xx"');
62
187
  ok('blocks an inline secret (exit 2)', b1.code === 2 && /"decision":"block"/.test(b1.out));
@@ -130,6 +255,413 @@ ok('allows a clean write (exit 0, no output)', a1.code === 0 && a1.out.trim() ==
130
255
  const a2 = runHook('gh issue view 5 --json body'); // read, never scanned
131
256
  ok('allows a read (exit 0)', a2.code === 0 && a2.out.trim() === '');
132
257
 
258
+ // --- end-to-end: body-file paths with variables, compound commands (#210) ---
259
+ {
260
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210-'));
261
+ const leakFile = path.join(dir, 'leak.md');
262
+ const cleanFile = path.join(dir, 'clean.md');
263
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
264
+ fs.writeFileSync(cleanFile, 'all good, nothing sensitive here\n');
265
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
266
+ const code = (r, c) => r.code === 2 && r.out.includes(`"code":"${c}"`);
267
+ const UNRES = 'SECRET_GUARD_UNRESOLVED_BODY_FILE';
268
+ const UNREAD = 'SECRET_GUARD_UNREADABLE_BODY_FILE';
269
+
270
+ ok('REPRO row 1: literal absolute body-file blocks', leaks(runHook(`gh issue comment 1 --body-file ${leakFile}`)));
271
+ ok('REPRO row 2: D=dir; ... $D/leak.md blocks', leaks(runHook(`D=${dir}; gh issue comment 1 --body-file $D/leak.md`)));
272
+ ok('REPRO row 3: D=dir && ... "${D}/leak.md" blocks', leaks(runHook(`D=${dir} && gh issue comment 1 --body-file "\${D}/leak.md"`)));
273
+ ok('a body-file behind cd && blocks', leaks(runHook(`cd / && gh issue comment 1 --body-file ${leakFile}`)));
274
+ ok('a body-file behind echo ; blocks', leaks(runHook(`echo hi; gh issue edit 1 --body-file ${leakFile}`)));
275
+ ok('a body-file behind a background & blocks', leaks(runHook(`true & gh issue comment 1 --body-file ${leakFile}`)));
276
+ ok('export D=dir && ... $D/leak.md blocks', leaks(runHook(`export D=${dir} && gh issue comment 1 --body-file $D/leak.md`)));
277
+ ok('two writes: only the second body leaks -> blocks',
278
+ leaks(runHook(`gh issue comment 1 --body-file ${cleanFile} && gh pr comment 2 --body-file ${leakFile}`)));
279
+ ok('two writes: both clean -> allowed',
280
+ (() => { const r = runHook(`gh issue comment 1 --body-file ${cleanFile} && gh pr comment 2 --body-file ${cleanFile}`); return r.code === 0 && r.out.trim() === ''; })());
281
+ ok('a clean body via D=dir; ... $D/clean.md is allowed silently',
282
+ (() => { const r = runHook(`D=${dir}; gh issue comment 1 --body-file $D/clean.md`); return r.code === 0 && r.out.trim() === ''; })());
283
+ ok('a clean body via ~ expansion is allowed silently',
284
+ (() => { const r = runHook('gh issue comment 1 --body-file ~/clean.md', { HOME: dir }); return r.code === 0 && r.out.trim() === ''; })());
285
+ ok('an env-only variable resolves from the hook env',
286
+ leaks(runHook('gh issue comment 1 --body-file "$SG_ENV_DIR/leak.md"', { SG_ENV_DIR: dir })));
287
+
288
+ // Fail closed: a path we cannot know, or cannot read, never passes as a clean write.
289
+ ok('an unset variable blocks as unresolved', code(runHook('gh issue comment 1 --body-file "$SG_UNSET_VAR_210/x.md"'), UNRES));
290
+ ok('a command substitution blocks as unresolved', code(runHook('gh issue comment 1 --body-file $(pwd)/x.md'), UNRES));
291
+ ok('a missing literal path blocks as unreadable', code(runHook(`gh issue comment 1 --body-file ${dir}/nope.md`), UNREAD));
292
+ ok('a directory as body-file blocks as unreadable', code(runHook(`gh issue comment 1 --body-file ${dir}`), UNREAD));
293
+ ok("D='$HOME' stays literal (unreadable, not expanded)", code(runHook("D='$HOME'; gh issue comment 1 --body-file $D/x.md", { HOME: dir }, dir), UNREAD));
294
+ ok('"~/x" in double quotes stays literal (unreadable)', code(runHook('gh issue comment 1 --body-file "~/clean.md"', { HOME: dir }, dir), UNREAD));
295
+ ok('an assignment piped into gh does not leak its variable', code(runHook(`SG_P=${dir} | gh issue comment 1 --body-file $SG_P/clean.md`), UNRES));
296
+ ok('an inline prefix is not used to expand its own arguments', code(runHook(`SG_I=${dir} gh issue comment 1 --body-file "$SG_I/clean.md"`), UNRES));
297
+ ok('an assignment after a pipe does not propagate', code(runHook(`true | SG_Q=${dir}; gh issue comment 1 --body-file "$SG_Q/clean.md"`), UNRES));
298
+ ok('a relative path after cd blocks as unresolved', code(runHook('cd / && gh issue comment 1 --body-file clean.md', {}, dir), UNRES));
299
+
300
+ // The hook input's cwd resolves a relative body-file path (single hook and merged runner).
301
+ ok('a relative body-file resolves against the hook input cwd and blocks', leaks(runHook('gh issue comment 1 --body-file leak.md', {}, dir)));
302
+ ok('a relative clean body-file resolves against the hook input cwd and passes',
303
+ (() => { const r = runHook('gh issue comment 1 --body-file clean.md', {}, dir); return r.code === 0 && r.out.trim() === ''; })());
304
+
305
+ fs.rmSync(dir, { recursive: true, force: true });
306
+ }
307
+
308
+ // --- end-to-end: compound constructs and opaque writes (#210 review C1) ---
309
+ {
310
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210c-'));
311
+ const leakFile = path.join(dir, 'leak.md');
312
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
313
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
314
+ const W = `gh issue comment 1 --body-file ${leakFile}`;
315
+ for (const [name, c] of [
316
+ ['do', `for f in a; do ${W}; done`], ['then', `if true; then ${W}; fi`], ['{ }', `{ ${W}; }`],
317
+ ['( )', `(${W})`], ['!', `! ${W}`], ['time', `time ${W}`], ['env', `env ${W}`], ['command', `command ${W}`],
318
+ ]) ok(`a leaking body-file inside ${name} blocks`, leaks(runHook(c)));
319
+ const op = runHook(`bash -c '${W}'`);
320
+ ok('bash -c with a gh write blocks as opaque', op.code === 2 && op.out.includes('"code":"SECRET_GUARD_OPAQUE_WRITE"'));
321
+ ok('eval with a gh write blocks as opaque', runHook(`eval ${W}`).out.includes('SECRET_GUARD_OPAQUE_WRITE'));
322
+ const rd = runHook('bash -c "gh issue list"');
323
+ ok('bash -c with a read is allowed silently', rd.code === 0 && rd.out.trim() === '');
324
+ fs.rmSync(dir, { recursive: true, force: true });
325
+ }
326
+
327
+ // --- end-to-end: bodies that do not come from a literal file argument (#210 review W2) ---
328
+ {
329
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210w2-'));
330
+ const leakFile = path.join(dir, 'leak.md');
331
+ const cleanFile = path.join(dir, 'clean.md');
332
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
333
+ fs.writeFileSync(cleanFile, 'all good, nothing sensitive here\n');
334
+ fs.writeFileSync(path.join(dir, 'leak.json'), '{"body":"AKIAIOSFODNN7EXAMPLE"}');
335
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
336
+ const unres = (r) => r.code === 2 && r.out.includes('"code":"SECRET_GUARD_UNRESOLVED_BODY_FILE"');
337
+ const passes = (r) => r.code === 0 && r.out.trim() === '';
338
+ const G = 'gh issue comment 1';
339
+
340
+ // stdin: heredoc / herestring / redirect / pipe
341
+ ok('stdin heredoc with a clean body passes', passes(runHook(`${G} -F - <<'EOF'\nhello world\nEOF`)));
342
+ ok('stdin heredoc with a secret blocks (scanned in the command)', leaks(runHook(`${G} -F - <<'EOF'\nDeploy: AKIAIOSFODNN7EXAMPLE\nEOF`)));
343
+ ok('stdin <<- heredoc passes', passes(runHook(`${G} --body-file - <<-EOF\n\thello\n\tEOF`)));
344
+ ok('stdin herestring passes', passes(runHook(`${G} -F - <<< "hello"`)));
345
+ ok('stdin < leak.md blocks', leaks(runHook(`${G} -F - < ${leakFile}`)));
346
+ ok('stdin < clean.md passes', passes(runHook(`${G} -F - < ${cleanFile}`)));
347
+ ok('/dev/stdin < leak.md blocks', leaks(runHook(`${G} --body-file /dev/stdin < ${leakFile}`)));
348
+ ok('stdin < $UNSET blocks as unresolved', unres(runHook(`${G} -F - < "$SG_UNSET_VAR_210"`)));
349
+ ok('stdin < relative path resolves against the hook cwd', leaks(runHook(`${G} -F - < leak.md`, {}, dir)));
350
+ ok('cat leak.md | gh -F - blocks', leaks(runHook(`cat ${leakFile} | ${G} -F -`)));
351
+ ok('cat clean.md | gh -F - passes', passes(runHook(`cat ${cleanFile} | ${G} -F -`)));
352
+ ok('echo | gh -F - passes (inline content is scanned in the command)', passes(runHook(`echo hello | ${G} -F -`)));
353
+ ok('echo secret | gh -F - blocks', leaks(runHook(`echo AKIAIOSFODNN7EXAMPLE | ${G} -F -`)));
354
+ ok('printf | gh -F - passes', passes(runHook(`printf '%s' hello | ${G} -F -`)));
355
+ ok('an unknown producer piped to gh -F - blocks as unresolved', unres(runHook(`curl -s http://x/y | ${G} -F -`)));
356
+ ok('gh -F - with no stdin source at all passes', passes(runHook(`${G} -F -`)));
357
+
358
+ ok('a leaking first of two --body-file flags still blocks', leaks(runHook(`${G} --body-file ${leakFile} --body-file ${cleanFile}`)));
359
+ ok('a leaking -F next to --body-file blocks', leaks(runHook(`${G} --body-file ${cleanFile} -F ${leakFile}`)));
360
+
361
+ // process substitution
362
+ ok('--body-file <(...) blocks as unresolved', unres(runHook(`${G} --body-file <(cat ${leakFile})`)));
363
+
364
+ // --body / -b values
365
+ ok('-b "$(cat leak.md)" blocks', leaks(runHook(`${G} -b "$(cat ${leakFile})"`)));
366
+ ok('--body "$(cat clean.md)" passes', passes(runHook(`${G} --body "$(cat ${cleanFile})"`)));
367
+ ok('-b "$(<leak.md)" blocks', leaks(runHook(`${G} -b "$(<${leakFile})"`)));
368
+ ok('unquoted -b $(cat leak.md) blocks', leaks(runHook(`${G} -b $(cat ${leakFile})`)));
369
+ ok('--body "$(date)" blocks as unresolved', unres(runHook(`${G} --body "$(date)"`)));
370
+ ok('--body with backticks blocks as unresolved', unres(runHook(`${G} --body "\`date\`"`)));
371
+ ok('the heredoc-in-substitution idiom passes', passes(runHook(`${G} --body "$(cat <<'EOF'\nhello there\nEOF\n)"`)));
372
+ ok('the heredoc-in-substitution idiom with a secret blocks', leaks(runHook(`${G} --body "$(cat <<'EOF'\nAKIAIOSFODNN7EXAMPLE\nEOF\n)"`)));
373
+ ok('--body "$VAR" scans the expanded value', leaks(runHook(`${G} --body "$SG_BODY"`, { SG_BODY: 'AKIAIOSFODNN7EXAMPLE' })));
374
+ ok('--body "$VAR" with a clean value passes', passes(runHook(`${G} --body "$SG_BODY"`, { SG_BODY: 'fine' })));
375
+ ok('--body "$UNSET" blocks as unresolved', unres(runHook(`${G} --body "$SG_UNSET_VAR_210"`)));
376
+ ok("--body 'literal $X' passes", passes(runHook(`${G} --body 'literal $X'`)));
377
+ ok('B=...; --body "$B" scans the variable set in the command', leaks(runHook(`B=AKIAIOSFODNN7EXAMPLE; ${G} --body "$B"`)));
378
+
379
+ // gh api
380
+ const A = 'gh api repos/o/r/issues/1/comments';
381
+ ok('api -F body=@leak.md blocks', leaks(runHook(`${A} -F body=@${leakFile}`)));
382
+ ok('api --field body=@clean.md passes', passes(runHook(`${A} --field body=@${cleanFile}`)));
383
+ ok('api -F body=@$D/leak.md resolves the variable', leaks(runHook(`D=${dir}; ${A} -F "body=@$D/leak.md"`)));
384
+ ok('api -F body=@- reads stdin from a redirect', leaks(runHook(`${A} -F body=@- < ${leakFile}`)));
385
+ ok('api -F body=@- with a heredoc passes', passes(runHook(`${A} -F body=@- <<'EOF'\nhello\nEOF`)));
386
+ ok('api --input leak.json blocks (any method)', leaks(runHook(`gh api -X PATCH repos/o/r/issues/1 --input ${path.join(dir, 'leak.json')}`)));
387
+ ok('api --input - < leak.json blocks', leaks(runHook(`gh api repos/o/r/issues/1 --input - < ${path.join(dir, 'leak.json')}`)));
388
+ ok('api --input with an unresolvable path blocks', unres(runHook('gh api repos/o/r/issues/1 --input "$SG_UNSET_VAR_210"')));
389
+ ok('api -f body=@leak.md is a raw field: literal, passes', passes(runHook(`${A} -f body=@${leakFile}`)));
390
+ ok('api -f body=hi passes', passes(runHook(`${A} -f body=hi`)));
391
+ ok('api read without body or --input is not a write', passes(runHook('gh api repos/o/r/issues/1')));
392
+
393
+ fs.rmSync(dir, { recursive: true, force: true });
394
+ }
395
+
396
+ // --- end-to-end: a body file written in the same command (#210 review W3) ---
397
+ {
398
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210w3-'));
399
+ const leakFile = path.join(dir, 'leak.md');
400
+ const cleanFile = path.join(dir, 'clean.md');
401
+ const N = path.join(dir, 'new.md');
402
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
403
+ fs.writeFileSync(cleanFile, 'all good, nothing sensitive here\n');
404
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
405
+ const code = (r, c) => r.code === 2 && r.out.includes(`"code":"${c}"`);
406
+ const passes = (r) => r.code === 0 && r.out.trim() === '';
407
+ const G = `gh issue comment 1 --body-file ${N}`;
408
+
409
+ ok('heredoc redirect then gh in the same command passes',
410
+ passes(runHook(`cat > ${N} <<'EOF'\nhello world\nEOF\n${G}`)));
411
+ ok('a secret in that heredoc blocks with SECRET_IN_ISSUE_WRITE',
412
+ code(runHook(`cat > ${N} <<'EOF'\nAKIAIOSFODNN7EXAMPLE\nEOF\n${G}`), 'SECRET_IN_ISSUE_WRITE'));
413
+ ok('a variable directory in the redirect path passes',
414
+ passes(runHook(`S=${dir}; cat > $S/new.md <<'EOF'\nhello\nEOF\ngh issue comment 1 --body-file $S/new.md`)));
415
+ ok('echo > path && gh passes', passes(runHook(`echo hello > ${N} && ${G}`)));
416
+ ok('printf >> path; gh passes', passes(runHook(`printf '%s' hello >> ${N}; ${G}`)));
417
+ ok('herestring redirect passes', passes(runHook(`cat > ${N} <<< "hello"\n${G}`)));
418
+ ok('echo | tee path; gh passes', passes(runHook(`echo hello | tee ${N}; ${G}`)));
419
+ ok('echo | tee -a path; gh passes', passes(runHook(`echo hello | tee -a ${N}; ${G}`)));
420
+ ok('cat clean > path passes', passes(runHook(`cat ${cleanFile} > ${N} && ${G}`)));
421
+ ok('cat leak > path blocks on the source', leaks(runHook(`cat ${leakFile} > ${N} && ${G}`)));
422
+ ok('cp leak path blocks on the source', leaks(runHook(`cp ${leakFile} ${N} && ${G}`)));
423
+ ok('cp clean path passes', passes(runHook(`cp ${cleanFile} ${N} && ${G}`)));
424
+ ok('cat leak | tee path blocks on the source', leaks(runHook(`cat ${leakFile} | tee ${N}; ${G}`)));
425
+ ok('a two-step copy chain blocks on the original source',
426
+ leaks(runHook(`cat ${leakFile} > ${dir}/a.md; cp ${dir}/a.md ${N}; ${G}`)));
427
+ ok('a redirect appended to an existing leaking file still scans the file',
428
+ leaks(runHook(`echo hello >> ${leakFile}; gh issue comment 1 --body-file ${leakFile}`)));
429
+ ok('a source that does not exist and is written by nobody blocks as unreadable',
430
+ code(runHook(`cp ${dir}/nope.md ${N} && ${G}`), 'SECRET_GUARD_UNREADABLE_BODY_FILE'));
431
+
432
+ // Not recognised as a writer: keep blocking.
433
+ ok('an unrecognised producer does not excuse a missing file',
434
+ code(runHook(`curl -so ${N} http://x/y; ${G}`), 'SECRET_GUARD_UNREADABLE_BODY_FILE'));
435
+ ok('a write AFTER the gh statement does not count',
436
+ code(runHook(`${G}; echo hello > ${N}`), 'SECRET_GUARD_UNREADABLE_BODY_FILE'));
437
+ ok('a backgrounded writer does not count',
438
+ code(runHook(`echo hello > ${N} & ${G}`), 'SECRET_GUARD_UNREADABLE_BODY_FILE'));
439
+ ok('a different path does not count',
440
+ code(runHook(`echo hello > ${dir}/other.md; ${G}`), 'SECRET_GUARD_UNREADABLE_BODY_FILE'));
441
+ const mk = runHook('f=$(mktemp); echo hi > $f; gh issue comment 1 --body-file $f');
442
+ ok('a $(mktemp) path blocks as unresolved', code(mk, 'SECRET_GUARD_UNRESOLVED_BODY_FILE'));
443
+ ok('the block reasons tell the agent how to proceed',
444
+ /separate Bash call/.test(mk.out) && /mktemp/.test(mk.out) && /heredoc/.test(mk.out));
445
+ ok('the unreadable reason does too', /separate Bash call/.test(runHook(`${G}`).out));
446
+
447
+ fs.rmSync(dir, { recursive: true, force: true });
448
+ }
449
+
450
+ // --- end-to-end: expansions inside inline content are scanned (#210 round 2, W5) ---
451
+ {
452
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210w5-'));
453
+ const leakFile = path.join(dir, 'leak.md');
454
+ const N = path.join(dir, 'new.md');
455
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
456
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
457
+ const unres = (r) => r.code === 2 && r.out.includes('"code":"SECRET_GUARD_UNRESOLVED_BODY_FILE"');
458
+ const passes = (r) => r.code === 0 && r.out.trim() === '';
459
+ const TOK = { SG_TOK: 'AKIAIOSFODNN7EXAMPLE' };
460
+ const G = 'gh issue comment 1';
461
+
462
+ ok('echo "$(cat leak)" | gh -F - blocks', leaks(runHook(`echo "$(cat ${leakFile})" | ${G} -F -`)));
463
+ ok('echo $(cat leak) unquoted | gh -F - blocks', leaks(runHook(`echo $(cat ${leakFile}) | ${G} -F -`)));
464
+ ok('printf "$VAR" | gh -F - scans the env value', leaks(runHook(`printf '%s' "$SG_TOK" | ${G} -F -`, TOK)));
465
+ ok('unquoted heredoc with $(cat leak) blocks', leaks(runHook(`${G} -F - <<EOF\nvalue $(cat ${leakFile})\nEOF`)));
466
+ ok('unquoted heredoc with $VAR blocks', leaks(runHook(`${G} -F - <<EOF\nvalue $SG_TOK\nEOF`, TOK)));
467
+ ok('an apostrophe in the heredoc does not hide a later substitution',
468
+ leaks(runHook(`${G} -F - <<EOF\ndon't $(cat ${leakFile})\nEOF`)));
469
+ ok('herestring "$(cat leak)" blocks', leaks(runHook(`${G} -F - <<< "$(cat ${leakFile})"`)));
470
+ ok('echo "$(cat leak)" > f && gh -F f blocks', leaks(runHook(`echo "$(cat ${leakFile})" > ${N} && ${G} -F ${N}`)));
471
+ ok('heredoc redirect with $(cat leak) then gh blocks',
472
+ leaks(runHook(`cat > ${N} <<EOF\n$(cat ${leakFile})\nEOF\n${G} -F ${N}`)));
473
+ ok('--body "$(cat <<EOF ... $(cat leak) ... EOF)" blocks',
474
+ leaks(runHook(`${G} --body "$(cat <<EOF\nx $(cat ${leakFile}) y\nEOF\n)"`)));
475
+ ok('--body "pre $(cat leak) post" (embedded substitution) blocks', leaks(runHook(`${G} --body "pre $(cat ${leakFile}) post"`)));
476
+ ok('a herestring redirect does not hide the next statement',
477
+ leaks(runHook(`cat > ${N} <<< "x"\n${G} --body-file ${leakFile}`)));
478
+
479
+ ok("quoted heredoc <<'EOF' stays literal", passes(runHook(`${G} -F - <<'EOF'\nuse $(cat ${leakFile}) and $SG_TOK\nEOF`, TOK)));
480
+ ok('quoted heredoc <<"EOF" stays literal', passes(runHook(`${G} -F - <<"EOF"\nuse $(cat ${leakFile})\nEOF`)));
481
+ ok('single-quoted echo argument stays literal', passes(runHook(`echo '$(cat ${leakFile})' | ${G} -F -`)));
482
+ ok('unquoted heredoc without expansions passes', passes(runHook(`${G} -F - <<EOF\nplain text, it's fine\nEOF`)));
483
+ ok('echo "$VAR" with a clean env value passes', passes(runHook(`echo "$SG_FINE" | ${G} -F -`, { SG_FINE: 'fine' })));
484
+ ok('an escaped \\$ in an unquoted heredoc is literal', passes(runHook(`${G} -F - <<EOF\ncost \\$SG_UNSET_VAR_210\nEOF`)));
485
+
486
+ ok('echo "$(date)" | gh -F - blocks as unresolved', unres(runHook(`echo "$(date)" | ${G} -F -`)));
487
+ ok('echo "$UNSET" | gh -F - blocks as unresolved', unres(runHook(`echo "$SG_UNSET_VAR_210" | ${G} -F -`)));
488
+ ok('unquoted heredoc with $(date) blocks as unresolved', unres(runHook(`${G} -F - <<EOF\n$(date)\nEOF`)));
489
+ ok('herestring "$UNSET" blocks as unresolved', unres(runHook(`${G} -F - <<< "$SG_UNSET_VAR_210"`)));
490
+
491
+ fs.rmSync(dir, { recursive: true, force: true });
492
+ }
493
+
494
+ // --- end-to-end: writes inside substitutions are evaluated, not blocked (#210 round 2, W6/W7) ---
495
+ {
496
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210w6-'));
497
+ const leakFile = path.join(dir, 'leak.md');
498
+ const cleanFile = path.join(dir, 'clean.md');
499
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
500
+ fs.writeFileSync(cleanFile, 'all good\n');
501
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
502
+ const passes = (r) => r.code === 0 && r.out.trim() === '';
503
+ ok('URL=$(gh issue create ... clean) passes', passes(runHook(`URL=$(gh issue create --title t --body-file ${cleanFile})`)));
504
+ ok('echo "$(gh issue create ... clean)" passes', passes(runHook(`echo "$(gh issue create --title t --body-file ${cleanFile})"`)));
505
+ ok('a backtick substitution with a clean write passes', passes(runHook(`echo \`gh issue create --title t --body-file ${cleanFile}\``)));
506
+ ok('URL=$(gh issue create ... leak) blocks on the body', leaks(runHook(`URL=$(gh issue create --title t --body-file ${leakFile})`)));
507
+ ok('echo "$(gh issue create ... leak)" blocks on the body', leaks(runHook(`echo "$(gh issue create --title t --body-file ${leakFile})"`)));
508
+ ok('a variable set before is visible inside the substitution',
509
+ leaks(runHook(`D=${dir}; X=$(gh issue create --title t --body-file $D/leak.md)`)));
510
+ ok('an unresolvable body inside a substitution still blocks as unresolved',
511
+ runHook('X=$(gh issue create --title t --body-file "$SG_UNSET_VAR_210")').out.includes('SECRET_GUARD_UNRESOLVED_BODY_FILE'));
512
+
513
+ // W7: the inline-body block tells the agent how to post literal text.
514
+ const inl = runHook('gh issue comment 1 --body "$(date)"');
515
+ ok('inline body block says to use single quotes or a file',
516
+ inl.code === 2 && /single quotes/.test(inl.out) && /--body-file/.test(inl.out));
517
+ const dollar = runHook('gh issue comment 1 --body "costs $5"');
518
+ ok('a $5 in an inline body gets the inline message', dollar.code === 2 && /single quotes/.test(dollar.out));
519
+ const fileCase = runHook('gh issue comment 1 --body-file "$SG_UNSET_VAR_210/x.md"');
520
+ ok('the body-file block keeps the file message, without the quoting hint',
521
+ fileCase.code === 2 && /separate Bash call/.test(fileCase.out) && !/single quotes/.test(fileCase.out));
522
+ ok('the inline message is distinct from the file message', !/separate Bash call/.test(inl.out));
523
+ fs.rmSync(dir, { recursive: true, force: true });
524
+ }
525
+
526
+ // --- unit: more wrappers, variable command words, function bodies, case arms (#210 round 2, W4) ---
527
+ {
528
+ const kinds = (c) => JSON.stringify(ghWrites(c).map((w) => w.kind));
529
+ const W = 'gh issue comment 1 --body-file /x/b.md';
530
+ const I = '["issue"]';
531
+ ok('write behind sudo', kinds(`sudo ${W}`) === I);
532
+ ok('write behind sudo -u USER -E', kinds(`sudo -u bob -E ${W}`) === I);
533
+ ok('write behind nice / nice -n 5', kinds(`nice ${W}`) === I && kinds(`nice -n 5 ${W}`) === I);
534
+ ok('write behind timeout DURATION / flags', kinds(`timeout 10 ${W}`) === I && kinds(`timeout -k 1 -s KILL 10s ${W}`) === I);
535
+ ok('write behind stdbuf -oL / -o L', kinds(`stdbuf -oL ${W}`) === I && kinds(`stdbuf -o L ${W}`) === I);
536
+ ok('write behind ionice -c 3', kinds(`ionice -c 3 ${W}`) === I);
537
+ ok('wrappers stack', kinds(`sudo nice -n 5 timeout 9 env A=1 ${W}`) === I);
538
+ ok('a wrapper around a read is not a write', kinds('sudo -u bob gh issue list') === '[]');
539
+
540
+ ok('variable command word that resolves to gh', kinds(`G=gh; $G issue comment 1 --body-file /x/b.md`) === I);
541
+ ok('quoted and braced variable command word', kinds(`G=gh; "$G" issue comment 1 -F /x/b.md`) === I && kinds(`G=gh; \${G} pr edit 1 -F /x/b.md`) === '["pr"]');
542
+ ok('variable command word resolving to a gh path', kinds(`G=/usr/bin/gh; $G issue comment 1 -F /x/b.md`) === I);
543
+ ok('variable command word resolving to something else is no write', kinds('G=echo; $G issue comment 1') === '[]');
544
+ ok('an unresolvable variable command word with a write verb is opaque', kinds('$SG_UNSET_CMD issue comment 1 --body x') === '["opaque"]');
545
+ ok('an unresolvable variable command word with a read verb is no write', kinds('$SG_UNSET_CMD issue list') === '[]');
546
+ ok('an unresolvable variable command word with no gh words is no write', kinds('$EDITOR file.txt') === '[]');
547
+
548
+ ok('write in name(){ ... }', kinds(`post(){ ${W}; }`) === I);
549
+ ok('write in name() { ... }', kinds(`post() { ${W}; }`) === I);
550
+ ok('write in name () { ... }', kinds(`post () { ${W}; }`) === I);
551
+ ok('write in function name { ... }', kinds(`function post { ${W}; }`) === I);
552
+ ok('write in function name() { ... }', kinds(`function post() { ${W}; }`) === I);
553
+ ok('write in a case arm', kinds(`case x in x) ${W};; esac`) === I);
554
+ ok('write in a later case arm and alternation', kinds(`case $a in a|b) true;; c) ${W};; esac`) === I);
555
+ ok('write in a case arm with a leading paren', kinds(`case x in (x) ${W};; esac`) === I);
556
+ ok('case without a write is none', kinds('case x in x) echo hi;; esac') === '[]');
557
+ }
558
+
559
+ // --- end-to-end: wrappers, variable command word, functions, case (#210 round 2, W4) ---
560
+ {
561
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210w4-'));
562
+ const leakFile = path.join(dir, 'leak.md');
563
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
564
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
565
+ const W = `gh issue comment 1 --body-file ${leakFile}`;
566
+ ok('sudo gh blocks', leaks(runHook(`sudo -u bob ${W}`)));
567
+ ok('timeout gh blocks', leaks(runHook(`timeout 10 ${W}`)));
568
+ ok('nice -n gh blocks', leaks(runHook(`nice -n 5 ${W}`)));
569
+ ok('$G gh blocks', leaks(runHook(`G=gh; $G issue comment 1 --body-file ${leakFile}`)));
570
+ ok('an unresolvable $CMD with a write verb blocks as opaque',
571
+ runHook('$SG_UNSET_CMD issue comment 1 --body x').out.includes('SECRET_GUARD_OPAQUE_WRITE'));
572
+ ok('a function body blocks', leaks(runHook(`post(){ ${W}; }; post`)));
573
+ ok('function keyword blocks', leaks(runHook(`function post { ${W}; }`)));
574
+ ok('a case arm blocks', leaks(runHook(`case x in x) ${W};; esac`)));
575
+ fs.rmSync(dir, { recursive: true, force: true });
576
+ }
577
+
578
+ // --- end-to-end: gh api inline field values are expanded and scanned (#210 round 3, W8) ---
579
+ {
580
+ const A = 'gh api repos/o/r/issues/1/comments';
581
+ const SEC = { SEC: 'AKIAIOSFODNN7EXAMPLE' };
582
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
583
+ const passes = (r) => r.code === 0 && r.out.trim() === '';
584
+ for (const f of [
585
+ '-f body=$SEC', '-F body=$SEC', '--field body=$SEC', '--raw-field body=$SEC',
586
+ '-f "body=$SEC"', '-fbody=$SEC', '-Fbody=$SEC', '--field=body=$SEC', '--raw-field=body=$SEC', '-f body="$SEC"',
587
+ ]) ok(`api ${f} with a leaking env value blocks`, leaks(runHook(`${A} ${f}`, SEC)));
588
+ ok('api -f body=$SEC with a clean value passes', passes(runHook(`${A} -f body=$SEC`, { SEC: 'fine' })));
589
+ ok("api -f 'body=$SEC' (single quotes) stays literal", passes(runHook(`${A} -f 'body=$SEC'`, SEC)));
590
+ ok('api -f body=$(cat leak) blocks as unresolved text',
591
+ runHook(`${A} -f body="$(date)"`).out.includes('single quotes'));
592
+ ok('api -f body=$UNSET blocks as unresolved', runHook(`${A} -f body=$SG_UNSET_VAR_210`).code === 2);
593
+ ok('api -f title=$SEC (not a body field) is not scanned', passes(runHook(`${A} -f body=hi -f title=$SEC`, SEC)));
594
+ ok('api -f body=@file is literal text for a raw field', passes(runHook(`${A} -f body=@/nonexistent/x.md`)));
595
+ ok('api -F body=@file still reads the file', runHook(`${A} -F body=@/nonexistent/x.md`).out.includes('UNREADABLE_BODY_FILE'));
596
+ ok('api body=$VAR set earlier in the command is expanded', leaks(runHook(`S=${SEC.SEC}; ${A} -f body="x $S"`)));
597
+ }
598
+
599
+ // --- unit: opaque runners only count a gh WRITE in command position (#210 round 3, I5) ---
600
+ {
601
+ const kinds = (c) => JSON.stringify(ghWrites(c).map((w) => w.kind));
602
+ const O = '["opaque"]';
603
+ ok('xargs grep "gh issue comment" is not a write', kinds('git ls-files | xargs grep -n "gh issue comment"') === '[]');
604
+ ok("bash -c with --grep='gh pr create' is not a write", kinds(`bash -c "git log --grep='gh pr create'"`) === '[]');
605
+ ok('bash ./x.sh # gh issue comment is not a write', kinds('bash ./x.sh # gh issue comment') === '[]');
606
+ ok('bash -c with the text only quoted in echo is not a write', kinds(`bash -c "echo 'gh issue comment 1'"`) === '[]');
607
+ ok('bash -c with the text only in a comment is not a write', kinds(`bash -c 'gh issue view 3 # gh issue comment 1'`) === '[]');
608
+ ok('a shell heredoc without a write beside a real write is just that write',
609
+ kinds("bash <<'EOF'\nls\nEOF\ngh issue comment 1 --body-file /x/b.md") === '["issue"]');
610
+ ok('a shell heredoc holding a write is opaque', kinds("bash <<'EOF'\ngh issue comment 1 -F /x/b.md\nEOF") === O);
611
+ ok('xargs -I{} gh issue edit is opaque', kinds('echo 1 | xargs -I{} gh issue edit {} --body x') === O);
612
+ ok('xargs -n1 gh issue comment is opaque', kinds('echo 1 | xargs -n1 gh issue comment --body-file /x/b.md') === O);
613
+ ok('xargs gh issue view is not a write', kinds('echo 1 | xargs gh issue view') === '[]');
614
+ ok('xargs sh -c with a write is opaque', kinds(`echo 1 | xargs sh -c 'gh issue comment 1 --body x'`) === O);
615
+ ok('bash -c with a write after && is opaque', kinds('bash -c "ls && gh issue comment 1 --body x"') === O);
616
+ ok('bash -lc with a write is opaque', kinds(`bash -lc 'gh pr create --body x'`) === O);
617
+ ok('eval of a quoted write is opaque', kinds('eval "echo a; gh pr create --body x"') === O);
618
+ ok('eval of a bare write is opaque', kinds('eval gh issue comment 1 --body x') === O);
619
+ ok('bash -c with a gh api body write is opaque', kinds('bash -c "gh api repos/o/r/issues/1/comments -f body=x"') === O);
620
+ ok('bash -c with a nested bash -c write is opaque', kinds(`bash -c "bash -c 'gh issue edit 1 --body x'"`) === O);
621
+ }
622
+
623
+ // --- end-to-end: glued redirects and /dev/null (#210 round 3, I7) ---
624
+ {
625
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-210i7-'));
626
+ const leakFile = path.join(dir, 'leak.md');
627
+ const C = path.join(dir, 'c.md');
628
+ fs.writeFileSync(leakFile, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
629
+ const leaks = (r) => r.code === 2 && /AWS access key/.test(r.out);
630
+ const code = (r, c) => r.code === 2 && r.out.includes(`"code":"${c}"`);
631
+ const passes = (r) => r.code === 0 && r.out.trim() === '';
632
+ const G = `gh issue comment 1 --body-file ${C}`;
633
+
634
+ ok('cat >path<<EOF (no spaces) is traced like the spaced form', passes(runHook(`cat >${C}<<'EOF'\nhello\nEOF\n${G}`)));
635
+ ok('cat >path<<EOF with a secret blocks', code(runHook(`cat >${C}<<'EOF'\nAKIAIOSFODNN7EXAMPLE\nEOF\n${G}`), 'SECRET_IN_ISSUE_WRITE'));
636
+ ok('echo x>path (no spaces) is traced', passes(runHook(`echo x>${C} && ${G}`)));
637
+ ok('echo x>>path (no spaces) is traced', passes(runHook(`echo x>>${C} && ${G}`)));
638
+ ok('echo "$(cat leak)">path (no spaces) scans the substitution', leaks(runHook(`echo "$(cat ${leakFile})">${C} && ${G}`)));
639
+ ok('cat leak>path (no spaces) scans the source', leaks(runHook(`cat ${leakFile}>${C}; ${G}`)));
640
+ ok('2>&1 and &>/dev/null stay fd redirects', passes(runHook(`echo hi 2>&1 &>/dev/null; echo ok`)));
641
+ ok('stdin <path glued is read', leaks(runHook(`gh issue comment 1 -F - <${leakFile}`)));
642
+
643
+ ok('--body-file /dev/null is an empty body: allowed', passes(runHook('gh issue comment 1 --body-file /dev/null')));
644
+ ok('cat /dev/null | gh -F - is allowed', passes(runHook('cat /dev/null | gh issue comment 1 -F -')));
645
+ ok('-F - < /dev/null is allowed', passes(runHook('gh issue comment 1 -F - < /dev/null')));
646
+ ok('other non-regular files still block as unreadable',
647
+ code(runHook('gh issue comment 1 --body-file /dev/zero'), 'SECRET_GUARD_UNREADABLE_BODY_FILE'));
648
+ fs.rmSync(dir, { recursive: true, force: true });
649
+ }
650
+
651
+ // --- unit: xargs long value flags (#210 round 4, W9) ---
652
+ {
653
+ const kinds = (c) => JSON.stringify(ghWrites(c).map((w) => w.kind));
654
+ const O = '["opaque"]';
655
+ for (const f of ['--max-args 1', '--max-procs 4', '--delimiter X', '--arg-file F', '--max-lines 2', '--eof E', '--max-chars 100', '--process-slot-var V', '--max-args=1', '--replace={}', '-n 1', '-P 4 -n 1', '--null --no-run-if-empty -t']) {
656
+ ok(`xargs ${f} gh issue edit is opaque`, kinds(`echo 1 | xargs ${f} gh issue edit --body hi`) === O);
657
+ }
658
+ ok('xargs --max-args 1 grep "gh issue comment" is allowed', kinds('echo 1 | xargs --max-args 1 grep "gh issue comment"') === '[]');
659
+ ok('xargs --max-procs 4 grep with a bare gh mention in quotes is allowed', kinds(`xargs --max-procs 4 grep -n 'gh pr create'`) === '[]');
660
+ ok('an unknown long xargs flag still fails closed when gh runs later',
661
+ kinds('echo 1 | xargs --future-flag VALUE gh issue edit --body hi') === O);
662
+ ok('an unknown long xargs flag with a harmless command is allowed', kinds('echo 1 | xargs --future-flag VALUE echo hi') === '[]');
663
+ }
664
+
133
665
  // --- THE FAILURE ARM: a scan we could not complete must be OBSERVABLE, not a silent pass ---
134
666
  const f = runHook('gh issue comment 5 --body "anything"', { TRAILHEAD_SECRETGUARD_THROW: '1' });
135
667
  ok('could-not-scan does not block (exit 0)', f.code === 0);
@@ -139,4 +671,468 @@ ok('could-not-scan is OBSERVABLE, not byte-identical to a clean pass',
139
671
  const f2 = runHook('gh issue view 5 --json body', { TRAILHEAD_SECRETGUARD_THROW: '1' });
140
672
  ok('could-not-scan advisory does NOT fire on a read', f2.code === 0 && f2.out.trim() === '');
141
673
 
674
+ // --- #212: a phantom << in a fragment must not hide a later opaque heredoc write ---
675
+ {
676
+ const hidden = "bash <<'EOF'\ngh issue comment 1 --body hi\nEOF";
677
+ for (const first of ['echo ok', 'echo $((1|2<<3))', 'echo $((1&&2<<3))', 'echo $((1;2<<3))', ': # ;true <<A']) {
678
+ ok(`an opaque heredoc write after ${JSON.stringify(first)} blocks`,
679
+ runHook(`${first}\n${hidden}`).out.includes('SECRET_GUARD_OPAQUE_WRITE'));
680
+ }
681
+ }
682
+
683
+ // --- #215: a token split by a backslash-newline continuation is joined by bash ---
684
+ {
685
+ const A = 'ghp_' + 'A1b2C3d4E5f6G7h8';
686
+ const B = 'I9j0K1l2M3n4O5p6Q7r8';
687
+ const BS = '\\'; // one backslash
688
+ const split = A + BS + '\n' + B;
689
+ const blocked = (cmd) => { const r = runHook(cmd); return r.code === 2 && /GitHub token/.test(r.out); };
690
+ const allowed = (cmd) => { const r = runHook(cmd); return r.code === 0 && r.out.trim() === ''; };
691
+ ok('double-quoted --body split by backslash-newline blocks', blocked(`gh issue comment 5 --body "${split}"`));
692
+ ok('unquoted --body split by backslash-newline blocks', blocked(`gh issue comment 5 --body ${split}`));
693
+ ok('single-quoted --body split stays literal and passes', allowed(`gh issue comment 5 --body '${split}'`));
694
+ ok('--title split by backslash-newline blocks', blocked(`gh issue create --title "${split}" --body hi`));
695
+ ok('escaped backslash before the newline is not a continuation', allowed(`gh issue comment 5 --body "${A}${BS}${BS}\n${B}"`));
696
+ const { expandPlain } = require('./lib/body-sources.js');
697
+ ok('expandPlain drops a backslash-newline pair', expandPlain('a' + BS + '\nb $X', {}, { X: 'v' }).value === 'ab v');
698
+ ok('expandPlain keeps an escaped backslash before a newline', expandPlain('a' + BS + BS + '\nb', {}, {}).value === 'a' + BS + '\nb');
699
+ const N = path.join(os.tmpdir(), `sg-215-${process.pid}.md`);
700
+ ok('unquoted heredoc body file with a split token blocks',
701
+ blocked(`cat <<EOF > ${N}\n${split}\nEOF\ngh issue comment 5 --body-file ${N}`));
702
+ ok('unquoted heredoc with $VAR and a split token blocks',
703
+ blocked(`cat <<EOF > ${N}\n$HOME ${split}\nEOF\ngh issue comment 5 --body-file ${N}`));
704
+ ok('quoted heredoc body file with a split token passes',
705
+ allowed(`cat <<'EOF' > ${N}\n${split}\nEOF\ngh issue comment 5 --body-file ${N}`));
706
+ }
707
+
708
+ // --- #217: a token split by adjacent quoted parts is one word for bash ---
709
+ {
710
+ const A = 'ghp_' + 'A1b2C3d4E5f6G7h8';
711
+ const B = 'I9j0K1l2M3n4O5p6Q7r8';
712
+ const BS = '\\'; // one backslash
713
+ const blocked = (cmd) => { const r = runHook(cmd); return r.code === 2 && /GitHub token/.test(r.out); };
714
+ const allowed = (cmd) => { const r = runHook(cmd); return r.code === 0 && r.out.trim() === ''; };
715
+ const G = 'gh issue comment 5';
716
+ ok('two adjacent double-quoted parts block', blocked(`${G} --body "${A}""${B}"`));
717
+ ok('a double-quoted part glued to a bare part blocks', blocked(`${G} --body "${A}"${B}`));
718
+ ok('a bare part and an empty single-quote pair block', blocked(`${G} --body ${A}''${B}`));
719
+ ok('bare, continuation, empty quotes block', blocked(`${G} --body ${A}${BS}\n''${B}`));
720
+ ok('quoted, continuation, quoted block', blocked(`${G} --body "${A}"${BS}\n"${B}"`));
721
+ ok('gh api -f body with adjacent quoted parts blocks',
722
+ blocked(`gh api repos/o/r/issues/5/comments -f body="${A}"'${B}'`));
723
+ ok('an echo producer with adjacent quoted parts blocks',
724
+ blocked(`echo "${A}""${B}" | ${G} --body-file -`));
725
+ ok('an unresolvable piece next to split literals is not allowed',
726
+ runHook(`${G} --body "$TRAILHEAD_UNSET_217"${A}''${B}`).code === 2);
727
+ ok('two separate quoted words do not join', allowed(`${G} --body "${A}" "${B}"`));
728
+ ok('-b with a double-quoted and a single-quoted part blocks', blocked(`${G} -b "${A}"'${B}'`));
729
+ ok('--body= with a double-quoted and a single-quoted part blocks', blocked(`${G} --body="${A}"'${B}'`));
730
+ ok('--title with adjacent double-quoted parts blocks', blocked(`gh issue create --title "${A}""${B}" --body hi`));
731
+ ok('a here-string with adjacent quoted parts blocks', blocked(`${G} --body-file - <<<"${A}"'${B}'`));
732
+ ok('a write nested in $(...) with adjacent quoted parts blocks', blocked(`echo $(${G} --body "${A}""${B}")`));
733
+ ok('adjacent ANSI-C quoted parts block', blocked(`${G} --body $'${A}'$'${B}'`));
734
+ ok('adjacent locale-quoted parts block', blocked(`${G} --body $"${A}"$"${B}"`));
735
+ ok('a single-quoted part glued to an ANSI-C part blocks', blocked(`${G} --body '${A}'$'${B}'`));
736
+ ok('an unquoted heredoc keeps quotes literal and passes',
737
+ allowed(`${G} --body-file - <<EOF\n${A}''${B}\nEOF`));
738
+ // A `$` that is escaped or the second char of `$$` does not open an ANSI-C span.
739
+ {
740
+ const dir217 = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-217-'));
741
+ const F = path.join(dir217, 'f1.txt');
742
+ fs.writeFileSync(F, `${A}${B}\n`);
743
+ const C = 'gh issue create --title';
744
+ ok('an escaped dollar before a quote keeps later flags visible',
745
+ blocked(`${C} ${BS}$'x${BS}' --body-file ${F} --label 'z'`));
746
+ ok('a doubled dollar before a quote keeps later flags visible',
747
+ blocked(`${C} $$'x${BS}' --body-file ${F} --label 'z'`));
748
+ ok('an escaped dollar before a quote at the end still reads the file',
749
+ blocked(`${C} ${BS}$'x${BS}' --body-file ${F}`));
750
+ // A fake ANSI-C span after `$$` must not hide a later write (`$$'x\'` is PID + 'x\').
751
+ const W = `gh issue comment 1 --body-file ${F}`;
752
+ const Wq = `gh issue comment 1 --body '${A}''${B}'`;
753
+ ok('a doubled dollar before a quote does not hide a later --body-file write',
754
+ blocked(`echo $$'x${BS}' ; ${W} # '`));
755
+ ok('a doubled dollar before a quote does not hide a later split-quote write',
756
+ blocked(`echo $$'x${BS}' ; ${Wq} # '`));
757
+ ok('an escaped dollar before a quote does not hide a later write',
758
+ blocked(`echo ${BS}$'x${BS}' ; ${W} # '`));
759
+ // The bare-dollar state survives a backslash-newline continuation.
760
+ ok('a continued doubled dollar does not hide a later --body-file write',
761
+ blocked(`echo $${BS}\n$'x${BS}' ; ${W} ; echo ''`));
762
+ ok('a continued doubled dollar does not hide a later split-quote write',
763
+ blocked(`echo $${BS}\n$'x${BS}' ; ${Wq} ; echo ''`));
764
+ // `$${` / `$$[` are the PID plus a literal char, so the write between them runs.
765
+ ok('a doubled dollar before a brace does not hide a later write',
766
+ blocked(`echo $${'${'} ; ${W} ; echo }`));
767
+ ok('a doubled dollar before a bracket does not hide a later write',
768
+ blocked(`echo $$[ ; ${W} ; echo ]`));
769
+ ok('a continued dollar before a brace does not hide a later write',
770
+ blocked(`echo $${BS}\n${'${'} ; ${W} ; echo }`));
771
+ {
772
+ const { words } = require('./lib/body-sources.js');
773
+ const J = JSON.stringify;
774
+ ok('words: a continued doubled dollar leaves the quote plain',
775
+ J(words(`echo $${BS}\n$'x${BS}' y'`)) === J(['echo', `$$'x${BS}'`, "y'"]));
776
+ ok('words: a continued dollar then a quote opens ANSI-C',
777
+ J(words(`echo $${BS}\n'x${BS}' y'`)) === J(['echo', `$'x${BS}' y'`]));
778
+ }
779
+ fs.rmSync(dir217, { recursive: true, force: true });
780
+ }
781
+ }
782
+
783
+ // --- #219: a quoted or split command word or field key is the same word for bash ---
784
+ {
785
+ const T = 'ghp_' + 'A'.repeat(36);
786
+ const BS = '\\'; // one backslash
787
+ const blocked = (cmd) => { const r = runHook(cmd); return r.code === 2 && /GitHub token/.test(r.out); };
788
+ const allowed = (cmd) => { const r = runHook(cmd); return r.code === 0 && r.out.trim() === ''; };
789
+ const opaque = (cmd) => { const r = runHook(cmd); return r.code === 2 && /SECRET_GUARD_OPAQUE_WRITE/.test(r.out); };
790
+ ok('a double-quoted gh command word blocks', blocked(`"gh" issue comment 5 --body ${T}`));
791
+ ok('a quoted subcommand blocks', blocked(`gh 'issue' comment 5 --body ${T}`));
792
+ ok('a split verb blocks', blocked(`gh issue com""ment 5 --body ${T}`));
793
+ ok('a quoted gh api field key blocks', blocked(`gh api repos/o/r/issues/5/comments -f 'body'=${T}`));
794
+ ok('a double-quoted gh api field key blocks', blocked(`gh api repos/o/r/issues/5/comments -f "body"=${T}`));
795
+ ok('a split gh api field key blocks', blocked(`gh api repos/o/r/issues/5/comments -f bo""dy=${T}`));
796
+ ok('a backslash-escaped command word blocks', blocked(`${BS}gh issue comment 5 --body ${T}`));
797
+ ok('a backslash-escaped subcommand blocks', blocked(`gh is${BS}sue comment 5 --body ${T}`));
798
+ ok('an ANSI-C quoted command word blocks', blocked(`$'gh' issue comment 5 --body ${T}`));
799
+ ok('a split command word blocks', blocked(`g"h" issue comment 5 --body ${T}`));
800
+ ok('a quoted path to gh blocks', blocked(`"/usr/bin/gh" issue comment 5 --body ${T}`));
801
+ ok('a quoted pr verb blocks', blocked(`gh pr 'edit' 5 --body ${T}`));
802
+ ok('a quoted gh behind a compound separator blocks', blocked(`cd /tmp && "gh" issue comment 5 --body ${T}`));
803
+ // A quoted shell runner or wrapper is the same command for bash: opaque like the bare form.
804
+ ok('the unquoted bash -c form is opaque (control)', opaque('bash -c "gh issue comment 5 --body x"'));
805
+ ok('a quoted bash runner is opaque', opaque(`'bash' -c "gh issue comment 5 --body x"`));
806
+ ok('a quoted env wrapper and bash runner are opaque', opaque(`"env" "bash" -c "gh issue comment 5 --body x"`));
807
+ ok('a quoted env wrapper in front of a quoted gh blocks', blocked(`"env" "gh" issue comment 5 --body ${T}`));
808
+ // A variable command word we cannot resolve falls back to the write words, quote-removed.
809
+ ok('an unresolved command word with a quoted subcommand is opaque',
810
+ opaque("$TRAILHEAD_UNSET_219 'issue' comment 5 --body x"));
811
+ ok('an unresolved command word with a split verb is opaque',
812
+ opaque('$TRAILHEAD_UNSET_219 issue com""ment 5 --body x'));
813
+ // A body file behind a quoted or split body= key is still read.
814
+ {
815
+ const dir219 = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-219-'));
816
+ const F = path.join(dir219, 'body.txt');
817
+ fs.writeFileSync(F, `${T}\n`);
818
+ ok("gh api -F 'body'=@file reads the file", blocked(`gh api x -F 'body'=@${F}`));
819
+ ok('gh api -F "body"=@file reads the file', blocked(`gh api x -F "body"=@${F}`));
820
+ ok('gh api -F bo""dy=@file reads the file', blocked(`gh api x -F bo""dy=@${F}`));
821
+ ok('gh api -F body=@file reads the file (control)', blocked(`gh api x -F body=@${F}`));
822
+ ok("gh api -f 'body'=@file keeps @ literal and passes", allowed(`gh api x -f 'body'=@${F}`));
823
+ fs.rmSync(dir219, { recursive: true, force: true });
824
+ }
825
+ // Negative controls: not a tracker write, or nothing to flag.
826
+ ok('a quoted read-only subcommand passes', allowed(`gh 'issue' view 5 --json body`));
827
+ ok('gh issue list passes', allowed('gh issue list'));
828
+ ok('a quoted gh issue list passes', allowed('"gh" "issue" list'));
829
+ ok('gh inside an echo string argument passes', allowed('echo "gh issue comment"'));
830
+ ok('a quoted bash running a non-write passes', allowed("'bash' -c \"echo hi\""));
831
+ ok('a clean body through a quoted command word passes', allowed('"gh" issue comment 5 --body hello'));
832
+ // A quoted flag name is the same flag: --input, wrapper flags, body-file flags.
833
+ {
834
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-219b-'));
835
+ const F = path.join(dir, 'body.txt');
836
+ fs.writeFileSync(F, `${T}\n`);
837
+ ok('gh api --input file blocks (control)', blocked(`gh api x --input ${F}`));
838
+ ok("gh api '--input' file blocks", blocked(`gh api x '--input' ${F}`));
839
+ ok('gh api --inp""ut file blocks', blocked(`gh api x --inp""ut ${F}`));
840
+ ok('a quoted gh and a quoted --input block', blocked(`"gh" api x "--input" ${F}`));
841
+ ok('env "-i" in front of gh blocks', blocked(`env "-i" gh issue comment 5 --body ${T}`));
842
+ ok('sudo "-u" root in front of gh blocks', blocked(`sudo "-u" root gh issue comment 5 --body ${T}`));
843
+ ok('nice "-n" 5 in front of gh blocks', blocked(`nice "-n" 5 gh issue comment 5 --body ${T}`));
844
+ ok("env '-u' X in front of gh blocks", blocked(`env '-u' X gh issue comment 5 --body ${T}`));
845
+ ok('env -u X in front of gh blocks (control)', blocked(`env -u X gh issue comment 5 --body ${T}`));
846
+ const G = 'gh issue comment 5';
847
+ ok('--body-file F blocks (control)', blocked(`${G} --body-file ${F}`));
848
+ ok("'-F' F blocks", blocked(`${G} '-F' ${F}`));
849
+ ok('"--body-file" F blocks', blocked(`${G} "--body-file" ${F}`));
850
+ ok('--body-fi""le F blocks', blocked(`${G} --body-fi""le ${F}`));
851
+ ok('"--body-file=F" blocks', blocked(`${G} "--body-file=${F}"`));
852
+ ok("'--body' T blocks", blocked(`${G} '--body' ${T}`));
853
+ ok("gh api '--field' body=@F blocks", blocked(`gh api x '--field' body=@${F}`));
854
+ ok("gh api '-F' body=@F blocks", blocked(`gh api x '-F' body=@${F}`));
855
+ ok('a quoted unrelated flag does not read the file', allowed(`${G} '--label' ${F} --body hi`));
856
+ // A quoted free-text VALUE that looks like a flag is a value, not a flag.
857
+ ok('a title that starts with a flag-like word passes', allowed('gh issue create --title "-F handling" --body hi'));
858
+ ok('a body that starts with a flag-like word passes', allowed('gh issue create --title "Fix thing" --body "-F is a flag"'));
859
+ // A quoted value equal to a body-file flag is read as the flag, erring toward detection
860
+ // (the unquoted form already did): a flag arity table without subcommand awareness is unsafe.
861
+ ok('a --body value equal to --body-file=x is read as the flag', runHook(`${G} --body '--body-file=x'`).code === 2);
862
+ ok('a -b value equal to -F is read as the flag', runHook(`${G} -b '-F' x`).code === 2);
863
+ // A quoted ATTACHED short form (`-Fpath`) is the same flag.
864
+ ok('attached -F"path" blocks (control)', blocked(`${G} -F"${F}"`));
865
+ ok('a fully double-quoted attached -Fpath blocks', blocked(`${G} "-F${F}"`));
866
+ ok('a fully single-quoted attached -Fpath blocks', blocked(`${G} '-F${F}'`));
867
+ ok('a quoted letter in an attached -Fpath blocks', blocked(`${G} -"F"${F}`));
868
+ ok('a quoted letter and value in an attached -Fpath blocks', blocked(`${G} -"F${F}"`));
869
+ ok('a quoted flag glued to its value blocks', blocked(`${G} "-F"${F}`));
870
+ // A quoted no-whitespace value that starts like the flag is read as the flag, erring toward detection.
871
+ ok('a title like "-Fixes" is read as an attached -F', runHook(`gh issue create --title "-Fixes" --body hi`).code === 2);
872
+ // A quoted attached value with whitespace in a span of its own is still the flag's value.
873
+ {
874
+ const dirSp = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-219 sp-'));
875
+ const FS = path.join(dirSp, 'my body.txt');
876
+ fs.writeFileSync(FS, `${T}\n`);
877
+ ok('-"F"/"p a" blocks', blocked(`${G} -"F"${'"'}${FS}${'"'}`));
878
+ ok('"-F"/"p a" blocks', blocked(`${G} "-F""${FS}"`));
879
+ ok("-'F'\"/p a\" blocks", blocked(`${G} -'F'"${FS}"`));
880
+ ok('-F"/p a" blocks (control)', blocked(`${G} -F"${FS}"`));
881
+ ok('"-F handling" with a space in the flag span still passes', allowed(`${G} --title "-F ${FS}" --body hi`));
882
+ // A whole-token quoted attached flag keeps its spaced path: only a space DIRECTLY after the letter is text.
883
+ ok('"-F<path with space>" blocks', blocked(`${G} "-F${FS}"`));
884
+ ok("'-F<path with space>' blocks", blocked(`${G} '-F${FS}'`));
885
+ ok('-"F<path with space>" blocks', blocked(`${G} -"F${FS}"`));
886
+ ok('"-F x" is still free text', allowed(`${G} --title "-F x" --body hi`));
887
+ fs.rmSync(dirSp, { recursive: true, force: true });
888
+ }
889
+ // -r and -a are boolean in `gh pr review`: the -F after them is still the body file.
890
+ ok('gh pr review -r -F file blocks', blocked(`gh pr review 5 -r -F ${F}`));
891
+ ok('gh pr review -a -F file blocks', blocked(`gh pr review 5 -a -F ${F}`));
892
+ ok('gh pr review -c -F file blocks', blocked(`gh pr review 5 -c -F ${F}`));
893
+ // ANSI-C escapes in a word are decoded by bash.
894
+ ok('a hex-escaped command word blocks', blocked(`$'\\x67h' issue comment 5 --body ${T}`));
895
+ ok('a hex-escaped verb blocks', blocked(`gh issue $'comm\\x65nt' 5 --body ${T}`));
896
+ ok('an octal-escaped subcommand blocks', blocked(`gh $'\\151ssue' comment 5 --body ${T}`));
897
+ ok('a unicode-escaped command word blocks', blocked(`$'\\u0067h' issue comment 5 --body ${T}`));
898
+ fs.rmSync(dir, { recursive: true, force: true });
899
+ }
900
+ }
901
+
902
+ // --- #220: a token rebuilt by brace expansion ---
903
+ {
904
+ const A = 'ghp_' + 'A1b2C3d4E5f6G7h8';
905
+ const B = 'I9j0K1l2M3n4O5p6Q7r8';
906
+ const BS = '\\'; // one backslash
907
+ const blocked = (cmd, env) => { const r = runHook(cmd, env); return r.code === 2 && /GitHub token/.test(r.out); };
908
+ const allowed = (cmd) => { const r = runHook(cmd); return r.code === 0 && r.out.trim() === ''; };
909
+ const G = 'gh issue comment 5';
910
+ ok('an empty first alternative rebuilds the token', blocked(`${G} --body ${A}{,x}${B}`));
911
+ ok('quoted halves around a brace group rebuild the token', blocked(`${G} --body "${A}"{,x}"${B}"`));
912
+ ok('an empty last alternative rebuilds the token', blocked(`${G} --body ${A}{x,}${B}`));
913
+ ok('a token inside an alternative rebuilds', blocked(`${G} --body :{${A},y}${B}`));
914
+ ok('gh api -f body with a brace group blocks',
915
+ blocked(`gh api repos/o/r/issues/5/comments -f body=${A}{,x}${B}`));
916
+ ok('--title with a brace group blocks', blocked(`gh issue create --title ${A}{,x}${B} --body hi`));
917
+ ok('an echo producer with a brace group blocks', blocked(`echo ${A}{,x}${B} | ${G} --body-file -`));
918
+ ok('a nested brace group blocks', blocked(`${G} --body ${A}{x,{,y}}${B}`));
919
+ {
920
+ const A16 = 'ghp_' + 'A1b2C3d4E5f6G7h8';
921
+ const B19 = 'I9j0K1l2M3n4O5p6Q7r';
922
+ ok('a letter sequence rebuilds the token', blocked(`${G} --body ${A16}{Z..Z}${B19}`));
923
+ }
924
+ ok('$VAR halves around a brace group block',
925
+ blocked(`${G} --body "$P"{,x}"$Q"`, { P: A, Q: B }));
926
+ // Quoted or escaped braces are literal.
927
+ ok('a double-quoted brace group passes', allowed(`${G} --body "${A}{,x}${B}"`));
928
+ ok('a single-quoted brace group passes', allowed(`${G} --body '${A}{,x}${B}'`));
929
+ ok('an escaped brace group passes', allowed(`${G} --body ${A}${BS}{,x${BS}}${B}`));
930
+ ok('{x} without a comma passes', allowed(`${G} --body ${A}{x}${B}`));
931
+ ok('{} passes', allowed(`${G} --body ${A}{}${B}`));
932
+ ok('an unquoted heredoc is not brace-expanded', allowed(`${G} --body-file - <<EOF\n${A}{,x}${B}\nEOF`));
933
+ // Too many results: fail closed.
934
+ ok('a brace product over the limit fails closed',
935
+ runHook(`${G} --body ${'{a,b}'.repeat(9)}`).code === 2);
936
+ ok('a huge sequence fails closed', runHook(`${G} --body {1..999999999}`).code === 2);
937
+ ok('a small brace group of clean text passes', allowed(`${G} --body {a,b}`));
938
+ // Signed sequences, as bash reads them.
939
+ ok('a +0..0 sequence rebuilds the token', blocked(`${G} --body ${A}{+0..0}${B}`));
940
+ ok('a 0..+0 sequence rebuilds the token', blocked(`${G} --body ${A}{0..+0}${B}`));
941
+ ok('a signed step sequence rebuilds the token', blocked(`${G} --body ${A}{1..+1..+1}${B}`));
942
+ ok('a -0..0 sequence still lists its values', blocked(`${G} --body ${A}{0..-0}${B}`));
943
+ // Deep nesting is a decoy word or a body: fail closed, never a crash or a skipped scan.
944
+ {
945
+ const deep = '{x,'.repeat(5000) + 'y' + '}'.repeat(5000);
946
+ ok('a deeply nested decoy word does not hide a rebuilt token', runHook(`${G} --body ${A}{,x}${B} ${deep}`).code === 2);
947
+ ok('a deeply nested body fails closed', runHook(`${G} --body ${deep}`).code === 2);
948
+ ok('a deeply nested ${ body fails closed', runHook(`${G} --body ${'${a'.repeat(5000)}`).code === 2);
949
+ }
950
+ // Brace groups in the command, subcommand or verb word.
951
+ ok('a brace group in the subcommand blocks', runHook(`gh {issue,issue} comment 5 --body ${A}${B}`).code === 2);
952
+ ok('a brace group in the command word blocks', runHook(`{gh,gh} issue comment 5 --body ${A}${B}`).code === 2);
953
+ ok('a brace group in the verb blocks', runHook(`gh issue {comment,comment} 5 --body ${A}${B}`).code === 2);
954
+ ok('a brace group that builds only one write verb blocks', runHook(`gh issue {view,comment} 5 --body ${A}${B}`).code === 2);
955
+ ok('a brace group in a read-only verb passes', allowed('gh issue {view,list} 5'));
956
+ // Brace groups anywhere in the gh argument list: flag names, field keys, global flags.
957
+ {
958
+ const T = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
959
+ const API = 'gh api repos/o/r/issues/5/comments';
960
+ ok('a brace group in a field key blocks', blocked(`${API} -f b{ody,ody}=${T}`));
961
+ ok('a brace group in a short flag blocks', blocked(`${API} -{f,f} body=${T}`));
962
+ ok('a brace group in a long flag blocks', blocked(`${API} --{field,field} body=${T}`));
963
+ ok('an empty-alternative group after the key blocks', blocked(`${API} -f body{,}=${T}`));
964
+ ok('a token split by a group in a field key blocks', blocked(`${API} -f b{ody,ody}=${A}{,x}${B}`));
965
+ ok('a gh global flag before a braced subcommand blocks', runHook(`gh -R o/r {issue,issue} comment 5 --body ${T}`).code === 2);
966
+ ok('gh --repo before a braced subcommand blocks', runHook(`gh --repo o/r {issue,issue} comment 5 --body ${T}`).code === 2);
967
+ ok('a read-only braced verb passes', allowed('gh issue {view,list} 5'));
968
+ ok('a read-only braced subcommand passes', allowed('gh pr {view,diff} 5'));
969
+ ok('a braced gh command word on a read passes', allowed('{gh,gh} issue view 5'));
970
+ ok('a braced --label with a clean body passes', allowed(`${G} --label {a,b} --body hello`));
971
+ }
972
+ // Only a gh WORD makes a braced statement a candidate write; an overflow only blocks a write-shaped one.
973
+ {
974
+ const T = 'ghp_' + 'A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8';
975
+ ok('"gh" inside another word is not a gh command (echo)', allowed('echo {a..z}{a..z}{a..z} high'));
976
+ ok('"gh" inside a path is not a gh command (cp)', allowed('cp f{1..1000} ~/highway/'));
977
+ ok('"gh" inside a quoted arg is not a gh command (npm)',
978
+ allowed(`npm run test -- --grep "right" ${'{a,b}'.repeat(9)}`));
979
+ ok('a read-only gh command with an oversized group passes', allowed('gh issue view {1..300}'));
980
+ ok('a read-only gh list with nine groups passes', allowed(`gh issue list ${'{a,b}'.repeat(9)}`));
981
+ ok('a gh comment with an oversized group blocks', runHook(`${G} --body hi {1..300}`).code === 2);
982
+ ok('a gh api body with nine groups blocks', runHook(`gh api x -f body=hi ${'{a,b}'.repeat(9)}`).code === 2);
983
+ ok('a braced gh command word still blocks', runHook(`{gh,gh} issue comment 5 --body ${T}`).code === 2);
984
+ ok('a split braced gh command word still blocks', runHook(`g{h,h} issue comment 5 --body ${T}`).code === 2);
985
+ }
986
+ {
987
+ const { braceExpand } = require('./lib/body-sources.js');
988
+ const J = JSON.stringify;
989
+ const w = (s) => J(braceExpand(s).words);
990
+ ok('braceExpand: comma list', w('a{b,c}d') === J(['abd', 'acd']));
991
+ ok('braceExpand: empty alternative', w('a{,x}b') === J(['ab', 'axb']));
992
+ ok('braceExpand: nested', w('{x,{,y}}') === J(['x', '', 'y']));
993
+ ok('braceExpand: numeric sequence', w('{1..3}') === J(['1', '2', '3']));
994
+ ok('braceExpand: descending sequence with step', w('{5..1..2}') === J(['5', '3', '1']));
995
+ ok('braceExpand: letter sequence', w('{a..c}') === J(['a', 'b', 'c']));
996
+ ok('braceExpand: cartesian product', w('{a,b}{1,2}') === J(['a1', 'a2', 'b1', 'b2']));
997
+ ok('braceExpand: ${HOME} is untouched', w('${HOME}') === J(['${HOME}']));
998
+ ok('braceExpand: a quoted comma is not a group', w('"{a,b}"') === J(['"{a,b}"']));
999
+ ok('braceExpand: an escaped brace is not a group', w('\\{a,b}') === J(['\\{a,b}']));
1000
+ ok('braceExpand: {x} and {} are literal', w('{x}{}') === J(['{x}{}']));
1001
+ ok('braceExpand: quotes are kept', w('"a"{b,c}') === J(['"a"b', '"a"c']));
1002
+ ok('braceExpand: a +0 sequence', w('a{+0..0}b') === J(['a0b']));
1003
+ ok('braceExpand: a signed step', w('{1..+3..+1}') === J(['1', '2', '3']));
1004
+ ok('braceExpand: padding counts the sign', w('{-01..2}') === J(['-01', '000', '001', '002']));
1005
+ ok('braceExpand: padding of a negative range', w('{-5..-01}') === J(['-05', '-04', '-03', '-02', '-01']));
1006
+ ok('braceExpand: padding down through zero', w('{01..-3}') === J(['01', '00', '-1', '-2', '-3']));
1007
+ ok('braceExpand: -0 is not padded', w('{-0..2}') === J(['0', '1', '2']));
1008
+ ok('braceExpand: a $(...) span is not split', w('$(echo {a,b})x') === J(['$(echo {a,b})x']));
1009
+ ok('braceExpand: a backtick span is not split', w('`echo {a,b}`') === J(['`echo {a,b}`']));
1010
+ ok('braceExpand: deep nesting overflows', braceExpand('{x,'.repeat(5000) + 'y' + '}'.repeat(5000)).overflow === true);
1011
+ ok('braceExpand: deep ${ overflows', braceExpand('${a'.repeat(5000)).overflow === true);
1012
+ ok('braceExpand: many unmatched braces stay fast', (() => { const t = Date.now(); braceExpand('{'.repeat(100000)); return Date.now() - t < 5000; })());
1013
+ ok('braceExpand: overflow is not a fault', braceExpand('{a,b}'.repeat(9)).fault !== true);
1014
+ ok('braceExpand: deep nesting is a size overflow, not a fault', braceExpand('{x,'.repeat(5000) + 'y' + '}'.repeat(5000)).fault !== true);
1015
+ ok('braceExpand: overflow', braceExpand('{a,b}'.repeat(9)).overflow === true);
1016
+ ok('braceExpand: a huge sequence overflows', braceExpand('{1..999999999}').overflow === true);
1017
+ ok('braceExpand: the limit is a parameter', braceExpand('{a,b,c}', 2).overflow === true);
1018
+ }
1019
+ }
1020
+
1021
+ // #225: a flag between the subcommand and the verb must not hide the write.
1022
+ {
1023
+ const T = 'ghp_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'.slice(0, 36);
1024
+ for (const c of [
1025
+ `gh issue -R o/r comment 5 --body ${T}`,
1026
+ `gh pr -R o/r comment 5 --body ${T}`,
1027
+ `gh issue --repo o/r comment 5 --body ${T}`,
1028
+ `gh issue --repo=o/r comment 5 --body ${T}`,
1029
+ ]) {
1030
+ const r = runHook(c);
1031
+ ok(`blocks a secret with a flag before the verb: ${c.slice(0, 40)} (exit 2)`, r.code === 2 && /"decision":"block"/.test(r.out));
1032
+ }
1033
+ ok('allows gh issue -R o/r view 5', runHook('gh issue -R o/r view 5').code === 0);
1034
+ }
1035
+
1036
+ // --- #227: constructs the guard cannot interpret are blocked, not emulated ---
1037
+ {
1038
+ const CODE = 'SECRET_GUARD_UNMODELLED_CONSTRUCT';
1039
+ const clean = path.join(os.tmpdir(), `sg-um-clean-${process.pid}.md`);
1040
+ const dirty = path.join(os.tmpdir(), `sg-um-dirty-${process.pid}.md`);
1041
+ fs.writeFileSync(clean, 'All good, 12 tests green.\n');
1042
+ fs.writeFileSync(dirty, 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
1043
+ const T = 'ghp_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'.slice(0, 36);
1044
+ const code = (c, env) => { const r = runHook(c, env); return r.code === 2 ? (JSON.parse(r.out).code || '?') : null; };
1045
+ const msg = (c) => { const r = runHook(c); return r.code === 2 ? JSON.parse(r.out).reason : ''; };
1046
+
1047
+ // Gaps that used to pass: the write hides behind a construct.
1048
+ for (const c of [
1049
+ `echo \${ gh issue comment 1 --body-file ${dirty}; }`,
1050
+ `echo \${ gh issue comment 1 --body-file ${clean}; }`,
1051
+ `echo \${| gh issue comment 1 --body x; }`,
1052
+ 'alias g=gh; g issue comment 1 --body x',
1053
+ `source ./post.sh; gh issue comment 1 --body-file ~/${path.basename(clean)}`, // HOME is the tmp dir below
1054
+ `. ./post.sh && gh issue comment 1 --body-file ~/${path.basename(clean)}`,
1055
+ `f() { gh issue comment 1 --body-file ${clean}; }; f`,
1056
+ `function f { gh issue comment 1 --body x; }\nf`,
1057
+ `gh issue com\\\nment 1 --body-file ${clean}`,
1058
+ ]) ok(`blocks an unmodelled construct: ${c.slice(0, 50).replace(/\n/g, ' ')}`, code(c, { HOME: os.tmpdir() }) === CODE);
1059
+
1060
+ // Existing verdicts keep priority over the new gate.
1061
+ ok('eval + gh write stays an opaque-write block', code(`eval 'gh issue comment 1 --body-file ${clean}'`) === 'SECRET_GUARD_OPAQUE_WRITE');
1062
+ ok('a function body with a literal secret stays a secret block', code(`f() { gh issue comment 1 --body ${T}; }; f`) === 'SECRET_IN_ISSUE_WRITE');
1063
+ ok('a secret file next to source stays a secret block', code(`source ./x.sh; gh issue comment 1 --body-file ${dirty}`) === 'SECRET_IN_ISSUE_WRITE');
1064
+ ok('an unreadable body file next to source keeps its code', code('source ./x.sh; gh issue comment 1 --body-file /nonexistent/sg-um.md') === 'SECRET_GUARD_UNREADABLE_BODY_FILE');
1065
+ for (const c of [
1066
+ `printf -v B %s ''; gh issue edit 1 --body "it's $B and 'x'"`,
1067
+ `source x.sh; gh issue edit 1 --body "it's $B and 'x'"`,
1068
+ `printf -v B %s ''; printf %s "$B" | gh issue edit 1 --body-file -`,
1069
+ `printf -v B %s ''; gh issue edit 1 --body-file - <<EOF\n$B\nEOF`,
1070
+ ]) ok(`W2: a state-dependent write after the construct still blocks: ${c.slice(0, 40).replace(/\n/g, ' ')}`, code(c) !== null);
1071
+ 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"`]) {
1072
+ ok(`W2: a literal write after source passes: ${c}`, runHook(c).code === 0);
1073
+ }
1074
+ ok('printf -v feeding a body still blocks (unresolved)', code('printf -v B %s x; gh issue edit 1 --body "$B"') !== null);
1075
+ ok('the gate also replaces the could-not-scan advisory', code(`source ./x.sh; gh issue comment 1 --body-file ~/${path.basename(clean)}`, { TRAILHEAD_SECRETGUARD_THROW: '1', HOME: os.tmpdir() }) === CODE);
1076
+
1077
+ // Plain controls pass.
1078
+ for (const c of [
1079
+ "alias ll='ls -l'",
1080
+ 'alias g=gh',
1081
+ "alias ll='ls -l'; gh issue comment 1 --body-file " + clean,
1082
+ 'alias g=gh; g issue list',
1083
+ 'source ~/.venv/bin/activate && pytest',
1084
+ 'eval "$(ssh-agent)"',
1085
+ 'eval "$(ssh-agent)"; gh issue comment 1 --body-file ' + clean,
1086
+ 'printf -v x %s y; echo $x',
1087
+ 'f() { gh issue comment 1 --body x; }',
1088
+ 'f() { ls; }; gh issue comment 1 --body-file ' + clean,
1089
+ `echo \${ date; }; gh issue comment 1 --body-file ${clean}`,
1090
+ `gh issue comment 1 --body-file ${clean}; source ./post.sh`,
1091
+ `. venv/bin/activate && gh issue edit 1 --body 'hello'`,
1092
+ `source venv/bin/activate && gh issue comment 1 --body-file ${clean}`,
1093
+ `gh issue comment 1 --body-file ${clean}; printf -v x %s y`,
1094
+ `gh issue comment 1 --body 'use eval and source and alias here'`,
1095
+ `gh issue comment 1 --body-file ${clean} # eval x`,
1096
+ 'echo "gh issue edit 1"; alias ll=ls',
1097
+ `gh issue comment 1 \\\n --body-file ${clean}`,
1098
+ `gh api repos/o/r/issues/1/comments -f body=x`,
1099
+ `gh api repos/o/r/issues/1/comments -f 'body'=x`, // followed exactly since #219
1100
+ `gh api repos/o/r/issues/1/comments -f bo""dy=x`,
1101
+ `gh api repos/o/r/issues/1/comments -f "body=x"`,
1102
+ `gh api repos/o/r/issues/1/comments -F title=x`,
1103
+ ]) ok(`passes a plain form: ${c.slice(0, 50).replace(/\n/g, ' ')}`, runHook(c).code === 0);
1104
+
1105
+ const m = msg(`echo \${ gh issue comment 1 --body ${T}; }`);
1106
+ ok('the message explains the rewrite', /cannot interpret with certainty/.test(m) && /Rewrite it in a simple form/.test(m));
1107
+ ok('the message never echoes the token', m !== '' && !m.includes(T) && !m.includes('ghp_'));
1108
+
1109
+ // Regression (#226): a variable-held directory resolves; clean passes, secret and missing block.
1110
+ const dir = path.join(os.tmpdir(), `sg-um-dir-${process.pid}`);
1111
+ fs.mkdirSync(dir, { recursive: true });
1112
+ fs.writeFileSync(path.join(dir, 'x.md'), 'All good.\n');
1113
+ ok('S=<dir>; --body-file $S/x.md passes on a clean file', runHook(`S=${dir}; gh issue edit 13 --body-file $S/x.md`).code === 0);
1114
+ fs.writeFileSync(path.join(dir, 'x.md'), 'Deploy: AKIAIOSFODNN7EXAMPLE\n');
1115
+ ok('S=<dir>; --body-file $S/x.md blocks on a secret file', code(`S=${dir}; gh issue edit 13 --body-file $S/x.md`) === 'SECRET_IN_ISSUE_WRITE');
1116
+ fs.rmSync(path.join(dir, 'x.md'));
1117
+ ok('S=<dir>; --body-file $S/x.md blocks when the file is missing', code(`S=${dir}; gh issue edit 13 --body-file $S/x.md`) === 'SECRET_GUARD_UNREADABLE_BODY_FILE');
1118
+ fs.rmSync(dir, { recursive: true });
1119
+ fs.rmSync(clean);
1120
+ fs.rmSync(dirty);
1121
+ }
1122
+
1123
+ // --- #227: a missing unmodelled lib degrades to the previous behaviour ---
1124
+ {
1125
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sg-nolib-'));
1126
+ fs.mkdirSync(path.join(dir, 'lib'));
1127
+ for (const f of fs.readdirSync(path.join(__dirname, 'lib'))) {
1128
+ if (/\.(js|json)$/.test(f) && !f.endsWith('.test.js') && f !== 'unmodelled.js') fs.copyFileSync(path.join(__dirname, 'lib', f), path.join(dir, 'lib', f));
1129
+ }
1130
+ fs.copyFileSync(path.join(__dirname, 'trailhead-secret-guard.js'), path.join(dir, 'trailhead-secret-guard.js'));
1131
+ const bare = require(path.join(dir, 'trailhead-secret-guard.js'));
1132
+ const T = 'ghp_' + 'a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6q7R8'.slice(0, 36);
1133
+ ok('without the unmodelled lib the guard still blocks a secret', bare.evaluate(`gh issue comment 1 --body ${T}`).kind === 'block');
1134
+ ok('without the unmodelled lib the unmodelled gate is off', bare.evaluate('alias g=gh; g issue comment 1 --body x').kind === 'allow');
1135
+ fs.rmSync(dir, { recursive: true });
1136
+ }
1137
+
142
1138
  console.log(`✓ secret-guard: ${passed} assertions passed`);