@marcomigozzi/trailhead 0.14.0 → 0.16.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 (156) hide show
  1. package/README.it.md +158 -34
  2. package/README.md +147 -33
  3. package/bin/auto-intro-docs.test.js +72 -0
  4. package/bin/auto-merge-docs.test.js +33 -0
  5. package/bin/auto-merge-ticket-docs.test.js +32 -0
  6. package/bin/auto-review-rounds-docs.test.js +45 -0
  7. package/bin/auto-run-docs.test.js +60 -0
  8. package/bin/auto-scope-docs.test.js +33 -0
  9. package/bin/auto-slots-docs.test.js +45 -0
  10. package/bin/auto-uat-docs.test.js +47 -0
  11. package/bin/base-preflight-docs.test.js +65 -0
  12. package/bin/execute-split-docs.test.js +146 -0
  13. package/bin/grilling-holistic-review-docs.test.js +90 -0
  14. package/bin/isolation-answer-docs.test.js +41 -0
  15. package/bin/isolation-copy-docs.test.js +28 -0
  16. package/bin/lib/codex-projection.js +6 -6
  17. package/bin/lib/codex-projection.test.js +43 -2
  18. package/bin/needs-you-docs.test.js +41 -0
  19. package/bin/pick-stress-test-docs.test.js +105 -0
  20. package/bin/report-status-docs.test.js +195 -0
  21. package/bin/resolve-helpers-docs.test.js +36 -0
  22. package/bin/scope-confirm-skip-docs.test.js +70 -0
  23. package/bin/trailhead.js +6 -6
  24. package/bin/trailhead.test.js +355 -16
  25. package/bin/uat-setup-docs.test.js +65 -0
  26. package/package.json +1 -1
  27. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  28. package/plugins/trailhead/agents/trailhead-code-review.md +15 -2
  29. package/plugins/trailhead/agents/trailhead-codebase-map.md +20 -1
  30. package/plugins/trailhead/agents/trailhead-debug.md +20 -1
  31. package/plugins/trailhead/agents/trailhead-executor.md +29 -2
  32. package/plugins/trailhead/agents/trailhead-fix.md +29 -2
  33. package/plugins/trailhead/agents/trailhead-plan.md +20 -1
  34. package/plugins/trailhead/agents/trailhead-research.md +22 -3
  35. package/plugins/trailhead/agents/trailhead-verify.md +15 -2
  36. package/plugins/trailhead/commands/auto.md +2 -2
  37. package/plugins/trailhead/hooks/lib/auto-merge.test.js +467 -0
  38. package/plugins/trailhead/hooks/lib/auto-queue.test.js +245 -0
  39. package/plugins/trailhead/hooks/lib/auto-run.test.js +318 -0
  40. package/plugins/trailhead/hooks/lib/auto-slot.test.js +278 -0
  41. package/plugins/trailhead/hooks/lib/base-preflight.test.js +513 -0
  42. package/plugins/trailhead/hooks/lib/checkout-in.test.js +173 -0
  43. package/plugins/trailhead/hooks/lib/commit-message-check.js +1 -1
  44. package/plugins/trailhead/hooks/lib/commit-message-check.test.js +3 -2
  45. package/plugins/trailhead/hooks/lib/commit-msg-module-repo.test.js +116 -0
  46. package/plugins/trailhead/hooks/lib/commit-msg-sync.test.js +100 -9
  47. package/plugins/trailhead/hooks/lib/dashboard.test.js +472 -0
  48. package/plugins/trailhead/hooks/lib/fixtures/commit-msg-legacy-js +104 -0
  49. package/plugins/trailhead/hooks/lib/isolation-answer.test.js +258 -0
  50. package/plugins/trailhead/hooks/lib/isolation-copy.test.js +298 -0
  51. package/plugins/trailhead/hooks/lib/issue-body.test.js +470 -0
  52. package/plugins/trailhead/hooks/lib/merge-ticket.test.js +394 -0
  53. package/plugins/trailhead/hooks/lib/needs-you.test.js +177 -0
  54. package/plugins/trailhead/hooks/lib/plan-review-rounds.test.js +319 -0
  55. package/plugins/trailhead/hooks/lib/report-status.test.js +366 -0
  56. package/plugins/trailhead/hooks/lib/shell-scan.js +36 -1
  57. package/plugins/trailhead/hooks/lib/shell-scan.test.js +14 -0
  58. package/plugins/trailhead/hooks/lib/statusline-context.test.js +140 -0
  59. package/plugins/trailhead/hooks/lib/uat-backfill.test.js +150 -0
  60. package/plugins/trailhead/hooks/lib/uat-followup.test.js +169 -0
  61. package/plugins/trailhead/hooks/lib/uat-setup.test.js +340 -0
  62. package/plugins/trailhead/hooks/lib/unblock.test.js +207 -0
  63. package/plugins/trailhead/hooks/lib/work-state.test.js +45 -0
  64. package/plugins/trailhead/hooks/mod/README.md +209 -36
  65. package/plugins/trailhead/hooks/mod/auto-run-view.ts +93 -0
  66. package/plugins/trailhead/hooks/mod/auto-run.ts +165 -0
  67. package/plugins/trailhead/hooks/mod/map-pane-view.ts +304 -26
  68. package/plugins/trailhead/hooks/mod/map-pane.tsx +763 -160
  69. package/plugins/trailhead/hooks/mod/register.tsx +312 -40
  70. package/plugins/trailhead/hooks/mod/repo-pin.ts +167 -0
  71. package/plugins/trailhead/hooks/mod/session-registry.ts +65 -0
  72. package/plugins/trailhead/hooks/mod/turn-state.ts +14 -0
  73. package/plugins/trailhead/hooks/mod/whiteboard-follow.ts +98 -0
  74. package/plugins/trailhead/hooks/mod/work-state-locate.ts +191 -0
  75. package/plugins/trailhead/hooks/mod/work-state-prompt.ts +13 -15
  76. package/plugins/trailhead/hooks/mod/work-state-view.ts +38 -5
  77. package/plugins/trailhead/hooks/mod/worktree-porcelain.ts +17 -0
  78. package/plugins/trailhead/hooks/trailhead-bash-guards.js +8 -8
  79. package/plugins/trailhead/hooks/trailhead-bash-guards.test.js +22 -16
  80. package/plugins/trailhead/hooks/trailhead-install-guard.js +169 -34
  81. package/plugins/trailhead/hooks/trailhead-install-guard.test.js +180 -1
  82. package/plugins/trailhead/hooks/trailhead-labels-guard.js +231 -0
  83. package/plugins/trailhead/hooks/trailhead-labels-guard.test.js +212 -0
  84. package/plugins/trailhead/hooks/trailhead-search-guard.js +43 -7
  85. package/plugins/trailhead/hooks/trailhead-search-guard.test.js +66 -2
  86. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +296 -22
  87. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +253 -0
  88. package/plugins/trailhead/skills/_shared/choices.md +11 -4
  89. package/plugins/trailhead/skills/_shared/configuration-reference.md +17 -5
  90. package/plugins/trailhead/skills/_shared/load-first.md +3 -3
  91. package/plugins/trailhead/skills/_shared/multi-map.md +2 -2
  92. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  93. package/plugins/trailhead/skills/_shared/substrate-commands.md +47 -14
  94. package/plugins/trailhead/skills/_shared/substrate.md +9 -4
  95. package/plugins/trailhead/skills/_shared/teamwork.md +17 -8
  96. package/plugins/trailhead/skills/_shared/techniques/acceptance-testing.md +18 -10
  97. package/plugins/trailhead/skills/_shared/techniques/code-review.md +1 -1
  98. package/plugins/trailhead/skills/_shared/techniques/codebase-map.md +1 -1
  99. package/plugins/trailhead/skills/_shared/techniques/debug.md +2 -0
  100. package/plugins/trailhead/skills/_shared/techniques/grilling.md +4 -2
  101. package/plugins/trailhead/skills/_shared/techniques/plan-review.md +3 -1
  102. package/plugins/trailhead/skills/_shared/techniques/research.md +4 -2
  103. package/plugins/trailhead/skills/_shared/techniques/verify.md +1 -1
  104. package/plugins/trailhead/skills/_shared/techniques.md +12 -2
  105. package/plugins/trailhead/skills/trailhead/SKILL.md +1 -1
  106. package/plugins/trailhead/skills/trailhead-capture/SKILL.md +1 -1
  107. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +3 -3
  108. package/plugins/trailhead/skills/trailhead-chart/SKILL.md +2 -2
  109. package/plugins/trailhead/skills/trailhead-chart/references/charting.md +5 -5
  110. package/plugins/trailhead/skills/trailhead-chart/references/inbox.md +2 -2
  111. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +1 -1
  112. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +1 -1
  113. package/plugins/trailhead/skills/trailhead-view/SKILL.md +9 -6
  114. package/plugins/trailhead/skills/trailhead-work/SKILL.md +31 -18
  115. package/plugins/trailhead/skills/trailhead-work/references/auto.md +70 -22
  116. package/plugins/trailhead/skills/trailhead-work/references/executor-brief.md +29 -0
  117. package/plugins/trailhead/skills/trailhead-work/references/ticket-engines.md +29 -13
  118. package/plugins/trailhead/templates/statusline-trailhead.sh +77 -12
  119. package/plugins/trailhead/templates/trailhead-auto-merge.js +318 -0
  120. package/plugins/trailhead/templates/trailhead-auto-queue.js +186 -0
  121. package/plugins/trailhead/templates/trailhead-auto-run.js +294 -0
  122. package/plugins/trailhead/templates/trailhead-auto-slot.js +239 -0
  123. package/plugins/trailhead/templates/trailhead-base-preflight.js +305 -0
  124. package/plugins/trailhead/templates/trailhead-checkout-in.js +88 -0
  125. package/plugins/trailhead/templates/trailhead-commit-msg +38 -102
  126. package/plugins/trailhead/templates/trailhead-commit-msg-sync.js +58 -27
  127. package/plugins/trailhead/templates/trailhead-commit-msg.cjs +114 -0
  128. package/plugins/trailhead/templates/trailhead-dashboard.js +360 -0
  129. package/plugins/trailhead/templates/trailhead-isolation-answer.js +227 -0
  130. package/plugins/trailhead/templates/trailhead-isolation-copy.js +239 -0
  131. package/plugins/trailhead/templates/trailhead-issue-body.js +459 -0
  132. package/plugins/trailhead/templates/trailhead-merge-ticket.js +366 -0
  133. package/plugins/trailhead/templates/trailhead-needs-you.js +193 -0
  134. package/plugins/trailhead/templates/trailhead-plan-review-rounds.js +289 -0
  135. package/plugins/trailhead/templates/trailhead-report-status.js +274 -0
  136. package/plugins/trailhead/templates/trailhead-uat-backfill.js +114 -0
  137. package/plugins/trailhead/templates/trailhead-uat-followup.js +128 -0
  138. package/plugins/trailhead/templates/trailhead-uat-setup.js +385 -0
  139. package/plugins/trailhead/templates/trailhead-unblock.js +111 -0
  140. package/plugins/trailhead/templates/trailhead-work-state.js +25 -7
  141. package/plugins/trailhead/tests/auto-run-ui.test.ts +444 -0
  142. package/plugins/trailhead/tests/auto-run-view.test.ts +111 -0
  143. package/plugins/trailhead/tests/map-pane-ui.test.ts +2519 -382
  144. package/plugins/trailhead/tests/map-pane-view.test.ts +600 -18
  145. package/plugins/trailhead/tests/map-pane-write.test.ts +51 -0
  146. package/plugins/trailhead/tests/repo-pin-late-repo.test.ts +77 -0
  147. package/plugins/trailhead/tests/repo-pin.test.ts +123 -0
  148. package/plugins/trailhead/tests/session-registry.test.ts +50 -0
  149. package/plugins/trailhead/tests/whiteboard-follow.test.ts +90 -0
  150. package/plugins/trailhead/tests/work-state-locate.test.ts +33 -0
  151. package/plugins/trailhead/tests/work-state-prompt-hook.test.ts +276 -2
  152. package/plugins/trailhead/tests/work-state-prompt.test.ts +29 -35
  153. package/plugins/trailhead/tests/work-state-ui.test.ts +73 -1
  154. package/plugins/trailhead/tests/work-state-view.test.ts +44 -1
  155. package/plugins/trailhead/tests/work-state-worktree.test.ts +310 -0
  156. package/plugins/trailhead/types/index.d.ts +62 -3
