@marcomigozzi/trailhead 0.6.0 → 0.8.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 (48) hide show
  1. package/README.it.md +12 -6
  2. package/README.md +12 -6
  3. package/bin/lib/codex-projection.js +9 -6
  4. package/bin/lib/codex-projection.test.js +5 -1
  5. package/bin/trailhead.js +50 -5
  6. package/bin/trailhead.test.js +55 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/agents/trailhead-code-review.md +2 -0
  10. package/plugins/trailhead/agents/trailhead-codebase-map.md +2 -0
  11. package/plugins/trailhead/agents/trailhead-debug.md +2 -0
  12. package/plugins/trailhead/agents/trailhead-executor.md +2 -0
  13. package/plugins/trailhead/agents/trailhead-fix.md +2 -0
  14. package/plugins/trailhead/agents/trailhead-plan.md +2 -0
  15. package/plugins/trailhead/agents/trailhead-research.md +2 -0
  16. package/plugins/trailhead/agents/trailhead-verify.md +2 -0
  17. package/plugins/trailhead/commands/auto.md +6 -0
  18. package/plugins/trailhead/commands/quick.md +1 -1
  19. package/plugins/trailhead/hooks/hooks.json +13 -0
  20. package/plugins/trailhead/hooks/lib/shell-scan.js +247 -0
  21. package/plugins/trailhead/hooks/lib/shell-scan.test.js +49 -0
  22. package/plugins/trailhead/hooks/trailhead-commit-guard.js +13 -2
  23. package/plugins/trailhead/hooks/trailhead-commit-guard.test.js +101 -0
  24. package/plugins/trailhead/hooks/trailhead-search-guard.js +279 -0
  25. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +116 -0
  26. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +291 -0
  27. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +95 -0
  28. package/plugins/trailhead/skills/_shared/choices.md +77 -0
  29. package/plugins/trailhead/skills/_shared/configuration-reference.md +4 -2
  30. package/plugins/trailhead/skills/_shared/session-handoff.md +4 -3
  31. package/plugins/trailhead/skills/_shared/substrate-commands.md +1 -1
  32. package/plugins/trailhead/skills/_shared/substrate.md +1 -1
  33. package/plugins/trailhead/skills/_shared/teamwork.md +2 -2
  34. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +1 -1
  35. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +3 -0
  36. package/plugins/trailhead/skills/_shared/techniques/grilling.md +1 -1
  37. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +1 -1
  38. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  39. package/plugins/trailhead/skills/_shared/techniques/verify.md +20 -3
  40. package/plugins/trailhead/skills/_shared/techniques.md +1 -1
  41. package/plugins/trailhead/skills/trailhead/SKILL.md +4 -2
  42. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +2 -0
  43. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -0
  44. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +2 -0
  45. package/plugins/trailhead/skills/trailhead-view/SKILL.md +3 -1
  46. package/plugins/trailhead/skills/trailhead-work/SKILL.md +19 -7
  47. package/plugins/trailhead/skills/trailhead-work/references/auto.md +54 -0
  48. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +6 -6
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ // trailhead-search-guard.js: PreToolUse(Bash) hook.
3
+ // Structural fix for the recurring #132 -> #133 -> #134 defect: the prose
4
+ // search-command-hygiene ban (never `cd` then read/search a RELATIVE path) is
5
+ // present in every engine agent and in `_shared/techniques.md`, but a subagent
6
+ // under load still emits the banned shape, and the resulting relative read is
7
+ // non-static, so a `Read()` deny rule under bypass permissions falls back to a
8
+ // manual approval prompt (defeating headless operation). Prose hardening keeps
9
+ // regressing, so this hook makes the shape structurally impossible instead of
10
+ // merely discouraged.
11
+ //
12
+ // Blocks a SINGLE Bash command string that both:
13
+ // (a) changes the working directory - a `cd <dir>`, `pushd <dir>`, or
14
+ // `env -C <dir>` statement - joined to other statements via `&&`, `;`,
15
+ // `|`, or a newline (i.e. NOT the sole statement); AND
16
+ // (b) a later statement in that same command reads/searches a RELATIVE path
17
+ // via a read/search verb (grep, rg, ag, cat, head, tail, wc, nl, sed,
18
+ // awk, less, more, or `git grep`/`git log`/`git show`/`git diff`
19
+ // without a `-C`).
20
+ //
21
+ // KNOWN LIMITATION (do not try to catch this here): a *standalone* `cd <abs>`
22
+ // in one Bash call followed by a relative read in a LATER, separate call
23
+ // relies on the shell's persisted cwd, which this stateless per-call hook
24
+ // cannot see. Blocking a lone `cd` would be far too aggressive (a legitimate,
25
+ // common shape) so it is deliberately left to the prose directive.
26
+ //
27
+ // Blocks with exit 2 + {"decision":"block","code":...,"reason":...}; allows
28
+ // everything else. Crash-safe: any error -> exit 0 (never wedge the user's
29
+ // workflow). Self-contained (its own dependency is the sibling lib/ module,
30
+ // no external packages). Pure functions exported for tests; runs as a hook
31
+ // when executed directly.
32
+
33
+ // --- tokenizing -------------------------------------------------------------
34
+ // The quote-aware tokenizer/statement-splitter (splitStatements, tokenize,
35
+ // stripQuotes, basename) and the search-verb pattern-positional-skipping
36
+ // machinery (SEARCH_VERB_CONFIG, scanArgsSkippingPattern) are shared with
37
+ // trailhead-secret-read-guard.js via lib/shell-scan.js (#135 follow-up), so
38
+ // the two guards can never drift into two divergent tokenizers.
39
+ const {
40
+ basename,
41
+ stripQuotes,
42
+ splitStatements,
43
+ tokenize,
44
+ skipEnvPrefix,
45
+ SEARCH_VERB_CONFIG,
46
+ scanArgsSkippingPattern,
47
+ } = require('./lib/shell-scan.js');
48
+
49
+ // A leading grouping/keyword token that can precede the "real" first word of a
50
+ // statement without itself being that word: `(cd app && ...)`, `{ cd app; ... }`,
51
+ // `do cd "$d" && ...` (loop body), `then cd x`, `else cd x`. Stripping these
52
+ // (and a `(`/`{` glued directly onto the next token, e.g. `(cd`) lets the
53
+ // `cd`/`pushd`/read-verb checks below see the actual command even when it's
54
+ // wrapped in a subshell, brace group, or loop/conditional body.
55
+ const LEADING_GROUP_TOKENS = new Set(['(', '{', 'do', 'then', 'else']);
56
+ // A trailing grouping/keyword token (or glued punctuation) that can follow the
57
+ // real last word of a statement: `grep ... foo.txt)`, `grep ... foo.txt;`,
58
+ // `grep ... foo.txt }`, `... ; fi`, `... ; done`.
59
+ const TRAILING_GROUP_TOKENS = new Set([')', '}', ';', 'fi', 'done']);
60
+
61
+ // Strip a single leading grouping/keyword token and a single trailing
62
+ // grouping/keyword token (or glued punctuation) from an already-tokenized
63
+ // statement, so subshells, brace groups, and loop/conditional bodies don't
64
+ // hide a `cd`/read-verb from the checks that follow. Best-effort: handles the
65
+ // common shapes, not a full shell parser.
66
+ function normalizeStatementTokens(tokens) {
67
+ let t = tokens;
68
+ if (t.length && LEADING_GROUP_TOKENS.has(t[0])) {
69
+ t = t.slice(1);
70
+ } else if (t.length && (t[0][0] === '(' || t[0][0] === '{') && t[0].length > 1) {
71
+ t = [t[0].slice(1), ...t.slice(1)];
72
+ }
73
+ if (t.length) {
74
+ const last = t[t.length - 1];
75
+ if (TRAILING_GROUP_TOKENS.has(last)) {
76
+ t = t.slice(0, -1);
77
+ } else {
78
+ const stripped = last.replace(/[)};]+$/, '');
79
+ if (stripped !== last) {
80
+ t = stripped ? [...t.slice(0, -1), stripped] : t.slice(0, -1);
81
+ }
82
+ }
83
+ }
84
+ return t;
85
+ }
86
+
87
+ // A relative-path FILE argument: not absolute, not stdin (`-`), not a dynamic
88
+ // value we cannot resolve statically (fail open on those, same posture as the
89
+ // commit-guard's UNEXPANDABLE handling).
90
+ function isRelativeFileArg(t) {
91
+ const bare = stripQuotes(t);
92
+ if (!bare || bare === '-') return false;
93
+ if (bare.startsWith('/')) return false;
94
+ if (bare.startsWith('$') || bare.includes('$(') || bare.includes('`')) return false;
95
+ return true;
96
+ }
97
+
98
+ // --- per-verb argument scanning ---------------------------------------------
99
+
100
+ // grep/rg/ag/sed/awk (the pattern-first search verbs) come from the shared
101
+ // lib (see lib/shell-scan.js); cat/head/tail/wc/nl/less/more take only FILE
102
+ // positionals and are specific to this guard's read-hygiene check.
103
+ const VERB_CONFIG = {
104
+ ...SEARCH_VERB_CONFIG,
105
+ cat: { patternFirst: false, valueFlags: new Set() },
106
+ head: { patternFirst: false, valueFlags: new Set(['-n', '-c']) },
107
+ tail: { patternFirst: false, valueFlags: new Set(['-n', '-c']) },
108
+ wc: { patternFirst: false, valueFlags: new Set() },
109
+ nl: { patternFirst: false, valueFlags: new Set() },
110
+ less: { patternFirst: false, valueFlags: new Set() },
111
+ more: { patternFirst: false, valueFlags: new Set() },
112
+ };
113
+
114
+ // Walk a verb's argument tokens (verb itself already stripped) for the first
115
+ // relative FILE argument found, via the shared pattern/flag-skipping walker.
116
+ function scanArgsForRelativeFile(args, config) {
117
+ return scanArgsSkippingPattern(args, config, isRelativeFileArg);
118
+ }
119
+
120
+ // `git grep`/`git log`/`git show`/`git diff` WITHOUT a `-C` (or --work-tree):
121
+ // git grep has a pattern positional like plain grep; log/show/diff don't, so
122
+ // only their pathspec after an explicit `--` is checked (the common idiom),
123
+ // keeping this conservative rather than guessing at ref vs. path tokens.
124
+ function detectGitRelativeRead(tokens) {
125
+ let i = 1; // tokens[0] === 'git'
126
+ let hasC = false;
127
+ while (i < tokens.length && tokens[i].startsWith('-')) {
128
+ if ((tokens[i] === '-C' || tokens[i] === '--work-tree') && !tokens[i].includes('=')) {
129
+ hasC = true;
130
+ i += 2;
131
+ continue;
132
+ }
133
+ if (tokens[i].startsWith('--work-tree=') || tokens[i].startsWith('--git-dir=')) {
134
+ if (tokens[i].startsWith('--work-tree=')) hasC = true;
135
+ i++;
136
+ continue;
137
+ }
138
+ i++;
139
+ }
140
+ if (hasC) return null; // already path-explicit
141
+ const sub = tokens[i];
142
+ if (!['grep', 'log', 'show', 'diff'].includes(sub)) return null;
143
+ i++;
144
+ if (sub === 'grep') {
145
+ return scanArgsForRelativeFile(tokens.slice(i), VERB_CONFIG.grep);
146
+ }
147
+ const dashIdx = tokens.indexOf('--', i);
148
+ if (dashIdx === -1) return null;
149
+ return scanArgsForRelativeFile(tokens.slice(dashIdx + 1), { patternFirst: false, valueFlags: new Set() });
150
+ }
151
+
152
+ // Is this statement itself a read/search verb call with a relative file arg?
153
+ function readViolationInStatement(stmt) {
154
+ const tokens = normalizeStatementTokens(tokenize(stmt));
155
+ const i = skipEnvPrefix(tokens);
156
+ const rest = tokens.slice(i);
157
+ if (rest.length === 0) return null;
158
+ const bin = basename(rest[0]);
159
+ if (bin === 'git') {
160
+ const file = detectGitRelativeRead(rest);
161
+ return file ? { verb: `git ${rest[1] || ''}`.trim(), file } : null;
162
+ }
163
+ const config = VERB_CONFIG[bin];
164
+ if (!config) return null;
165
+ const file = scanArgsForRelativeFile(rest.slice(1), config);
166
+ return file ? { verb: bin, file } : null;
167
+ }
168
+
169
+ // Does this statement change the shell's persistent cwd (`cd`/`pushd`)? `env
170
+ // -C` is deliberately excluded here: it changes the directory only for the ONE
171
+ // command it wraps, not the rest of the script, so it's handled separately
172
+ // as a same-statement check, not this cross-statement sticky flag.
173
+ function isCdOrPushdStatement(stmt) {
174
+ const tokens = normalizeStatementTokens(tokenize(stmt));
175
+ const i = skipEnvPrefix(tokens);
176
+ const bin = tokens[i];
177
+ return bin === 'cd' || bin === 'pushd';
178
+ }
179
+
180
+ // `env -C <dir> <cmd> …`: the directory change and the read live in the SAME
181
+ // statement (env execs its argument as a single command with that cwd), so
182
+ // this is checked independently of the cross-statement `cwdChanged` flag.
183
+ function envDashCRelativeRead(stmt) {
184
+ const tokens = tokenize(stmt);
185
+ let i = skipEnvPrefix(tokens);
186
+ if (basename(tokens[i]) !== 'env') return null;
187
+ i++;
188
+ let foundC = false;
189
+ while (i < tokens.length) {
190
+ const t = tokens[i];
191
+ if (t === '-C') { foundC = true; i += 2; break; }
192
+ if (t.startsWith('--chdir=')) { foundC = true; i += 1; break; }
193
+ if (t.startsWith('-')) { i++; continue; } // some other env flag, best-effort skip
194
+ break; // reached the wrapped command
195
+ }
196
+ if (!foundC) return null;
197
+ const rest = tokens.slice(i);
198
+ if (rest.length === 0) return null;
199
+ const bin = basename(rest[0]);
200
+ if (bin === 'git') {
201
+ const file = detectGitRelativeRead(rest);
202
+ return file ? { verb: 'git', file } : null;
203
+ }
204
+ const config = VERB_CONFIG[bin];
205
+ if (!config) return null;
206
+ const file = scanArgsForRelativeFile(rest.slice(1), config);
207
+ return file ? { verb: bin, file } : null;
208
+ }
209
+
210
+ // --- top-level detection -----------------------------------------------------
211
+
212
+ // Detect the banned shape across the whole command. Returns
213
+ // { verb, file } on a hit, or null. Pure, exported for tests.
214
+ // KNOWN NARROWER GAP (deliberately not implemented): `cd app && grep -rn TODO`
215
+ // with no path argument at all implicitly searches the (now-relative) cwd,
216
+ // but adding that case risks false-positiving on legitimate stdin-reading
217
+ // commands, so it is left to the prose directive.
218
+ function detectSearchHygieneViolation(cmd) {
219
+ const statements = splitStatements(cmd);
220
+ let cwdChanged = false;
221
+ for (let idx = 0; idx < statements.length; idx++) {
222
+ const stmt = statements[idx];
223
+
224
+ const envHit = envDashCRelativeRead(stmt);
225
+ if (envHit) return envHit;
226
+
227
+ if (cwdChanged) {
228
+ const hit = readViolationInStatement(stmt);
229
+ if (hit) return hit;
230
+ }
231
+
232
+ // A `cd`/`pushd` only counts when it is joined to a LATER statement (not
233
+ // the sole statement) - a lone standalone `cd` is the known limitation
234
+ // above and must never be blocked here.
235
+ if (isCdOrPushdStatement(stmt) && idx < statements.length - 1) {
236
+ cwdChanged = true;
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function block(hit) {
243
+ const reason =
244
+ "trailhead search-hygiene: a 'cd <dir>' (or pushd/env -C) followed by a relative " +
245
+ `${hit.verb} read (\`${hit.file}\`) makes the read target non-static, which trips the ` +
246
+ 'Read() deny rule under bypass permissions (prompting for manual approval instead of ' +
247
+ 'running headless). Pass explicit paths instead and never cd first: ' +
248
+ 'grep -niE "<patterns>" <abs>/<file>, git -C <abs> ..., or use the Grep/Read tools.';
249
+ process.stdout.write(JSON.stringify({ decision: 'block', code: 'SEARCH_HYGIENE_VIOLATION', reason }));
250
+ process.exit(2);
251
+ }
252
+
253
+ function run(data) {
254
+ let cmd = '';
255
+ try {
256
+ cmd = (JSON.parse(data).tool_input || {}).command || '';
257
+ } catch {
258
+ process.exit(0); // unparseable input: nothing to gate
259
+ }
260
+ try {
261
+ const hit = detectSearchHygieneViolation(cmd);
262
+ if (hit) block(hit);
263
+ } catch {
264
+ // fall through to allow: a guard must never wedge the workflow
265
+ }
266
+ process.exit(0);
267
+ }
268
+
269
+ if (require.main === module) {
270
+ let data = '';
271
+ const timer = setTimeout(() => process.exit(0), 5000); // stdin never ends -> give up
272
+ process.stdin.on('data', (c) => (data += c));
273
+ process.stdin.on('end', () => {
274
+ clearTimeout(timer);
275
+ run(data);
276
+ });
277
+ }
278
+
279
+ module.exports = { detectSearchHygieneViolation };
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ // Tests for trailhead-search-guard.js. Run: node trailhead-search-guard.test.js
3
+ // No framework: plain asserts + child_process for the end-to-end hook behaviour.
4
+ const assert = require('assert');
5
+ const { execFileSync } = require('child_process');
6
+ const path = require('path');
7
+
8
+ const HOOK = path.join(__dirname, 'trailhead-search-guard.js');
9
+
10
+ let passed = 0;
11
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
12
+
13
+ // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
14
+ function runHook(command) {
15
+ const input = JSON.stringify({ tool_input: { command } });
16
+ try {
17
+ const out = execFileSync('node', [HOOK], { input });
18
+ return { code: 0, out: out.toString() };
19
+ } catch (e) {
20
+ return { code: e.status, out: (e.stdout || '').toString(), err: (e.stderr || '').toString() };
21
+ }
22
+ }
23
+
24
+ // --- ticket #134: the exact regression command must be BLOCKED ---
25
+ const ticketCmd = String.raw`cd app && grep -n "go_router" pubspec.yaml pubspec.lock | head; echo "=== router file ==="; grep -rln "GoRouter(" lib/ ; echo "=== /who route def ==="; grep -rn "'/who'\|\"/who\"\|/profile/manage\|path: '/home'" lib/ | head -30`;
26
+ const rTicket = runHook(ticketCmd);
27
+ ok('ticket #134 exact command is blocked (exit 2)', rTicket.code === 2 && /SEARCH_HYGIENE_VIOLATION|search-hygiene/.test(rTicket.out));
28
+
29
+ // --- BLOCK cases ---
30
+ const r1 = runHook('cd app && grep -n "go_router" pubspec.yaml');
31
+ ok('cd <relative> && grep <relative> is blocked (exit 2)', r1.code === 2 && /search-hygiene/.test(r1.out));
32
+
33
+ const r2 = runHook('cd /abs/app; grep -rln "GoRouter(" lib/');
34
+ ok('cd <abs>; grep <relative> is blocked (exit 2)', r2.code === 2 && /search-hygiene/.test(r2.out));
35
+
36
+ const r3 = runHook('pushd app && cat file.txt');
37
+ ok('pushd <dir> && cat <relative> is blocked (exit 2)', r3.code === 2 && /search-hygiene/.test(r3.out));
38
+
39
+ const r4 = runHook('cd app && wc -l foo.txt');
40
+ ok('cd <dir> && wc -l <relative> is blocked (exit 2)', r4.code === 2 && /search-hygiene/.test(r4.out));
41
+
42
+ const r5 = runHook('cd app &&\ngrep -n "x" foo.txt\n');
43
+ ok('cd <dir> && then relative grep on a later line (newline-joined) is blocked (exit 2)', r5.code === 2 && /search-hygiene/.test(r5.out));
44
+
45
+ const r6 = runHook('cd app; echo hi; grep -n "x" foo.txt');
46
+ ok('cd <dir>; ...; relative grep several statements later is blocked (exit 2)', r6.code === 2 && /search-hygiene/.test(r6.out));
47
+
48
+ const r7 = runHook('env -C app grep -n "x" foo.txt');
49
+ ok('env -C <dir> grep <relative> (single statement) is blocked (exit 2)', r7.code === 2 && /search-hygiene/.test(r7.out));
50
+
51
+ const r8 = runHook('cd app && git log -- foo.txt');
52
+ ok('cd <dir> && git log without -C on a relative path is blocked (exit 2)', r8.code === 2 && /search-hygiene/.test(r8.out));
53
+
54
+ const r9 = runHook('cd app && rg "TODO" foo.txt');
55
+ ok('cd <dir> && rg <relative> is blocked (exit 2)', r9.code === 2 && /search-hygiene/.test(r9.out));
56
+
57
+ // --- ALLOW cases (no false positives) ---
58
+ const a1 = runHook('grep -n "x" /abs/app/pubspec.yaml');
59
+ ok('path-explicit grep with no cd is allowed (exit 0)', a1.code === 0 && a1.out.trim() === '');
60
+
61
+ const a2 = runHook('git -C /abs/app grep "x"');
62
+ ok('git -C <abs> grep is allowed (exit 0)', a2.code === 0 && a2.out.trim() === '');
63
+
64
+ const a3 = runHook('cd /abs && npm test');
65
+ ok('cd <abs> && npm test (no read/search verb) is allowed (exit 0)', a3.code === 0 && a3.out.trim() === '');
66
+
67
+ const a4 = runHook('cd /abs && ls -la');
68
+ ok('cd <abs> && ls (not a Read()-denied verb) is allowed (exit 0)', a4.code === 0 && a4.out.trim() === '');
69
+
70
+ const a5 = runHook('cd /abs/app');
71
+ ok('a bare standalone cd <abs> alone is allowed (exit 0) - known limitation, never block a lone cd', a5.code === 0 && a5.out.trim() === '');
72
+
73
+ const a6 = runHook('grep "cd foo" /abs/file');
74
+ ok('grep whose pattern happens to contain "cd" but path is absolute is allowed (exit 0)', a6.code === 0 && a6.out.trim() === '');
75
+
76
+ const a7 = runHook('grep -n "x" file --with-something');
77
+ ok('command with no cd at all is allowed regardless of relative path (exit 0)', a7.code === 0 && a7.out.trim() === '');
78
+
79
+ const a8 = runHook('cd /abs/app && grep -n "x" /abs/app/foo.txt');
80
+ ok('cd <abs> && grep with an absolute-path argument is allowed (exit 0)', a8.code === 0 && a8.out.trim() === '');
81
+
82
+ const a9 = runHook('cd /abs/app && grep -n "x" -r --include=*.js');
83
+ ok('cd <abs> && grep with only flags/patterns (no file arg) is allowed (exit 0)', a9.code === 0 && a9.out.trim() === '');
84
+
85
+ // --- #134 follow-up: Finding 1 - heredoc bodies are DATA, not statements ---
86
+ const h1 = runHook(`cat > setup.sh <<'EOF'\ncd /some/dir\ngrep -n pattern file.txt\nEOF\n`);
87
+ ok('heredoc body (cd + grep as literal data) is allowed, not parsed as statements (exit 0)', h1.code === 0 && h1.out.trim() === '');
88
+
89
+ const h2 = runHook(`bash -c 'cat' <<EOF\ncd app\ngrep -n x foo.txt\nEOF\n`);
90
+ ok('heredoc fed to a command is allowed, its body is data not executed statements (exit 0)', h2.code === 0 && h2.out.trim() === '');
91
+
92
+ const h3 = runHook(`cat > setup.sh <<'EOF'\nsome literal data\nEOF\ncd app && grep -n x foo.txt`);
93
+ ok('a REAL cd + grep violation outside a heredoc on the same command still blocks (exit 2)', h3.code === 2 && /search-hygiene/.test(h3.out));
94
+
95
+ // --- #134 follow-up: Finding 2 - grouping containers must not hide the violation ---
96
+ const g1 = runHook('(cd app && grep -n x foo.txt)');
97
+ ok('subshell-wrapped cd + grep is blocked (exit 2)', g1.code === 2 && /search-hygiene/.test(g1.out));
98
+
99
+ const g2 = runHook('{ cd app; grep -n x foo.txt; }');
100
+ ok('brace-group-wrapped cd + grep is blocked (exit 2)', g2.code === 2 && /search-hygiene/.test(g2.out));
101
+
102
+ const g3 = runHook('for d in */; do cd "$d" && grep -n x foo.txt; cd ..; done');
103
+ ok('loop-body cd + grep is blocked (exit 2)', g3.code === 2 && /search-hygiene/.test(g3.out));
104
+
105
+ const g4 = runHook('(cd /abs && npm test)');
106
+ ok('subshell-wrapped cd <abs> && npm test (no read verb) is still allowed (exit 0)', g4.code === 0 && g4.out.trim() === '');
107
+
108
+ // --- #134 follow-up: Finding 3 - `--` end-of-options marker must not swallow the file ---
109
+ const d1 = runHook('cd app && grep -- -x foo.txt');
110
+ ok('cd + grep -- -x <relative file> is blocked (exit 2), `--` must not eat the file arg', d1.code === 2 && /search-hygiene/.test(d1.out) && /foo\.txt/.test(d1.out));
111
+
112
+ // --- #134 follow-up: Finding 4 - rg-specific value flags must not misattribute the file ---
113
+ const f1 = runHook('cd app && rg -g "*.js" TODO src/foo.js');
114
+ ok('cd + rg -g <glob> <pattern> <relative file> reports the real file, not the pattern (exit 2)', f1.code === 2 && /search-hygiene/.test(f1.out) && /src\/foo\.js/.test(f1.out) && !/`TODO`/.test(f1.out));
115
+
116
+ console.log(`✓ search-guard: ${passed} assertions passed`);