@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/cli/dry-run.js +18 -12
  3. package/dist/cli/dry-run.js.map +1 -1
  4. package/dist/cli/index.js +4 -3
  5. package/dist/cli/index.js.map +1 -1
  6. package/dist/cli/init/templates.d.ts +1 -1
  7. package/dist/cli/init/templates.js +6 -4
  8. package/dist/cli/init/templates.js.map +1 -1
  9. package/dist/cli/pack/hook-post-merge-gate.js +43 -25
  10. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  11. package/dist/cli/policy/intercept.js +168 -40
  12. package/dist/cli/policy/intercept.js.map +1 -1
  13. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +90 -10
  14. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +112 -16
  15. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +1 -1
  16. package/dist/policy-packs/builtin/post-merge-gate.js +39 -22
  17. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  18. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +13 -0
  19. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +14 -1
  20. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  21. package/dist/runtime/command-normalize.d.ts +317 -4
  22. package/dist/runtime/command-normalize.js +741 -35
  23. package/dist/runtime/command-normalize.js.map +1 -1
  24. package/dist/runtime/index.d.ts +1 -1
  25. package/dist/runtime/index.js +1 -1
  26. package/dist/runtime/index.js.map +1 -1
  27. package/dist/runtime/intercept.d.ts +171 -3
  28. package/dist/runtime/intercept.js +422 -42
  29. package/dist/runtime/intercept.js.map +1 -1
  30. package/dist/runtime/read-only-bash.js +58 -7
  31. package/dist/runtime/read-only-bash.js.map +1 -1
  32. package/dist/runtime/shell-word.d.ts +21 -0
  33. package/dist/runtime/shell-word.js +242 -0
  34. package/dist/runtime/shell-word.js.map +1 -0
  35. 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). `targetDir` / `targetBase` — the effective target
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 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.
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. 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.
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, truncated: false };
1082
+ return { normalized: parts.join(""), targetDir, targetBase, segments };
589
1083
  }
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);
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 { text: segmentText, targetDir: null, targetBase: null, isGit: false };
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 { text: segmentText, targetDir: null, targetBase: null, isGit: false };
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 { text: segmentText, targetDir: null, targetBase: null, isGit: false };
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
- return { text: rewritten, targetDir, targetBase, isGit: true };
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 { text: segmentText, targetDir: null, targetBase: null, isGit: false };
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 { text: rewritten, targetDir: null, targetBase: null, isGit: false };
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 { text: segmentText, targetDir: null, targetBase: null, isGit: false };
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). `malformed: true` means a value-requiring
1101
- * option was missing its value — the caller bails without rewriting
1102
- * rather than guess.
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