@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.
- package/CHANGELOG.md +62 -0
- package/dist/cli/approve/branch-protection.js +1 -1
- package/dist/cli/approve/branch-protection.js.map +1 -1
- package/dist/cli/dry-run.js +19 -1
- package/dist/cli/dry-run.js.map +1 -1
- package/dist/cli/index.js +4 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/composer.js +6 -6
- package/dist/cli/init/composer.js.map +1 -1
- package/dist/cli/init/profiles.d.ts +1 -1
- package/dist/cli/init/profiles.js +1 -1
- package/dist/cli/init/templates.d.ts +1 -1
- package/dist/cli/init/templates.js +53 -31
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/hook-branch-protection.js +1 -1
- package/dist/cli/pack/hook-branch-protection.js.map +1 -1
- package/dist/cli/pack/hook-post-merge-gate.js +43 -25
- package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js +1 -1
- package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +1 -1
- package/dist/cli/pack/hook-solution-acceptance.js +1 -1
- package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
- package/dist/cli/policy/intercept.js +209 -14
- package/dist/cli/policy/intercept.js.map +1 -1
- package/dist/cli/validate/checks.js +2 -2
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +1 -1
- package/dist/policy-packs/builtin/branch-protection-runtime.js +1 -1
- package/dist/policy-packs/builtin/branch-protection.js +2 -2
- package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +90 -10
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
- package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/post-merge-gate.js +40 -23
- package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +13 -0
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js +15 -2
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/policy-packs/builtin/solution-acceptance.js +1 -1
- package/dist/runtime/bash-match-facts.d.ts +58 -0
- package/dist/runtime/bash-match-facts.js +194 -0
- package/dist/runtime/bash-match-facts.js.map +1 -0
- package/dist/runtime/bash-match-registry.d.ts +75 -0
- package/dist/runtime/bash-match-registry.js +359 -0
- package/dist/runtime/bash-match-registry.js.map +1 -0
- package/dist/runtime/bash-prefix-parse.js +23 -0
- package/dist/runtime/bash-prefix-parse.js.map +1 -1
- package/dist/runtime/command-normalize.d.ts +405 -0
- package/dist/runtime/command-normalize.js +1888 -0
- package/dist/runtime/command-normalize.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/intercept.d.ts +208 -2
- package/dist/runtime/intercept.js +437 -28
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/read-only-bash.d.ts +43 -0
- package/dist/runtime/read-only-bash.js +73 -10
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/shell-word.d.ts +21 -0
- package/dist/runtime/shell-word.js +242 -0
- package/dist/runtime/shell-word.js.map +1 -0
- package/dist/schema/policies.js +1 -1
- package/dist/schema/policies.js.map +1 -1
- 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
|