@@ -24,6 +24,21 @@
24
24
  // cannot see. Blocking a lone `cd` would be far too aggressive (a legitimate,
25
25
  // common shape) so it is deliberately left to the prose directive.
26
26
  //
27
+ // Redirections are not reads of a relative path: before a verb's args are
28
+ // walked, output redirections (`>`, `>>`, `2>`, `&>`, glued or with a separate
29
+ // target), fd duplications (`2>&1`) and heredoc/herestring introducers are
30
+ // dropped together with their target, so `cat /abs/a > out.txt` or
31
+ // `tail /abs/log 2>/dev/null` are not reported. An input redirection `< f` IS a
32
+ // read: only the operator is dropped and `f` stays in the arg list, so
33
+ // `cd app && cat < rel.txt` still blocks while `wc -l < /abs/a` passes.
34
+ //
35
+ // cwd exemption: when the hook payload carries the session `cwd` (absolute), a
36
+ // joined `cd X`/`pushd X` whose literal target resolves to exactly that cwd is
37
+ // a no-op, so it does not make later relative reads non-static. Dynamic or
38
+ // special targets (`$VAR`, backticks, `~`, `-`, a bare `cd`) always count as a
39
+ // directory change, and an unknown cwd behaves as before. The exemption only
40
+ // avoids ARMING: once a real cd has armed the guard, nothing disarms it.
41
+ //
27
42
  // Blocks with exit 2 + {"decision":"block","code":...,"reason":...}; allows
