@lannguyensi/harness 0.43.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 +35 -0
- package/dist/cli/dry-run.js +18 -12
- 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/templates.d.ts +1 -1
- package/dist/cli/init/templates.js +6 -4
- package/dist/cli/init/templates.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/policy/intercept.js +168 -40
- package/dist/cli/policy/intercept.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 +39 -22
- 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 +14 -1
- package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
- package/dist/runtime/command-normalize.d.ts +317 -4
- package/dist/runtime/command-normalize.js +741 -35
- package/dist/runtime/command-normalize.js.map +1 -1
- 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 +171 -3
- package/dist/runtime/intercept.js +422 -42
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/read-only-bash.js +58 -7
- 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/package.json +3 -1
|
@@ -8,7 +8,14 @@
|
|
|
8
8
|
// `bash_match` regex against the raw command first and, only on a raw
|
|
9
9
|
// miss, against this normalised form too (raw-OR-normalised, never raw-
|
|
10
10
|
// replaced-by-normalised), closing a class of silent trigger bypasses
|
|
11
|
-
// (see below)
|
|
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
|
|
12
19
|
// directory of the command's own git invocation(s), when one can be
|
|
13
20
|
// named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
|
|
14
21
|
// this run used them to resolve the `${REPO}`/`${BRANCH}` policy
|
|
@@ -27,6 +34,38 @@
|
|
|
27
34
|
// `98ad072f`. Nothing below describes gate wiring; it describes what
|
|
28
35
|
// THIS MODULE computes, live or not.
|
|
29
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
|
+
//
|
|
30
69
|
// MEASUREMENT RULE (task 47297478): before measuring THIS module's
|
|
31
70
|
// extraction across builds with an ad-hoc corpus, read the per-arm-gate
|
|
32
71
|
// design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
|
|
@@ -35,7 +74,12 @@
|
|
|
35
74
|
// Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
|
|
36
75
|
// extraction; this module's `normalized`/`targetDir`/`targetBase`
|
|
37
76
|
// outputs have no instrument yet, so cross-build claims about them need
|
|
38
|
-
// their own per-arm-gated measurement
|
|
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.
|
|
39
83
|
//
|
|
40
84
|
// THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
|
|
41
85
|
// (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
|
|
@@ -315,6 +359,28 @@
|
|
|
315
359
|
// trips the gate, the same cost the dbc6d303 quoted/heredoc class
|
|
316
360
|
// already carries.
|
|
317
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
|
+
//
|
|
318
384
|
// `$(...)` command substitution is ACCIDENTALLY covered — not a
|
|
319
385
|
// deliberate feature, and distinct from the backtick case above, which
|
|
320
386
|
// is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
|
|
@@ -407,26 +473,263 @@ export const MAX_NORMALIZE_LENGTH = 100_000;
|
|
|
407
473
|
* starts with `&`), so there is no tie for the regex engine's leftmost-
|
|
408
474
|
* match rule to break.
|
|
409
475
|
*
|
|
410
|
-
* DELIBERATELY MISSING: single `&`, even though bash starts a new
|
|
411
|
-
* command after it
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
* the value continuation never engages and the wrapper is never peeled.
|
|
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.
|
|
416
481
|
* Measured against the pre-change build with a passing positive
|
|
417
482
|
* control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
|
|
418
483
|
* spellings LOST a gate they previously held, including the
|
|
419
|
-
* `operator_only` kill-switch deny.
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
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`.
|
|
424
507
|
*/
|
|
425
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;
|
|
426
563
|
/** A target value starting with `~` — not expanded, treated as unparseable (F5). */
|
|
427
564
|
function isTildeTarget(dir) {
|
|
428
565
|
return dir.startsWith("~");
|
|
429
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
|
+
}
|
|
430
733
|
/**
|
|
431
734
|
* Given a Bash command string, return its normalised form and the target
|
|
432
735
|
* directory (and base, if relative) of its git invocation. Never throws:
|
|
@@ -454,7 +757,149 @@ export function normalizeCommand(command) {
|
|
|
454
757
|
return { normalized: command, targetDir: null, targetBase: null, truncated: false };
|
|
455
758
|
}
|
|
456
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
|
+
}
|
|
457
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) {
|
|
458
903
|
const parts = [];
|
|
459
904
|
const explicitTargets = new Set();
|
|
460
905
|
let explicitTargetBase = null;
|
|
@@ -462,6 +907,14 @@ function normalizeCommandInner(command) {
|
|
|
462
907
|
let bareGitSegmentCount = 0;
|
|
463
908
|
let i = 0;
|
|
464
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;
|
|
465
918
|
// A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
|
|
466
919
|
// review round 2, 2026-07-27) instead of the two separate calls the
|
|
467
920
|
// size===1 and size===0 branches below each used to make on their own.
|
|
@@ -486,11 +939,52 @@ function normalizeCommandInner(command) {
|
|
|
486
939
|
// least one boundary token's length each iteration a boundary is
|
|
487
940
|
// found, and the loop stops once none remain.
|
|
488
941
|
for (;;) {
|
|
489
|
-
const boundary = findNextBoundary(command, i);
|
|
942
|
+
const boundary = findNextBoundary(command, i, boundaryRe);
|
|
490
943
|
const segEnd = boundary ? boundary.start : n;
|
|
491
944
|
const segmentText = command.slice(i, segEnd);
|
|
492
945
|
const result = canonicalizeSegment(segmentText);
|
|
493
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
|
+
}
|
|
494
988
|
if (result.isGit) {
|
|
495
989
|
if (result.targetDir === null) {
|
|
496
990
|
bareGitSegmentCount += 1;
|
|
@@ -585,12 +1079,50 @@ function normalizeCommandInner(command) {
|
|
|
585
1079
|
// push-gate fail-open this closes).
|
|
586
1080
|
targetDir = null;
|
|
587
1081
|
}
|
|
588
|
-
return { normalized: parts.join(""), targetDir, targetBase,
|
|
1082
|
+
return { normalized: parts.join(""), targetDir, targetBase, segments };
|
|
589
1083
|
}
|
|
590
|
-
/**
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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);
|
|
594
1126
|
return m === null ? null : { start: m.index, token: m[0] };
|
|
595
1127
|
}
|
|
596
1128
|
/**
|
|
@@ -723,7 +1255,14 @@ function tokenizeWithOffsets(s) {
|
|
|
723
1255
|
function canonicalizeSegment(segmentText) {
|
|
724
1256
|
const tokens = tokenizeWithOffsets(segmentText);
|
|
725
1257
|
if (tokens.length === 0) {
|
|
726
|
-
return {
|
|
1258
|
+
return {
|
|
1259
|
+
text: segmentText,
|
|
1260
|
+
targetDir: null,
|
|
1261
|
+
targetBase: null,
|
|
1262
|
+
identityTargetDir: null,
|
|
1263
|
+
identityTargetBase: null,
|
|
1264
|
+
isGit: false,
|
|
1265
|
+
};
|
|
727
1266
|
}
|
|
728
1267
|
let idx = 0;
|
|
729
1268
|
let envTargetDir = null;
|
|
@@ -783,7 +1322,14 @@ function canonicalizeSegment(segmentText) {
|
|
|
783
1322
|
}
|
|
784
1323
|
const headTok = tokens[idx]?.text;
|
|
785
1324
|
if (headTok === undefined) {
|
|
786
|
-
return {
|
|
1325
|
+
return {
|
|
1326
|
+
text: segmentText,
|
|
1327
|
+
targetDir: null,
|
|
1328
|
+
targetBase: null,
|
|
1329
|
+
identityTargetDir: null,
|
|
1330
|
+
identityTargetBase: null,
|
|
1331
|
+
isGit: false,
|
|
1332
|
+
};
|
|
787
1333
|
}
|
|
788
1334
|
if (GIT_TOKEN_RE.test(headTok)) {
|
|
789
1335
|
idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
|
|
@@ -791,7 +1337,14 @@ function canonicalizeSegment(segmentText) {
|
|
|
791
1337
|
if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
|
|
792
1338
|
// Malformed global option (missing required value) or no subcommand
|
|
793
1339
|
// token left: nothing safe to canonicalise. Leave untouched.
|
|
794
|
-
return {
|
|
1340
|
+
return {
|
|
1341
|
+
text: segmentText,
|
|
1342
|
+
targetDir: null,
|
|
1343
|
+
targetBase: null,
|
|
1344
|
+
identityTargetDir: null,
|
|
1345
|
+
identityTargetBase: null,
|
|
1346
|
+
isGit: false,
|
|
1347
|
+
};
|
|
795
1348
|
}
|
|
796
1349
|
const subcommandTok = tokens[gitOpts.idx];
|
|
797
1350
|
const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
|
|
@@ -805,7 +1358,50 @@ function canonicalizeSegment(segmentText) {
|
|
|
805
1358
|
!path.isAbsolute(gitOpts.targetDir)
|
|
806
1359
|
? envTargetDir
|
|
807
1360
|
: null;
|
|
808
|
-
|
|
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
|
+
};
|
|
809
1405
|
}
|
|
810
1406
|
if (NON_GIT_HEAD_TOKENS.has(headTok)) {
|
|
811
1407
|
// D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
|
|
@@ -836,15 +1432,36 @@ function canonicalizeSegment(segmentText) {
|
|
|
836
1432
|
// just the head would silently drop whatever wrapper preceded it for
|
|
837
1433
|
// no benefit. Leave untouched, same fail-safe shape as the git
|
|
838
1434
|
// malformed/no-subcommand case above.
|
|
839
|
-
return {
|
|
1435
|
+
return {
|
|
1436
|
+
text: segmentText,
|
|
1437
|
+
targetDir: null,
|
|
1438
|
+
targetBase: null,
|
|
1439
|
+
identityTargetDir: null,
|
|
1440
|
+
identityTargetBase: null,
|
|
1441
|
+
isGit: false,
|
|
1442
|
+
};
|
|
840
1443
|
}
|
|
841
1444
|
const rewritten = tokens
|
|
842
1445
|
.slice(idx)
|
|
843
1446
|
.map((t) => t.text)
|
|
844
1447
|
.join(" ");
|
|
845
|
-
return {
|
|
1448
|
+
return {
|
|
1449
|
+
text: rewritten,
|
|
1450
|
+
targetDir: null,
|
|
1451
|
+
targetBase: null,
|
|
1452
|
+
identityTargetDir: null,
|
|
1453
|
+
identityTargetBase: null,
|
|
1454
|
+
isGit: false,
|
|
1455
|
+
};
|
|
846
1456
|
}
|
|
847
|
-
return {
|
|
1457
|
+
return {
|
|
1458
|
+
text: segmentText,
|
|
1459
|
+
targetDir: null,
|
|
1460
|
+
targetBase: null,
|
|
1461
|
+
identityTargetDir: null,
|
|
1462
|
+
identityTargetBase: null,
|
|
1463
|
+
isGit: false,
|
|
1464
|
+
};
|
|
848
1465
|
}
|
|
849
1466
|
/**
|
|
850
1467
|
* Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
|
|
@@ -1097,36 +1714,121 @@ function parentIfDotGit(dir) {
|
|
|
1097
1714
|
* adjacent to `git`), tracking the first `-C` / `--work-tree` /
|
|
1098
1715
|
* `--git-dir` target directory encountered (excluding a `~`-prefixed
|
|
1099
1716
|
* value — F5, treated as if absent, but still consumed as an option so
|
|
1100
|
-
* parsing continues past it)
|
|
1101
|
-
*
|
|
1102
|
-
*
|
|
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.
|
|
1103
1777
|
*/
|
|
1104
1778
|
function peelGitGlobalOptions(tokens, startIdx) {
|
|
1105
1779
|
let idx = startIdx;
|
|
1106
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
|
+
}
|
|
1107
1807
|
while (idx < tokens.length) {
|
|
1108
1808
|
const t = tokens[idx].text;
|
|
1109
1809
|
if (t === "-C") {
|
|
1110
1810
|
const dir = tokens[idx + 1]?.text;
|
|
1111
1811
|
if (dir === undefined)
|
|
1112
|
-
return { idx, targetDir, malformed: true };
|
|
1812
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1113
1813
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1114
1814
|
targetDir = dir;
|
|
1815
|
+
noteRelocatingOption(isTildeTarget(dir) ? null : dir);
|
|
1115
1816
|
idx += 2;
|
|
1116
1817
|
continue;
|
|
1117
1818
|
}
|
|
1118
1819
|
if (t === "-c") {
|
|
1119
1820
|
if (tokens[idx + 1] === undefined)
|
|
1120
|
-
return { idx, targetDir, malformed: true };
|
|
1821
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1121
1822
|
idx += 2;
|
|
1122
1823
|
continue;
|
|
1123
1824
|
}
|
|
1124
1825
|
if (t === "--git-dir") {
|
|
1125
1826
|
const dir = tokens[idx + 1]?.text;
|
|
1126
1827
|
if (dir === undefined)
|
|
1127
|
-
return { idx, targetDir, malformed: true };
|
|
1828
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1128
1829
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1129
1830
|
targetDir = parentIfDotGit(dir);
|
|
1831
|
+
noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
|
|
1130
1832
|
idx += 2;
|
|
1131
1833
|
continue;
|
|
1132
1834
|
}
|
|
@@ -1134,13 +1836,17 @@ function peelGitGlobalOptions(tokens, startIdx) {
|
|
|
1134
1836
|
const dir = t.slice("--git-dir=".length);
|
|
1135
1837
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1136
1838
|
targetDir = parentIfDotGit(dir);
|
|
1839
|
+
noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
|
|
1137
1840
|
idx += 1;
|
|
1138
1841
|
continue;
|
|
1139
1842
|
}
|
|
1140
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.
|
|
1141
1847
|
const dir = tokens[idx + 1]?.text;
|
|
1142
1848
|
if (dir === undefined)
|
|
1143
|
-
return { idx, targetDir, malformed: true };
|
|
1849
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1144
1850
|
if (targetDir === null && !isTildeTarget(dir))
|
|
1145
1851
|
targetDir = dir;
|
|
1146
1852
|
idx += 2;
|
|
@@ -1163,7 +1869,7 @@ function peelGitGlobalOptions(tokens, startIdx) {
|
|
|
1163
1869
|
}
|
|
1164
1870
|
if (t === "--namespace") {
|
|
1165
1871
|
if (tokens[idx + 1] === undefined)
|
|
1166
|
-
return { idx, targetDir, malformed: true };
|
|
1872
|
+
return { idx, targetDir, relocateTargetDir, malformed: true };
|
|
1167
1873
|
idx += 2;
|
|
1168
1874
|
continue;
|
|
1169
1875
|
}
|
|
@@ -1177,6 +1883,6 @@ function peelGitGlobalOptions(tokens, startIdx) {
|
|
|
1177
1883
|
}
|
|
1178
1884
|
break;
|
|
1179
1885
|
}
|
|
1180
|
-
return { idx, targetDir, malformed: false };
|
|
1886
|
+
return { idx, targetDir, relocateTargetDir, malformed: false };
|
|
1181
1887
|
}
|
|
1182
1888
|
//# sourceMappingURL=command-normalize.js.map
|