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