28
43
  // everything else. Crash-safe: any error -> exit 0 (never wedge the user's
29
44
  // workflow). Self-contained (its own dependency is the sibling lib/ module,
@@ -36,12 +51,14 @@
36
51
  // machinery (SEARCH_VERB_CONFIG, scanArgsSkippingPattern) are shared with
37
52
  // trailhead-secret-read-guard.js via lib/shell-scan.js (#135 follow-up), so
38
53
  // the two guards can never drift into two divergent tokenizers.
54
+ const path = require('path');
39
55
  const {
40
56
  basename,
41
57
  stripQuotes,
42
58
  splitStatements,
43
59
  tokenize,
44
60
  skipEnvPrefix,
61
+ stripRedirections,
45
62
  SEARCH_VERB_CONFIG,
46
63
  scanArgsSkippingPattern,
47
64
  } = require('./lib/shell-scan.js');
@@ -114,7 +131,7 @@ const VERB_CONFIG = {
114
131
  // Walk a verb's argument tokens (verb itself already stripped) for the first
115
132
  // relative FILE argument found, via the shared pattern/flag-skipping walker.
116
133
  function scanArgsForRelativeFile(args, config) {
117
- return scanArgsSkippingPattern(args, config, isRelativeFileArg);
134
+ return scanArgsSkippingPattern(stripRedirections(args), config, isRelativeFileArg);
118
135
  }
119
136
 
120
137
  // `git grep`/`git log`/`git show`/`git diff` WITHOUT a `-C` (or --work-tree):
@@ -177,6 +194,22 @@ function isCdOrPushdStatement(stmt) {
177
194
  return bin === 'cd' || bin === 'pushd';
178
195
  }
179
196
 
197
+ // Does this cd/pushd statement provably stay in the session cwd? Only with a
198
+ // known absolute cwd and exactly one static literal target resolving to it.
199
+ function cdTargetsSessionCwd(stmt, cwd) {
200
+ if (typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
201
+ const tokens = normalizeStatementTokens(tokenize(stmt));
202
+ let i = skipEnvPrefix(tokens) + 1; // past cd/pushd
203
+ while (i < tokens.length && tokens[i].startsWith('-') && tokens[i] !== '-') {
204
+ if (tokens[i++] === '--') break; // option flags (-P/-L/--) before the target
205
+ }
206
+ if (i !== tokens.length - 1) return false; // bare cd goes to $HOME; extra targets are unsure
207
+ const target = stripQuotes(tokens[i]);
208
+ if (!target || target === '-' || target.startsWith('~')) return false;
209
+ if (target.includes('$') || target.includes('`')) return false;
210
+ return path.resolve(cwd, target) === path.resolve(cwd);
211
+ }
212
+
180
213
  // `env -C <dir> <cmd> …`: the directory change and the read live in the SAME
181
214
  // statement (env execs its argument as a single command with that cwd), so
182
215
  // this is checked independently of the cross-statement `cwdChanged` flag.
@@ -215,7 +248,7 @@ function envDashCRelativeRead(stmt) {
215
248
  // with no path argument at all implicitly searches the (now-relative) cwd,
216
249
  // but adding that case risks false-positiving on legitimate stdin-reading
217
250
  // commands, so it is left to the prose directive.
218
- function detectSearchHygieneViolation(cmd) {
251
+ function detectSearchHygieneViolation(cmd, { cwd } = {}) {
219
252
  const statements = splitStatements(cmd);
220
253
  let cwdChanged = false;
221
254
  for (let idx = 0; idx < statements.length; idx++) {
@@ -233,7 +266,7 @@ function detectSearchHygieneViolation(cmd) {
233
266
  // the sole statement) - a lone standalone `cd` is the known limitation
234
267
  // above and must never be blocked here.
235
268
  if (isCdOrPushdStatement(stmt) && idx < statements.length - 1) {
236
- cwdChanged = true;
269
+ if (!cdTargetsSessionCwd(stmt, cwd)) cwdChanged = true;
237
270
  }
238
271
  }
239
272
  return null;
@@ -251,9 +284,9 @@ function blockVerdict(hit) {
251
284
 
252
285
  // Pure verdict for one command: {kind:'allow'} or {kind:'block', payload}. Never
253
286
  // exits or writes; any internal error falls through to allow.
254
- function evaluate(cmd) {
287
+ function evaluate(cmd, { cwd } = {}) {
255
288
  try {
256
- const hit = detectSearchHygieneViolation(cmd);
289
+ const hit = detectSearchHygieneViolation(cmd, { cwd });
257
290
  if (hit) return blockVerdict(hit);
258
291
  } catch {
259
292
  // fall through to allow: a guard must never wedge the workflow
@@ -263,12 +296,15 @@ function evaluate(cmd) {
263
296
 
264
297
  function run(data) {
265
298
  let cmd = '';
299
+ let cwd;
266
300
  try {
267
- cmd = (JSON.parse(data).tool_input || {}).command || '';
301
+ const input = JSON.parse(data);
302
+ cmd = (input.tool_input || {}).command || '';
303
+ cwd = typeof input.cwd === 'string' && input.cwd ? input.cwd : undefined;
268
304
  } catch {
269
305
  process.exit(0); // unparseable input: nothing to gate
270
306
  }
271
- const v = evaluate(cmd);
307
+ const v = evaluate(cmd, { cwd });
272
308
  if (v.kind === 'block') {
273
309
  process.stdout.write(JSON.stringify(v.payload));
274
310
  process.exit(2);
@@ -12,8 +12,10 @@ let passed = 0;
12
12
  const ok = (name, cond) => { assert.ok(cond, name); passed++; };
13
13
 
14
14
  // Run the hook end-to-end: pipe tool_input JSON on stdin, capture {code, out}.
15
- function runHook(command) {
16
- const input = JSON.stringify({ tool_input: { command } });
15
+ function runHook(command, cwd) {
16
+ const payload = { tool_input: { command } };
17
+ if (cwd !== undefined) payload.cwd = cwd; // optional top-level session cwd
18
+ const input = JSON.stringify(payload);
17
19
  try {
18
20
  const out = execFileSync('node', [HOOK], { input });
19
21
  return { code: 0, out: out.toString() };
@@ -114,4 +116,66 @@ ok('cd + grep -- -x <relative file> is blocked (exit 2), `--` must not eat the f
114
116
  const f1 = runHook('cd app && rg -g "*.js" TODO src/foo.js');
115
117
  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));
116
118
 
119
+
120
+ // --- #238: redirections are not relative reads; a cd to the session cwd is static ---
121
+ const blocked = (r) => r.code === 2 && /search-hygiene/.test(r.out);
122
+ const allowed = (r) => r.code === 0 && r.out.trim() === '';
123
+
124
+ for (const [name, cmd] of [
125
+ ['heredoc-fed cat > abs', 'cd /x && cat > /abs/file <<EOF\nhi\nEOF'],
126
+ ['append redirect target', 'cd /x && cat /abs/a >> out.txt'],
127
+ ['output redirect target', 'cd /x && cat /abs/a > out.txt'],
128
+ ['glued 2>/dev/null', 'cd /x && tail -5 /abs/log 2>/dev/null'],
129
+ ['separate 2> target', 'cd /x && tail -5 /abs/log 2> err.log'],
130
+ ['&> target', 'cd /x && cat /abs/a &> out.txt'],
131
+ ['2>&1 duplication', 'cd /x && cat /abs/a 2>&1'],
132
+ ['absolute input redirect', 'cd /x && wc -l < /abs/a'],
133
+ ['grep with 2>/dev/null', 'cd /x && grep -n foo /abs/a 2>/dev/null'],
134
+ ['herestring', 'cd /x && cat <<<hello'],
135
+ ]) ok(`#238 allow: ${name}`, allowed(runHook(cmd)));
136
+
137
+ for (const [name, cmd, file] of [
138
+ ['input redirect from relative file', 'cd app && cat < rel.txt', 'rel.txt'],
139
+ ['relative read before output redirect', 'cd app && cat rel.txt > /abs/out', 'rel.txt'],
140
+ ['relative read before 2>/dev/null', 'cd app && tail -5 rel.log 2>/dev/null', 'rel.log'],
141
+ ['relative read before 2>&1', 'cd app && grep -n x rel.txt 2>&1', 'rel.txt'],
142
+ ]) {
143
+ const r = runHook(cmd);
144
+ ok(`#238 block: ${name}`, blocked(r) && r.out.includes('`' + file + '`'));
145
+ }
146
+
147
+ const CWD = '/home/u/p';
148
+ for (const [name, cmd] of [
149
+ ['cd to session cwd', 'cd /home/u/p && cat rel.txt'],
150
+ ['cd to session cwd with trailing slash', 'cd /home/u/p/ && grep -n x rel.txt'],
151
+ ['cd .', 'cd . && cat rel.txt'],
152
+ ]) ok(`#238 allow with cwd: ${name}`, allowed(runHook(cmd, CWD)));
153
+
154
+ for (const [name, cmd] of [
155
+ ['cd elsewhere', 'cd /home/u/other && cat rel.txt'],
156
+ ['cd relative subdir', 'cd sub && cat rel.txt'],
157
+ ['cd $HOME', 'cd $HOME && cat rel.txt'],
158
+ ['cd ~', 'cd ~ && cat rel.txt'],
159
+ ['cd -', 'cd - && cat rel.txt'],
160
+ ]) ok(`#238 block with cwd: ${name}`, blocked(runHook(cmd, CWD)));
161
+ ok('#238 block: cd to would-be cwd without payload cwd', blocked(runHook('cd /home/u/p && cat rel.txt')));
162
+
163
+ // pure unit checks on the exported detector
164
+ const { detectSearchHygieneViolation } = require('./trailhead-search-guard.js');
165
+ ok('#238 unit: cd <cwd> is static when cwd is known',
166
+ detectSearchHygieneViolation('cd "/home/u/p" && cat rel.txt', { cwd: CWD }) === null);
167
+ ok('#238 unit: cd <cwd> still violates when cwd unknown',
168
+ detectSearchHygieneViolation('cd /home/u/p && cat rel.txt').file === 'rel.txt');
169
+ ok('#238 unit: relative cd after a real cd stays armed',
170
+ detectSearchHygieneViolation('cd sub && cd . && cat rel.txt', { cwd: CWD }).file === 'rel.txt');
171
+ // once armed by a real cd, nothing disarms: a cd to the session cwd only avoids arming
172
+ for (const cmd of [
173
+ 'cd sub || cd /home/u/p && cat rel.txt',
174
+ 'cd sub && (cd /home/u/p) && cat rel.txt',
175
+ 'cd sub && cd /home/u/p && cat rel.txt',
176
+ ]) ok(`#238 unit: stays armed: ${cmd}`, detectSearchHygieneViolation(cmd, { cwd: CWD }).file === 'rel.txt');
177
+ ok('#238 unit: cd with an extra target is not a no-op',
178
+ detectSearchHygieneViolation('cd /home/u/p /other && cat rel.txt', { cwd: CWD }).file === 'rel.txt');
179
+ ok('#238 allow: separate-token >& target', allowed(runHook('cd app && cat /abs/a >& out.txt')));
180
+
117
181
  console.log(`✓ search-guard: ${passed} assertions passed`);
@@ -86,6 +86,20 @@
86
86
  // a link prepared under a non-secret name onto `.env`
87
87
  // (`ln -s .env.example l && mv l .env`).
88
88
  //
89
+ // #273: a command carried in a git config value (`git -c alias.x="!cat .env" x`,
90
+ // `-c core.pager=...`, `--config-env=core.pager=VAR` with VAR assigned in the same
91
+ // statement) is scanned as a nested command, like `bash -c`; `--config-env` on a
92
+ // command key whose variable is not assigned there denies as unmodelled.
93
+ //
94
+ // The same scan covers the statement's own leading env assignments that inject config or
95
+ // commands: GIT_CONFIG_KEY_<n> / GIT_CONFIG_VALUE_<n> pairs (read whatever GIT_CONFIG_COUNT
96
+ // says), GIT_CONFIG_PARAMETERS (an unparsable one on a command key denies as unmodelled), and
97
+ // the command-valued variables GIT_PAGER, PAGER, GIT_SSH_COMMAND, GIT_SSH, GIT_EXTERNAL_DIFF,
98
+ // GIT_EDITOR, GIT_SEQUENCE_EDITOR, EDITOR, VISUAL, GIT_ASKPASS, SSH_ASKPASS. Only for a git
99
+ // statement; variables exported in an earlier statement are not visible. Backslash escapes in a
100
+ // quoted value follow the shell (shellUnquote). Global options git does not list before -c are
101
+ // walked as flags (walkGitGlobals), never a reason to stop scanning.
102
+ //
89
103
  // #227: a construct the guard cannot interpret with certainty (eval, source / `.`, a
90
104
  // bash 5.3 `${ cmd; }`, printf -v, a function definition, an alias, a quoted gh api body
91
105
  // key, a backslash-newline inside a word) is not emulated. As a FINAL gate, after the
@@ -142,10 +156,17 @@ function isSecretBasename(name) {
142
156
  return !NON_SECRET_ENV_SUFFIXES.has(lower.slice(lower.lastIndexOf('.') + 1));
143
157
  }
144
158
 
159
+ // #276: il token grezzo tiene `\`+char verbatim. Con un backslash la parola si legge come
160
+ // la shell (`\.env` è `.env`); senza, resta il solo strip della coppia di apici esterna
161
+ // (le forme con apici nel mezzo, `.e""nv`, restano al gate dei costrutti non modellati).
162
+ function unquoteWord(t) {
163
+ return String(t).includes('\\') ? shellUnquote(t) : stripQuotes(t);
164
+ }
165
+
145
166
  // Anche la forma `<rev>:<path>` (`git show HEAD:.env`, `scp host:.env .`):
146
167
  // il path dopo il primo `:` è letto quanto un operando nudo.
147
168
  function isSecretPathArg(t) {
148
- const bare = stripQuotes(t);
169
+ const bare = unquoteWord(t);
149
170
  if (!bare) return false;
150
171
  if (isSecretBasename(basename(bare))) return true;
151
172
  const colon = bare.indexOf(':');
@@ -201,14 +222,18 @@ const GIT_PATHSPEC_SUBCOMMANDS = new Map([
201
222
 
202
223
  // Opzioni globali di git (usage line, git 2.49), per trovare il sottocomando.
203
224
  // Un'opzione con valore consuma la parola successiva salvo forma
204
- // `--opt=value`; un'opzione in nessuno dei due set fa fail closed, così un
205
- // valore non è mai letto come sottocomando (`git -C ls-files show HEAD:.env`
206
- // esegue `show`).
207
- const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env']);
225
+ // `--opt=value`. Un'opzione in nessuno dei due set NON ferma la camminata
226
+ // (vedi walkGitGlobals): per il sottocomando esenta nulla (fail closed, un
227
+ // valore non è mai letto come sottocomando: `git -C ls-files show HEAD:.env`
228
+ // esegue `show`), per gli `-c` successivi li lascia comunque controllati.
229
+ const GIT_GLOBAL_VALUE_OPTIONS = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source', '--super-prefix']);
208
230
  const GIT_GLOBAL_FLAGS = new Set([
209
231
  '-p', '--paginate', '-P', '--no-pager', '--no-replace-objects', '--no-lazy-fetch',
210
- '--no-optional-locks', '--no-advice', '--bare',
232
+ '--no-optional-locks', '--no-advice', '--bare', '--literal-pathspecs', '--glob-pathspecs',
233
+ '--noglob-pathspecs', '--icase-pathspecs', '--exec-path', '--html-path', '--man-path', '--info-path',
211
234
  ]);
235
+ // Flag che accetta anche la forma `--opt=value` ma mai un valore separato.
236
+ const GIT_GLOBAL_EQ_FLAGS = new Set(['--exec-path', '--list-cmds']);
212
237
 
213
238
  // `cp`/`mv` scrivono la destinazione e non la stampano: un nome segreto che è
214
239
  // SOLO la destinazione di una copia/spostamento a sorgente singola è un nome
@@ -318,19 +343,52 @@ function isListedFlag(text, flags) {
318
343
  return /^-[A-Za-z]+$/.test(text) && [...text.slice(1)].every((ch) => flags.has(`-${ch}`));
319
344
  }
320
345
 
321
- // Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
322
- // un'opzione fuori da GIT_GLOBAL_VALUE_OPTIONS / GIT_GLOBAL_FLAGS.
323
- function gitSubcommandIndex(operands) {
346
+ // UNICA camminata sulle opzioni globali di git, condivisa da gitSubcommandIndex
347
+ // e da extractGitConfigCommands così le due non divergono. Restituisce
348
+ // { sub, known, configs }: `sub` = indice del primo operando non-opzione (-1 se
349
+ // assente), `known` = false se la camminata ha incontrato un'opzione fuori dai
350
+ // set (allora nessuna esenzione per il sottocomando), `configs` = i valori
351
+ // `-c` / `-ckey=value` / `--config-env` visti prima del sottocomando.
352
+ // Un'opzione sconosciuta non interrompe la camminata: si tratta come flag fino al
353
+ // primo operando non-opzione (fail toward scanning).
354
+ function walkGitGlobals(operands) {
355
+ const out = { sub: -1, known: true, configs: [] };
324
356
  for (let k = 0; k < operands.length; k++) {
325
357
  const t = operands[k];
326
- if (!t.startsWith('-')) return k;
327
- if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) { k++; continue; }
328
- if (GIT_GLOBAL_FLAGS.has(t)) continue;
329
- const eq = t.indexOf('=');
330
- const isLongValueForm = t.startsWith('--') && eq !== -1 && GIT_GLOBAL_VALUE_OPTIONS.has(t.slice(0, eq));
331
- if (!isLongValueForm) return -1;
358
+ if (!t.startsWith('-') || t === '-') { out.sub = k; return out; }
359
+ if (t === '-c' || t === '--config-env') {
360
+ if (k + 1 >= operands.length) { out.known = false; return out; }
361
+ out.configs.push({ kind: t === '-c' ? 'c' : 'env', value: operands[++k], sample: `${t} ${operands[k]}` });
362
+ } else if (t.startsWith('--config-env=')) {
363
+ out.configs.push({ kind: 'env', value: t.slice('--config-env='.length), sample: t });
364
+ } else if (isGluedGitConfig(t)) {
365
+ out.configs.push({ kind: 'c', value: t.slice(2), sample: t }); // #273: `-ckey=value`
366
+ } else if (GIT_GLOBAL_VALUE_OPTIONS.has(t)) {
367
+ if (k + 1 >= operands.length) { out.known = false; return out; }
368
+ k++; // `-C <path>` and friends: the value is not a config
369
+ } else if (GIT_GLOBAL_FLAGS.has(t)) {
370
+ // known flag
371
+ } else {
372
+ const eq = t.indexOf('=');
373
+ const name = eq === -1 ? t : t.slice(0, eq);
374
+ const longForm = t.startsWith('--') && eq !== -1 && (GIT_GLOBAL_VALUE_OPTIONS.has(name) || GIT_GLOBAL_EQ_FLAGS.has(name));
375
+ if (!longForm) out.known = false; // unknown option: treated as a flag, no exemption
376
+ }
332
377
  }
333
- return -1;
378
+ return out;
379
+ }
380
+
381
+ // Indice del sottocomando git oltre le opzioni globali, o -1 se lo precede
382
+ // un'opzione sconosciuta o manca.
383
+ function gitSubcommandIndex(operands) {
384
+ const w = walkGitGlobals(operands);
385
+ return w.known ? w.sub : -1;
386
+ }
387
+
388
+ // #275: come gitSubcommandIndex ma anche dopo un'opzione globale sconosciuta
389
+ // (fail toward scanning: l'esenzione no, la scansione sì).
390
+ function gitSubcommandIndexLoose(operands) {
391
+ return walkGitGlobals(operands).sub;
334
392
  }
335
393
 
336
394
  // Indici dei pathspec di un sottocomando in GIT_PATHSPEC_SUBCOMMANDS. git
@@ -374,7 +432,7 @@ function copyDestinationIndices(operands) {
374
432
  // controllo; ogni altro token resta controllato, quindi l'esenzione non può
375
433
  // riciclare una lettura altrove nello statement.
376
434
  function nameOnlyTokenIndices(bin, tokens) {
377
- const operands = tokens.slice(1).map((t) => stripQuotes(stripTrailingPunct(t)));
435
+ const operands = tokens.slice(1).map((t) => unquoteWord(stripTrailingPunct(t)));
378
436
  let indices = NO_SKIP;
379
437
  if (bin === 'git') indices = gitPathspecIndices(operands);
380
438
  else if (bin === 'cp' || bin === 'mv') indices = copyDestinationIndices(operands);
@@ -386,22 +444,225 @@ function nameOnlyTokenIndices(bin, tokens) {
386
444
  // yet re-tokenized) so the caller can recurse into it as a nested command.
387
445
  // Best-effort: only the common single-quoted-argument idiom, not every way a
388
446
  // shell can be told to execute a string.
447
+ // #276: ogni token si legge come la shell esterna (`\\` dentro apici doppi diventa `\`)
448
+ // prima del join, così la shell interna vede lo stesso testo.
389
449
  function extractNestedCommand(stmtTokens) {
390
450
  if (!stmtTokens.length) return null;
391
451
  const bin = basename(stmtTokens[0]);
392
452
  if (bin === 'eval') {
393
453
  const rest = stmtTokens.slice(1);
394
454
  if (!rest.length) return null;
395
- return stripQuotes(rest.join(' '));
455
+ return rest.map(unquoteWord).join(' ');
396
456
  }
397
457
  if (SHELL_DASH_C_VERBS.has(bin)) {
398
458
  const cIdx = stmtTokens.indexOf('-c');
399
459
  if (cIdx === -1 || cIdx + 1 >= stmtTokens.length) return null;
400
- return stripQuotes(stmtTokens.slice(cIdx + 1).join(' '));
460
+ return stmtTokens.slice(cIdx + 1).map(unquoteWord).join(' ');
401
461
  }
402
462
  return null;
403
463
  }
404
464
 
465
+ // #273: `git -c key=value` / `--config-env=key=ENV` can name a COMMAND git runs
466
+ // (an alias with `!`, a pager, an editor, an ssh command, ...), which the plain
467
+ // token walk only tests as a file name. The key families git runs as a command,
468
+ // matched on the lowercased key (section and variable are case-insensitive; a
469
+ // lowercased middle segment only widens a wildcard). `bang` = the value is a
470
+ // command only when it starts with `!` (the `!` is not part of the command).
471
+ const GIT_COMMAND_KEYS = [
472
+ { re: /^alias\..+$/, bang: true },
473
+ { re: /^core\.(pager|editor|sshcommand|fsmonitor)$/ },
474
+ { re: /^pager\..+$/ },
475
+ { re: /^sequence\.editor$/ },
476
+ { re: /^diff\.external$/ },
477
+ { re: /^diff\..+\.textconv$/ },
478
+ { re: /^filter\..+\.(clean|smudge|process)$/ },
479
+ { re: /^credential\.(.+\.)?helper$/, bang: true },
480
+ { re: /^gpg\.(.+\.)?program$/ },
481
+ // #275: more keys git runs as a command.
482
+ { re: /^core\.(askpass|gitproxy)$/ },
483
+ { re: /^(mergetool|difftool|browser|man)\..+\.cmd$/ },
484
+ { re: /^merge\..+\.driver$/ },
485
+ { re: /^uploadpack\.packobjectshook$/ },
486
+ { re: /^diff\..+\.command$/ },
487
+ ];
488
+
489
+ // `-ckey=value` (value glued on, no space).
490
+ function isGluedGitConfig(t) {
491
+ return t.length > 2 && t.startsWith('-c') && !t.startsWith('--');
492
+ }
493
+
494
+ // The word as the shell hands it over: quote pairs removed, their content kept
495
+ // (`alias.x="!cat .env"` -> `alias.x=!cat .env`). Backslashes follow the shell:
496
+ // outside quotes one escapes the next char (backslash-newline vanishes), inside
497
+ // double quotes only before `"`, `\`, `$`, a backtick or a newline, and inside
498
+ // single quotes nothing is special. No expansions: best-effort like the rest.
499
+ function shellUnquote(word) {
500
+ const chars = [...String(word)];
501
+ let out = '';
502
+ let q = null;
503
+ for (let i = 0; i < chars.length; i++) {
504
+ const ch = chars[i];
505
+ if (q === "'") { if (ch === "'") q = null; else out += ch; continue; }
506
+ if (ch === '\\' && i + 1 < chars.length) {
507
+ const nx = chars[i + 1];
508
+ if (q === null || '"\\$`\n'.includes(nx)) {
509
+ i++;
510
+ if (nx !== '\n') out += nx;
511
+ continue;
512
+ }
513
+ }
514
+ if (q === '"') { if (ch === '"') q = null; else out += ch; }
515
+ else if (ch === '"' || ch === "'") q = ch;
516
+ else out += ch;
517
+ }
518
+ return out;
519
+ }
520
+
521
+ // The command text a git config value carries for `key`, or null when the key
522
+ // is not command-valued (or `!`-less where the `!` is required).
523
+ function isGitCommandKey(key) {
524
+ return GIT_COMMAND_KEYS.some((k) => k.re.test(key.toLowerCase()));
525
+ }
526
+
527
+ function gitConfigCommand(key, value) {
528
+ const spec = GIT_COMMAND_KEYS.find((k) => k.re.test(key.toLowerCase()));
529
+ if (!spec) return null;
530
+ if (spec.bang) return value.startsWith('!') && value.length > 1 ? value.slice(1) : null;
531
+ return value ? value : null;
532
+ }
533
+
534
+ // #273: environment variables whose value git (or ssh under it) runs as a command.
535
+ const GIT_COMMAND_ENV_VARS = [
536
+ 'GIT_PAGER', 'PAGER', 'GIT_SSH_COMMAND', 'GIT_SSH', 'GIT_EXTERNAL_DIFF', 'GIT_EDITOR',
537
+ 'GIT_SEQUENCE_EDITOR', 'EDITOR', 'VISUAL', 'GIT_ASKPASS', 'SSH_ASKPASS',
538
+ ];
539
+
540
+ // Entries of a GIT_CONFIG_PARAMETERS value (space-separated `'key=value'` or
541
+ // `'key'='value'`) as [key, value] pairs, or null when the quoting is unbalanced.
542
+ function parseGitConfigParameters(text) {
543
+ const entries = [];
544
+ for (const word of tokenize(text)) {
545
+ const kv = splitAssignment(shellUnquote(word));
546
+ if (kv) entries.push(kv);
547
+ }
548
+ const quotes = (text.match(/'/g) || []).length;
549
+ return quotes % 2 === 0 ? entries : null;
550
+ }
551
+
552
+ // Splits `NAME=value` at the first `=` (null when there is none or the name is empty).
553
+ function splitAssignment(kv) {
554
+ const eq = kv.indexOf('=');
555
+ return eq <= 0 ? null : [kv.slice(0, eq), kv.slice(eq + 1)];
556
+ }
557
+
558
+ // #275: `git config` options that take a separate value, and the ones that only read.
559
+ const GIT_CONFIG_VALUE_OPTIONS = new Set(['-f', '--file', '--blob', '-t', '--type', '--default', '--comment', '--value']);
560
+ const GIT_CONFIG_FLAGS = new Set([
561
+ '--global', '--system', '--local', '--worktree', '--add', '--replace-all', '--append', '--all',
562
+ '--bool', '--int', '--bool-or-int', '--path', '--expiry-date', '--includes', '--no-includes',
563
+ '--null', '-z', '--fixed-value', '--show-origin', '--show-scope', '--name-only', '--no-type',
564
+ ]);
565
+ const GIT_CONFIG_READ_OPTIONS = new Set([
566
+ '--get', '--get-all', '--get-regexp', '--get-urlmatch', '--get-color', '--get-colorbool', '-l',
567
+ '--list', '--unset', '--unset-all', '-e', '--edit', '--rename-section', '--remove-section',
568
+ ]);
569
+ const GIT_CONFIG_READ_WORDS = new Set([
570
+ 'get', 'list', 'unset', 'edit', 'rename-section', 'remove-section',
571
+ ]);
572
+
573
+ // #275: the [key, value] pairs a `git config` invocation writes. `args` = the words after
574
+ // `config`. The first positional is the key and the second the value (a leading `set`
575
+ // is the mode word); read verbs write nothing. An option the walk does not know could
576
+ // hide which word is the value, so each positional after a command-valued key is also
577
+ // taken as its value (fail toward scanning). A single `key=value` token is not a write.
578
+ function gitConfigWritePairs(args) {
579
+ const positional = [];
580
+ let unknown = false;
581
+ for (let k = 0; k < args.length; k++) {
582
+ const t = args[k];
583
+ // #275: `--` ends the options, every later word is a positional (`-n; cat .env` too).
584
+ if (t === '--') { positional.push(...args.slice(k + 1)); break; }
585
+ if (GIT_CONFIG_READ_OPTIONS.has(t)) return [];
586
+ if (!t.startsWith('-') || t === '-') {
587
+ if (positional.length === 0 && GIT_CONFIG_READ_WORDS.has(t)) return [];
588
+ if (positional.length === 0 && t === 'set') continue;
589
+ positional.push(t);
590
+ } else if (GIT_CONFIG_VALUE_OPTIONS.has(t)) k++;
591
+ else if (!GIT_CONFIG_FLAGS.has(t) && !/^--(file|blob|type|default|comment|value)=/.test(t)) unknown = true;
592
+ }
593
+ const pairs = positional.length >= 2 ? [[positional[0], positional[1]]] : [];
594
+ if (unknown) {
595
+ positional.forEach((key, i) => {
596
+ if (i + 1 < positional.length && isGitCommandKey(key)) pairs.push([key, positional[i + 1]]);
597
+ });
598
+ }
599
+ return pairs;
600
+ }
601
+
602
+ // Every command text carried in the git global options of a statement, from
603
+ // `-c key=value`, `-ckey=value` and `--config-env[=| ]key=ENV`, plus the
604
+ // `--config-env` forms on a command-valued key whose ENV the statement does not
605
+ // assign (`unresolved`: the guard cannot see the value, so it fails closed).
606
+ // ENV is resolved from the statement's own leading NAME=value assignments.
607
+ function extractGitConfigCommands(tokens) {
608
+ const found = { commands: [], unresolved: [], unparsed: [] };
609
+ const first = skipEnvPrefix(tokens);
610
+ if (first >= tokens.length || basename(shellUnquote(tokens[first])) !== 'git') return found;
611
+ const env = new Map();
612
+ for (const a of tokens.slice(0, first)) {
613
+ const kv = splitAssignment(shellUnquote(a));
614
+ if (kv) env.set(kv[0], kv[1]);
615
+ }
616
+ const add = (key, value) => {
617
+ const cmd = gitConfigCommand(key, value);
618
+ if (cmd) found.commands.push(cmd);
619
+ };
620
+ // #273: config injected through the statement's own leading assignments. Pairs
621
+ // are read regardless of GIT_CONFIG_COUNT (fail toward scanning).
622
+ for (const [name, value] of env) {
623
+ const m = /^GIT_CONFIG_KEY_(\d+)$/.exec(name);
624
+ if (m && env.has(`GIT_CONFIG_VALUE_${m[1]}`)) add(value, env.get(`GIT_CONFIG_VALUE_${m[1]}`));
625
+ if (GIT_COMMAND_ENV_VARS.includes(name) && value) found.commands.push(value);
626
+ }
627
+ if (env.has('GIT_CONFIG_PARAMETERS')) {
628
+ const text = env.get('GIT_CONFIG_PARAMETERS');
629
+ const entries = parseGitConfigParameters(text);
630
+ if (entries) entries.forEach(([k, v]) => add(k, v));
631
+ else if ((text.match(/[A-Za-z0-9._-]+(?==)/g) || []).some(isGitCommandKey)) found.unparsed.push(`GIT_CONFIG_PARAMETERS=${text}`);
632
+ }
633
+ const operands = tokens.slice(first + 1).map(shellUnquote);
634
+ for (const cfg of walkGitGlobals(operands).configs) {
635
+ const kv = splitAssignment(cfg.value);
636
+ if (!kv) continue;
637
+ if (cfg.kind === 'c') { add(kv[0], kv[1]); continue; }
638
+ if (!isGitCommandKey(kv[0])) continue;
639
+ if (env.has(kv[1])) add(kv[0], env.get(kv[1])); else found.unresolved.push(cfg.sample);
640
+ }
641
+ // #275: `git config <key> <value>` writes a command into the config.
642
+ const sub = gitSubcommandIndexLoose(operands);
643
+ if (sub !== -1 && operands[sub] === 'config') {
644
+ gitConfigWritePairs(operands.slice(sub + 1)).forEach(([k, v]) => add(k, v));
645
+ }
646
+ return found;
647
+ }
648
+
649
+ // The unmodelled-hit shape (lib/unmodelled.js) for a `--config-env` on a command key whose
650
+ // variable the statement does not assign.
651
+ const GIT_CONFIG_ENV_UNMODELLED = (sample) => ({
652
+ id: 'git-config-env',
653
+ label: 'a git --config-env on a command-valued key whose variable is not assigned in the same statement',
654
+ hint: 'assign it in the same statement (`VAR="cmd" git --config-env=key=VAR ...`) or pass the command with `git -c key=cmd`',
655
+ sample,
656
+ });
657
+
658
+ // The unmodelled-hit shape for a GIT_CONFIG_PARAMETERS value the guard cannot parse.
659
+ const GIT_CONFIG_PARAMETERS_UNMODELLED = (sample) => ({
660
+ id: 'git-config-parameters',
661
+ label: 'a GIT_CONFIG_PARAMETERS value with unbalanced quoting that names a command-valued key',
662
+ hint: 'pass the command with `git -c key=cmd` or a well-quoted GIT_CONFIG_PARAMETERS',
663
+ sample,
664
+ });
665
+
405
666
  // Scan a single top-level statement for a secret path. Recurses into a
406
667
  // bash -c/eval quoted sub-command first (finding #1), routes grep/rg/ag/sed/
407
668
  // awk through the shared pattern-skipping walk (finding #4), and falls back
@@ -419,6 +680,19 @@ function scanStatementForSecretPath(stmt) {
419
680
  // stray `-f .env` on the wrapper itself) for defence in depth.
420
681
  }
421
682
 
683
+ // #273: a command carried in a git config value (`-c core.pager="cat .env"`).
684
+ const git = extractGitConfigCommands(tokens);
685
+ for (const text of git.commands) {
686
+ const hit = scanBashForSecretPath(text);
687
+ if (hit) return hit;
688
+ }
689
+ if (git.unresolved.length) {
690
+ return { path: git.unresolved[0], unmodelled: GIT_CONFIG_ENV_UNMODELLED(git.unresolved[0]) };
691
+ }
692
+ if (git.unparsed.length) {
693
+ return { path: git.unparsed[0], unmodelled: GIT_CONFIG_PARAMETERS_UNMODELLED(git.unparsed[0]) };
694
+ }
695
+
422
696
  const bin = basename(tokens[0]);
423
697
  if (SEARCH_VERBS.has(bin)) {
424
698
  return scanArgsSkippingPattern(tokens.slice(1), SEARCH_VERB_CONFIG[bin], isSecretPathArg);
@@ -462,13 +736,13 @@ function invokesInterpreter(stmt) {
462
736
  let i = 0;
463
737
  while (i < tokens.length && LEADING_KEYWORDS.has(tokens[i])) i++;
464
738
  i += skipEnvPrefix(tokens.slice(i));
465
- if (i < tokens.length && basename(stripQuotes(tokens[i])) === 'env') {
739
+ if (i < tokens.length && basename(unquoteWord(tokens[i])) === 'env') {
466
740
  i++;
467
741
  while (i < tokens.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]) || tokens[i].startsWith('-'))) {
468
742
  i += ENV_VALUE_OPTIONS.has(tokens[i]) ? 2 : 1;
469
743
  }
470
744
  }
471
- return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(stripQuotes(tokens[i])));
745
+ return i < tokens.length && STDIN_SCRIPT_INTERPRETERS.has(basename(unquoteWord(tokens[i])));
472
746
  }
473
747
 
474
748
  // Walk the command's top-level statements (quote-aware; `|`, `;`, `&&`,
@@ -570,7 +844,7 @@ function detectSecretRead(toolName, toolInput) {
570
844
  if (toolName === 'Bash') {
571
845
  const cmd = input.command || '';
572
846
  const hit = scanBashForSecretPath(cmd);
573
- if (hit) return { path: hit };
847
+ if (hit) return typeof hit === 'object' ? hit : { path: hit }; // object: an unmodelled hit (#273)
574
848
  const um = unmodelledSecretPiece(cmd); // a final gate: the precise scan keeps priority
575
849
  return um ? { path: um.piece, unmodelled: um.hit } : null;
576
850
  }