@lannguyensi/harness 0.41.0 → 0.43.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 (137) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +9 -0
  3. package/dist/cli/adopt/derive.d.ts +15 -0
  4. package/dist/cli/adopt/derive.js +34 -7
  5. package/dist/cli/adopt/derive.js.map +1 -1
  6. package/dist/cli/adopt/index.d.ts +30 -0
  7. package/dist/cli/adopt/index.js +23 -3
  8. package/dist/cli/adopt/index.js.map +1 -1
  9. package/dist/cli/apply/generate-settings.d.ts +14 -1
  10. package/dist/cli/apply/generate-settings.js +7 -3
  11. package/dist/cli/apply/generate-settings.js.map +1 -1
  12. package/dist/cli/approve/branch-protection.js +1 -1
  13. package/dist/cli/approve/branch-protection.js.map +1 -1
  14. package/dist/cli/approve/stdin-report.js +2 -0
  15. package/dist/cli/approve/stdin-report.js.map +1 -1
  16. package/dist/cli/doctor/claude-mcp.d.ts +50 -0
  17. package/dist/cli/doctor/claude-mcp.js +170 -0
  18. package/dist/cli/doctor/claude-mcp.js.map +1 -0
  19. package/dist/cli/doctor/format.js +37 -0
  20. package/dist/cli/doctor/format.js.map +1 -1
  21. package/dist/cli/doctor/index.d.ts +10 -0
  22. package/dist/cli/doctor/index.js +24 -0
  23. package/dist/cli/doctor/index.js.map +1 -1
  24. package/dist/cli/doctor/npm-bin-path.js +42 -0
  25. package/dist/cli/doctor/npm-bin-path.js.map +1 -1
  26. package/dist/cli/doctor/types.d.ts +11 -0
  27. package/dist/cli/doctor/types.js.map +1 -1
  28. package/dist/cli/dry-run.js +13 -1
  29. package/dist/cli/dry-run.js.map +1 -1
  30. package/dist/cli/index.js +253 -5
  31. package/dist/cli/index.js.map +1 -1
  32. package/dist/cli/init/agent-tasks-auth.js +32 -0
  33. package/dist/cli/init/agent-tasks-auth.js.map +1 -1
  34. package/dist/cli/init/composer.js +9 -15
  35. package/dist/cli/init/composer.js.map +1 -1
  36. package/dist/cli/init/dependencies.js +21 -0
  37. package/dist/cli/init/dependencies.js.map +1 -1
  38. package/dist/cli/init/detect.d.ts +20 -3
  39. package/dist/cli/init/detect.js +34 -36
  40. package/dist/cli/init/detect.js.map +1 -1
  41. package/dist/cli/init/interactive.d.ts +33 -0
  42. package/dist/cli/init/interactive.js +399 -5
  43. package/dist/cli/init/interactive.js.map +1 -1
  44. package/dist/cli/init/profiles.d.ts +1 -1
  45. package/dist/cli/init/profiles.js +2 -2
  46. package/dist/cli/init/templates.d.ts +1 -1
  47. package/dist/cli/init/templates.js +111 -32
  48. package/dist/cli/init/templates.js.map +1 -1
  49. package/dist/cli/main.js +10 -0
  50. package/dist/cli/main.js.map +1 -1
  51. package/dist/cli/pack/hook-branch-protection.js +1 -1
  52. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  53. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +30 -0
  54. package/dist/cli/pack/hook-post-merge-gate-record.js +153 -0
  55. package/dist/cli/pack/hook-post-merge-gate-record.js.map +1 -0
  56. package/dist/cli/pack/hook-post-merge-gate.d.ts +28 -0
  57. package/dist/cli/pack/hook-post-merge-gate.js +244 -0
  58. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -0
  59. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +149 -3
  60. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
  61. package/dist/cli/pack/hook-solution-acceptance.js +1 -1
  62. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  63. package/dist/cli/pack/understanding-report-schema-hint.js +1 -1
  64. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  65. package/dist/cli/policy/intercept.js +77 -10
  66. package/dist/cli/policy/intercept.js.map +1 -1
  67. package/dist/cli/record/index.d.ts +64 -0
  68. package/dist/cli/record/index.js +211 -0
  69. package/dist/cli/record/index.js.map +1 -0
  70. package/dist/cli/session-start/index.d.ts +7 -0
  71. package/dist/cli/session-start/index.js +133 -38
  72. package/dist/cli/session-start/index.js.map +1 -1
  73. package/dist/cli/session-start/toolchain-parity.d.ts +149 -0
  74. package/dist/cli/session-start/toolchain-parity.js +618 -0
  75. package/dist/cli/session-start/toolchain-parity.js.map +1 -0
  76. package/dist/cli/uninstall/index.d.ts +73 -1
  77. package/dist/cli/uninstall/index.js +134 -2
  78. package/dist/cli/uninstall/index.js.map +1 -1
  79. package/dist/cli/validate/checks.js +2 -2
  80. package/dist/cli/validate/checks.js.map +1 -1
  81. package/dist/io/claude-mcp.d.ts +258 -0
  82. package/dist/io/claude-mcp.js +544 -0
  83. package/dist/io/claude-mcp.js.map +1 -0
  84. package/dist/policies/ledger-client.d.ts +34 -1
  85. package/dist/policies/ledger-client.js +21 -16
  86. package/dist/policies/ledger-client.js.map +1 -1
  87. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +1 -1
  88. package/dist/policy-packs/builtin/branch-protection-runtime.js +1 -1
  89. package/dist/policy-packs/builtin/branch-protection.js +2 -2
  90. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  91. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +195 -0
  92. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +290 -0
  93. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -0
  94. package/dist/policy-packs/builtin/post-merge-gate.d.ts +56 -0
  95. package/dist/policy-packs/builtin/post-merge-gate.js +291 -0
  96. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -0
  97. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +29 -14
  98. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  99. package/dist/policy-packs/builtin/solution-acceptance.js +1 -1
  100. package/dist/policy-packs/registry.d.ts +1 -1
  101. package/dist/policy-packs/registry.js +12 -0
  102. package/dist/policy-packs/registry.js.map +1 -1
  103. package/dist/runtime/bash-match-facts.d.ts +58 -0
  104. package/dist/runtime/bash-match-facts.js +194 -0
  105. package/dist/runtime/bash-match-facts.js.map +1 -0
  106. package/dist/runtime/bash-match-registry.d.ts +75 -0
  107. package/dist/runtime/bash-match-registry.js +359 -0
  108. package/dist/runtime/bash-match-registry.js.map +1 -0
  109. package/dist/runtime/bash-prefix-parse.js +23 -0
  110. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  111. package/dist/runtime/command-normalize.d.ts +92 -0
  112. package/dist/runtime/command-normalize.js +1182 -0
  113. package/dist/runtime/command-normalize.js.map +1 -0
  114. package/dist/runtime/git-context.d.ts +50 -0
  115. package/dist/runtime/git-context.js +108 -1
  116. package/dist/runtime/git-context.js.map +1 -1
  117. package/dist/runtime/hermetic-spawn-guard.d.ts +25 -0
  118. package/dist/runtime/hermetic-spawn-guard.js +136 -0
  119. package/dist/runtime/hermetic-spawn-guard.js.map +1 -0
  120. package/dist/runtime/intercept.d.ts +39 -1
  121. package/dist/runtime/intercept.js +33 -4
  122. package/dist/runtime/intercept.js.map +1 -1
  123. package/dist/runtime/ledger-writer.d.ts +27 -0
  124. package/dist/runtime/ledger-writer.js +46 -0
  125. package/dist/runtime/ledger-writer.js.map +1 -0
  126. package/dist/runtime/read-only-bash.d.ts +43 -0
  127. package/dist/runtime/read-only-bash.js +133 -3
  128. package/dist/runtime/read-only-bash.js.map +1 -1
  129. package/dist/schema/index.d.ts +41 -0
  130. package/dist/schema/index.js +5 -0
  131. package/dist/schema/index.js.map +1 -1
  132. package/dist/schema/policies.js +1 -1
  133. package/dist/schema/policies.js.map +1 -1
  134. package/dist/schema/toolchain-parity.d.ts +18 -0
  135. package/dist/schema/toolchain-parity.js +35 -0
  136. package/dist/schema/toolchain-parity.js.map +1 -0
  137. package/package.json +5 -2
