@lannguyensi/harness 0.42.0 → 0.44.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 (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/dist/cli/approve/branch-protection.js +1 -1
  3. package/dist/cli/approve/branch-protection.js.map +1 -1
  4. package/dist/cli/dry-run.js +19 -1
  5. package/dist/cli/dry-run.js.map +1 -1
  6. package/dist/cli/index.js +4 -3
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/cli/init/composer.js +6 -6
  9. package/dist/cli/init/composer.js.map +1 -1
  10. package/dist/cli/init/profiles.d.ts +1 -1
  11. package/dist/cli/init/profiles.js +1 -1
  12. package/dist/cli/init/templates.d.ts +1 -1
  13. package/dist/cli/init/templates.js +53 -31
  14. package/dist/cli/init/templates.js.map +1 -1
  15. package/dist/cli/pack/hook-branch-protection.js +1 -1
  16. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  17. package/dist/cli/pack/hook-post-merge-gate.js +43 -25
  18. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  19. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +1 -1
  20. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  21. package/dist/cli/pack/hook-solution-acceptance.js +1 -1
  22. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  23. package/dist/cli/policy/intercept.js +209 -14
  24. package/dist/cli/policy/intercept.js.map +1 -1
  25. package/dist/cli/validate/checks.js +2 -2
  26. package/dist/cli/validate/checks.js.map +1 -1
  27. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +1 -1
  28. package/dist/policy-packs/builtin/branch-protection-runtime.js +1 -1
  29. package/dist/policy-packs/builtin/branch-protection.js +2 -2
  30. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  31. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +90 -10
  32. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
  33. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
  34. package/dist/policy-packs/builtin/post-merge-gate.js +40 -23
  35. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  36. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +13 -0
  37. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +15 -2
  38. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  39. package/dist/policy-packs/builtin/solution-acceptance.js +1 -1
  40. package/dist/runtime/bash-match-facts.d.ts +58 -0
  41. package/dist/runtime/bash-match-facts.js +194 -0
  42. package/dist/runtime/bash-match-facts.js.map +1 -0
  43. package/dist/runtime/bash-match-registry.d.ts +75 -0
  44. package/dist/runtime/bash-match-registry.js +359 -0
  45. package/dist/runtime/bash-match-registry.js.map +1 -0
  46. package/dist/runtime/bash-prefix-parse.js +23 -0
  47. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  48. package/dist/runtime/command-normalize.d.ts +405 -0
  49. package/dist/runtime/command-normalize.js +1888 -0
  50. package/dist/runtime/command-normalize.js.map +1 -0
  51. package/dist/runtime/index.d.ts +1 -1
  52. package/dist/runtime/index.js +1 -1
  53. package/dist/runtime/index.js.map +1 -1
  54. package/dist/runtime/intercept.d.ts +208 -2
  55. package/dist/runtime/intercept.js +437 -28
  56. package/dist/runtime/intercept.js.map +1 -1
  57. package/dist/runtime/read-only-bash.d.ts +43 -0
  58. package/dist/runtime/read-only-bash.js +73 -10
  59. package/dist/runtime/read-only-bash.js.map +1 -1
  60. package/dist/runtime/shell-word.d.ts +21 -0
  61. package/dist/runtime/shell-word.js +242 -0
  62. package/dist/runtime/shell-word.js.map +1 -0
  63. package/dist/schema/policies.js +1 -1
  64. package/dist/schema/policies.js.map +1 -1
  65. package/package.json +4 -1
@@ -0,0 +1,1888 @@
1
+ // Trigger-matching normaliser for Bash command strings.
2
+ //
3
+ // STATUS (read this before wiring anything new to `targetDir`/
4
+ // `targetBase`): this module has two independent outputs with different
5
+ // consumption status. `normalized` — the command with wrapper prefixes
6
+ // peeled and a git invocation's own global options collapsed — is LIVE:
7
+ // `policyMatchesEvent` (`src/runtime/intercept.ts`) tests every
8
+ // `bash_match` regex against the raw command first and, only on a raw
9
+ // miss, against this normalised form too (raw-OR-normalised, never raw-
10
+ // replaced-by-normalised), closing a class of silent trigger bypasses
11
+ // (see below) — and, only on a further miss of THIS form, against a
12
+ // SECOND, independent, ampersand-aware normalisation pass (`AMP_BOUNDARY_RE`
13
+ // / `normalizeCommandAmpAware` below, task aabbad63) that this header
14
+ // otherwise never mentions; see that function's own comment for its scope
15
+ // and why it carries no `targetDir`/`targetBase` of its own (fix round 1,
16
+ // finding F5 — added so a reader of this header, the module's index,
17
+ // learns a second pass exists at all instead of finding it only by
18
+ // reading past this point). `targetDir` / `targetBase` — the effective target
19
+ // directory of the command's own git invocation(s), when one can be
20
+ // named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
21
+ // this run used them to resolve the `${REPO}`/`${BRANCH}` policy
22
+ // builtins (`src/cli/policy/intercept.ts`) to a command's named target
23
+ // instead of the hook's own cwd; that wiring produced three consecutive
24
+ // review-round security regressions (Risk Gate risk declassification, a
25
+ // push-gate multi-invocation fail-open, and a non-git gated verb leaking
26
+ // a decoy repo's target, first via `&&`, still open via `|`/`||`), each
27
+ // only partially closeable because a single per-event `targetDir` cannot
28
+ // express "this invocation targets repo B, but the gated verb after it
29
+ // runs in the caller's cwd." The wiring was removed before shipping (see
30
+ // `src/cli/policy/intercept.ts` and `CHANGELOG.md`); the extraction and
31
+ // its own ambiguity rules stay here, fully tested, as the foundation for
32
+ // a redesign that attributes a target to the SEGMENT satisfying a
33
+ // policy's own trigger instead of to the whole event — follow-up task
34
+ // `98ad072f`. Nothing below describes gate wiring; it describes what
35
+ // THIS MODULE computes, live or not.
36
+ //
37
+ // UPDATE (task `98ad072f`, run `2026-08-02-per-repo-gate-scoping-
38
+ // redesign`, T-002): the per-segment foundation named above now EXISTS as
39
+ // a real export — `segmentViewOf` / `CommandSegment`, defined below
40
+ // `normalizeCommand` — instead of being only a design intention. It
41
+ // exposes, per boundary-delimited segment, the canonicalised text, the
42
+ // segment's OWN explicit target, and an EFFECTIVE target composed with the
43
+ // last preceding `cd <path>` segment (see `CommandSegment`'s own doc
44
+ // comment for the composition rules and its own not-covered list, which
45
+ // is narrower in places than `targetDir`/`targetBase` above — e.g. no
46
+ // `VAR=value cd <path>` tolerance). `normalizeCommand`'s own `targetDir`/
47
+ // `targetBase` are UNCHANGED by this addition — byte-identical in every
48
+ // pre-existing test.
49
+ //
50
+ // UPDATE (task `98ad072f`, T-003, this run): `segmentViewOf` /
51
+ // `CommandSegment` — NOT `normalizeCommand`'s own `targetDir`/`targetBase`
52
+ // above, which stay exactly as unwired as before this slice — IS now
53
+ // wired to a gate: `src/runtime/intercept.ts`'s `attributeTriggerSegments`
54
+ // re-tests a matched policy's own `bash_match` regex against each
55
+ // segment's `text`, and, for a policy whose `requires:` uses
56
+ // `${REPO}`/`${BRANCH}`/`at_head`, resolves those builtins (and
57
+ // `currentHeadSha`) from the trigger-satisfying segment's
58
+ // `effectiveTarget` instead of the event's cwd — lazily, memoised per
59
+ // resolved path, never touching `resolverGit`/`riskContext`. Trust in
60
+ // `effectiveTarget` is UNIFORM — no distinction between a segment's own
61
+ // explicit target and one inherited from a preceding `cd` (orchestrator
62
+ // decision D-010: an initial revision distinguished the two and was
63
+ // rejected — bash itself draws no such distinction, a `cd <B> && <verb>`
64
+ // chain genuinely runs `<verb>` inside B). See that module's own comments
65
+ // (`attributeTriggerSegments`, `resolveAttributedContexts`) for the
66
+ // consumption rules; this module's OWN extraction and ambiguity rules
67
+ // below are unchanged by being consumed.
68
+ //
69
+ // MEASUREMENT RULE (task 47297478): before measuring THIS module's
70
+ // extraction across builds with an ad-hoc corpus, read the per-arm-gate
71
+ // design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
72
+ // baseline never hit proves nothing, and an ungated zero from an ad-hoc
73
+ // corpus has already been wrong three times in a row (b093911d run).
74
+ // Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
75
+ // extraction; this module's `normalized`/`targetDir`/`targetBase`
76
+ // outputs have no instrument yet, so cross-build claims about them need
77
+ // their own per-arm-gated measurement — that instrument now exists:
78
+ // scripts/measure-command-normalize.mjs (groundwork for task aabbad63, the
79
+ // BOUNDARY_RE bare-`&` fix below). It rebuilds a faithful superset of the
80
+ // reverted fix's 140-form quoted-value regression corpus and gates arm A
81
+ // on bash actually running the verb, per the same never-fold-into-a-zero
82
+ // discipline.
83
+ //
84
+ // THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
85
+ // (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
86
+ // single regex tested against the UNPARSED command string, anchored on a
87
+ // shell boundary (`^ \n ; | && (`) followed by optional `VAR=value`
88
+ // tokens and a literal head token — `git` (most triggers), `gh` (`gh pr
89
+ // merge`/`gh pr create`), `npm` (`npm publish`), or `harness` (the
90
+ // kill-switch verbs). That anchor is exact-spelling brittle: a wrapper
91
+ // binary, a git global option other than `-C`, or even a second space
92
+ // between the head token and its subcommand defeats the match silently —
93
+ // the policy is skipped, no ledger query happens, no audit signal at all.
94
+ // Measured bypasses closed by this module (task `432db3d3`, run
95
+ // `2026-07-28-nongit-trigger-wrappers`, D-001): `env gh pr merge`, `env -C
96
+ // <dir> gh pr merge`, `nice gh pr merge`, `gh pr merge` (double space),
97
+ // `env gh pr create`, `env npm publish`, `nice npm publish`, `env harness
98
+ // pause`, `nice harness pause`, `command harness pause`, `env -C <dir>
99
+ // harness pause` — the exact one-word-wrapper class already fixed for
100
+ // `git` in the prior run, reproduced against `gh`/`npm`/`harness` because
101
+ // the head-token condition at the end of `canonicalizeSegment` checked
102
+ // literal equality with `git` and nothing else.
103
+ //
104
+ // CURRENT RULES — trigger normalisation (peeled/canonicalised into
105
+ // `normalized`):
106
+ // - Leading `VAR=value` tokens (any number, in sequence).
107
+ // - `env`, including `-C <dir>` / `--chdir <dir>` / `-C<dir>` /
108
+ // `--chdir=<dir>` and its own `VAR=value` arguments. The flag-NAME
109
+ // enumeration (which flags exist, not their VALUES) is IMPORTED from
110
+ // `read-only-bash.ts`'s `ENV_LEADING_FLAGS` / `ENV_VALUE_FLAGS` /
111
+ // `ENV_SPLIT_STRING_FLAGS` rather than hand-rolled here a second
112
+ // time, so the two peelers cannot independently drift on which
113
+ // flags exist; `read-only-bash.ts`'s generic "any OTHER glued
114
+ // `--long=value` flag" catch-all is mirrored here too. Each module
115
+ // keeps its own decision about what a recognised flag MEANS: this
116
+ // module additionally needs the VALUE of `-C`/`--chdir` to extract
117
+ // `targetDir`, which `read-only-bash.ts` never needs, so that
118
+ // glued-value parsing (`-C<dir>`, `--chdir=<dir>`) stays local here.
119
+ // - `command` (its own flags, e.g. `-p`/`-v`/`-V`, and `--`).
120
+ // - `nice`, including `-n <n>` / `-n<n>` / `--adjustment=<n>`, and the
121
+ // bare `-<n>` / `+<n>` forms — `nice -10 cmd` (increment glued
122
+ // straight to the leading dash, no `n`) is `nice(1)`'s PRIMARY
123
+ // documented spelling.
124
+ // - `sudo` / `doas` (their own user/group/config flags — see
125
+ // `SUDO_VALUE_FLAGS` / `DOAS_VALUE_FLAGS`), `time` (`-o`/`-f`/
126
+ // `--output`/`--format` plus boolean flags), `timeout` (its own flags
127
+ // PLUS the mandatory leading DURATION positional — see
128
+ // `peelTimeout`), `stdbuf` (`-i`/`-o`/`-e`, always value-taking), and
129
+ // `setsid` (`-w`/`-c`/`-f`, boolean). NOT `xargs` — deliberately
130
+ // excluded, see NOT SUPPORTED below.
131
+ // - A git invocation's own global options: `-C <dir>`, `-c <k=v>`,
132
+ // `--git-dir[= ]<dir>`, `--work-tree[= ]<dir>`, `--no-pager`,
133
+ // `-p`/`--paginate`, `--exec-path[=<path>]`, `--namespace[= ]<ns>`,
134
+ // `--literal-pathspecs`, `--no-replace-objects` — dropped so the
135
+ // subcommand becomes adjacent to the literal `git` token, and the
136
+ // whitespace between them collapses to exactly one space.
137
+ // - The `git` token itself may be path-qualified (`/usr/bin/git`,
138
+ // `./git`, any `\S*/git`), matched by BASENAME (mirrors the existing
139
+ // `deny-kill-switch-bypass` regex's own `(?:npx\s+|\S*/)?harness`
140
+ // shape). The canonicalised output always writes the literal `git`,
141
+ // regardless of how the invocation spelled the binary.
142
+ // - THE CLOSED HEAD-TOKEN SET (D-001, run
143
+ // 2026-07-28-nongit-trigger-wrappers): after the SAME wrapper-peeling
144
+ // loop above, the head token is checked against `git` OR the three
145
+ // other literal head tokens this module covers — `gh` (`gh pr merge`
146
+ // / `gh pr create`), `npm` (`npm publish`), and `harness` (the
147
+ // kill-switch verbs). `git` alone gets the git-specific global-option
148
+ // dropping described above; the other three get wrapper peeling PLUS
149
+ // whitespace collapsing across the REST OF THE SEGMENT (fix round 2,
150
+ // finding F2 — collapsing only the head-to-next-token gap, the
151
+ // original scope, still let an interior whitespace run defeat a
152
+ // multi-word trigger like `gh pr merge`), no option-dropping (`gh
153
+ // -R`, `npm --loglevel`, and any other tool-specific flag stay
154
+ // UNSUPPORTED — see below — because a flag inserted between the head
155
+ // token and its subcommand tokens is not looked past). Matched by
156
+ // EXACT literal equality, not basename like `git` — a path-qualified
157
+ // `gh`/`npm`/`harness` invocation is out of scope for this set (named
158
+ // residual below); `harness`'s own trigger regex already covers a
159
+ // path-qualified spelling at the RAW-match level (`(?:npx\s+|\S*/)?
160
+ // harness` in `deny-kill-switch-bypass`), so nothing is lost there
161
+ // specifically. This is a CLOSED set, not a general "peel wrappers
162
+ // for any head token" rule.
163
+ // - SHIPPED BUT NOT COVERED (fix round 2, finding F1 — corrects an
164
+ // inaccurate claim in the prior version of this comment, which said
165
+ // this module's four head tokens were "the head tokens shipped
166
+ // policies actually gate today"): a shipped `bash_match` trigger
167
+ // actually keys on EIGHT distinct COMMAND-NAME head tokens, not four —
168
+ // plus one HEADLESS alternative with no command name at all:
169
+ // `deny-session-env-strip`'s bare `<SESSION_VAR>=` empty-assignment
170
+ // form, whose wrapped spelling (`nice CLAUDE_SESSION_ID= npm publish`)
171
+ // is equally out of this module's reach, because the peel loop consumes
172
+ // the assignment as an ordinary `VAR=value` prefix (measured
173
+ // 2026-07-28, pinned alongside the four below).
174
+ // `deny-session-env-strip` also keys on `env` (`env ... -u <VAR>` /
175
+ // `env ... --unset <VAR>`) and `unset` (`unset <VAR>...`);
176
+ // `deny-pause-sentinel-forgery` also keys on `tee` and `cp`. None of
177
+ // these four are in the closed set above, and adding them is not
178
+ // simply "the same kind of change again":
179
+ // - `env` is STRUCTURALLY unreachable by this module's own
180
+ // architecture, not merely unimplemented: the wrapper-peeling
181
+ // loop treats `env` ONLY as a pass-through wrapper hunting for a
182
+ // `git`/`gh`/`npm`/`harness` invocation behind it, so it ALWAYS
183
+ // calls `peelEnv` and consumes `env`'s OWN `-u`/`--unset` flags
184
+ // (and their VALUES) as part of that hunt — by the time `headTok`
185
+ // is checked, the very `-u <VAR>` text `deny-session-env-strip`'s
186
+ // trigger keys on has already been peeled away. Recognising
187
+ // `env` as ITSELF a head token would need the SAME literal token
188
+ // to get two different treatments depending on WHY it is there —
189
+ // an architecture change, not a set-membership change — and is
190
+ // out of scope for this closed-set run. Measured, still-open
191
+ // bypass: `nice env -u CLAUDE_SESSION_ID ls` (pinned as a
192
+ // documented ceiling, not silently left to a comment).
193
+ // - `unset` is simply not one of the wrapper-peeling loop's
194
+ // recognised names at all (unlike `env`, there is no
195
+ // `peelUnset`), so a wrapped `unset` invocation is not looked
196
+ // past either. Measured, still-open bypass: `nice unset
197
+ // CLAUDE_SESSION_ID`.
198
+ // - `tee` / `cp` are never wrapper-peeled or recognised as a head
199
+ // token by this module at all; `deny-pause-sentinel-forgery`'s
200
+ // own raw regex is the only thing that ever catches them.
201
+ // Measured, still-open bypasses: `nice tee /tmp/.harness-paused`,
202
+ // `nice cp a /tmp/.harness-paused`.
203
+ // A future policy gating some OTHER head token (one of these four, or
204
+ // any other) stays uncovered by this module too — named residual, no
205
+ // follow-up filed unless a real consumer appears. See
206
+ // `tests/runtime/bash-match-head-token-drift.test.ts` for the guard
207
+ // that couples this module's covered set to what FULL_TEMPLATE's
208
+ // shipped `bash_match` policies actually key on today.
209
+ //
210
+ // CURRENT RULES — target-directory extraction (`targetDir`/`targetBase`;
211
+ // UNCONSUMED — see STATUS above):
212
+ // - The effective target directory of the command's git invocation(s),
213
+ // from (in priority order per invocation) the git invocation's own
214
+ // `-C` / `--work-tree` / `--git-dir` (parent directory when the path
215
+ // ends in `.git`), the wrapping `env -C` / `--chdir` on the SAME
216
+ // invocation, or — only when NO invocation named one explicitly — a
217
+ // leading `cd <dir> &&|;` prefix (delegated to `bash-prefix-
218
+ // parse.ts`, which already parses that idiom). A `~`-prefixed value
219
+ // is treated as though no target were named AT ALL: this module does
220
+ // not expand `~`, and letting it resolve against the caller's cwd
221
+ // would produce a CONFIDENTLY WRONG answer (an unrelated repo found
222
+ // by walking up from a bogus path) instead of the documented "no
223
+ // target" fallback.
224
+ // - `targetBase`: when exactly one git invocation named an explicit
225
+ // target (or several agree on the SAME one) and it is a RELATIVE
226
+ // path, this names the directory it is relative to, when this
227
+ // module saw one — the wrapping `env -C` on the SAME invocation
228
+ // (highest priority), else a leading `cd <dir> &&|;` prefix (`cd
229
+ // <repo> && git -C src status` resolves `src` against `<repo>`, not
230
+ // a caller's own cwd). `null` when neither is present — a
231
+ // hypothetical future consumer resolves `targetDir` against its own
232
+ // cwd instead.
233
+ // - When a command names MORE THAN ONE git invocation, `targetDir` (and
234
+ // `targetBase`) are populated only when every invocation AGREES: all
235
+ // of them name the SAME explicit target, or NONE of them do (falling
236
+ // through to the leading-`cd` case above). A command mixing a bare
237
+ // invocation with an explicitly-targeted one — `git -C <B> log &&
238
+ // git push` — or naming two DIFFERENT explicit targets, is AMBIGUOUS
239
+ // and `targetDir` is `null`.
240
+ // - "Every invocation agrees" is ALSO broken by a non-git command that
241
+ // shares the chain: a command-starting segment (follows
242
+ // `&&`/`;`/`\n`/`(`, or is the first segment — never a bare `|`,
243
+ // which stays in the SAME directory) that is NEITHER a git
244
+ // invocation NOR the recognised leading `cd` prefix forces
245
+ // `targetDir` to `null` too, the same conservative fallback used for
246
+ // git-vs-git disagreement — measured case: `git -C <B> rev-parse
247
+ // HEAD && gh pr merge`, where `-C` scopes only the ONE git call it
248
+ // decorates and `gh pr merge` genuinely runs at the real, unaffected
249
+ // cwd. The leading `cd` prefix stays exempt on purpose: unlike `-C`,
250
+ // `cd` genuinely persists for the rest of the chain, so `cd <B> &&
251
+ // gh pr create` really does run against B — only a PER-INVOCATION
252
+ // override (`-C`, `--work-tree`, `--git-dir`, wrapping `env -C`) can
253
+ // leak across an unrelated later command; a persistent `cd` cannot,
254
+ // by construction.
255
+ //
256
+ // DELIBERATELY NOT SUPPORTED, and out of reach of ANY string-level
257
+ // approach (see `CHANGELOG.md` task `2cc73f55`, decision D-005):
258
+ // - `sh -c '...'` / `bash -c '...'`: the wrapped command lives inside a
259
+ // single string argument. This module does not parse into it — note
260
+ // that such a command usually still matches a `bash_match` regex
261
+ // anyway, because the regex scans the RAW command string without
262
+ // respecting quoting, so a shell boundary (`&&`, `;`, ...) inside the
263
+ // quoted string is still "seen".
264
+ // - Heredocs, `eval`, base64-decoded payloads, or a script file the
265
+ // agent writes and then executes.
266
+ // - `pushd`/`popd`, and a `cd` inside a nested subshell
267
+ // (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
268
+ // - A shell-boundary character INSIDE a quoted assignment VALUE
269
+ // (`VAR='a; b' git push` — task 13e55484's pinned residual):
270
+ // `BOUNDARY_RE` splits segments BEFORE tokenisation and is quote-
271
+ // unaware, so the quoted value is cut at the `;` and neither
272
+ // resulting segment carries a recognisable invocation. Closing this
273
+ // means a quote-aware segmenter, a different (and riskier) change
274
+ // than the assignment-value continuation that task shipped.
275
+ // - A quoted assignment VALUE containing the literal word `git`/`gh`/
276
+ // `npm`/`harness` as its own token (`VAR='a git b' git push`): the
277
+ // continuation's one-directional guard abandons rather than risk
278
+ // swallowing a real head token (see `consumeAssignment`), leaving
279
+ // the byte-exact pre-task behaviour on those spellings.
280
+ // - Quoted directory arguments containing whitespace (e.g. `git -C
281
+ // '/path with spaces' status`): the tokeniser splits on whitespace
282
+ // without quote-awareness, so such a case falls through to "no git
283
+ // invocation found here" and the segment is left unchanged. Safe
284
+ // (never a false positive), just not one of the covered shapes.
285
+ // - `xargs git status`: unlike `env`/`command`/`nice`/`sudo`/`doas`/...,
286
+ // `xargs`'s own argv is not simply "the command to run" — it appends
287
+ // stdin lines as trailing arguments and may invoke the wrapped
288
+ // command MULTIPLE times, or zero, depending on stdin. Different
289
+ // enough semantics that peeling it the same way would be misleading,
290
+ // not just incomplete, so it is deliberately excluded, not merely
291
+ // missed.
292
+ // - A quoted git subcommand (`git "status"`): the tokeniser sees the
293
+ // literal token `"status"` (quotes included) and canonicalises to
294
+ // `git "status"`, which does not satisfy a `bash_match` regex
295
+ // expecting a bare word boundary. The same class of problem as the
296
+ // whitespace-in-quoted-path gap above.
297
+ // - Backtick command substitution wrapping the real invocation, e.g.
298
+ // `` echo `env -C /tmp git status` ``: this module canonicalises the
299
+ // OUTER command only and does not recurse into a substitution's
300
+ // contents — the same boundary `sh -c` stops at, above.
301
+ // - `$(...)` command substitution is NOT in this list — see the
302
+ // "accidentally covered" note right below. Do not read its absence
303
+ // here as "also open"; it genuinely blocks today, pinned by a test.
304
+ // - Ten more measured-bypass spellings, none of them peeled wrapper
305
+ // prefixes or a recognised `git` spelling: `exec git status`, `nohup
306
+ // git status`, `ionice -c3 git status`, `flock /tmp/l git status`,
307
+ // `script -q /dev/null git status`, `chrt -b 0 git status`, `taskset
308
+ // -c 0 git status`, `\git status` (the backslash defeats
309
+ // `GIT_TOKEN_RE`, which requires the bare basename), `"git" status`
310
+ // (a QUOTED binary name — the mirror image of the quoted-subcommand
311
+ // gap above), and `env -S "git status"` (the split-string flag hands
312
+ // the rest of the argv to a fresh, opaque re-parse this module
313
+ // deliberately does not follow, per `peelEnv`'s own comment).
314
+ // - `gh`'s / `npm`'s own tool-specific flags inserted BETWEEN the head
315
+ // token and its subcommand: `gh -R owner/repo pr merge`, `gh --repo
316
+ // owner/repo pr create`, `npm --loglevel=silent publish`, `npm
317
+ // --registry <url> publish`. Only wrapper prefixes (`env`, `nice`,
318
+ // `command`, ...) and whitespace collapsing across the rest of the
319
+ // segment (see the closed head-token-set rule above) are peeled for
320
+ // these three heads — a flag belonging to `gh`/`npm` itself is never
321
+ // recognised or skipped, so it defeats the match the same way an
322
+ // unrecognised git flag would if `peelGitGlobalOptions` did not know
323
+ // its name.
324
+ // - A path-qualified `gh`/`npm`/`harness` invocation (`/usr/local/bin/gh
325
+ // pr merge`, `./node_modules/.bin/npm publish`): the closed
326
+ // head-token-set check is EXACT literal equality, not basename like
327
+ // `GIT_TOKEN_RE`. `harness`'s own trigger regex already covers a
328
+ // path-qualified spelling at the raw-match level, so nothing is lost
329
+ // there specifically; `gh`/`npm` genuinely have no coverage for this
330
+ // shape.
331
+ //
332
+ // KNOWN OVER-MATCHING (FALSE POSITIVE) CLASS — `command -v NAME` / `sudo
333
+ // -l [NAME]` (fix round 2, finding F5; doc-only, no behaviour change):
334
+ // both are INTROSPECTION forms — `command -v NAME` prints what `NAME`
335
+ // resolves to without running it, `sudo -l [NAME]` lists (without
336
+ // running) whether `NAME` is permitted — but this module's wrapper
337
+ // peeling treats `-v` (an otherwise-unrecognised `command` flag) and `-l`
338
+ // (an otherwise-unrecognised `sudo` flag) as ordinary BOOLEAN flags to
339
+ // skip past, exactly like any other flag either wrapper legitimately
340
+ // takes before its real, EXECUTED payload. `command -v harness pause`
341
+ // therefore canonicalises to `harness pause` and DENIES via
342
+ // `deny-kill-switch-bypass` even though nothing was actually paused
343
+ // (measured). This predates this run — `sudo -l git status` was already
344
+ // a false positive under the git-only normaliser — this run only widens
345
+ // it to also cover `gh`/`npm`/`harness`. Fail-CLOSED direction (an
346
+ // operator gets an unnecessary deny, never a missed real gate), so left
347
+ // as-is rather than special-cased; noted here so it is not mistaken for
348
+ // a security hole if reported.
349
+ //
350
+ // SAME CLASS, second member (task 13e55484, review round 1): a QUOTED
351
+ // ASSIGNMENT INSIDE TEXT at a segment-start position now over-matches.
352
+ // `BOUNDARY_RE` splits segments with no quote awareness, so in
353
+ // `echo "a; VAR='x y' git push"` the text after the `;` becomes its own
354
+ // segment, the assignment continuation normalises it to `git push`, and
355
+ // the push gate DENIES a command that only PRINTS the spelling
356
+ // (measured; master allowed it). Fail-closed direction, accepted and
357
+ // pinned in the test file as a boundary assertion — the practical cost
358
+ // is that documenting or testing that bypass class through a Bash call
359
+ // trips the gate, the same cost the dbc6d303 quoted/heredoc class
360
+ // already carries.
361
+ //
362
+ // SAME CLASS, third member (task aabbad63, this run's own amp-aware
363
+ // second pass): a WIDENING of the second member above, not a new class —
364
+ // `AMP_BOUNDARY_RE` is quote-unaware in exactly the same way `BOUNDARY_RE`
365
+ // is, so a bare `&` INSIDE a quoted string is now also a segment
366
+ // boundary, and a wrapper spelling sitting behind it can be peeled and
367
+ // canonicalised as though it were a real invocation. Measured on a
368
+ // 160-form quoted-text corpus arbitrated by real bash (PATH-shimmed
369
+ // verbs + a marker file the verb would touch if it actually ran): 144
370
+ // spellings of `<text> & <wrapper> <gated verb>` INSIDE a quoted string
371
+ // now match where the base (pre-aabbad63) build did not, and bash ran
372
+ // the gated verb in 0 of them — a false alarm on a command that only
373
+ // PRINTS the spelling, never a missed real gate. The wrapper-LESS
374
+ // variant of the same shape (`echo 'a & git push'`) already false-
375
+ // alarmed on the base build 16/16 via the RAW regex alone, because every
376
+ // shipped `bash_match` trigger already anchors on a bare `&` (task
377
+ // `d834a065`) — so this is a WIDENING of an already-accepted over-block
378
+ // class to the wrapped spellings of it, not a new false-positive class
379
+ // this task introduces. The opposite direction, measured on the general
380
+ // (non-adversarial) corpus rather than this quoted-text one: of 276
381
+ // matches this pass gains there, all 151 classifiable ones were TRUE
382
+ // positives, 0 false alarms.
383
+ //
384
+ // `$(...)` command substitution is ACCIDENTALLY covered — not a
385
+ // deliberate feature, and distinct from the backtick case above, which
386
+ // is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
387
+ // shell-boundary token (needed for the leading-command case, `(cd X &&
388
+ // ...)`), and `$(...)`'s opening paren is, character-for-character, the
389
+ // same `(`. So `echo $(env -C /tmp git status)` splits into a segment
390
+ // starting exactly at that `(`, `canonicalizeSegment` finds a real git
391
+ // invocation inside it, and the close paren rides along harmlessly as
392
+ // trailing text after the subcommand (`git status)` still satisfies a
393
+ // `...status\b` regex, since `)` is a non-word character). This is
394
+ // coincidental: `BOUNDARY_RE` was never designed with `$(...)` in mind,
395
+ // and an unrelated future change to it could silently drop this coverage
396
+ // with no test noticing — pinned by a dedicated test for exactly that
397
+ // reason.
398
+ //
399
+ // Above `MAX_NORMALIZE_LENGTH` characters, normalisation is skipped
400
+ // entirely and the command is returned unchanged: `harness policy
401
+ // intercept` runs on every Bash/Edit/Write tool call and `require-
402
+ // preflight-evidence` declares `budget_ms: 1000`, so command SIZE must
403
+ // never be able to drive the hook past its own timeout budget. The RAW
404
+ // command is still tested by `policyMatchesEvent` regardless (raw-OR-
405
+ // normalised construction), so an oversized command only loses the
406
+ // ADDITIONAL normalised-form coverage, never the baseline one.
407
+ // `NormalizedCommand.truncated` reports the skip back to the caller;
408
+ // `runInterceptCli` (`src/cli/policy/intercept.ts`) writes exactly one
409
+ // stderr line when it sees `truncated: true`, keeping this module itself
410
+ // pure and I/O-free (see below) while making the skip observable at the
411
+ // one place that already owns a stderr stream for this event.
412
+ //
413
+ // Bounded, allocation-light, pure string work: no `fs`, no
414
+ // `child_process`, no network — `node:path` is used ONLY for
415
+ // `path.isAbsolute`, a synchronous string check with no I/O. This runs on
416
+ // every Bash/Edit/Write tool call, so it must stay cheap: `findNextBoundary`
417
+ // is a single combined-alternation regex scan rather than one `indexOf`
418
+ // per boundary token (a command with many segments and at least one
419
+ // boundary kind that never occurs anywhere would otherwise degenerate to
420
+ // O(segments × length) — see `findNextBoundary`'s own comment). Never
421
+ // throws: a malformed or unparseable command falls through cleanly and
422
+ // `normalized` is the input unchanged.
423
+ import * as path from "node:path";
424
+ import { parseBashPrefix } from "./bash-prefix-parse.js";
425
+ import { ENV_LEADING_FLAGS, ENV_SPLIT_STRING_FLAGS, ENV_VALUE_FLAGS, } from "./read-only-bash.js";
426
+ const VAR_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
427
+ /**
428
+ * The `git` token itself, matched by BASENAME so a path-qualified
429
+ * invocation (`/usr/bin/git`, `./git`) is recognised too (F4 fix).
430
+ * Anchored to the WHOLE token (not a substring) so `mygit` / `git-foo`
431
+ * still correctly fail to match — `\S*` only ever contributes characters
432
+ * immediately before a literal `/`, never before `git` directly.
433
+ * Exported (fix round 2, finding F3) so
434
+ * `tests/runtime/bash-match-head-token-drift.test.ts` can couple this
435
+ * module's covered set to what FULL_TEMPLATE's shipped `bash_match`
436
+ * policies actually key on today, instead of a test-owned duplicate.
437
+ */
438
+ export const GIT_TOKEN_RE = /^(?:\S*\/)?git$/;
439
+ /**
440
+ * The three OTHER head tokens this module covers (D-001, run
441
+ * 2026-07-28-nongit-trigger-wrappers) — `gh` (`gh pr merge` / `gh pr
442
+ * create`), `npm` (`npm publish`), `harness` (the kill-switch verbs). A
443
+ * closed set, deliberately NOT a general "any head token" rule (see the
444
+ * module header): matched by EXACT literal equality, unlike `GIT_TOKEN_RE`'s
445
+ * basename match, so a path-qualified spelling of any of these three stays
446
+ * out of scope (named residual, module header NOT-SUPPORTED list). This is
447
+ * NOT the complete set of head tokens a shipped `bash_match` trigger keys
448
+ * on — see the module header's "SHIPPED BUT NOT COVERED" paragraph (fix
449
+ * round 2, finding F1) for the four that are NOT in this set (`env`,
450
+ * `unset`, `tee`, `cp`) and why. Exported (fix round 2, finding F3) for the
451
+ * same drift-guard reason as `GIT_TOKEN_RE` above.
452
+ */
453
+ export const NON_GIT_HEAD_TOKENS = new Set(["gh", "npm", "harness"]);
454
+ /**
455
+ * Above this length, normalisation is skipped entirely and the command
456
+ * is returned unchanged (F3 fix — see the module header for why command
457
+ * SIZE must be bounded even after `findNextBoundary` was made linear).
458
+ * 100k characters comfortably covers any command a human or agent would
459
+ * plausibly type, while keeping the worst case a small constant instead
460
+ * of scaling with whatever ends up pasted into a single Bash call.
461
+ * Exported (G4 fix, review round 2) so a caller reporting the skip (see
462
+ * `NormalizedCommand.truncated`) can reference the same number instead of
463
+ * hand-copying it into a diagnostic message.
464
+ */
465
+ export const MAX_NORMALIZE_LENGTH = 100_000;
466
+ /**
467
+ * Shell boundary tokens a `bash_match` regex can anchor on, expressed as
468
+ * ONE alternation so `findNextBoundary` does a single regex scan per
469
+ * remaining span instead of one `indexOf` per token (F3 fix, review
470
+ * round 2026-07-27). Order among the alternatives does not affect
471
+ * correctness: no two of these tokens can start at the same string
472
+ * position (only `&&` is multi-character, and no other alternative
473
+ * starts with `&`), so there is no tie for the regex engine's leftmost-
474
+ * match rule to break.
475
+ *
476
+ * DELIBERATELY MISSING, STILL: single `&`, even though bash starts a new
477
+ * command after it. Adding it HERE was tried and REVERTED after
478
+ * measurement: this regex is quote-unaware, `&` inside a quoted
479
+ * assignment value is common, and the split lands mid-quote, so the
480
+ * value continuation never engages and the wrapper is never peeled.
481
+ * Measured against the pre-change build with a passing positive
482
+ * control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
483
+ * spellings LOST a gate they previously held, including the
484
+ * `operator_only` kill-switch deny. That evidence is why THIS alphabet
485
+ * stays byte-identical rather than growing a bare `&` alternative
486
+ * directly — see `scripts/measure-command-normalize.mjs`'s arm A for the
487
+ * regression corpus that keeps proving it stays byte-identical.
488
+ *
489
+ * TASK aabbad63 (this run): the bare-`&` family this alphabet cannot see
490
+ * (`A=x&env -C /tmp git status`, `echo hi & nice git status`) is now
491
+ * handled WITHOUT touching this regex at all — by a SECOND, independent
492
+ * boundary alphabet (`AMP_BOUNDARY_RE` below) driving a SECOND
493
+ * normalisation pass (`normalizeCommandAmpAware`) that
494
+ * `policyMatchesEvent` (`src/runtime/intercept.ts`) tries ONLY as a
495
+ * third, additional raw-OR-normalised-OR-amp-normalised disjunct — never
496
+ * a replacement for either of the first two arms. That construction is
497
+ * exactly what avoids re-triggering the 140/140 regression above: every
498
+ * command that matched via the raw test or via THIS (BOUNDARY_RE)
499
+ * normalised form keeps matching through those same two arms, byte-for-
500
+ * byte unchanged; the amp-aware arm can only ADD a match a spelling
501
+ * would otherwise have missed, so a quoted `FOO='a&b'` value that this
502
+ * alphabet already normalises correctly is never at risk of losing that
503
+ * gate to the new arm. See `AMP_BOUNDARY_RE`'s own comment for why it
504
+ * has to be a distinct regex object rather than a mutation of this one,
505
+ * and `normalizeCommandAmpAware`'s comment for why its return type
506
+ * carries no `targetDir`/`targetBase`.
507
+ */
508
+ const BOUNDARY_RE = /\n|&&|;|\||\(/g;
509
+ /**
510
+ * Second boundary alphabet (task aabbad63): identical to `BOUNDARY_RE`
511
+ * plus a bare `&` — bash's OWN "start a new command here" token, which
512
+ * every shipped `bash_match` trigger regex has treated as one of its own
513
+ * anchor characters since task `d834a065`, but which `BOUNDARY_RE` above
514
+ * has never recognised as a NORMALISATION-time boundary (see its comment
515
+ * for the reverted, measured-140/140-regression attempt to add it there
516
+ * directly).
517
+ *
518
+ * ALTERNATION ORDER IS LOAD-BEARING: `&&` MUST be listed to the LEFT of
519
+ * the bare `&` alternative. JS regex alternation is leftmost-FIRST, not
520
+ * leftmost-longest — with `&` tried before `&&`, every `&&` in a command
521
+ * would tokenise as TWO consecutive one-character `&` boundary MATCHES
522
+ * instead of a single two-character `&&` boundary match.
523
+ *
524
+ * MEASURED, NOT ASSUMED (do not "simplify" the pin below to an
525
+ * end-to-end `.normalized` string assertion): for THIS module's segment-
526
+ * and-rejoin architecture specifically, that reordering is, perhaps
527
+ * counter-intuitively, INVISIBLE in `normalizeCommandAmpAware`'s own
528
+ * output string. Two adjacent single-`&` matches sandwich a genuinely
529
+ * zero-length segment between them (the two `&` characters of a real
530
+ * `&&` are, by definition, adjacent), `canonicalizeSegment("")` is a
531
+ * verbatim no-op, and `parts.join("")` reassembles the exact same bytes
532
+ * either way — verified directly (not merely reasoned about) by
533
+ * temporarily swapping this alternation's order, rebuilding, and
534
+ * diffing `normalizeCommandAmpAware(...).normalized` across several
535
+ * `&&`-bearing commands (including this comment's own `git -C
536
+ * /tmp/repoB status && git -C /tmp/repoB log` example): byte-identical
537
+ * output both ways. So a test asserting on the OUTPUT STRING alone
538
+ * cannot distinguish the two orderings for this architecture — the pin
539
+ * in `tests/runtime/command-normalize.test.ts` therefore execs this
540
+ * regex object DIRECTLY against a `&&`-bearing string and asserts the
541
+ * match text is `"&&"`, not `"&"`, which DOES fail under a swap. The
542
+ * order is still specified exactly as documented above (leftmost-first
543
+ * is real JS semantics, and the internal `segmentAndCanonicalize`
544
+ * bookkeeping this alphabet ALSO drives — `hasAmbiguousNonGitSegment`,
545
+ * discarded by `normalizeCommandAmpAware` but not by a hypothetical
546
+ * future consumer of `segmentAndCanonicalize` under this alphabet —
547
+ * would still see a spurious extra "non-git segment" under the wrong
548
+ * order), it just cannot be pinned through this module's ONE exposed
549
+ * string alone.
550
+ *
551
+ * Used ONLY by `normalizeCommandAmpAware` below, which is consulted ONLY
552
+ * as `policyMatchesEvent`'s third, additional matching arm — see that
553
+ * function's own comment for why its result never carries a `targetDir`/
554
+ * `targetBase`, and `src/runtime/intercept.ts` for the memoised-thunk
555
+ * threading that keeps this off the common (raw-or-normalised-already-
556
+ * matched) path. Exported (mirrors `GIT_TOKEN_RE` / `NON_GIT_HEAD_TOKENS`
557
+ * / `MAX_NORMALIZE_LENGTH` above) solely so the ordering pin test can
558
+ * exec it directly instead of re-declaring an equivalent regex literal
559
+ * that would test JS semantics in general, not THIS source's actual
560
+ * alternation.
561
+ */
562
+ export const AMP_BOUNDARY_RE = /\n|&&|&|;|\||\(/g;
563
+ /** A target value starting with `~` — not expanded, treated as unparseable (F5). */
564
+ function isTildeTarget(dir) {
565
+ return dir.startsWith("~");
566
+ }
567
+ /**
568
+ * A target value `segmentViewOf`'s `CommandSegment.ownTarget` (task
569
+ * `98ad072f` groundwork) treats as unattributable even where the
570
+ * pre-existing per-segment machinery below (`canonicalizeSegment` /
571
+ * `peelGitGlobalOptions` / `peelEnv`) still captures SOME string for the
572
+ * OLD aggregate's `targetDir` — that extraction is UNCHANGED by this check
573
+ * (see the module header's STATUS/UPDATE note): the tokeniser is not
574
+ * quote-aware, so a quoted `-C` / `--work-tree` / `--git-dir` / `env -C`
575
+ * value is captured WITH its quote character(s) still attached
576
+ * (`git -C "/tmp/repoB" status` captures `"/tmp/repoB"`, quotes included —
577
+ * not a real path, and not `null` either under the pre-existing
578
+ * extraction) rather than being rejected outright. The same is true of a
579
+ * value containing a command-substitution marker (`$(` or a backtick) —
580
+ * this module never resolves substitutions, so a value carrying one names
581
+ * nothing this module can vouch for as a literal path. A
582
+ * whitespace-containing path needs no separate check here: the
583
+ * whitespace-splitting tokeniser never captures a value containing
584
+ * whitespace as ONE token in the first place — it becomes a different,
585
+ * unrecognised token sequence instead (see the module header's own
586
+ * "quoted directory arguments containing whitespace" note), which for
587
+ * `segmentViewOf` purposes simply fails to produce a recognised own target
588
+ * at all (see `parseCdSegmentTarget` below for the `cd` case).
589
+ *
590
+ * A value ENDING in a bare `$` is ALSO rejected, even though it does not
591
+ * literally contain the two-character `$(` marker: `BOUNDARY_RE` (this
592
+ * function is only ever reached through the primary, non-amp-aware pass —
593
+ * `segmentViewOf` never runs under `AMP_BOUNDARY_RE`) treats a bare `(` as
594
+ * a shell boundary UNCONDITIONALLY (needed for the leading-command case,
595
+ * module header), so `$(` can never survive as a substring WITHIN one
596
+ * token or segment in the first place — the `(` always splits it into its
597
+ * own boundary match first. A `cd` target ending in `$` is the tell-tale
598
+ * leftover of exactly that split (`cd $(pwd)/T && ...` tokenises this
599
+ * function's caller's OWN segment as literal `cd $`, a syntactically
600
+ * valid-LOOKING two-token `cd <path>` with `<path>` being the single
601
+ * character `$` — measured live, not hypothetical). Rejecting it here
602
+ * closes that gap without needing `parseCdSegmentTarget` (which has no
603
+ * visibility into what boundary token follows its own segment) to
604
+ * special-case it itself. The `-C`/`--work-tree`/`--git-dir`/`env -C` side
605
+ * of this module never has this specific gap: a value-capturing git
606
+ * global option additionally requires a SUBCOMMAND token after its value
607
+ * (`peelGitGlobalOptions`'s caller checks `gitOpts.idx >= tokens.length`),
608
+ * so `git -C $(pwd) status` already comes back `isGit: false` (no
609
+ * subcommand token survives the split) before this function is ever
610
+ * consulted for it — this extra check is `cd`-specific, `cd <path>` having
611
+ * no analogous "and then a subcommand" requirement.
612
+ */
613
+ function isUnattributableTargetValue(value) {
614
+ return (value.includes("'") ||
615
+ value.includes('"') ||
616
+ value.includes("$(") ||
617
+ value.includes("`") ||
618
+ value.endsWith("$"));
619
+ }
620
+ function classifyCdSegment(segmentText) {
621
+ const tokens = tokenizeWithOffsets(segmentText);
622
+ if (tokens.length === 0)
623
+ return { kind: "none" };
624
+ const head = tokens[0].text;
625
+ if (head === "pushd" || head === "popd")
626
+ return { kind: "reset" };
627
+ if (head !== "cd")
628
+ return { kind: "none" };
629
+ if (tokens.length === 1)
630
+ return { kind: "reset" }; // bare `cd`, no argument
631
+ if (tokens.length > 2)
632
+ return { kind: "reset" }; // flagged cd: `cd -P /tmp/x`
633
+ const arg = tokens[1].text;
634
+ if (arg.length === 0 || arg.startsWith("-"))
635
+ return { kind: "reset" }; // `cd -`, `cd -P` alone
636
+ return { kind: "cd", arg };
637
+ }
638
+ /**
639
+ * Compute one segment's `ownTarget` / `effectiveTarget` (task `98ad072f`
640
+ * groundwork, `segmentViewOf` / `CommandSegment`) and the cd-basis to
641
+ * carry into the NEXT segment, from: this segment's own canonicalisation
642
+ * result (already computed by the shared walk — `identityTargetDir` /
643
+ * `identityTargetBase` / `isGit`, unmodified here; NOT the OLD aggregate's
644
+ * `targetDir`/`targetBase`, which still includes `--work-tree` — D-017,
645
+ * fix round 2), whether THIS segment is itself a recognised `cd <path>`
646
+ * segment, and the cd-basis carried in from whatever preceded it.
647
+ * Implements `CommandSegment.effectiveTarget`'s doc comment verbatim; see
648
+ * that comment for the consumer-facing statement of these rules.
649
+ */
650
+ function computeSegmentTarget(segmentText, canon, incomingCdBasis) {
651
+ // Same-invocation `env -C`/`--chdir` base + a RELATIVE own git
652
+ // REPO-RELOCATING target (`-C`/`--git-dir` — orchestrator follow-up
653
+ // after the initial T-002 round, still task `98ad072f` groundwork):
654
+ // `canon.identityTargetBase` is non-null EXACTLY in this mixed case —
655
+ // see `canonicalizeSegment`'s own `identityTargetBase` computation,
656
+ // reused here unmodified, never when there is no wrapping `env -C` on
657
+ // the SAME invocation. `env -C /tmp/base git -C sub status` must NOT
658
+ // hand a future consumer `ownTarget`/`effectiveTarget: "sub"` (raw,
659
+ // relative): a naive consumer resolving a relative value against ITS
660
+ // OWN cwd (this module's documented fallback for the "no known basis"
661
+ // branch below) would land on a CONFIDENTLY WRONG repository whenever
662
+ // the caller's cwd differs from `/tmp/base` — precisely the "relative
663
+ // and `~` target dirs resolve to a confidently wrong repo" class named
664
+ // in the 07-27 review. Composing correctly to `/tmp/base/sub` would ALSO
665
+ // be correct but is deliberately NOT this module's job (real
666
+ // filesystem-shaped path joining — the same reason the cd-basis
667
+ // composition rule below refuses to compose `cd T && git -C sub` into
668
+ // `T/sub`); noted here as a possible LATER precision gain, not built in
669
+ // this slice. A bare git invocation with an `env -C` base and no
670
+ // relative own REPO-RELOCATING target (`env -C /abs git status`) is
671
+ // UNAFFECTED — the ordinary `canon.identityTargetDir` extraction below
672
+ // already folds that in (`gitOpts.relocateTargetDir ?? envTargetDir`),
673
+ // and `canon.identityTargetBase` stays `null` for that shape (no
674
+ // relative own repo-relocating target to be relative TO). A `--work-
675
+ // tree`-only invocation (no `-C`/`--git-dir` on the same call) is NEVER
676
+ // "mixed" here even when wrapped by `env -C`: `relocateTargetDir` is
677
+ // `null` for it, so `identityTargetDir` falls through to `envTargetDir`
678
+ // (or stays `null`) and `identityTargetBase` stays `null` — D-017, this
679
+ // is the fix. A `cd`-shaped segment never reaches this branch: it never
680
+ // matches `GIT_TOKEN_RE`, so `canon.isGit` is always `false` for one.
681
+ if (canon.isGit && canon.identityTargetBase !== null) {
682
+ return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: incomingCdBasis };
683
+ }
684
+ const cdClass = classifyCdSegment(segmentText);
685
+ // D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign): a
686
+ // segment that GENUINELY changes directory to somewhere this module
687
+ // cannot name (bare `cd`, `cd -`, a flagged `cd`, `pushd`, `popd`)
688
+ // RESETS the running basis to `null` for every later segment, rather
689
+ // than the pre-fix behaviour of leaving it unchanged (silently
690
+ // INHERITING whatever preceded it, as though the shell had not moved at
691
+ // all). This segment names nothing itself either. A PRECISION fix, not
692
+ // the security fix: `src/runtime/intercept.ts`'s D-011 additive
693
+ // attribution rule means an inherited target always also demands the
694
+ // cwd context, so an over-cautious reset here can only make a LATER
695
+ // segment fall back to cwd-only evaluation — identical to the shipped
696
+ // baseline for an unattributable form (D-003), never a fail-open.
697
+ if (cdClass.kind === "reset") {
698
+ return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: null };
699
+ }
700
+ const cdArg = cdClass.kind === "cd" ? cdClass.arg : null;
701
+ const rawOwn = cdArg !== null ? cdArg : canon.isGit ? canon.identityTargetDir : null;
702
+ const ownTarget = rawOwn !== null && !isTildeTarget(rawOwn) && !isUnattributableTargetValue(rawOwn)
703
+ ? rawOwn
704
+ : null;
705
+ let effectiveTarget;
706
+ if (ownTarget === null) {
707
+ // D-014: a recognised `cd <path>` segment whose OWN value came out
708
+ // unattributable (tilde/quoted/substitution) RESETS rather than
709
+ // inherits — the shell genuinely changed directory to somewhere this
710
+ // module cannot name, so silently keeping an earlier basis in place
711
+ // would be a confidently-wrong carry-forward, exactly the class this
712
+ // whole module exists to avoid. A segment that is NOT cd-shaped at
713
+ // all (`cdArg === null`, `cdClass.kind === "none"`) still inherits —
714
+ // this module has no evidence the shell moved, so it does not guess.
715
+ effectiveTarget = cdArg !== null ? null : incomingCdBasis;
716
+ }
717
+ else if (path.isAbsolute(ownTarget)) {
718
+ effectiveTarget = ownTarget;
719
+ }
720
+ else {
721
+ effectiveTarget = incomingCdBasis === null ? ownTarget : null;
722
+ }
723
+ // Only a recognised `cd <path>` segment updates the outgoing basis
724
+ // (whether or not its OWN value came out attributable — see
725
+ // `CommandSegment.effectiveTarget`'s doc comment for why an
726
+ // unattributable `cd` must still RESET the basis to `null`, not leave a
727
+ // stale earlier one in place). Every other segment — gated or not —
728
+ // never itself changes the shell's directory, so the incoming basis
729
+ // passes through unchanged.
730
+ const outgoingCdBasis = cdArg !== null ? effectiveTarget : incomingCdBasis;
731
+ return { ownTarget, effectiveTarget, outgoingCdBasis };
732
+ }
733
+ /**
734
+ * Given a Bash command string, return its normalised form and the target
735
+ * directory (and base, if relative) of its git invocation. Never throws:
736
+ * any internal failure (there should be none — this is a defensive
737
+ * backstop matching the fail-safe shape used elsewhere in this codebase,
738
+ * e.g. `policyMatchesEvent`'s `try/catch` around `new RegExp`) falls back
739
+ * to the input unchanged.
740
+ */
741
+ export function normalizeCommand(command) {
742
+ if (typeof command !== "string" || command.length === 0) {
743
+ return {
744
+ normalized: typeof command === "string" ? command : "",
745
+ targetDir: null,
746
+ targetBase: null,
747
+ truncated: false,
748
+ };
749
+ }
750
+ if (command.length > MAX_NORMALIZE_LENGTH) {
751
+ return { normalized: command, targetDir: null, targetBase: null, truncated: true };
752
+ }
753
+ try {
754
+ return normalizeCommandInner(command);
755
+ }
756
+ catch {
757
+ return { normalized: command, targetDir: null, targetBase: null, truncated: false };
758
+ }
759
+ }
760
+ /**
761
+ * Given a Bash command string, return its per-segment view (task
762
+ * `98ad072f` groundwork, T-002) — see `CommandSegment`'s own doc comment
763
+ * for the field-level composition rules. `null` when the input exceeded
764
+ * `MAX_NORMALIZE_LENGTH` (mirrors `NormalizedCommand.truncated`, but as
765
+ * the ABSENCE of a view rather than a boolean flag alongside one, since
766
+ * there is no meaningful per-segment view to return for an input whose
767
+ * normalisation itself was skipped). `[]` for an empty or non-string
768
+ * command, and as the never-throws fallback (mirrors `normalizeCommand`'s
769
+ * own defensive backstop — see its doc comment above).
770
+ *
771
+ * THIS function's OWN call into `segmentAndCanonicalize` (`collectSegments:
772
+ * true`) is one walk, O(length) — the "no additional pass" claim is scoped
773
+ * to that ONE call, not to a caller's total work. CORRECTED (D-015, fix
774
+ * round, run 2026-08-02-per-repo-gate-scoping-redesign) — the prior wording
775
+ * here read "no additional pass over the command string" without that
776
+ * scoping, which a reader could take as "calling this costs nothing beyond
777
+ * `normalizeCommand`'s own walk." Measured false: `normalizeCommand(cmd)`
778
+ * and `segmentViewOf(cmd)` are two SEPARATE top-level calls, each running
779
+ * its own full `segmentAndCanonicalize` pass over the SAME string — a
780
+ * caller needing both (as `src/cli/policy/intercept.ts` does, for
781
+ * `normalized`/`truncated` and the segment view respectively) pays TWO
782
+ * walks, not one; measured +206% at the `MAX_NORMALIZE_LENGTH` bound
783
+ * (2.73 → 8.36 ms), harmless against the 1000 ms hook budget but not free.
784
+ * As of the same fix round, the one production caller only pays this
785
+ * second walk lazily, when some matching policy actually needs it (see
786
+ * `InterceptOptions.commandSegmentsThunk` in `src/runtime/intercept.ts`) —
787
+ * but that laziness lives in the CALLER, not in this function, which still
788
+ * does exactly one walk every time it runs. The ampersand-aware second pass
789
+ * (`normalizeCommandAmpAware`) NEVER collects a segment view (always calls
790
+ * `segmentAndCanonicalize` with `collectSegments: false`) — see that
791
+ * function's own comment for why a directory extracted under the
792
+ * ampersand-aware alphabet would be unreliable in the first place; the
793
+ * same reasoning applies here, so this module offers no amp-aware variant
794
+ * of this function at all.
795
+ */
796
+ export function segmentViewOf(command) {
797
+ if (typeof command !== "string" || command.length === 0)
798
+ return [];
799
+ if (command.length > MAX_NORMALIZE_LENGTH)
800
+ return null;
801
+ try {
802
+ return segmentAndCanonicalize(command, BOUNDARY_RE, true).segments ?? [];
803
+ }
804
+ catch {
805
+ return [];
806
+ }
807
+ }
808
+ /**
809
+ * Ampersand-aware SECOND normalisation pass (task aabbad63). Segments the
810
+ * command under `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead
811
+ * of `BOUNDARY_RE`, then canonicalises exactly like the primary pass
812
+ * (same `canonicalizeSegment`, same wrapper-peeling rules, same
813
+ * `MAX_NORMALIZE_LENGTH` bound). Closes the gap `BOUNDARY_RE`'s own
814
+ * comment names: `A=x&env -C /tmp git status` and `echo hi & nice git
815
+ * status` were ungated because a wrapper can only be peeled after a
816
+ * recognised boundary, and a bare `&` was not one.
817
+ *
818
+ * Return type deliberately has NO `targetDir`/`targetBase` — not merely
819
+ * "always null under this alphabet", genuinely ABSENT from the type, so
820
+ * no consumer can read one by mistake. Reason: a bare `&` is ALSO how
821
+ * bash spells a background job (`git -C /x push &`) and how a redirect
822
+ * merges streams (`git -C /x log 2>&1`, `... &> out`). Under THIS
823
+ * alphabet each of those splits into a spurious extra "segment", which
824
+ * `segmentAndCanonicalize`'s own non-git-segment ambiguity rule (a
825
+ * segment that starts a new command and is not itself a git invocation
826
+ * forces `targetDir` to `null` — see its G1 comment) would turn into a
827
+ * wrong-shaped loss for a case the PRIMARY pass still resolves correctly
828
+ * today. Pinned in `tests/runtime/command-normalize.test.ts`'s "aabbad63
829
+ * groundwork" describe block, which exercises ONLY `normalizeCommand`
830
+ * (the primary pass), never this function — `targetDir`/`targetBase`
831
+ * stay the primary pass's property alone.
832
+ *
833
+ * Only consulted by `policyMatchesEvent`'s third arm
834
+ * (`src/runtime/intercept.ts`), and only when a policy's `bash_match`
835
+ * regex has already missed BOTH the raw command and the primary
836
+ * normalised form — see that module and `src/cli/policy/intercept.ts`
837
+ * for the memoised-thunk threading that keeps this computation off the
838
+ * common (already-matched) path.
839
+ */
840
+ export function normalizeCommandAmpAware(command) {
841
+ if (typeof command !== "string" || command.length === 0) {
842
+ return { normalized: typeof command === "string" ? command : "", truncated: false };
843
+ }
844
+ if (command.length > MAX_NORMALIZE_LENGTH) {
845
+ return { normalized: command, truncated: true };
846
+ }
847
+ try {
848
+ return {
849
+ normalized: segmentAndCanonicalize(command, AMP_BOUNDARY_RE, false).normalized,
850
+ truncated: false,
851
+ };
852
+ }
853
+ catch {
854
+ return { normalized: command, truncated: false };
855
+ }
856
+ }
857
+ function normalizeCommandInner(command) {
858
+ const { normalized, targetDir, targetBase } = segmentAndCanonicalize(command, BOUNDARY_RE, false);
859
+ return { normalized, targetDir, targetBase, truncated: false };
860
+ }
861
+ /**
862
+ * The shared segmentation + canonicalisation engine behind BOTH
863
+ * normalisation passes (task aabbad63). Parameterised on the boundary
864
+ * alphabet (`boundaryRe`) instead of closing over a single module-level
865
+ * regex, so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
866
+ * second pass (`AMP_BOUNDARY_RE`) never share regex scan state: each
867
+ * call is handed its OWN regex object, and `findNextBoundary` resets
868
+ * that object's own `lastIndex` before every use, so the two passes
869
+ * (even if a future change nested one inside the other, which nothing
870
+ * here does today) can never observe or clobber each other's position.
871
+ * `targetDir`/`targetBase` ARE still computed here regardless of which
872
+ * alphabet drove the segmentation — `normalizeCommandAmpAware` below
873
+ * simply never returns them to its own caller; see that function's
874
+ * comment for why exposing them would be wrong under the amp alphabet
875
+ * specifically. Fix round 1, finding F10: under the amp alphabet
876
+ * specifically, ALL of this function's target bookkeeping
877
+ * (`explicitTargets`, `explicitTargetBase`, `bareGitSegmentCount`,
878
+ * `leadingCd`'s full `parseBashPrefix` pass, and the whole G1 ambiguity
879
+ * resolution below) is knowingly computed and then discarded by
880
+ * `normalizeCommandAmpAware` ITSELF, which returns only
881
+ * `{ normalized, truncated }` — its own caller never sees these fields at
882
+ * all. This is the containment design's cost, not an oversight, so a
883
+ * future reader should not "fix" it by exposing these fields to a
884
+ * hypothetical amp-alphabet consumer.
885
+ *
886
+ * `collectSegments` (task `98ad072f` groundwork, T-002): when `true`, ALSO
887
+ * builds the per-segment view `segmentViewOf` returns (`CommandSegment[]`)
888
+ * inside this SAME walk — no SECOND walk INSIDE this one function call,
889
+ * just a bounded amount of extra work per segment already being visited
890
+ * (see `computeSegmentTarget`). Scoped claim only (D-015 correction, fix
891
+ * round, run 2026-08-02-per-repo-gate-scoping-redesign): this says nothing
892
+ * about a CALLER that invokes this function twice with different
893
+ * `collectSegments` values on the same string — see `segmentViewOf`'s own
894
+ * doc comment, corrected in the same fix round, for the measured cost of
895
+ * exactly that. `normalizeCommandInner` and
896
+ * `normalizeCommandAmpAware` both pass `false`: the OLD aggregate never
897
+ * needed this, and the amp-aware pass deliberately never exposes a segment
898
+ * view at all (see that function's own comment). `segments` is `null` on
899
+ * the returned object when `collectSegments` is `false`, an array
900
+ * (possibly empty) otherwise.
901
+ */
902
+ function segmentAndCanonicalize(command, boundaryRe, collectSegments) {
903
+ const parts = [];
904
+ const explicitTargets = new Set();
905
+ let explicitTargetBase = null;
906
+ let sawExplicitBase = false;
907
+ let bareGitSegmentCount = 0;
908
+ let i = 0;
909
+ const n = command.length;
910
+ // task `98ad072f` groundwork (T-002): the per-segment view's own,
911
+ // independent bookkeeping — a running cd-basis (see
912
+ // `computeSegmentTarget`) and the accumulated segments themselves.
913
+ // Entirely separate from `explicitTargets` / `bareGitSegmentCount` /
914
+ // `hasAmbiguousNonGitSegment` above, which stay exactly as they were:
915
+ // this never reads or writes them, and they never read or write this.
916
+ const segments = collectSegments ? [] : null;
917
+ let cdBasis = null;
918
+ // A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
919
+ // review round 2, 2026-07-27) instead of the two separate calls the
920
+ // size===1 and size===0 branches below each used to make on their own.
921
+ // Also needed here now to exempt the recognised leading-cd segment
922
+ // itself from the new non-git-command ambiguity check just below: that
923
+ // segment is not "some other command sharing the chain" — it IS the
924
+ // directory every later bare command in the chain actually runs
925
+ // inside.
926
+ const leadingCd = parseBashPrefix(command).cdTarget;
927
+ // Boundary token immediately preceding the segment currently being
928
+ // examined (`null` for the very first segment). Tracked so the G1
929
+ // check below can tell a real "new command" boundary (`&&`/`;`/`\n`/
930
+ // `(`) apart from a bare `|`, which keeps running in the SAME
931
+ // directory as whatever precedes it.
932
+ let precedingBoundaryToken = null;
933
+ let hasAmbiguousNonGitSegment = false;
934
+ // Walk the command as alternating (segment, boundary) pairs. Each
935
+ // segment is handed to `canonicalizeSegment`, which rewrites ONLY its
936
+ // head (wrapper prefixes + a git invocation's global options, if any
937
+ // are found) and leaves the remainder of the segment — and every
938
+ // boundary token — untouched. Bounded: `i` strictly increases by at
939
+ // least one boundary token's length each iteration a boundary is
940
+ // found, and the loop stops once none remain.
941
+ for (;;) {
942
+ const boundary = findNextBoundary(command, i, boundaryRe);
943
+ const segEnd = boundary ? boundary.start : n;
944
+ const segmentText = command.slice(i, segEnd);
945
+ const result = canonicalizeSegment(segmentText);
946
+ parts.push(result.text);
947
+ if (segments !== null) {
948
+ // task `98ad072f` groundwork (T-002): O(1) extra work per segment
949
+ // already being visited — see `segmentAndCanonicalize`'s own doc
950
+ // comment for why this is not a second pass over `command`.
951
+ //
952
+ // D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign),
953
+ // two bash-truthfulness corrections applied around the call, not
954
+ // inside `computeSegmentTarget` itself (both are about WHICH basis
955
+ // crosses a BOUNDARY, not about one segment's own composition):
956
+ //
957
+ // - a running cd-basis never crosses a bare `|` — each side of a
958
+ // pipeline is bash's own subshell, so a `cd` on one side cannot
959
+ // affect the other (measured bypass: `cd <forged> | git push`
960
+ // really runs `git push` at the real cwd, never `<forged>`).
961
+ // The segment immediately after a `|` is handed `null` as its
962
+ // incoming basis, as though nothing preceded it.
963
+ // - the OUTGOING basis resets to `null` whenever THIS segment's
964
+ // own text contains a `)` (a subshell close riding along after
965
+ // `BOUNDARY_RE`'s bare `(` boundary — this alphabet has no `)`
966
+ // boundary of its own, see its header comment — so a subshell's
967
+ // `cd` genuinely stops applying once the subshell exits: `(cd
968
+ // <forged> ; echo hi) && git push` must not let `<forged>` reach
969
+ // `git push`). THIS segment's own `effectiveTarget` is computed
970
+ // from the UNCHANGED incoming basis first — a segment ending in
971
+ // `)` (e.g. the `git status)` tail of `(cd A && git status)`)
972
+ // still correctly attributes to A for itself, exactly as it
973
+ // should while still inside the subshell; only what carries
974
+ // FORWARD to the next segment is affected. A simple substring
975
+ // check, not paren-depth tracking: conservative, precision-only
976
+ // under D-011 (see `computeSegmentTarget`'s own D-014 note) — an
977
+ // over-eager reset here can only make a later segment fall back
978
+ // to cwd-only, never a fail-open.
979
+ const incomingBasis = precedingBoundaryToken === "|" ? null : cdBasis;
980
+ const seg = computeSegmentTarget(segmentText, result, incomingBasis);
981
+ segments.push({
982
+ text: result.text,
983
+ ownTarget: seg.ownTarget,
984
+ effectiveTarget: seg.effectiveTarget,
985
+ });
986
+ cdBasis = segmentText.includes(")") ? null : seg.outgoingCdBasis;
987
+ }
988
+ if (result.isGit) {
989
+ if (result.targetDir === null) {
990
+ bareGitSegmentCount += 1;
991
+ }
992
+ else {
993
+ explicitTargets.add(result.targetDir);
994
+ if (!sawExplicitBase && result.targetBase !== null) {
995
+ explicitTargetBase = result.targetBase;
996
+ sawExplicitBase = true;
997
+ }
998
+ }
999
+ }
1000
+ else if (precedingBoundaryToken !== "|" &&
1001
+ !(precedingBoundaryToken === null && leadingCd !== null)) {
1002
+ // G1 fix (HIGH, review round 2, 2026-07-27): a segment that STARTS
1003
+ // a new command (follows `&&`/`;`/`\n`/`(` — or is the very first
1004
+ // segment in the command — never a bare `|`, which keeps running
1005
+ // in the SAME directory as whatever it reads from) and is NOT
1006
+ // itself a git invocation is a genuinely DIFFERENT command that
1007
+ // does not necessarily run wherever some OTHER git invocation
1008
+ // elsewhere in the chain explicitly pointed. Measured case: `git
1009
+ // -C <B> rev-parse HEAD && gh pr merge` — `-C` scopes only the ONE
1010
+ // git call it decorates, so `gh pr merge` actually runs at the
1011
+ // real (unaffected) cwd; resolving ${REPO}/${BRANCH} from B for it
1012
+ // let a fact recorded against a decoy repo satisfy
1013
+ // `review-before-merge-bash` / `review-subagent-before-pr-create-
1014
+ // bash` for a merge/PR that never touched B. The one exemption is
1015
+ // the recognised LEADING `cd` prefix itself: unlike `-C`, `cd`
1016
+ // genuinely persists for the rest of the chain, so `cd <B> && git
1017
+ // status` (or any other command after it) really does run in B —
1018
+ // that stays the untouched `explicitTargets.size === 0` branch
1019
+ // below, not this one.
1020
+ hasAmbiguousNonGitSegment = true;
1021
+ }
1022
+ if (!boundary)
1023
+ break;
1024
+ parts.push(boundary.token);
1025
+ i = boundary.start + boundary.token.length;
1026
+ precedingBoundaryToken = boundary.token;
1027
+ }
1028
+ let targetDir;
1029
+ let targetBase = null;
1030
+ if (explicitTargets.size === 1 &&
1031
+ bareGitSegmentCount === 0 &&
1032
+ !hasAmbiguousNonGitSegment) {
1033
+ // Every git invocation the command names agrees on ONE repository —
1034
+ // safe to use it even when there are several (e.g. two `git -C <same
1035
+ // dir>` calls chained together) — AND no OTHER, non-git command
1036
+ // shares the chain that this single target does not actually apply
1037
+ // to (G1 fix, review round 2).
1038
+ targetDir = [...explicitTargets][0];
1039
+ targetBase = explicitTargetBase;
1040
+ if (targetBase === null && !path.isAbsolute(targetDir)) {
1041
+ // F5 fix: a RELATIVE git-level target with no more specific base
1042
+ // (no wrapping `env -C` on the SAME invocation) is relative to
1043
+ // wherever the shell's cwd was at that point in the chain, which a
1044
+ // LEADING `cd <dir> &&|;` prefix moves for every later command.
1045
+ // Only the leading form is consulted — this module does not
1046
+ // simulate the chain deeply enough to attribute a NON-leading `cd`
1047
+ // to a specific later invocation, same ceiling as the leading-only
1048
+ // fallback immediately below.
1049
+ if (leadingCd !== null && !isTildeTarget(leadingCd))
1050
+ targetBase = leadingCd;
1051
+ }
1052
+ }
1053
+ else if (explicitTargets.size === 0) {
1054
+ // No git invocation named an explicit target of its own (whether
1055
+ // there were zero git invocations, or one or more entirely bare
1056
+ // ones). A leading `cd <dir> &&|;` prefix, if present, names the
1057
+ // target DIRECTLY here — every bare invocation in the chain runs
1058
+ // inside it, so this is not a base for something else, it IS the
1059
+ // answer — delegated to the existing leading-prefix parser rather
1060
+ // than reimplementing its quote-handling. Unaffected by the G1
1061
+ // ambiguity flag above: a non-git command after a leading `cd` is
1062
+ // exactly the case that flag exempts, and a non-git command with NO
1063
+ // git invocation anywhere in the chain never disagrees with
1064
+ // anything, since there is no explicit git target to disagree with
1065
+ // in the first place.
1066
+ targetDir = leadingCd !== null && !isTildeTarget(leadingCd) ? leadingCd : null;
1067
+ }
1068
+ else {
1069
+ // Ambiguous: either more than one git invocation names a DIFFERENT
1070
+ // repository, at least one invocation names a repo explicitly while
1071
+ // another runs bare (which may be a THIRD repo — whatever the
1072
+ // shell's cwd happens to be at that point), or (G1 fix, review round
1073
+ // 2) a non-git, non-leading-cd command elsewhere in the chain that
1074
+ // the single explicit git target does not actually apply to. None of
1075
+ // these has one unambiguous answer, so — per the "when in doubt,
1076
+ // fall back to cwd" rule this whole run was built on — report no
1077
+ // target at all rather than silently picking one invocation's answer
1078
+ // for the whole command (F2 fix; see the module header for the
1079
+ // push-gate fail-open this closes).
1080
+ targetDir = null;
1081
+ }
1082
+ return { normalized: parts.join(""), targetDir, targetBase, segments };
1083
+ }
1084
+ /**
1085
+ * Find the earliest shell boundary token at or after `from`, under the
1086
+ * CALLER-SUPPLIED boundary alphabet. Takes `boundaryRe` as an explicit
1087
+ * parameter rather than closing over a single module-level regex (task
1088
+ * aabbad63) so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
1089
+ * second pass (`AMP_BOUNDARY_RE`) each carry their own `lastIndex` state.
1090
+ *
1091
+ * Fix round 1, finding F8 (corrects the prior wording here): resetting
1092
+ * `lastIndex = from` on every call is NOT what stops the two passes from
1093
+ * clobbering each other's scan position — they are two DISTINCT regex
1094
+ * objects, so there is nothing shared for either to clobber, and
1095
+ * `segmentAndCanonicalize`'s own loop always calls this with `from`
1096
+ * already equal to where the previous match's `lastIndex` would have
1097
+ * landed anyway. That is why removing this reset measured 0 output
1098
+ * differences across the corpus and left the suite green (see
1099
+ * `tests/runtime/command-normalize.test.ts`'s F8 note — the mutation was
1100
+ * applied and reverted to confirm this, not merely reasoned about). The
1101
+ * line is still a legitimate guard, just for a DIFFERENT reason:
1102
+ * `BOUNDARY_RE` and `AMP_BOUNDARY_RE` are module-level `/g` objects
1103
+ * shared by every call in the process, so their `lastIndex` outlives any
1104
+ * single scan. If `canonicalizeSegment` THROWS partway through a scan,
1105
+ * the top-level `catch` in `normalizeCommand` /
1106
+ * `normalizeCommandAmpAware` returns a fallback but leaves that shared
1107
+ * object's `lastIndex` wherever the last successful match landed — and
1108
+ * the NEXT call, on a completely different command, would then start
1109
+ * scanning from that stale offset. This reset re-anchors it.
1110
+ *
1111
+ * DO NOT DELETE THIS LINE ON THE STRENGTH OF ITS MUTATION-INERTNESS.
1112
+ * Measured (fix round 2): with the reset removed and a throw forced into
1113
+ * `canonicalizeSegment`, the FOLLOWING call to
1114
+ * `normalizeCommandAmpAware("a | env git push origin master")` returned
1115
+ * the un-normalised string, and `preflight-before-push` went from MATCH
1116
+ * to NO-MATCH. That is a gate-losing fail-open, not a stylistic nit.
1117
+ * The honest caveat, stated rather than implied: no throw is KNOWN to be
1118
+ * reachable from real input — the leak above was demonstrated with a
1119
+ * forced throw, which is why the line measures inert. The module's own
1120
+ * top-level `catch` exists because a throw is treated as possible, and
1121
+ * this reset is the other half of that same posture.
1122
+ */
1123
+ function findNextBoundary(s, from, boundaryRe) {
1124
+ boundaryRe.lastIndex = from;
1125
+ const m = boundaryRe.exec(s);
1126
+ return m === null ? null : { start: m.index, token: m[0] };
1127
+ }
1128
+ /**
1129
+ * Consume one leading `VAR=value` assignment starting at `idx`, returning
1130
+ * the index of the first token AFTER it. Task 13e55484: the VALUE may be
1131
+ * quoted and span multiple whitespace-split tokens (`VAR='hello world'`),
1132
+ * which used to leave a dangling `world'` token that aborted the peel
1133
+ * loop — the one measured spelling where BOTH matching layers failed on
1134
+ * the same character. The continuation engages ONLY while an opening
1135
+ * quote from the assignment stays unbalanced at a token's end and a
1136
+ * matching close exists in a later token; every other shape keeps the
1137
+ * exact pre-task one-token consume:
1138
+ * - an UNTERMINATED quote (`VAR='a git push`) falls back to consuming
1139
+ * one token, because that spelling normalised to `git push` before
1140
+ * this task and must keep doing so (never-unmatch);
1141
+ * - backslash-escaped whitespace without quotes (`VAR=a\ b`) is task
1142
+ * b093911d's escape-handling class and is deliberately NOT continued
1143
+ * here (pinned as a still-open bypass in the test file).
1144
+ * Quote semantics per POSIX: outside quotes `\` escapes the next char,
1145
+ * single quotes close only at `'`, double quotes close at an unescaped
1146
+ * `"`. A quote run may chain (`VAR='a b'"c d"`); the assignment ends at
1147
+ * the first token that finishes outside any quote.
1148
+ *
1149
+ * ONE-DIRECTIONAL GUARD (review round 1, CRITICAL): the continuation
1150
+ * ABANDONS (returns the pre-task one-token consume) the moment the token
1151
+ * it would consume next is a recognised head token (`GIT_TOKEN_RE` or
1152
+ * `NON_GIT_HEAD_TOKENS`). Without this, a quote model diverging from
1153
+ * bash on even ONE spelling lets the continuation swallow the gated
1154
+ * command word and turn a previously-BLOCKED command into a bypass —
1155
+ * measured live with ANSI-C quoting, where bash escapes `\'` inside
1156
+ * `$'...'` but this scanner (correctly for plain `'...'`) treats the
1157
+ * backslash as literal: `A=$'don\'t' env harness pause # '` produced a
1158
+ * phantom-open state that consumed the whole segment, and NEITHER layer
1159
+ * matched where master's one-token consume blocked. The guard closes
1160
+ * the HEAD-SWALLOW channel specifically: a diverging quote state can
1161
+ * never re-target or swallow a recognised head token (measured — see
1162
+ * the differential pins in the test file). It is NOT a universal
1163
+ * monotonicity guarantee (round-2 review, measured): when the
1164
+ * continuation closes on a WRAPPER'S glued flag token, the peel resumes
1165
+ * at that wrapper's next ARGUMENT (a positional like timeout's
1166
+ * duration, or a second flag), which is neither head nor wrapper, so
1167
+ * the segment comes back unchanged where the pre-continuation peel
1168
+ * completed — e.g. `A='x timeout --signal=INT' 5 git push` matched on
1169
+ * master and does not here. Every found member of that class (550 of
1170
+ * 41,440 adversarial cases) is a MASTER FALSE POSITIVE, PATH-shim-
1171
+ * verified: bash treats the quoted run as one assignment word and then
1172
+ * executes the next word (`5`, `-e0`), never the gated verb — 110 are
1173
+ * outright `bash -n` syntax errors — so no gated invocation is lost and
1174
+ * this side is the more bash-accurate one; the class is pinned in the
1175
+ * test file so it cannot silently widen. The honest cost of the guard,
1176
+ * measured and named rather than implied: a quoted VALUE containing the
1177
+ * literal word `git`/`gh`/`npm`/`harness` as its own token
1178
+ * (`VAR='a git b' git push`) is not continued — the pre-task consume
1179
+ * applies, byte-identical to master's behaviour, so nothing regresses.
1180
+ * The guard checks EXACT head spellings only: a path-qualified head
1181
+ * glued to the closing quote (`VAR='a /usr/bin/git' git push` — the
1182
+ * token is `/usr/bin/git'`, which GIT_TOKEN_RE rejects) is NOT
1183
+ * abandoned, the continuation completes, and the REAL invocation behind
1184
+ * it now matches — a measured fail-closed gain over master, also
1185
+ * pinned.
1186
+ */
1187
+ function consumeAssignment(tokens, idx) {
1188
+ let state = "";
1189
+ let escaped = false;
1190
+ for (let i = idx; i < tokens.length; i++) {
1191
+ const text = tokens[i].text;
1192
+ if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
1193
+ return idx + 1;
1194
+ }
1195
+ for (let k = 0; k < text.length; k++) {
1196
+ const c = text[k];
1197
+ if (escaped) {
1198
+ escaped = false;
1199
+ continue;
1200
+ }
1201
+ if (state === "'") {
1202
+ if (c === "'")
1203
+ state = "";
1204
+ continue;
1205
+ }
1206
+ if (state === '"') {
1207
+ if (c === "\\")
1208
+ escaped = true;
1209
+ else if (c === '"')
1210
+ state = "";
1211
+ continue;
1212
+ }
1213
+ if (c === "\\")
1214
+ escaped = true;
1215
+ else if (c === "'" || c === '"')
1216
+ state = c;
1217
+ }
1218
+ // A backslash pending at a token's end would escape the separating
1219
+ // whitespace itself — b093911d's class, not continued here.
1220
+ escaped = false;
1221
+ if (state === "")
1222
+ return i + 1;
1223
+ }
1224
+ // Ran out of tokens with a quote still open: unterminated. Preserve the
1225
+ // pre-task behaviour exactly (consume only the assignment's first token).
1226
+ return idx + 1;
1227
+ }
1228
+ /** Split a segment into whitespace-delimited tokens with their offsets. */
1229
+ function tokenizeWithOffsets(s) {
1230
+ const out = [];
1231
+ const re = /\S+/g;
1232
+ let m;
1233
+ while ((m = re.exec(s)) !== null) {
1234
+ out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
1235
+ }
1236
+ return out;
1237
+ }
1238
+ /**
1239
+ * Rewrite one boundary-delimited segment: peel wrapper prefixes to look
1240
+ * for an invocation of one of the closed head-token set's four members
1241
+ * (`git`, `gh`, `npm`, `harness` — D-001, run
1242
+ * 2026-07-28-nongit-trigger-wrappers), and — ONLY if one is found —
1243
+ * canonicalise it. `git` is canonicalised to `git <subcommand>` with its
1244
+ * OWN global options dropped, same as before this run. The other three
1245
+ * are canonicalised by wrapper peeling plus whitespace collapsing across
1246
+ * the REST OF THE SEGMENT (fix round 2, finding F2 — rejoining every
1247
+ * token from the head onward with a single space, not just the head-to-
1248
+ * next-token gap), no option-dropping (see the module header). When no
1249
+ * recognised head token is found (wrong binary, ran out of tokens, or a
1250
+ * malformed git global option), the segment is returned COMPLETELY
1251
+ * UNCHANGED: peeling is tentative, and nothing is stripped from a
1252
+ * segment that turns out not to be a recognised call (so `digit=1 foo`,
1253
+ * `env -C X ls`, `ghx pr merge`, etc. are never touched).
1254
+ */
1255
+ function canonicalizeSegment(segmentText) {
1256
+ const tokens = tokenizeWithOffsets(segmentText);
1257
+ if (tokens.length === 0) {
1258
+ return {
1259
+ text: segmentText,
1260
+ targetDir: null,
1261
+ targetBase: null,
1262
+ identityTargetDir: null,
1263
+ identityTargetBase: null,
1264
+ isGit: false,
1265
+ };
1266
+ }
1267
+ let idx = 0;
1268
+ let envTargetDir = null;
1269
+ // Peel wrapper prefixes: VAR=value tokens, `env` (+ its own flags/
1270
+ // assignments), `command` (+ its own flags), `nice` (+ its own
1271
+ // flags), `sudo` / `doas` / `time` / `timeout` / `stdbuf` / `setsid`
1272
+ // (+ each one's own flags — F4 fix). Bounded by tokens.length so a
1273
+ // pathological input cannot spin.
1274
+ for (let guard = 0; guard < tokens.length; guard++) {
1275
+ const head = tokens[idx]?.text;
1276
+ if (head === undefined)
1277
+ break;
1278
+ if (VAR_ASSIGN_RE.test(head)) {
1279
+ idx = consumeAssignment(tokens, idx);
1280
+ continue;
1281
+ }
1282
+ if (head === "env") {
1283
+ const r = peelEnv(tokens, idx + 1);
1284
+ idx = r.idx;
1285
+ if (envTargetDir === null)
1286
+ envTargetDir = r.targetDir;
1287
+ continue;
1288
+ }
1289
+ if (head === "command") {
1290
+ idx = peelCommand(tokens, idx + 1);
1291
+ continue;
1292
+ }
1293
+ if (head === "nice") {
1294
+ idx = peelNice(tokens, idx + 1);
1295
+ continue;
1296
+ }
1297
+ if (head === "sudo") {
1298
+ idx = peelSudo(tokens, idx + 1);
1299
+ continue;
1300
+ }
1301
+ if (head === "doas") {
1302
+ idx = peelDoas(tokens, idx + 1);
1303
+ continue;
1304
+ }
1305
+ if (head === "time") {
1306
+ idx = peelTime(tokens, idx + 1);
1307
+ continue;
1308
+ }
1309
+ if (head === "timeout") {
1310
+ idx = peelTimeout(tokens, idx + 1);
1311
+ continue;
1312
+ }
1313
+ if (head === "stdbuf") {
1314
+ idx = peelStdbuf(tokens, idx + 1);
1315
+ continue;
1316
+ }
1317
+ if (head === "setsid") {
1318
+ idx = peelSetsid(tokens, idx + 1);
1319
+ continue;
1320
+ }
1321
+ break;
1322
+ }
1323
+ const headTok = tokens[idx]?.text;
1324
+ if (headTok === undefined) {
1325
+ return {
1326
+ text: segmentText,
1327
+ targetDir: null,
1328
+ targetBase: null,
1329
+ identityTargetDir: null,
1330
+ identityTargetBase: null,
1331
+ isGit: false,
1332
+ };
1333
+ }
1334
+ if (GIT_TOKEN_RE.test(headTok)) {
1335
+ idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
1336
+ const gitOpts = peelGitGlobalOptions(tokens, idx);
1337
+ if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
1338
+ // Malformed global option (missing required value) or no subcommand
1339
+ // token left: nothing safe to canonicalise. Leave untouched.
1340
+ return {
1341
+ text: segmentText,
1342
+ targetDir: null,
1343
+ targetBase: null,
1344
+ identityTargetDir: null,
1345
+ identityTargetBase: null,
1346
+ isGit: false,
1347
+ };
1348
+ }
1349
+ const subcommandTok = tokens[gitOpts.idx];
1350
+ const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
1351
+ const targetDir = gitOpts.targetDir ?? envTargetDir;
1352
+ // F5: the git invocation's OWN target wins (already the priority
1353
+ // order above), but when it's RELATIVE and this same segment is
1354
+ // wrapped by `env -C`, that env target is the base it's relative to —
1355
+ // only meaningful when git's own target is the one actually used.
1356
+ const targetBase = gitOpts.targetDir !== null &&
1357
+ envTargetDir !== null &&
1358
+ !path.isAbsolute(gitOpts.targetDir)
1359
+ ? envTargetDir
1360
+ : null;
1361
+ // D-017: the identity pair mirrors targetDir/targetBase's composition
1362
+ // exactly, but is fed by `relocateTargetDir` (repo-relocating flags
1363
+ // only — `-C`/`--git-dir`, never `--work-tree`) instead of
1364
+ // `gitOpts.targetDir` (which still includes `--work-tree`).
1365
+ //
1366
+ // D-018 (fix round 3): a same-invocation wrapping `env -C`/`--chdir`
1367
+ // PLUS the git invocation's OWN absolute repo-relocating flag are two
1368
+ // repo-relocating options on one invocation, the same "more than one"
1369
+ // class `noteRelocatingOption` (in `peelGitGlobalOptions`) closes for
1370
+ // git's own flags — extended here across the `env -C` wrap. When they
1371
+ // diverge (different resolved directories), git's own flag DOES
1372
+ // deterministically win in reality, but this module deliberately does
1373
+ // not encode that composition rule (`computeSegmentTarget`'s own
1374
+ // comment on why path composition is out of scope) — `null` (cwd
1375
+ // fallback, D-003) rather than keep preferring git's own flag as
1376
+ // before this fix. An identical pair (`env -C X git -C X ...`) is the
1377
+ // same idempotent-repeat allowance as `noteRelocatingOption`'s single-
1378
+ // function case, so it stays `X`, not `null`. The pre-existing
1379
+ // RELATIVE-own-target + env-base mix is untouched by this check —
1380
+ // `identityTargetBase` below already resolves that class fully to
1381
+ // `null` via `computeSegmentTarget`'s early return; this new check
1382
+ // only has any effect when `gitOpts.relocateTargetDir` is ABSOLUTE
1383
+ // (`path.isAbsolute` guards it out for the relative case, the same
1384
+ // condition `identityTargetBase` below already uses).
1385
+ const relocateEnvDiverges = gitOpts.relocateTargetDir !== null &&
1386
+ envTargetDir !== null &&
1387
+ path.isAbsolute(gitOpts.relocateTargetDir) &&
1388
+ gitOpts.relocateTargetDir !== envTargetDir;
1389
+ const identityTargetDir = relocateEnvDiverges
1390
+ ? null
1391
+ : (gitOpts.relocateTargetDir ?? envTargetDir);
1392
+ const identityTargetBase = gitOpts.relocateTargetDir !== null &&
1393
+ envTargetDir !== null &&
1394
+ !path.isAbsolute(gitOpts.relocateTargetDir)
1395
+ ? envTargetDir
1396
+ : null;
1397
+ return {
1398
+ text: rewritten,
1399
+ targetDir,
1400
+ targetBase,
1401
+ identityTargetDir,
1402
+ identityTargetBase,
1403
+ isGit: true,
1404
+ };
1405
+ }
1406
+ if (NON_GIT_HEAD_TOKENS.has(headTok)) {
1407
+ // D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
1408
+ // wrapper peeling (already applied above, same loop as `git`'s) plus
1409
+ // whitespace collapsing ACROSS THE REST OF THE SEGMENT — rejoining
1410
+ // every token from the head token onward with exactly one space
1411
+ // between each. The original scope (collapse only between the head
1412
+ // token and the SINGLE token immediately following it) left an
1413
+ // INTERIOR whitespace run further into a multi-word trigger able to
1414
+ // defeat the match: `gh pr merge` (double space between `pr` and
1415
+ // `merge`, not `gh` and `pr`), `gh pr<TAB>merge`, `gh pr create`.
1416
+ // Full-segment rejoin closes any such run anywhere after the head,
1417
+ // not just immediately after it. Safe because this is additive-only
1418
+ // matching (raw-OR-normalised, module header) and byte-identity is an
1419
+ // acceptance criterion only for heads NOT in this set (see the
1420
+ // byte-identity negative tests below) — a RECOGNISED head's own tail
1421
+ // may be freely rewritten. Still no option-dropping (`gh -R`, `npm
1422
+ // --loglevel`, etc. stay unsupported — module header): a tool-specific
1423
+ // flag between the head and its subcommand still isn't recognised or
1424
+ // skipped, it just no longer needs an interior whitespace run to
1425
+ // survive the rejoin. `targetDir`/`targetBase` stay `null`: these are
1426
+ // never git invocations, so no per-invocation target is extracted for
1427
+ // them (unaffected by the STATUS note above — that extraction was
1428
+ // never wired to a gate either way).
1429
+ if (idx + 1 >= tokens.length) {
1430
+ // Head token alone, no further token to canonicalise toward — no
1431
+ // shipped trigger matches a bare head token anyway, and rewriting to
1432
+ // just the head would silently drop whatever wrapper preceded it for
1433
+ // no benefit. Leave untouched, same fail-safe shape as the git
1434
+ // malformed/no-subcommand case above.
1435
+ return {
1436
+ text: segmentText,
1437
+ targetDir: null,
1438
+ targetBase: null,
1439
+ identityTargetDir: null,
1440
+ identityTargetBase: null,
1441
+ isGit: false,
1442
+ };
1443
+ }
1444
+ const rewritten = tokens
1445
+ .slice(idx)
1446
+ .map((t) => t.text)
1447
+ .join(" ");
1448
+ return {
1449
+ text: rewritten,
1450
+ targetDir: null,
1451
+ targetBase: null,
1452
+ identityTargetDir: null,
1453
+ identityTargetBase: null,
1454
+ isGit: false,
1455
+ };
1456
+ }
1457
+ return {
1458
+ text: segmentText,
1459
+ targetDir: null,
1460
+ targetBase: null,
1461
+ identityTargetDir: null,
1462
+ identityTargetBase: null,
1463
+ isGit: false,
1464
+ };
1465
+ }
1466
+ /**
1467
+ * Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
1468
+ * NAME sets are shared with `read-only-bash.ts` (F6 fix — see the module
1469
+ * header); the VALUE extraction for `-C`/`--chdir` (needed for
1470
+ * `targetDir`, not needed by that module) stays local. Returns the
1471
+ * cursor after the last recognised token and the `-C`/`--chdir` target
1472
+ * directory, if any (excluding a `~`-prefixed value — F5, treated as if
1473
+ * absent).
1474
+ */
1475
+ function peelEnv(tokens, startIdx) {
1476
+ let idx = startIdx;
1477
+ let targetDir = null;
1478
+ while (idx < tokens.length) {
1479
+ const t = tokens[idx].text;
1480
+ // `-S`/`--split-string` re-parses a single string argument into a
1481
+ // fresh argv — we cannot safely see through it. Stop peeling here;
1482
+ // whatever follows is opaque, and since it is not the literal `git`
1483
+ // token the caller's git-check below fails harmlessly.
1484
+ if (ENV_SPLIT_STRING_FLAGS.test(t))
1485
+ break;
1486
+ if (t === "--") {
1487
+ idx += 1;
1488
+ break;
1489
+ }
1490
+ if (t === "-C" || t === "--chdir") {
1491
+ const dir = tokens[idx + 1]?.text;
1492
+ if (dir === undefined)
1493
+ break;
1494
+ if (targetDir === null && !isTildeTarget(dir))
1495
+ targetDir = dir;
1496
+ idx += 2;
1497
+ continue;
1498
+ }
1499
+ if (ENV_VALUE_FLAGS.has(t)) {
1500
+ // Reached only for `-u` / `--unset` — `-C` / `--chdir` are handled
1501
+ // above (with value capture) before this generic check runs.
1502
+ idx += 2;
1503
+ continue;
1504
+ }
1505
+ if (ENV_LEADING_FLAGS.has(t)) {
1506
+ idx += 1;
1507
+ continue;
1508
+ }
1509
+ if (t.startsWith("-C") && t.length > 2) {
1510
+ if (targetDir === null && !isTildeTarget(t.slice(2)))
1511
+ targetDir = t.slice(2);
1512
+ idx += 1;
1513
+ continue;
1514
+ }
1515
+ if (t.startsWith("--chdir=")) {
1516
+ const dir = t.slice("--chdir=".length);
1517
+ if (targetDir === null && !isTildeTarget(dir))
1518
+ targetDir = dir;
1519
+ idx += 1;
1520
+ continue;
1521
+ }
1522
+ if (t.startsWith("--unset=") || /^-u./.test(t)) {
1523
+ idx += 1;
1524
+ continue;
1525
+ }
1526
+ // Generic glued long flag with a value (`--default-signal=INT`, or
1527
+ // any OTHER `env` long option this module does not need the VALUE
1528
+ // of): single token, skip it (G6 fix, review round 2, 2026-07-27 —
1529
+ // mirrors `read-only-bash.ts`'s own generic catch-all, so the two
1530
+ // peelers' coverage cannot drift again the way `--default-signal=INT`
1531
+ // once did; see the module header). Placed AFTER the named `-C` /
1532
+ // `--chdir` / `--unset` forms above so it only ever catches a flag
1533
+ // this module has no more specific handling for.
1534
+ if (t.startsWith("--") && t.includes("=")) {
1535
+ idx += 1;
1536
+ continue;
1537
+ }
1538
+ if (VAR_ASSIGN_RE.test(t)) {
1539
+ idx = consumeAssignment(tokens, idx);
1540
+ continue;
1541
+ }
1542
+ break;
1543
+ }
1544
+ return { idx, targetDir };
1545
+ }
1546
+ /** Peel `command`'s own flags (e.g. `-p`, `-v`, `-V`, `--`). */
1547
+ function peelCommand(tokens, startIdx) {
1548
+ let idx = startIdx;
1549
+ while (idx < tokens.length) {
1550
+ const t = tokens[idx].text;
1551
+ if (t === "--") {
1552
+ idx += 1;
1553
+ break;
1554
+ }
1555
+ if (t.startsWith("-") && t !== "-") {
1556
+ idx += 1;
1557
+ continue;
1558
+ }
1559
+ break;
1560
+ }
1561
+ return idx;
1562
+ }
1563
+ /**
1564
+ * Peel `nice`'s own flags: `-n <n>`, `-n<n>`, `--adjustment=<n>`, and the
1565
+ * bare `-<n>` / `+<n>` forms (G3 fix, review round 2, 2026-07-27):
1566
+ * `nice(1)` documents `nice -10 cmd` (increment glued directly to the
1567
+ * leading dash, no `n`) as its PRIMARY spelling, and some BSD variants
1568
+ * also accept a leading `+` for a positive increment — `nice -10 git
1569
+ * status` bypassed even though `nice` was already in the SUPPORTED list,
1570
+ * because only the `-n`-prefixed spellings were recognised.
1571
+ */
1572
+ function peelNice(tokens, startIdx) {
1573
+ let idx = startIdx;
1574
+ while (idx < tokens.length) {
1575
+ const t = tokens[idx].text;
1576
+ if (t === "-n" || t === "--adjustment") {
1577
+ if (tokens[idx + 1] === undefined)
1578
+ break;
1579
+ idx += 2;
1580
+ continue;
1581
+ }
1582
+ if (/^-n-?\d+$/.test(t)) {
1583
+ idx += 1;
1584
+ continue;
1585
+ }
1586
+ if (t.startsWith("--adjustment=")) {
1587
+ idx += 1;
1588
+ continue;
1589
+ }
1590
+ if (/^[-+]\d+$/.test(t)) {
1591
+ idx += 1;
1592
+ continue;
1593
+ }
1594
+ break;
1595
+ }
1596
+ return idx;
1597
+ }
1598
+ /**
1599
+ * Generic "peel this wrapper's own flags" loop shared by the small,
1600
+ * bounded-vocabulary runners added for F4 (`sudo`, `doas`, `time`,
1601
+ * `stdbuf`, `setsid`): consume `--`, any flag in `valueFlags` (+ its
1602
+ * following token), any glued short/long form matching `gluedRe`, then
1603
+ * any OTHER `-`-prefixed token treated as boolean (mirrors `peelCommand`
1604
+ * above — safe because an unrecognised flag is either genuinely boolean,
1605
+ * in which case this is correct, or value-taking, in which case its
1606
+ * VALUE token is left for the next iteration and — not being `git`, a
1607
+ * known wrapper, or another flag — simply causes a clean, safe bail-out
1608
+ * a token late rather than a false match). Does NOT capture flag
1609
+ * VALUES: none of these five wrappers have a "run in a different
1610
+ * directory" semantic this module tracks as a target directory — only
1611
+ * `env -C`/`--chdir` does, and that stays hand-written in `peelEnv`.
1612
+ */
1613
+ function peelGenericFlags(tokens, startIdx, valueFlags, gluedRe) {
1614
+ let idx = startIdx;
1615
+ while (idx < tokens.length) {
1616
+ const t = tokens[idx].text;
1617
+ if (t === "--") {
1618
+ idx += 1;
1619
+ break;
1620
+ }
1621
+ if (valueFlags.has(t)) {
1622
+ if (tokens[idx + 1] === undefined)
1623
+ break;
1624
+ idx += 2;
1625
+ continue;
1626
+ }
1627
+ if (t.startsWith("--") && t.includes("=")) {
1628
+ idx += 1;
1629
+ continue;
1630
+ }
1631
+ if (gluedRe !== null && gluedRe.test(t)) {
1632
+ idx += 1;
1633
+ continue;
1634
+ }
1635
+ if (t.startsWith("-") && t !== "-") {
1636
+ idx += 1;
1637
+ continue;
1638
+ }
1639
+ break;
1640
+ }
1641
+ return idx;
1642
+ }
1643
+ /**
1644
+ * `sudo`'s own value-taking flags (user/group/prompt/chroot/role/type/
1645
+ * timeout/close-from). Every other `-`-prefixed `sudo` flag (`-E`, `-H`,
1646
+ * `-i`, `-k`, `-n`, `-S`, `-v`, ...) is boolean and falls through
1647
+ * `peelGenericFlags`'s generic "-flag consumes one token" branch.
1648
+ */
1649
+ const SUDO_VALUE_FLAGS = new Set([
1650
+ "-g", "-h", "-p", "-R", "-r", "-t", "-T", "-u", "-C",
1651
+ "--group", "--host", "--prompt", "--chroot", "--role",
1652
+ "--type", "--command-timeout", "--user", "--close-from",
1653
+ ]);
1654
+ /** `sudo`'s glued short-value forms: `-uroot`, `-Cbastion`, etc. */
1655
+ const SUDO_GLUED_RE = /^-[ghpRrtTuC]./;
1656
+ function peelSudo(tokens, startIdx) {
1657
+ return peelGenericFlags(tokens, startIdx, SUDO_VALUE_FLAGS, SUDO_GLUED_RE);
1658
+ }
1659
+ /** `doas`'s own value-taking flags: `-u user`, `-C configfile`. */
1660
+ const DOAS_VALUE_FLAGS = new Set(["-u", "-C"]);
1661
+ const DOAS_GLUED_RE = /^-[uC]./;
1662
+ function peelDoas(tokens, startIdx) {
1663
+ return peelGenericFlags(tokens, startIdx, DOAS_VALUE_FLAGS, DOAS_GLUED_RE);
1664
+ }
1665
+ /** `time`'s own value-taking flags: `-o`/`--output`, `-f`/`--format`. */
1666
+ const TIME_VALUE_FLAGS = new Set(["-o", "-f", "--output", "--format"]);
1667
+ const TIME_GLUED_RE = /^-[of]./;
1668
+ function peelTime(tokens, startIdx) {
1669
+ return peelGenericFlags(tokens, startIdx, TIME_VALUE_FLAGS, TIME_GLUED_RE);
1670
+ }
1671
+ /** `timeout`'s own value-taking flags: `-k`/`--kill-after`, `-s`/`--signal`. */
1672
+ const TIMEOUT_VALUE_FLAGS = new Set([
1673
+ "-k", "-s", "--kill-after", "--signal",
1674
+ ]);
1675
+ const TIMEOUT_GLUED_RE = /^-[ks]./;
1676
+ /**
1677
+ * `timeout DURATION command...` requires a DURATION positional BEFORE
1678
+ * the wrapped command — unlike the other four runners, so it cannot
1679
+ * share `peelGenericFlags` unmodified. Peel `timeout`'s own flags first,
1680
+ * then consume the mandatory duration token. A missing duration
1681
+ * (malformed/truncated command) leaves `idx` where the flag loop left
1682
+ * it; the caller's "is this token `git`?" check bails safely on
1683
+ * whatever token is actually there.
1684
+ */
1685
+ function peelTimeout(tokens, startIdx) {
1686
+ let idx = peelGenericFlags(tokens, startIdx, TIMEOUT_VALUE_FLAGS, TIMEOUT_GLUED_RE);
1687
+ if (tokens[idx] !== undefined)
1688
+ idx += 1;
1689
+ return idx;
1690
+ }
1691
+ /** `stdbuf`'s flags are ALWAYS value-taking: `-i`/`-o`/`-e` (buffering mode). */
1692
+ const STDBUF_VALUE_FLAGS = new Set([
1693
+ "-i", "-o", "-e", "--input", "--output", "--error",
1694
+ ]);
1695
+ const STDBUF_GLUED_RE = /^-[ioe]./;
1696
+ function peelStdbuf(tokens, startIdx) {
1697
+ return peelGenericFlags(tokens, startIdx, STDBUF_VALUE_FLAGS, STDBUF_GLUED_RE);
1698
+ }
1699
+ /** `setsid`'s flags (`-w`/`--wait`, `-c`/`--ctty`, `-f`/`--fork`) are all boolean. */
1700
+ function peelSetsid(tokens, startIdx) {
1701
+ return peelGenericFlags(tokens, startIdx, new Set(), null);
1702
+ }
1703
+ /** Strip a path that ends in a `.git` directory down to its parent. */
1704
+ function parentIfDotGit(dir) {
1705
+ const trimmed = dir.replace(/\/+$/, "");
1706
+ if (trimmed === ".git")
1707
+ return ".";
1708
+ if (trimmed.endsWith("/.git"))
1709
+ return trimmed.slice(0, -"/.git".length) || "/";
1710
+ return dir;
1711
+ }
1712
+ /**
1713
+ * Peel git's own global options (dropping them so the subcommand becomes
1714
+ * adjacent to `git`), tracking the first `-C` / `--work-tree` /
1715
+ * `--git-dir` target directory encountered (excluding a `~`-prefixed
1716
+ * value — F5, treated as if absent, but still consumed as an option so
1717
+ * parsing continues past it) as `targetDir` — the OLD, UNCONSUMED
1718
+ * aggregate's own extraction (module header STATUS note), UNCHANGED by
1719
+ * the fix below. `malformed: true` means a value-requiring option was
1720
+ * missing its value — the caller bails without rewriting rather than
1721
+ * guess.
1722
+ *
1723
+ * `relocateTargetDir` (D-017, fix round 2, run `2026-08-02-per-repo-
1724
+ * gate-scoping-redesign`): a SEPARATE tracker, fed ONLY by the
1725
+ * repo-relocating flags `-C` and `--git-dir` — NEVER `--work-tree`.
1726
+ * `--work-tree` sets the working tree but does NOT relocate git's
1727
+ * `--git-dir` search; `git push`/`log`/`status` under `--work-tree=<B>`
1728
+ * alone still operate on whatever repo the CURRENT directory (or an
1729
+ * actual `-C`/`--git-dir`) names, not `<B>`. Pass-2 review measured this
1730
+ * as a live cross-repo fail-open once `--work-tree` was folded into the
1731
+ * same `targetDir` bucket as `-C`/`--git-dir` and that bucket fed
1732
+ * `CommandSegment.ownTarget`'s REPLACE attribution (`computeSegmentTarget`
1733
+ * below, `src/runtime/intercept.ts`'s `resolveAttributedContexts`,
1734
+ * D-011): `git --work-tree=<B> push`, cwd = A, was attributed to B alone,
1735
+ * dropping the cwd demand a real `git push` there still has to satisfy.
1736
+ * `relocateTargetDir` is what `computeSegmentTarget` now consults for a
1737
+ * git segment's `ownTarget` — see that function and `canonicalizeSegment`
1738
+ * below. Kept as a distinct field (a whitelist of repo-relocating flags),
1739
+ * not a `--work-tree` special-case, so a future path-valued-but-repo-
1740
+ * neutral git global option cannot silently reopen this class by being
1741
+ * folded into `targetDir` the way `--work-tree` originally was.
1742
+ *
1743
+ * D-018 (fix round 3, pass-3 CRITICAL): `relocateTargetDir` is set ONLY
1744
+ * when the invocation carries EXACTLY ONE recognised, fully-resolved
1745
+ * repo-relocating option (a single `-C` or a single `--git-dir`/
1746
+ * `--git-dir=`). Pass-3 review measured (against real git 2.34.1) that
1747
+ * `git -C <forge> -C <realA> push` attributes to the FIRST `-C` here
1748
+ * while git composes ALL of them cumulatively and actually operates in
1749
+ * `<realA>` — the same first-token-wins gap spans `-C` combined with
1750
+ * `--git-dir`. `noteRelocatingOption` below counts every RESOLVED
1751
+ * (non-`~`) occurrence of either flag across the whole invocation; the
1752
+ * second (or later) occurrence forces `relocateTargetDir` to `null` and
1753
+ * LOCKS it there for the rest of this call — UNLESS its value is an
1754
+ * exact repeat of the one already recorded (idempotent, e.g. `-C X -C
1755
+ * X`), which is not new information and does not trigger the lock. This
1756
+ * is a deliberate under-approximation of git's real `-C`-chains-relative-
1757
+ * to-the-previous-`-C` composition rule (the module has never encoded
1758
+ * that, by design — see `computeSegmentTarget`'s own comment on why
1759
+ * composing paths is not this module's job): ANY invocation this module
1760
+ * cannot resolve to a single, unambiguous flag falls back to `null` here,
1761
+ * which downstream (`computeSegmentTarget`) means "no own target" and the
1762
+ * cwd context is demanded instead — exactly the shipped, pre-this-run
1763
+ * semantics, never a forged first-token guess.
1764
+ *
1765
+ * D-021 (UNIVERSAL-ADDITIVE, operator decision, run 2026-08-02-per-repo-
1766
+ * gate-scoping-redesign): both the D-017 `--work-tree` exclusion and the
1767
+ * D-018 multi-option lock above were originally the SAFETY mechanism
1768
+ * (the thing standing between an attacker-composed invocation and a
1769
+ * forged REPLACE attribution); under D-021, `src/runtime/intercept.ts`'s
1770
+ * `resolveAttributedContexts` never replaces the cwd demand at all, so
1771
+ * neither exclusion can be bypassed into a weaker-than-shipped gate any
1772
+ * more — the cwd demand is never dropped regardless of what
1773
+ * `relocateTargetDir` resolves to. Both stay in place as PRECISION
1774
+ * (avoiding a spurious extra demand for an ambiguous or non-relocating
1775
+ * target), not as the security boundary; see `resolveAttributedContexts`
1776
+ * itself for where that boundary now lives.
1777
+ */
1778
+ function peelGitGlobalOptions(tokens, startIdx) {
1779
+ let idx = startIdx;
1780
+ let targetDir = null;
1781
+ let relocateTargetDir = null;
1782
+ let relocateResolvedCount = 0;
1783
+ let relocateAmbiguous = false;
1784
+ // D-018: record one repo-relocating flag's resolved value (`null` for a
1785
+ // `~`-prefixed value, which — same as the pre-fix code — never sets or
1786
+ // counts against `relocateTargetDir` at all, D-003's "treated as if
1787
+ // absent"). The SECOND (or later) resolved occurrence nulls and LOCKS
1788
+ // `relocateTargetDir`, unless it is an exact repeat of the value
1789
+ // already recorded (idempotent, e.g. `-C X -C X` or `-C X --git-dir
1790
+ // X/.git`), which stays a single value rather than tripping the lock —
1791
+ // "im Zweifel null" still applies to every OTHER two-or-more case.
1792
+ function noteRelocatingOption(resolved) {
1793
+ if (resolved === null)
1794
+ return;
1795
+ relocateResolvedCount += 1;
1796
+ if (relocateAmbiguous)
1797
+ return;
1798
+ if (relocateResolvedCount === 1) {
1799
+ relocateTargetDir = resolved;
1800
+ return;
1801
+ }
1802
+ if (resolved === relocateTargetDir)
1803
+ return; // idempotent repeat
1804
+ relocateTargetDir = null;
1805
+ relocateAmbiguous = true;
1806
+ }
1807
+ while (idx < tokens.length) {
1808
+ const t = tokens[idx].text;
1809
+ if (t === "-C") {
1810
+ const dir = tokens[idx + 1]?.text;
1811
+ if (dir === undefined)
1812
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1813
+ if (targetDir === null && !isTildeTarget(dir))
1814
+ targetDir = dir;
1815
+ noteRelocatingOption(isTildeTarget(dir) ? null : dir);
1816
+ idx += 2;
1817
+ continue;
1818
+ }
1819
+ if (t === "-c") {
1820
+ if (tokens[idx + 1] === undefined)
1821
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1822
+ idx += 2;
1823
+ continue;
1824
+ }
1825
+ if (t === "--git-dir") {
1826
+ const dir = tokens[idx + 1]?.text;
1827
+ if (dir === undefined)
1828
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1829
+ if (targetDir === null && !isTildeTarget(dir))
1830
+ targetDir = parentIfDotGit(dir);
1831
+ noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
1832
+ idx += 2;
1833
+ continue;
1834
+ }
1835
+ if (t.startsWith("--git-dir=")) {
1836
+ const dir = t.slice("--git-dir=".length);
1837
+ if (targetDir === null && !isTildeTarget(dir))
1838
+ targetDir = parentIfDotGit(dir);
1839
+ noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
1840
+ idx += 1;
1841
+ continue;
1842
+ }
1843
+ if (t === "--work-tree") {
1844
+ // D-017: `--work-tree` still feeds the OLD `targetDir` aggregate
1845
+ // (unchanged, unconsumed by any gate) but deliberately NEVER
1846
+ // `relocateTargetDir` — see this function's own doc comment.
1847
+ const dir = tokens[idx + 1]?.text;
1848
+ if (dir === undefined)
1849
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1850
+ if (targetDir === null && !isTildeTarget(dir))
1851
+ targetDir = dir;
1852
+ idx += 2;
1853
+ continue;
1854
+ }
1855
+ if (t.startsWith("--work-tree=")) {
1856
+ const dir = t.slice("--work-tree=".length);
1857
+ if (targetDir === null && !isTildeTarget(dir))
1858
+ targetDir = dir;
1859
+ idx += 1;
1860
+ continue;
1861
+ }
1862
+ if (t === "--no-pager" || t === "-p" || t === "--paginate") {
1863
+ idx += 1;
1864
+ continue;
1865
+ }
1866
+ if (t === "--exec-path" || t.startsWith("--exec-path=")) {
1867
+ idx += 1;
1868
+ continue;
1869
+ }
1870
+ if (t === "--namespace") {
1871
+ if (tokens[idx + 1] === undefined)
1872
+ return { idx, targetDir, relocateTargetDir, malformed: true };
1873
+ idx += 2;
1874
+ continue;
1875
+ }
1876
+ if (t.startsWith("--namespace=")) {
1877
+ idx += 1;
1878
+ continue;
1879
+ }
1880
+ if (t === "--literal-pathspecs" || t === "--no-replace-objects") {
1881
+ idx += 1;
1882
+ continue;
1883
+ }
1884
+ break;
1885
+ }
1886
+ return { idx, targetDir, relocateTargetDir, malformed: false };
1887
+ }
1888
+ //# sourceMappingURL=command-normalize.js.map