@@ -0,0 +1,1182 @@
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). `targetDir` / `targetBase` — the effective target
12
+ // directory of the command's own git invocation(s), when one can be
13
+ // named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
14
+ // this run used them to resolve the `${REPO}`/`${BRANCH}` policy
15
+ // builtins (`src/cli/policy/intercept.ts`) to a command's named target
16
+ // instead of the hook's own cwd; that wiring produced three consecutive
17
+ // review-round security regressions (Risk Gate risk declassification, a
18
+ // push-gate multi-invocation fail-open, and a non-git gated verb leaking
19
+ // a decoy repo's target, first via `&&`, still open via `|`/`||`), each
20
+ // only partially closeable because a single per-event `targetDir` cannot
21
+ // express "this invocation targets repo B, but the gated verb after it
22
+ // runs in the caller's cwd." The wiring was removed before shipping (see
23
+ // `src/cli/policy/intercept.ts` and `CHANGELOG.md`); the extraction and
24
+ // its own ambiguity rules stay here, fully tested, as the foundation for
25
+ // a redesign that attributes a target to the SEGMENT satisfying a
26
+ // policy's own trigger instead of to the whole event — follow-up task
27
+ // `98ad072f`. Nothing below describes gate wiring; it describes what
28
+ // THIS MODULE computes, live or not.
29
+ //
30
+ // MEASUREMENT RULE (task 47297478): before measuring THIS module's
31
+ // extraction across builds with an ad-hoc corpus, read the per-arm-gate
32
+ // design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
33
+ // baseline never hit proves nothing, and an ungated zero from an ad-hoc
34
+ // corpus has already been wrong three times in a row (b093911d run).
35
+ // Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
36
+ // extraction; this module's `normalized`/`targetDir`/`targetBase`
37
+ // outputs have no instrument yet, so cross-build claims about them need
38
+ // their own per-arm-gated measurement.
39
+ //
40
+ // THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
41
+ // (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
42
+ // single regex tested against the UNPARSED command string, anchored on a
43
+ // shell boundary (`^ \n ; | && (`) followed by optional `VAR=value`
44
+ // tokens and a literal head token — `git` (most triggers), `gh` (`gh pr
45
+ // merge`/`gh pr create`), `npm` (`npm publish`), or `harness` (the
46
+ // kill-switch verbs). That anchor is exact-spelling brittle: a wrapper
47
+ // binary, a git global option other than `-C`, or even a second space
48
+ // between the head token and its subcommand defeats the match silently —
49
+ // the policy is skipped, no ledger query happens, no audit signal at all.
50
+ // Measured bypasses closed by this module (task `432db3d3`, run
51
+ // `2026-07-28-nongit-trigger-wrappers`, D-001): `env gh pr merge`, `env -C
52
+ // <dir> gh pr merge`, `nice gh pr merge`, `gh pr merge` (double space),
53
+ // `env gh pr create`, `env npm publish`, `nice npm publish`, `env harness
54
+ // pause`, `nice harness pause`, `command harness pause`, `env -C <dir>
55
+ // harness pause` — the exact one-word-wrapper class already fixed for
56
+ // `git` in the prior run, reproduced against `gh`/`npm`/`harness` because
57
+ // the head-token condition at the end of `canonicalizeSegment` checked
58
+ // literal equality with `git` and nothing else.
59
+ //
60
+ // CURRENT RULES — trigger normalisation (peeled/canonicalised into
61
+ // `normalized`):
62
+ // - Leading `VAR=value` tokens (any number, in sequence).
63
+ // - `env`, including `-C <dir>` / `--chdir <dir>` / `-C<dir>` /
64
+ // `--chdir=<dir>` and its own `VAR=value` arguments. The flag-NAME
65
+ // enumeration (which flags exist, not their VALUES) is IMPORTED from
66
+ // `read-only-bash.ts`'s `ENV_LEADING_FLAGS` / `ENV_VALUE_FLAGS` /
67
+ // `ENV_SPLIT_STRING_FLAGS` rather than hand-rolled here a second
68
+ // time, so the two peelers cannot independently drift on which
69
+ // flags exist; `read-only-bash.ts`'s generic "any OTHER glued
70
+ // `--long=value` flag" catch-all is mirrored here too. Each module
71
+ // keeps its own decision about what a recognised flag MEANS: this
72
+ // module additionally needs the VALUE of `-C`/`--chdir` to extract
73
+ // `targetDir`, which `read-only-bash.ts` never needs, so that
74
+ // glued-value parsing (`-C<dir>`, `--chdir=<dir>`) stays local here.
75
+ // - `command` (its own flags, e.g. `-p`/`-v`/`-V`, and `--`).
76
+ // - `nice`, including `-n <n>` / `-n<n>` / `--adjustment=<n>`, and the
77
+ // bare `-<n>` / `+<n>` forms — `nice -10 cmd` (increment glued
78
+ // straight to the leading dash, no `n`) is `nice(1)`'s PRIMARY
79
+ // documented spelling.
80
+ // - `sudo` / `doas` (their own user/group/config flags — see
81
+ // `SUDO_VALUE_FLAGS` / `DOAS_VALUE_FLAGS`), `time` (`-o`/`-f`/
82
+ // `--output`/`--format` plus boolean flags), `timeout` (its own flags
83
+ // PLUS the mandatory leading DURATION positional — see
84
+ // `peelTimeout`), `stdbuf` (`-i`/`-o`/`-e`, always value-taking), and
85
+ // `setsid` (`-w`/`-c`/`-f`, boolean). NOT `xargs` — deliberately
86
+ // excluded, see NOT SUPPORTED below.
87
+ // - A git invocation's own global options: `-C <dir>`, `-c <k=v>`,
88
+ // `--git-dir[= ]<dir>`, `--work-tree[= ]<dir>`, `--no-pager`,
89
+ // `-p`/`--paginate`, `--exec-path[=<path>]`, `--namespace[= ]<ns>`,
90
+ // `--literal-pathspecs`, `--no-replace-objects` — dropped so the
91
+ // subcommand becomes adjacent to the literal `git` token, and the
92
+ // whitespace between them collapses to exactly one space.
93
+ // - The `git` token itself may be path-qualified (`/usr/bin/git`,
94
+ // `./git`, any `\S*/git`), matched by BASENAME (mirrors the existing
95
+ // `deny-kill-switch-bypass` regex's own `(?:npx\s+|\S*/)?harness`
96
+ // shape). The canonicalised output always writes the literal `git`,
97
+ // regardless of how the invocation spelled the binary.
98
+ // - THE CLOSED HEAD-TOKEN SET (D-001, run
99
+ // 2026-07-28-nongit-trigger-wrappers): after the SAME wrapper-peeling
100
+ // loop above, the head token is checked against `git` OR the three
101
+ // other literal head tokens this module covers — `gh` (`gh pr merge`
102
+ // / `gh pr create`), `npm` (`npm publish`), and `harness` (the
103
+ // kill-switch verbs). `git` alone gets the git-specific global-option
104
+ // dropping described above; the other three get wrapper peeling PLUS
105
+ // whitespace collapsing across the REST OF THE SEGMENT (fix round 2,
106
+ // finding F2 — collapsing only the head-to-next-token gap, the
107
+ // original scope, still let an interior whitespace run defeat a
108
+ // multi-word trigger like `gh pr merge`), no option-dropping (`gh
109
+ // -R`, `npm --loglevel`, and any other tool-specific flag stay
110
+ // UNSUPPORTED — see below — because a flag inserted between the head
111
+ // token and its subcommand tokens is not looked past). Matched by
112
+ // EXACT literal equality, not basename like `git` — a path-qualified
113
+ // `gh`/`npm`/`harness` invocation is out of scope for this set (named
114
+ // residual below); `harness`'s own trigger regex already covers a
115
+ // path-qualified spelling at the RAW-match level (`(?:npx\s+|\S*/)?
116
+ // harness` in `deny-kill-switch-bypass`), so nothing is lost there
117
+ // specifically. This is a CLOSED set, not a general "peel wrappers
118
+ // for any head token" rule.
119
+ // - SHIPPED BUT NOT COVERED (fix round 2, finding F1 — corrects an
120
+ // inaccurate claim in the prior version of this comment, which said
121
+ // this module's four head tokens were "the head tokens shipped
122
+ // policies actually gate today"): a shipped `bash_match` trigger
123
+ // actually keys on EIGHT distinct COMMAND-NAME head tokens, not four —
124
+ // plus one HEADLESS alternative with no command name at all:
125
+ // `deny-session-env-strip`'s bare `<SESSION_VAR>=` empty-assignment
126
+ // form, whose wrapped spelling (`nice CLAUDE_SESSION_ID= npm publish`)
127
+ // is equally out of this module's reach, because the peel loop consumes
128
+ // the assignment as an ordinary `VAR=value` prefix (measured
129
+ // 2026-07-28, pinned alongside the four below).
130
+ // `deny-session-env-strip` also keys on `env` (`env ... -u <VAR>` /
131
+ // `env ... --unset <VAR>`) and `unset` (`unset <VAR>...`);
132
+ // `deny-pause-sentinel-forgery` also keys on `tee` and `cp`. None of
133
+ // these four are in the closed set above, and adding them is not
134
+ // simply "the same kind of change again":
135
+ // - `env` is STRUCTURALLY unreachable by this module's own
136
+ // architecture, not merely unimplemented: the wrapper-peeling
137
+ // loop treats `env` ONLY as a pass-through wrapper hunting for a
138
+ // `git`/`gh`/`npm`/`harness` invocation behind it, so it ALWAYS
139
+ // calls `peelEnv` and consumes `env`'s OWN `-u`/`--unset` flags
140
+ // (and their VALUES) as part of that hunt — by the time `headTok`
141
+ // is checked, the very `-u <VAR>` text `deny-session-env-strip`'s
142
+ // trigger keys on has already been peeled away. Recognising
143
+ // `env` as ITSELF a head token would need the SAME literal token
144
+ // to get two different treatments depending on WHY it is there —
145
+ // an architecture change, not a set-membership change — and is
146
+ // out of scope for this closed-set run. Measured, still-open
147
+ // bypass: `nice env -u CLAUDE_SESSION_ID ls` (pinned as a
148
+ // documented ceiling, not silently left to a comment).
149
+ // - `unset` is simply not one of the wrapper-peeling loop's
150
+ // recognised names at all (unlike `env`, there is no
151
+ // `peelUnset`), so a wrapped `unset` invocation is not looked
152
+ // past either. Measured, still-open bypass: `nice unset
153
+ // CLAUDE_SESSION_ID`.
154
+ // - `tee` / `cp` are never wrapper-peeled or recognised as a head
155
+ // token by this module at all; `deny-pause-sentinel-forgery`'s
156
+ // own raw regex is the only thing that ever catches them.
157
+ // Measured, still-open bypasses: `nice tee /tmp/.harness-paused`,
158
+ // `nice cp a /tmp/.harness-paused`.
159
+ // A future policy gating some OTHER head token (one of these four, or
160
+ // any other) stays uncovered by this module too — named residual, no
161
+ // follow-up filed unless a real consumer appears. See
162
+ // `tests/runtime/bash-match-head-token-drift.test.ts` for the guard
163
+ // that couples this module's covered set to what FULL_TEMPLATE's
164
+ // shipped `bash_match` policies actually key on today.
165
+ //
166
+ // CURRENT RULES — target-directory extraction (`targetDir`/`targetBase`;
167
+ // UNCONSUMED — see STATUS above):
168
+ // - The effective target directory of the command's git invocation(s),
169
+ // from (in priority order per invocation) the git invocation's own
170
+ // `-C` / `--work-tree` / `--git-dir` (parent directory when the path
171
+ // ends in `.git`), the wrapping `env -C` / `--chdir` on the SAME
172
+ // invocation, or — only when NO invocation named one explicitly — a
173
+ // leading `cd <dir> &&|;` prefix (delegated to `bash-prefix-
174
+ // parse.ts`, which already parses that idiom). A `~`-prefixed value
175
+ // is treated as though no target were named AT ALL: this module does
176
+ // not expand `~`, and letting it resolve against the caller's cwd
177
+ // would produce a CONFIDENTLY WRONG answer (an unrelated repo found
178
+ // by walking up from a bogus path) instead of the documented "no
179
+ // target" fallback.
180
+ // - `targetBase`: when exactly one git invocation named an explicit
181
+ // target (or several agree on the SAME one) and it is a RELATIVE
182
+ // path, this names the directory it is relative to, when this
183
+ // module saw one — the wrapping `env -C` on the SAME invocation
184
+ // (highest priority), else a leading `cd <dir> &&|;` prefix (`cd
185
+ // <repo> && git -C src status` resolves `src` against `<repo>`, not
186
+ // a caller's own cwd). `null` when neither is present — a
187
+ // hypothetical future consumer resolves `targetDir` against its own
188
+ // cwd instead.
189
+ // - When a command names MORE THAN ONE git invocation, `targetDir` (and
190
+ // `targetBase`) are populated only when every invocation AGREES: all
191
+ // of them name the SAME explicit target, or NONE of them do (falling
192
+ // through to the leading-`cd` case above). A command mixing a bare
193
+ // invocation with an explicitly-targeted one — `git -C <B> log &&
194
+ // git push` — or naming two DIFFERENT explicit targets, is AMBIGUOUS
195
+ // and `targetDir` is `null`.
196
+ // - "Every invocation agrees" is ALSO broken by a non-git command that
197
+ // shares the chain: a command-starting segment (follows
198
+ // `&&`/`;`/`\n`/`(`, or is the first segment — never a bare `|`,
199
+ // which stays in the SAME directory) that is NEITHER a git
200
+ // invocation NOR the recognised leading `cd` prefix forces
201
+ // `targetDir` to `null` too, the same conservative fallback used for
202
+ // git-vs-git disagreement — measured case: `git -C <B> rev-parse
203
+ // HEAD && gh pr merge`, where `-C` scopes only the ONE git call it
204
+ // decorates and `gh pr merge` genuinely runs at the real, unaffected
205
+ // cwd. The leading `cd` prefix stays exempt on purpose: unlike `-C`,
206
+ // `cd` genuinely persists for the rest of the chain, so `cd <B> &&
207
+ // gh pr create` really does run against B — only a PER-INVOCATION
208
+ // override (`-C`, `--work-tree`, `--git-dir`, wrapping `env -C`) can
209
+ // leak across an unrelated later command; a persistent `cd` cannot,
210
+ // by construction.
211
+ //
212
+ // DELIBERATELY NOT SUPPORTED, and out of reach of ANY string-level
213
+ // approach (see `CHANGELOG.md` task `2cc73f55`, decision D-005):
214
+ // - `sh -c '...'` / `bash -c '...'`: the wrapped command lives inside a
215
+ // single string argument. This module does not parse into it — note
216
+ // that such a command usually still matches a `bash_match` regex
217
+ // anyway, because the regex scans the RAW command string without
218
+ // respecting quoting, so a shell boundary (`&&`, `;`, ...) inside the
219
+ // quoted string is still "seen".
220
+ // - Heredocs, `eval`, base64-decoded payloads, or a script file the
221
+ // agent writes and then executes.
222
+ // - `pushd`/`popd`, and a `cd` inside a nested subshell
223
+ // (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
224
+ // - A shell-boundary character INSIDE a quoted assignment VALUE
225
+ // (`VAR='a; b' git push` — task 13e55484's pinned residual):
226
+ // `BOUNDARY_RE` splits segments BEFORE tokenisation and is quote-
227
+ // unaware, so the quoted value is cut at the `;` and neither
228
+ // resulting segment carries a recognisable invocation. Closing this
229
+ // means a quote-aware segmenter, a different (and riskier) change
230
+ // than the assignment-value continuation that task shipped.
231
+ // - A quoted assignment VALUE containing the literal word `git`/`gh`/
232
+ // `npm`/`harness` as its own token (`VAR='a git b' git push`): the
233
+ // continuation's one-directional guard abandons rather than risk
234
+ // swallowing a real head token (see `consumeAssignment`), leaving
235
+ // the byte-exact pre-task behaviour on those spellings.
236
+ // - Quoted directory arguments containing whitespace (e.g. `git -C
237
+ // '/path with spaces' status`): the tokeniser splits on whitespace
238
+ // without quote-awareness, so such a case falls through to "no git
239
+ // invocation found here" and the segment is left unchanged. Safe
240
+ // (never a false positive), just not one of the covered shapes.
241
+ // - `xargs git status`: unlike `env`/`command`/`nice`/`sudo`/`doas`/...,
242
+ // `xargs`'s own argv is not simply "the command to run" — it appends
243
+ // stdin lines as trailing arguments and may invoke the wrapped
244
+ // command MULTIPLE times, or zero, depending on stdin. Different
245
+ // enough semantics that peeling it the same way would be misleading,
246
+ // not just incomplete, so it is deliberately excluded, not merely
247
+ // missed.
248
+ // - A quoted git subcommand (`git "status"`): the tokeniser sees the
249
+ // literal token `"status"` (quotes included) and canonicalises to
250
+ // `git "status"`, which does not satisfy a `bash_match` regex
251
+ // expecting a bare word boundary. The same class of problem as the
252
+ // whitespace-in-quoted-path gap above.
253
+ // - Backtick command substitution wrapping the real invocation, e.g.
254
+ // `` echo `env -C /tmp git status` ``: this module canonicalises the
255
+ // OUTER command only and does not recurse into a substitution's
256
+ // contents — the same boundary `sh -c` stops at, above.
257
+ // - `$(...)` command substitution is NOT in this list — see the
258
+ // "accidentally covered" note right below. Do not read its absence
259
+ // here as "also open"; it genuinely blocks today, pinned by a test.
260
+ // - Ten more measured-bypass spellings, none of them peeled wrapper
261
+ // prefixes or a recognised `git` spelling: `exec git status`, `nohup
262
+ // git status`, `ionice -c3 git status`, `flock /tmp/l git status`,
263
+ // `script -q /dev/null git status`, `chrt -b 0 git status`, `taskset
264
+ // -c 0 git status`, `\git status` (the backslash defeats
265
+ // `GIT_TOKEN_RE`, which requires the bare basename), `"git" status`
266
+ // (a QUOTED binary name — the mirror image of the quoted-subcommand
267
+ // gap above), and `env -S "git status"` (the split-string flag hands
268
+ // the rest of the argv to a fresh, opaque re-parse this module
269
+ // deliberately does not follow, per `peelEnv`'s own comment).
270
+ // - `gh`'s / `npm`'s own tool-specific flags inserted BETWEEN the head
271
+ // token and its subcommand: `gh -R owner/repo pr merge`, `gh --repo
272
+ // owner/repo pr create`, `npm --loglevel=silent publish`, `npm
273
+ // --registry <url> publish`. Only wrapper prefixes (`env`, `nice`,
274
+ // `command`, ...) and whitespace collapsing across the rest of the
275
+ // segment (see the closed head-token-set rule above) are peeled for
276
+ // these three heads — a flag belonging to `gh`/`npm` itself is never
277
+ // recognised or skipped, so it defeats the match the same way an
278
+ // unrecognised git flag would if `peelGitGlobalOptions` did not know
279
+ // its name.
280
+ // - A path-qualified `gh`/`npm`/`harness` invocation (`/usr/local/bin/gh
281
+ // pr merge`, `./node_modules/.bin/npm publish`): the closed
282
+ // head-token-set check is EXACT literal equality, not basename like
283
+ // `GIT_TOKEN_RE`. `harness`'s own trigger regex already covers a
284
+ // path-qualified spelling at the raw-match level, so nothing is lost
285
+ // there specifically; `gh`/`npm` genuinely have no coverage for this
286
+ // shape.
287
+ //
288
+ // KNOWN OVER-MATCHING (FALSE POSITIVE) CLASS — `command -v NAME` / `sudo
289
+ // -l [NAME]` (fix round 2, finding F5; doc-only, no behaviour change):
290
+ // both are INTROSPECTION forms — `command -v NAME` prints what `NAME`
291
+ // resolves to without running it, `sudo -l [NAME]` lists (without
292
+ // running) whether `NAME` is permitted — but this module's wrapper
293
+ // peeling treats `-v` (an otherwise-unrecognised `command` flag) and `-l`
294
+ // (an otherwise-unrecognised `sudo` flag) as ordinary BOOLEAN flags to
295
+ // skip past, exactly like any other flag either wrapper legitimately
296
+ // takes before its real, EXECUTED payload. `command -v harness pause`
297
+ // therefore canonicalises to `harness pause` and DENIES via
298
+ // `deny-kill-switch-bypass` even though nothing was actually paused
299
+ // (measured). This predates this run — `sudo -l git status` was already
300
+ // a false positive under the git-only normaliser — this run only widens
301
+ // it to also cover `gh`/`npm`/`harness`. Fail-CLOSED direction (an
302
+ // operator gets an unnecessary deny, never a missed real gate), so left
303
+ // as-is rather than special-cased; noted here so it is not mistaken for
304
+ // a security hole if reported.
305
+ //
306
+ // SAME CLASS, second member (task 13e55484, review round 1): a QUOTED
307
+ // ASSIGNMENT INSIDE TEXT at a segment-start position now over-matches.
308
+ // `BOUNDARY_RE` splits segments with no quote awareness, so in
309
+ // `echo "a; VAR='x y' git push"` the text after the `;` becomes its own
310
+ // segment, the assignment continuation normalises it to `git push`, and
311
+ // the push gate DENIES a command that only PRINTS the spelling
312
+ // (measured; master allowed it). Fail-closed direction, accepted and
313
+ // pinned in the test file as a boundary assertion — the practical cost
314
+ // is that documenting or testing that bypass class through a Bash call
315
+ // trips the gate, the same cost the dbc6d303 quoted/heredoc class
316
+ // already carries.
317
+ //
318
+ // `$(...)` command substitution is ACCIDENTALLY covered — not a
319
+ // deliberate feature, and distinct from the backtick case above, which
320
+ // is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
321
+ // shell-boundary token (needed for the leading-command case, `(cd X &&
322
+ // ...)`), and `$(...)`'s opening paren is, character-for-character, the
323
+ // same `(`. So `echo $(env -C /tmp git status)` splits into a segment
324
+ // starting exactly at that `(`, `canonicalizeSegment` finds a real git
325
+ // invocation inside it, and the close paren rides along harmlessly as
326
+ // trailing text after the subcommand (`git status)` still satisfies a
327
+ // `...status\b` regex, since `)` is a non-word character). This is
328
+ // coincidental: `BOUNDARY_RE` was never designed with `$(...)` in mind,
329
+ // and an unrelated future change to it could silently drop this coverage
330
+ // with no test noticing — pinned by a dedicated test for exactly that
331
+ // reason.
332
+ //
333
+ // Above `MAX_NORMALIZE_LENGTH` characters, normalisation is skipped
334
+ // entirely and the command is returned unchanged: `harness policy
335
+ // intercept` runs on every Bash/Edit/Write tool call and `require-
336
+ // preflight-evidence` declares `budget_ms: 1000`, so command SIZE must
337
+ // never be able to drive the hook past its own timeout budget. The RAW
338
+ // command is still tested by `policyMatchesEvent` regardless (raw-OR-
339
+ // normalised construction), so an oversized command only loses the
340
+ // ADDITIONAL normalised-form coverage, never the baseline one.
341
+ // `NormalizedCommand.truncated` reports the skip back to the caller;
342
+ // `runInterceptCli` (`src/cli/policy/intercept.ts`) writes exactly one
343
+ // stderr line when it sees `truncated: true`, keeping this module itself
344
+ // pure and I/O-free (see below) while making the skip observable at the
345
+ // one place that already owns a stderr stream for this event.
346
+ //
347
+ // Bounded, allocation-light, pure string work: no `fs`, no
348
+ // `child_process`, no network — `node:path` is used ONLY for
349
+ // `path.isAbsolute`, a synchronous string check with no I/O. This runs on
350
+ // every Bash/Edit/Write tool call, so it must stay cheap: `findNextBoundary`
351
+ // is a single combined-alternation regex scan rather than one `indexOf`
352
+ // per boundary token (a command with many segments and at least one
353
+ // boundary kind that never occurs anywhere would otherwise degenerate to
354
+ // O(segments × length) — see `findNextBoundary`'s own comment). Never
355
+ // throws: a malformed or unparseable command falls through cleanly and
356
+ // `normalized` is the input unchanged.
357
+ import * as path from "node:path";
358
+ import { parseBashPrefix } from "./bash-prefix-parse.js";
359
+ import { ENV_LEADING_FLAGS, ENV_SPLIT_STRING_FLAGS, ENV_VALUE_FLAGS, } from "./read-only-bash.js";
360
+ const VAR_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
361
+ /**
362
+ * The `git` token itself, matched by BASENAME so a path-qualified
363
+ * invocation (`/usr/bin/git`, `./git`) is recognised too (F4 fix).
364
+ * Anchored to the WHOLE token (not a substring) so `mygit` / `git-foo`
365
+ * still correctly fail to match — `\S*` only ever contributes characters
366
+ * immediately before a literal `/`, never before `git` directly.
367
+ * Exported (fix round 2, finding F3) so
368
+ * `tests/runtime/bash-match-head-token-drift.test.ts` can couple this
369
+ * module's covered set to what FULL_TEMPLATE's shipped `bash_match`
370
+ * policies actually key on today, instead of a test-owned duplicate.
371
+ */
372
+ export const GIT_TOKEN_RE = /^(?:\S*\/)?git$/;
373
+ /**
374
+ * The three OTHER head tokens this module covers (D-001, run
375
+ * 2026-07-28-nongit-trigger-wrappers) — `gh` (`gh pr merge` / `gh pr
376
+ * create`), `npm` (`npm publish`), `harness` (the kill-switch verbs). A
377
+ * closed set, deliberately NOT a general "any head token" rule (see the
378
+ * module header): matched by EXACT literal equality, unlike `GIT_TOKEN_RE`'s
379
+ * basename match, so a path-qualified spelling of any of these three stays
380
+ * out of scope (named residual, module header NOT-SUPPORTED list). This is
381
+ * NOT the complete set of head tokens a shipped `bash_match` trigger keys
382
+ * on — see the module header's "SHIPPED BUT NOT COVERED" paragraph (fix
383
+ * round 2, finding F1) for the four that are NOT in this set (`env`,
384
+ * `unset`, `tee`, `cp`) and why. Exported (fix round 2, finding F3) for the
385
+ * same drift-guard reason as `GIT_TOKEN_RE` above.
386
+ */
387
+ export const NON_GIT_HEAD_TOKENS = new Set(["gh", "npm", "harness"]);
388
+ /**
389
+ * Above this length, normalisation is skipped entirely and the command
390
+ * is returned unchanged (F3 fix — see the module header for why command
391
+ * SIZE must be bounded even after `findNextBoundary` was made linear).
392
+ * 100k characters comfortably covers any command a human or agent would
393
+ * plausibly type, while keeping the worst case a small constant instead
394
+ * of scaling with whatever ends up pasted into a single Bash call.
395
+ * Exported (G4 fix, review round 2) so a caller reporting the skip (see
396
+ * `NormalizedCommand.truncated`) can reference the same number instead of
397
+ * hand-copying it into a diagnostic message.
398
+ */
399
+ export const MAX_NORMALIZE_LENGTH = 100_000;
400
+ /**
401
+ * Shell boundary tokens a `bash_match` regex can anchor on, expressed as
402
+ * ONE alternation so `findNextBoundary` does a single regex scan per
403
+ * remaining span instead of one `indexOf` per token (F3 fix, review
404
+ * round 2026-07-27). Order among the alternatives does not affect
405
+ * correctness: no two of these tokens can start at the same string
406
+ * position (only `&&` is multi-character, and no other alternative
407
+ * starts with `&`), so there is no tie for the regex engine's leftmost-
408
+ * match rule to break.
409
+ *
410
+ * DELIBERATELY MISSING: single `&`, even though bash starts a new
411
+ * command after it and every shipped `bash_match` trigger now treats it
412
+ * as a boundary (task `d834a065`). Adding it here was tried and
413
+ * REVERTED after measurement: this regex is quote-unaware, `&` inside a
414
+ * quoted assignment value is common, and the split lands mid-quote, so
415
+ * the value continuation never engages and the wrapper is never peeled.
416
+ * Measured against the pre-change build with a passing positive
417
+ * control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
418
+ * spellings LOST a gate they previously held, including the
419
+ * `operator_only` kill-switch deny. The consequence of leaving it out
420
+ * is that `A=x&env -C /tmp git status` stays ungated (the wrapper can
421
+ * only be peeled after a recognised boundary); that gap is tracked
422
+ * separately and needs a design that does not regress the quoted-value
423
+ * case, not another character in this alternation.
424
+ */
425
+ const BOUNDARY_RE = /\n|&&|;|\||\(/g;
426
+ /** A target value starting with `~` — not expanded, treated as unparseable (F5). */
427
+ function isTildeTarget(dir) {
428
+ return dir.startsWith("~");
429
+ }
430
+ /**
431
+ * Given a Bash command string, return its normalised form and the target
432
+ * directory (and base, if relative) of its git invocation. Never throws:
433
+ * any internal failure (there should be none — this is a defensive
434
+ * backstop matching the fail-safe shape used elsewhere in this codebase,
435
+ * e.g. `policyMatchesEvent`'s `try/catch` around `new RegExp`) falls back
436
+ * to the input unchanged.
437
+ */
438
+ export function normalizeCommand(command) {
439
+ if (typeof command !== "string" || command.length === 0) {
440
+ return {
441
+ normalized: typeof command === "string" ? command : "",
442
+ targetDir: null,
443
+ targetBase: null,
444
+ truncated: false,
445
+ };
446
+ }
447
+ if (command.length > MAX_NORMALIZE_LENGTH) {
448
+ return { normalized: command, targetDir: null, targetBase: null, truncated: true };
449
+ }
450
+ try {
451
+ return normalizeCommandInner(command);
452
+ }
453
+ catch {
454
+ return { normalized: command, targetDir: null, targetBase: null, truncated: false };
455
+ }
456
+ }
457
+ function normalizeCommandInner(command) {
458
+ const parts = [];
459
+ const explicitTargets = new Set();
460
+ let explicitTargetBase = null;
461
+ let sawExplicitBase = false;
462
+ let bareGitSegmentCount = 0;
463
+ let i = 0;
464
+ const n = command.length;
465
+ // A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
466
+ // review round 2, 2026-07-27) instead of the two separate calls the
467
+ // size===1 and size===0 branches below each used to make on their own.
468
+ // Also needed here now to exempt the recognised leading-cd segment
469
+ // itself from the new non-git-command ambiguity check just below: that
470
+ // segment is not "some other command sharing the chain" — it IS the
471
+ // directory every later bare command in the chain actually runs
472
+ // inside.
473
+ const leadingCd = parseBashPrefix(command).cdTarget;
474
+ // Boundary token immediately preceding the segment currently being
475
+ // examined (`null` for the very first segment). Tracked so the G1
476
+ // check below can tell a real "new command" boundary (`&&`/`;`/`\n`/
477
+ // `(`) apart from a bare `|`, which keeps running in the SAME
478
+ // directory as whatever precedes it.
479
+ let precedingBoundaryToken = null;
480
+ let hasAmbiguousNonGitSegment = false;
481
+ // Walk the command as alternating (segment, boundary) pairs. Each
482
+ // segment is handed to `canonicalizeSegment`, which rewrites ONLY its
483
+ // head (wrapper prefixes + a git invocation's global options, if any
484
+ // are found) and leaves the remainder of the segment — and every
485
+ // boundary token — untouched. Bounded: `i` strictly increases by at
486
+ // least one boundary token's length each iteration a boundary is
487
+ // found, and the loop stops once none remain.
488
+ for (;;) {
489
+ const boundary = findNextBoundary(command, i);
490
+ const segEnd = boundary ? boundary.start : n;
491
+ const segmentText = command.slice(i, segEnd);
492
+ const result = canonicalizeSegment(segmentText);
493
+ parts.push(result.text);
494
+ if (result.isGit) {
495
+ if (result.targetDir === null) {
496
+ bareGitSegmentCount += 1;
497
+ }
498
+ else {
499
+ explicitTargets.add(result.targetDir);
500
+ if (!sawExplicitBase && result.targetBase !== null) {
501
+ explicitTargetBase = result.targetBase;
502
+ sawExplicitBase = true;
503
+ }
504
+ }
505
+ }
506
+ else if (precedingBoundaryToken !== "|" &&
507
+ !(precedingBoundaryToken === null && leadingCd !== null)) {
508
+ // G1 fix (HIGH, review round 2, 2026-07-27): a segment that STARTS
509
+ // a new command (follows `&&`/`;`/`\n`/`(` — or is the very first
510
+ // segment in the command — never a bare `|`, which keeps running
511
+ // in the SAME directory as whatever it reads from) and is NOT
512
+ // itself a git invocation is a genuinely DIFFERENT command that
513
+ // does not necessarily run wherever some OTHER git invocation
514
+ // elsewhere in the chain explicitly pointed. Measured case: `git
515
+ // -C <B> rev-parse HEAD && gh pr merge` — `-C` scopes only the ONE
516
+ // git call it decorates, so `gh pr merge` actually runs at the
517
+ // real (unaffected) cwd; resolving ${REPO}/${BRANCH} from B for it
518
+ // let a fact recorded against a decoy repo satisfy
519
+ // `review-before-merge-bash` / `review-subagent-before-pr-create-
520
+ // bash` for a merge/PR that never touched B. The one exemption is
521
+ // the recognised LEADING `cd` prefix itself: unlike `-C`, `cd`
522
+ // genuinely persists for the rest of the chain, so `cd <B> && git
523
+ // status` (or any other command after it) really does run in B —
524
+ // that stays the untouched `explicitTargets.size === 0` branch
525
+ // below, not this one.
526
+ hasAmbiguousNonGitSegment = true;
527
+ }
528
+ if (!boundary)
529
+ break;
530
+ parts.push(boundary.token);
531
+ i = boundary.start + boundary.token.length;
532
+ precedingBoundaryToken = boundary.token;
533
+ }
534
+ let targetDir;
535
+ let targetBase = null;
536
+ if (explicitTargets.size === 1 &&
537
+ bareGitSegmentCount === 0 &&
538
+ !hasAmbiguousNonGitSegment) {
539
+ // Every git invocation the command names agrees on ONE repository —
540
+ // safe to use it even when there are several (e.g. two `git -C <same
541
+ // dir>` calls chained together) — AND no OTHER, non-git command
542
+ // shares the chain that this single target does not actually apply
543
+ // to (G1 fix, review round 2).
544
+ targetDir = [...explicitTargets][0];
545
+ targetBase = explicitTargetBase;
546
+ if (targetBase === null && !path.isAbsolute(targetDir)) {
547
+ // F5 fix: a RELATIVE git-level target with no more specific base
548
+ // (no wrapping `env -C` on the SAME invocation) is relative to
549
+ // wherever the shell's cwd was at that point in the chain, which a
550
+ // LEADING `cd <dir> &&|;` prefix moves for every later command.
551
+ // Only the leading form is consulted — this module does not
552
+ // simulate the chain deeply enough to attribute a NON-leading `cd`
553
+ // to a specific later invocation, same ceiling as the leading-only
554
+ // fallback immediately below.
555
+ if (leadingCd !== null && !isTildeTarget(leadingCd))
556
+ targetBase = leadingCd;
557
+ }
558
+ }
559
+ else if (explicitTargets.size === 0) {
560
+ // No git invocation named an explicit target of its own (whether
561
+ // there were zero git invocations, or one or more entirely bare
562
+ // ones). A leading `cd <dir> &&|;` prefix, if present, names the
563
+ // target DIRECTLY here — every bare invocation in the chain runs
564
+ // inside it, so this is not a base for something else, it IS the
565
+ // answer — delegated to the existing leading-prefix parser rather
566
+ // than reimplementing its quote-handling. Unaffected by the G1
567
+ // ambiguity flag above: a non-git command after a leading `cd` is
568
+ // exactly the case that flag exempts, and a non-git command with NO
569
+ // git invocation anywhere in the chain never disagrees with
570
+ // anything, since there is no explicit git target to disagree with
571
+ // in the first place.
572
+ targetDir = leadingCd !== null && !isTildeTarget(leadingCd) ? leadingCd : null;
573
+ }
574
+ else {
575
+ // Ambiguous: either more than one git invocation names a DIFFERENT
576
+ // repository, at least one invocation names a repo explicitly while
577
+ // another runs bare (which may be a THIRD repo — whatever the
578
+ // shell's cwd happens to be at that point), or (G1 fix, review round
579
+ // 2) a non-git, non-leading-cd command elsewhere in the chain that
580
+ // the single explicit git target does not actually apply to. None of
581
+ // these has one unambiguous answer, so — per the "when in doubt,
582
+ // fall back to cwd" rule this whole run was built on — report no
583
+ // target at all rather than silently picking one invocation's answer
584
+ // for the whole command (F2 fix; see the module header for the
585
+ // push-gate fail-open this closes).
586
+ targetDir = null;
587
+ }
588
+ return { normalized: parts.join(""), targetDir, targetBase, truncated: false };
589
+ }
590
+ /** Find the earliest shell boundary token at or after `from`. Returns null when none remain. */
591
+ function findNextBoundary(s, from) {
592
+ BOUNDARY_RE.lastIndex = from;
593
+ const m = BOUNDARY_RE.exec(s);
594
+ return m === null ? null : { start: m.index, token: m[0] };
595
+ }
596
+ /**
597
+ * Consume one leading `VAR=value` assignment starting at `idx`, returning
598
+ * the index of the first token AFTER it. Task 13e55484: the VALUE may be
599
+ * quoted and span multiple whitespace-split tokens (`VAR='hello world'`),
600
+ * which used to leave a dangling `world'` token that aborted the peel
601
+ * loop — the one measured spelling where BOTH matching layers failed on
602
+ * the same character. The continuation engages ONLY while an opening
603
+ * quote from the assignment stays unbalanced at a token's end and a
604
+ * matching close exists in a later token; every other shape keeps the
605
+ * exact pre-task one-token consume:
606
+ * - an UNTERMINATED quote (`VAR='a git push`) falls back to consuming
607
+ * one token, because that spelling normalised to `git push` before
608
+ * this task and must keep doing so (never-unmatch);
609
+ * - backslash-escaped whitespace without quotes (`VAR=a\ b`) is task
610
+ * b093911d's escape-handling class and is deliberately NOT continued
611
+ * here (pinned as a still-open bypass in the test file).
612
+ * Quote semantics per POSIX: outside quotes `\` escapes the next char,
613
+ * single quotes close only at `'`, double quotes close at an unescaped
614
+ * `"`. A quote run may chain (`VAR='a b'"c d"`); the assignment ends at
615
+ * the first token that finishes outside any quote.
616
+ *
617
+ * ONE-DIRECTIONAL GUARD (review round 1, CRITICAL): the continuation
618
+ * ABANDONS (returns the pre-task one-token consume) the moment the token
619
+ * it would consume next is a recognised head token (`GIT_TOKEN_RE` or
620
+ * `NON_GIT_HEAD_TOKENS`). Without this, a quote model diverging from
621
+ * bash on even ONE spelling lets the continuation swallow the gated
622
+ * command word and turn a previously-BLOCKED command into a bypass —
623
+ * measured live with ANSI-C quoting, where bash escapes `\'` inside
624
+ * `$'...'` but this scanner (correctly for plain `'...'`) treats the
625
+ * backslash as literal: `A=$'don\'t' env harness pause # '` produced a
626
+ * phantom-open state that consumed the whole segment, and NEITHER layer
627
+ * matched where master's one-token consume blocked. The guard closes
628
+ * the HEAD-SWALLOW channel specifically: a diverging quote state can
629
+ * never re-target or swallow a recognised head token (measured — see
630
+ * the differential pins in the test file). It is NOT a universal
631
+ * monotonicity guarantee (round-2 review, measured): when the
632
+ * continuation closes on a WRAPPER'S glued flag token, the peel resumes
633
+ * at that wrapper's next ARGUMENT (a positional like timeout's
634
+ * duration, or a second flag), which is neither head nor wrapper, so
635
+ * the segment comes back unchanged where the pre-continuation peel
636
+ * completed — e.g. `A='x timeout --signal=INT' 5 git push` matched on
637
+ * master and does not here. Every found member of that class (550 of
638
+ * 41,440 adversarial cases) is a MASTER FALSE POSITIVE, PATH-shim-
639
+ * verified: bash treats the quoted run as one assignment word and then
640
+ * executes the next word (`5`, `-e0`), never the gated verb — 110 are
641
+ * outright `bash -n` syntax errors — so no gated invocation is lost and
642
+ * this side is the more bash-accurate one; the class is pinned in the
643
+ * test file so it cannot silently widen. The honest cost of the guard,
644
+ * measured and named rather than implied: a quoted VALUE containing the
645
+ * literal word `git`/`gh`/`npm`/`harness` as its own token
646
+ * (`VAR='a git b' git push`) is not continued — the pre-task consume
647
+ * applies, byte-identical to master's behaviour, so nothing regresses.
648
+ * The guard checks EXACT head spellings only: a path-qualified head
649
+ * glued to the closing quote (`VAR='a /usr/bin/git' git push` — the
650
+ * token is `/usr/bin/git'`, which GIT_TOKEN_RE rejects) is NOT
651
+ * abandoned, the continuation completes, and the REAL invocation behind
652
+ * it now matches — a measured fail-closed gain over master, also
653
+ * pinned.
654
+ */
655
+ function consumeAssignment(tokens, idx) {
656
+ let state = "";
657
+ let escaped = false;
658
+ for (let i = idx; i < tokens.length; i++) {
659
+ const text = tokens[i].text;
660
+ if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
661
+ return idx + 1;
662
+ }
663
+ for (let k = 0; k < text.length; k++) {
664
+ const c = text[k];
665
+ if (escaped) {
666
+ escaped = false;
667
+ continue;
668
+ }
669
+ if (state === "'") {
670
+ if (c === "'")
671
+ state = "";
672
+ continue;
673
+ }
674
+ if (state === '"') {
675
+ if (c === "\\")
676
+ escaped = true;
677
+ else if (c === '"')
678
+ state = "";
679
+ continue;
680
+ }
681
+ if (c === "\\")
682
+ escaped = true;
683
+ else if (c === "'" || c === '"')
684
+ state = c;
685
+ }
686
+ // A backslash pending at a token's end would escape the separating
687
+ // whitespace itself — b093911d's class, not continued here.
688
+ escaped = false;
689
+ if (state === "")
690
+ return i + 1;
691
+ }
692
+ // Ran out of tokens with a quote still open: unterminated. Preserve the
693
+ // pre-task behaviour exactly (consume only the assignment's first token).
694
+ return idx + 1;
695
+ }
696
+ /** Split a segment into whitespace-delimited tokens with their offsets. */
697
+ function tokenizeWithOffsets(s) {
698
+ const out = [];
699
+ const re = /\S+/g;
700
+ let m;
701
+ while ((m = re.exec(s)) !== null) {
702
+ out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
703
+ }
704
+ return out;
705
+ }
706
+ /**
707
+ * Rewrite one boundary-delimited segment: peel wrapper prefixes to look
708
+ * for an invocation of one of the closed head-token set's four members
709
+ * (`git`, `gh`, `npm`, `harness` — D-001, run
710
+ * 2026-07-28-nongit-trigger-wrappers), and — ONLY if one is found —
711
+ * canonicalise it. `git` is canonicalised to `git <subcommand>` with its
712
+ * OWN global options dropped, same as before this run. The other three
713
+ * are canonicalised by wrapper peeling plus whitespace collapsing across
714
+ * the REST OF THE SEGMENT (fix round 2, finding F2 — rejoining every
715
+ * token from the head onward with a single space, not just the head-to-
716
+ * next-token gap), no option-dropping (see the module header). When no
717
+ * recognised head token is found (wrong binary, ran out of tokens, or a
718
+ * malformed git global option), the segment is returned COMPLETELY
719
+ * UNCHANGED: peeling is tentative, and nothing is stripped from a
720
+ * segment that turns out not to be a recognised call (so `digit=1 foo`,
721
+ * `env -C X ls`, `ghx pr merge`, etc. are never touched).
722
+ */
723
+ function canonicalizeSegment(segmentText) {
724
+ const tokens = tokenizeWithOffsets(segmentText);
725
+ if (tokens.length === 0) {
726
+ return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
727
+ }
728
+ let idx = 0;
729
+ let envTargetDir = null;
730
+ // Peel wrapper prefixes: VAR=value tokens, `env` (+ its own flags/
731
+ // assignments), `command` (+ its own flags), `nice` (+ its own
732
+ // flags), `sudo` / `doas` / `time` / `timeout` / `stdbuf` / `setsid`
733
+ // (+ each one's own flags — F4 fix). Bounded by tokens.length so a
734
+ // pathological input cannot spin.
735
+ for (let guard = 0; guard < tokens.length; guard++) {
736
+ const head = tokens[idx]?.text;
737
+ if (head === undefined)
738
+ break;
739
+ if (VAR_ASSIGN_RE.test(head)) {
740
+ idx = consumeAssignment(tokens, idx);
741
+ continue;
742
+ }
743
+ if (head === "env") {
744
+ const r = peelEnv(tokens, idx + 1);
745
+ idx = r.idx;
746
+ if (envTargetDir === null)
747
+ envTargetDir = r.targetDir;
748
+ continue;
749
+ }
750
+ if (head === "command") {
751
+ idx = peelCommand(tokens, idx + 1);
752
+ continue;
753
+ }
754
+ if (head === "nice") {
755
+ idx = peelNice(tokens, idx + 1);
756
+ continue;
757
+ }
758
+ if (head === "sudo") {
759
+ idx = peelSudo(tokens, idx + 1);
760
+ continue;
761
+ }
762
+ if (head === "doas") {
763
+ idx = peelDoas(tokens, idx + 1);
764
+ continue;
765
+ }
766
+ if (head === "time") {
767
+ idx = peelTime(tokens, idx + 1);
768
+ continue;
769
+ }
770
+ if (head === "timeout") {
771
+ idx = peelTimeout(tokens, idx + 1);
772
+ continue;
773
+ }
774
+ if (head === "stdbuf") {
775
+ idx = peelStdbuf(tokens, idx + 1);
776
+ continue;
777
+ }
778
+ if (head === "setsid") {
779
+ idx = peelSetsid(tokens, idx + 1);
780
+ continue;
781
+ }
782
+ break;
783
+ }
784
+ const headTok = tokens[idx]?.text;
785
+ if (headTok === undefined) {
786
+ return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
787
+ }
788
+ if (GIT_TOKEN_RE.test(headTok)) {
789
+ idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
790
+ const gitOpts = peelGitGlobalOptions(tokens, idx);
791
+ if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
792
+ // Malformed global option (missing required value) or no subcommand
793
+ // token left: nothing safe to canonicalise. Leave untouched.
794
+ return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
795
+ }
796
+ const subcommandTok = tokens[gitOpts.idx];
797
+ const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
798
+ const targetDir = gitOpts.targetDir ?? envTargetDir;
799
+ // F5: the git invocation's OWN target wins (already the priority
800
+ // order above), but when it's RELATIVE and this same segment is
801
+ // wrapped by `env -C`, that env target is the base it's relative to —
802
+ // only meaningful when git's own target is the one actually used.
803
+ const targetBase = gitOpts.targetDir !== null &&
804
+ envTargetDir !== null &&
805
+ !path.isAbsolute(gitOpts.targetDir)
806
+ ? envTargetDir
807
+ : null;
808
+ return { text: rewritten, targetDir, targetBase, isGit: true };
809
+ }
810
+ if (NON_GIT_HEAD_TOKENS.has(headTok)) {
811
+ // D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
812
+ // wrapper peeling (already applied above, same loop as `git`'s) plus
813
+ // whitespace collapsing ACROSS THE REST OF THE SEGMENT — rejoining
814
+ // every token from the head token onward with exactly one space
815
+ // between each. The original scope (collapse only between the head
816
+ // token and the SINGLE token immediately following it) left an
817
+ // INTERIOR whitespace run further into a multi-word trigger able to
818
+ // defeat the match: `gh pr merge` (double space between `pr` and
819
+ // `merge`, not `gh` and `pr`), `gh pr<TAB>merge`, `gh pr create`.
820
+ // Full-segment rejoin closes any such run anywhere after the head,
821
+ // not just immediately after it. Safe because this is additive-only
822
+ // matching (raw-OR-normalised, module header) and byte-identity is an
823
+ // acceptance criterion only for heads NOT in this set (see the
824
+ // byte-identity negative tests below) — a RECOGNISED head's own tail
825
+ // may be freely rewritten. Still no option-dropping (`gh -R`, `npm
826
+ // --loglevel`, etc. stay unsupported — module header): a tool-specific
827
+ // flag between the head and its subcommand still isn't recognised or
828
+ // skipped, it just no longer needs an interior whitespace run to
829
+ // survive the rejoin. `targetDir`/`targetBase` stay `null`: these are
830
+ // never git invocations, so no per-invocation target is extracted for
831
+ // them (unaffected by the STATUS note above — that extraction was
832
+ // never wired to a gate either way).
833
+ if (idx + 1 >= tokens.length) {
834
+ // Head token alone, no further token to canonicalise toward — no
835
+ // shipped trigger matches a bare head token anyway, and rewriting to
836
+ // just the head would silently drop whatever wrapper preceded it for
837
+ // no benefit. Leave untouched, same fail-safe shape as the git
838
+ // malformed/no-subcommand case above.
839
+ return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
840
+ }
841
+ const rewritten = tokens
842
+ .slice(idx)
843
+ .map((t) => t.text)
844
+ .join(" ");
845
+ return { text: rewritten, targetDir: null, targetBase: null, isGit: false };
846
+ }
847
+ return { text: segmentText, targetDir: null, targetBase: null, isGit: false };
848
+ }
849
+ /**
850
+ * Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
851
+ * NAME sets are shared with `read-only-bash.ts` (F6 fix — see the module
852
+ * header); the VALUE extraction for `-C`/`--chdir` (needed for
853
+ * `targetDir`, not needed by that module) stays local. Returns the
854
+ * cursor after the last recognised token and the `-C`/`--chdir` target
855
+ * directory, if any (excluding a `~`-prefixed value — F5, treated as if
856
+ * absent).
857
+ */
858
+ function peelEnv(tokens, startIdx) {
859
+ let idx = startIdx;
860
+ let targetDir = null;
861
+ while (idx < tokens.length) {
862
+ const t = tokens[idx].text;
863
+ // `-S`/`--split-string` re-parses a single string argument into a
864
+ // fresh argv — we cannot safely see through it. Stop peeling here;
865
+ // whatever follows is opaque, and since it is not the literal `git`
866
+ // token the caller's git-check below fails harmlessly.
867
+ if (ENV_SPLIT_STRING_FLAGS.test(t))
868
+ break;
869
+ if (t === "--") {
870
+ idx += 1;
871
+ break;
872
+ }
873
+ if (t === "-C" || t === "--chdir") {
874
+ const dir = tokens[idx + 1]?.text;
875
+ if (dir === undefined)
876
+ break;
877
+ if (targetDir === null && !isTildeTarget(dir))
878
+ targetDir = dir;
879
+ idx += 2;
880
+ continue;
881
+ }
882
+ if (ENV_VALUE_FLAGS.has(t)) {
883
+ // Reached only for `-u` / `--unset` — `-C` / `--chdir` are handled
884
+ // above (with value capture) before this generic check runs.
885
+ idx += 2;
886
+ continue;
887
+ }
888
+ if (ENV_LEADING_FLAGS.has(t)) {
889
+ idx += 1;
890
+ continue;
891
+ }
892
+ if (t.startsWith("-C") && t.length > 2) {
893
+ if (targetDir === null && !isTildeTarget(t.slice(2)))
894
+ targetDir = t.slice(2);
895
+ idx += 1;
896
+ continue;
897
+ }
898
+ if (t.startsWith("--chdir=")) {
899
+ const dir = t.slice("--chdir=".length);
900
+ if (targetDir === null && !isTildeTarget(dir))
901
+ targetDir = dir;
902
+ idx += 1;
903
+ continue;
904
+ }
905
+ if (t.startsWith("--unset=") || /^-u./.test(t)) {
906
+ idx += 1;
907
+ continue;
908
+ }
909
+ // Generic glued long flag with a value (`--default-signal=INT`, or
910
+ // any OTHER `env` long option this module does not need the VALUE
911
+ // of): single token, skip it (G6 fix, review round 2, 2026-07-27 —
912
+ // mirrors `read-only-bash.ts`'s own generic catch-all, so the two
913
+ // peelers' coverage cannot drift again the way `--default-signal=INT`
914
+ // once did; see the module header). Placed AFTER the named `-C` /
915
+ // `--chdir` / `--unset` forms above so it only ever catches a flag
916
+ // this module has no more specific handling for.
917
+ if (t.startsWith("--") && t.includes("=")) {
918
+ idx += 1;
919
+ continue;
920
+ }
921
+ if (VAR_ASSIGN_RE.test(t)) {
922
+ idx = consumeAssignment(tokens, idx);
923
+ continue;
924
+ }
925
+ break;
926
+ }
927
+ return { idx, targetDir };
928
+ }
929
+ /** Peel `command`'s own flags (e.g. `-p`, `-v`, `-V`, `--`). */
930
+ function peelCommand(tokens, startIdx) {
931
+ let idx = startIdx;
932
+ while (idx < tokens.length) {
933
+ const t = tokens[idx].text;
934
+ if (t === "--") {
935
+ idx += 1;
936
+ break;
937
+ }
938
+ if (t.startsWith("-") && t !== "-") {
939
+ idx += 1;
940
+ continue;
941
+ }
942
+ break;
943
+ }
944
+ return idx;
945
+ }
946
+ /**
947
+ * Peel `nice`'s own flags: `-n <n>`, `-n<n>`, `--adjustment=<n>`, and the
948
+ * bare `-<n>` / `+<n>` forms (G3 fix, review round 2, 2026-07-27):
949
+ * `nice(1)` documents `nice -10 cmd` (increment glued directly to the
950
+ * leading dash, no `n`) as its PRIMARY spelling, and some BSD variants
951
+ * also accept a leading `+` for a positive increment — `nice -10 git
952
+ * status` bypassed even though `nice` was already in the SUPPORTED list,
953
+ * because only the `-n`-prefixed spellings were recognised.
954
+ */
955
+ function peelNice(tokens, startIdx) {
956
+ let idx = startIdx;
957
+ while (idx < tokens.length) {
958
+ const t = tokens[idx].text;
959
+ if (t === "-n" || t === "--adjustment") {
960
+ if (tokens[idx + 1] === undefined)
961
+ break;
962
+ idx += 2;
963
+ continue;
964
+ }
965
+ if (/^-n-?\d+$/.test(t)) {
966
+ idx += 1;
967
+ continue;
968
+ }
969
+ if (t.startsWith("--adjustment=")) {
970
+ idx += 1;
971
+ continue;
972
+ }
973
+ if (/^[-+]\d+$/.test(t)) {
974
+ idx += 1;
975
+ continue;
976
+ }
977
+ break;
978
+ }
979
+ return idx;
980
+ }
981
+ /**
982
+ * Generic "peel this wrapper's own flags" loop shared by the small,
983
+ * bounded-vocabulary runners added for F4 (`sudo`, `doas`, `time`,
984
+ * `stdbuf`, `setsid`): consume `--`, any flag in `valueFlags` (+ its
985
+ * following token), any glued short/long form matching `gluedRe`, then
986
+ * any OTHER `-`-prefixed token treated as boolean (mirrors `peelCommand`
987
+ * above — safe because an unrecognised flag is either genuinely boolean,
988
+ * in which case this is correct, or value-taking, in which case its
989
+ * VALUE token is left for the next iteration and — not being `git`, a
990
+ * known wrapper, or another flag — simply causes a clean, safe bail-out
991
+ * a token late rather than a false match). Does NOT capture flag
992
+ * VALUES: none of these five wrappers have a "run in a different
993
+ * directory" semantic this module tracks as a target directory — only
994
+ * `env -C`/`--chdir` does, and that stays hand-written in `peelEnv`.
995
+ */
996
+ function peelGenericFlags(tokens, startIdx, valueFlags, gluedRe) {
997
+ let idx = startIdx;
998
+ while (idx < tokens.length) {
999
+ const t = tokens[idx].text;
1000
+ if (t === "--") {
1001
+ idx += 1;
1002
+ break;
1003
+ }
1004
+ if (valueFlags.has(t)) {
1005
+ if (tokens[idx + 1] === undefined)
1006
+ break;
1007
+ idx += 2;
1008
+ continue;
1009
+ }
1010
+ if (t.startsWith("--") && t.includes("=")) {
1011
+ idx += 1;
1012
+ continue;
1013
+ }
1014
+ if (gluedRe !== null && gluedRe.test(t)) {
1015
+ idx += 1;
1016
+ continue;
1017
+ }
1018
+ if (t.startsWith("-") && t !== "-") {
1019
+ idx += 1;
1020
+ continue;
1021
+ }
1022
+ break;
1023
+ }
1024
+ return idx;
1025
+ }
1026
+ /**
1027
+ * `sudo`'s own value-taking flags (user/group/prompt/chroot/role/type/
1028
+ * timeout/close-from). Every other `-`-prefixed `sudo` flag (`-E`, `-H`,
1029
+ * `-i`, `-k`, `-n`, `-S`, `-v`, ...) is boolean and falls through
1030
+ * `peelGenericFlags`'s generic "-flag consumes one token" branch.
1031
+ */
1032
+ const SUDO_VALUE_FLAGS = new Set([
1033
+ "-g", "-h", "-p", "-R", "-r", "-t", "-T", "-u", "-C",
1034
+ "--group", "--host", "--prompt", "--chroot", "--role",
1035
+ "--type", "--command-timeout", "--user", "--close-from",
1036
+ ]);
1037
+ /** `sudo`'s glued short-value forms: `-uroot`, `-Cbastion`, etc. */
1038
+ const SUDO_GLUED_RE = /^-[ghpRrtTuC]./;
1039
+ function peelSudo(tokens, startIdx) {
1040
+ return peelGenericFlags(tokens, startIdx, SUDO_VALUE_FLAGS, SUDO_GLUED_RE);
1041
+ }
1042
+ /** `doas`'s own value-taking flags: `-u user`, `-C configfile`. */
1043
+ const DOAS_VALUE_FLAGS = new Set(["-u", "-C"]);
1044
+ const DOAS_GLUED_RE = /^-[uC]./;
1045
+ function peelDoas(tokens, startIdx) {
1046
+ return peelGenericFlags(tokens, startIdx, DOAS_VALUE_FLAGS, DOAS_GLUED_RE);
1047
+ }
1048
+ /** `time`'s own value-taking flags: `-o`/`--output`, `-f`/`--format`. */
1049
+ const TIME_VALUE_FLAGS = new Set(["-o", "-f", "--output", "--format"]);
1050
+ const TIME_GLUED_RE = /^-[of]./;
1051
+ function peelTime(tokens, startIdx) {
1052
+ return peelGenericFlags(tokens, startIdx, TIME_VALUE_FLAGS, TIME_GLUED_RE);
1053
+ }
1054
+ /** `timeout`'s own value-taking flags: `-k`/`--kill-after`, `-s`/`--signal`. */
1055
+ const TIMEOUT_VALUE_FLAGS = new Set([
1056
+ "-k", "-s", "--kill-after", "--signal",
1057
+ ]);
1058
+ const TIMEOUT_GLUED_RE = /^-[ks]./;
1059
+ /**
1060
+ * `timeout DURATION command...` requires a DURATION positional BEFORE
1061
+ * the wrapped command — unlike the other four runners, so it cannot
1062
+ * share `peelGenericFlags` unmodified. Peel `timeout`'s own flags first,
1063
+ * then consume the mandatory duration token. A missing duration
1064
+ * (malformed/truncated command) leaves `idx` where the flag loop left
1065
+ * it; the caller's "is this token `git`?" check bails safely on
1066
+ * whatever token is actually there.
1067
+ */
1068
+ function peelTimeout(tokens, startIdx) {
1069
+ let idx = peelGenericFlags(tokens, startIdx, TIMEOUT_VALUE_FLAGS, TIMEOUT_GLUED_RE);
1070
+ if (tokens[idx] !== undefined)
1071
+ idx += 1;
1072
+ return idx;
1073
+ }
1074
+ /** `stdbuf`'s flags are ALWAYS value-taking: `-i`/`-o`/`-e` (buffering mode). */
1075
+ const STDBUF_VALUE_FLAGS = new Set([
1076
+ "-i", "-o", "-e", "--input", "--output", "--error",
1077
+ ]);
1078
+ const STDBUF_GLUED_RE = /^-[ioe]./;
1079
+ function peelStdbuf(tokens, startIdx) {
1080
+ return peelGenericFlags(tokens, startIdx, STDBUF_VALUE_FLAGS, STDBUF_GLUED_RE);
1081
+ }
1082
+ /** `setsid`'s flags (`-w`/`--wait`, `-c`/`--ctty`, `-f`/`--fork`) are all boolean. */
1083
+ function peelSetsid(tokens, startIdx) {
1084
+ return peelGenericFlags(tokens, startIdx, new Set(), null);
1085
+ }
1086
+ /** Strip a path that ends in a `.git` directory down to its parent. */
1087
+ function parentIfDotGit(dir) {
1088
+ const trimmed = dir.replace(/\/+$/, "");
1089
+ if (trimmed === ".git")
1090
+ return ".";
1091
+ if (trimmed.endsWith("/.git"))
1092
+ return trimmed.slice(0, -"/.git".length) || "/";
1093
+ return dir;
1094
+ }
1095
+ /**
1096
+ * Peel git's own global options (dropping them so the subcommand becomes
1097
+ * adjacent to `git`), tracking the first `-C` / `--work-tree` /
1098
+ * `--git-dir` target directory encountered (excluding a `~`-prefixed
1099
+ * value — F5, treated as if absent, but still consumed as an option so
1100
+ * parsing continues past it). `malformed: true` means a value-requiring
1101
+ * option was missing its value — the caller bails without rewriting
1102
+ * rather than guess.
1103
+ */
1104
+ function peelGitGlobalOptions(tokens, startIdx) {
1105
+ let idx = startIdx;
1106
+ let targetDir = null;
1107
+ while (idx < tokens.length) {
1108
+ const t = tokens[idx].text;
1109
+ if (t === "-C") {
1110
+ const dir = tokens[idx + 1]?.text;
1111
+ if (dir === undefined)
1112
+ return { idx, targetDir, malformed: true };
1113
+ if (targetDir === null && !isTildeTarget(dir))
1114
+ targetDir = dir;
1115
+ idx += 2;
1116
+ continue;
1117
+ }
1118
+ if (t === "-c") {
1119
+ if (tokens[idx + 1] === undefined)
1120
+ return { idx, targetDir, malformed: true };
1121
+ idx += 2;
1122
+ continue;
1123
+ }
1124
+ if (t === "--git-dir") {
1125
+ const dir = tokens[idx + 1]?.text;
1126
+ if (dir === undefined)
1127
+ return { idx, targetDir, malformed: true };
1128
+ if (targetDir === null && !isTildeTarget(dir))
1129
+ targetDir = parentIfDotGit(dir);
1130
+ idx += 2;
1131
+ continue;
1132
+ }
1133
+ if (t.startsWith("--git-dir=")) {
1134
+ const dir = t.slice("--git-dir=".length);
1135
+ if (targetDir === null && !isTildeTarget(dir))
1136
+ targetDir = parentIfDotGit(dir);
1137
+ idx += 1;
1138
+ continue;
1139
+ }
1140
+ if (t === "--work-tree") {
1141
+ const dir = tokens[idx + 1]?.text;
1142
+ if (dir === undefined)
1143
+ return { idx, targetDir, malformed: true };
1144
+ if (targetDir === null && !isTildeTarget(dir))
1145
+ targetDir = dir;
1146
+ idx += 2;
1147
+ continue;
1148
+ }
1149
+ if (t.startsWith("--work-tree=")) {
1150
+ const dir = t.slice("--work-tree=".length);
1151
+ if (targetDir === null && !isTildeTarget(dir))
1152
+ targetDir = dir;
1153
+ idx += 1;
1154
+ continue;
1155
+ }
1156
+ if (t === "--no-pager" || t === "-p" || t === "--paginate") {
1157
+ idx += 1;
1158
+ continue;
1159
+ }
1160
+ if (t === "--exec-path" || t.startsWith("--exec-path=")) {
1161
+ idx += 1;
1162
+ continue;
1163
+ }
1164
+ if (t === "--namespace") {
1165
+ if (tokens[idx + 1] === undefined)
1166
+ return { idx, targetDir, malformed: true };
1167
+ idx += 2;
1168
+ continue;
1169
+ }
1170
+ if (t.startsWith("--namespace=")) {
1171
+ idx += 1;
1172
+ continue;
1173
+ }
1174
+ if (t === "--literal-pathspecs" || t === "--no-replace-objects") {
1175
+ idx += 1;
1176
+ continue;
1177
+ }
1178
+ break;
1179
+ }
1180
+ return { idx, targetDir, malformed: false };
1181
+ }
1182
+ //# sourceMappingURL=command-normalize.js.map