@lannguyensi/harness 0.44.0 → 0.45.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 (231) hide show
  1. package/CHANGELOG.md +1213 -0
  2. package/dist/cli/adopt/derive.d.ts +16 -7
  3. package/dist/cli/adopt/derive.js +19 -2
  4. package/dist/cli/adopt/derive.js.map +1 -1
  5. package/dist/cli/adopt/index.js +18 -3
  6. package/dist/cli/adopt/index.js.map +1 -1
  7. package/dist/cli/apply/apply.d.ts +1 -0
  8. package/dist/cli/apply/apply.js +62 -6
  9. package/dist/cli/apply/apply.js.map +1 -1
  10. package/dist/cli/apply/generate-codex-config.js +5 -6
  11. package/dist/cli/apply/generate-codex-config.js.map +1 -1
  12. package/dist/cli/apply/generate-opencode-config.d.ts +58 -0
  13. package/dist/cli/apply/generate-opencode-config.js +210 -0
  14. package/dist/cli/apply/generate-opencode-config.js.map +1 -0
  15. package/dist/cli/apply/generate-settings.d.ts +90 -32
  16. package/dist/cli/apply/generate-settings.js +133 -10
  17. package/dist/cli/apply/generate-settings.js.map +1 -1
  18. package/dist/cli/apply/index.d.ts +2 -1
  19. package/dist/cli/apply/index.js +2 -1
  20. package/dist/cli/apply/index.js.map +1 -1
  21. package/dist/cli/approve/stdin-report.d.ts +15 -0
  22. package/dist/cli/approve/stdin-report.js +1 -1
  23. package/dist/cli/approve/stdin-report.js.map +1 -1
  24. package/dist/cli/approve/understanding.d.ts +75 -0
  25. package/dist/cli/approve/understanding.js +150 -2
  26. package/dist/cli/approve/understanding.js.map +1 -1
  27. package/dist/cli/audit.d.ts +1 -1
  28. package/dist/cli/audit.js +3 -2
  29. package/dist/cli/audit.js.map +1 -1
  30. package/dist/cli/doctor/claude-mcp.d.ts +19 -1
  31. package/dist/cli/doctor/claude-mcp.js +36 -10
  32. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  33. package/dist/cli/doctor/codex.d.ts +3 -1
  34. package/dist/cli/doctor/codex.js +6 -9
  35. package/dist/cli/doctor/codex.js.map +1 -1
  36. package/dist/cli/doctor/format.js +94 -8
  37. package/dist/cli/doctor/format.js.map +1 -1
  38. package/dist/cli/doctor/index.d.ts +23 -0
  39. package/dist/cli/doctor/index.js +106 -2
  40. package/dist/cli/doctor/index.js.map +1 -1
  41. package/dist/cli/doctor/opencode.d.ts +24 -0
  42. package/dist/cli/doctor/opencode.js +107 -0
  43. package/dist/cli/doctor/opencode.js.map +1 -0
  44. package/dist/cli/doctor/target-checks.d.ts +11 -0
  45. package/dist/cli/doctor/target-checks.js +25 -0
  46. package/dist/cli/doctor/target-checks.js.map +1 -0
  47. package/dist/cli/doctor/toolchain-parity.d.ts +63 -0
  48. package/dist/cli/doctor/toolchain-parity.js +154 -0
  49. package/dist/cli/doctor/toolchain-parity.js.map +1 -0
  50. package/dist/cli/doctor/types.d.ts +85 -1
  51. package/dist/cli/doctor/types.js +1 -1
  52. package/dist/cli/doctor/types.js.map +1 -1
  53. package/dist/cli/doctor/understanding-mode-env.d.ts +39 -0
  54. package/dist/cli/doctor/understanding-mode-env.js +83 -0
  55. package/dist/cli/doctor/understanding-mode-env.js.map +1 -0
  56. package/dist/cli/dry-run.js +32 -17
  57. package/dist/cli/dry-run.js.map +1 -1
  58. package/dist/cli/explain.d.ts +1 -1
  59. package/dist/cli/explain.js +1 -1
  60. package/dist/cli/explain.js.map +1 -1
  61. package/dist/cli/index.js +58 -4
  62. package/dist/cli/index.js.map +1 -1
  63. package/dist/cli/init/composer.js +36 -13
  64. package/dist/cli/init/composer.js.map +1 -1
  65. package/dist/cli/init/interactive.d.ts +7 -4
  66. package/dist/cli/init/interactive.js +79 -27
  67. package/dist/cli/init/interactive.js.map +1 -1
  68. package/dist/cli/init/profiles.d.ts +2 -2
  69. package/dist/cli/init/profiles.js +32 -1
  70. package/dist/cli/init/profiles.js.map +1 -1
  71. package/dist/cli/init/templates.d.ts +2 -1
  72. package/dist/cli/init/templates.js +130 -20
  73. package/dist/cli/init/templates.js.map +1 -1
  74. package/dist/cli/pack/approve-escape.js +69 -8
  75. package/dist/cli/pack/approve-escape.js.map +1 -1
  76. package/dist/cli/pack/hook-branch-protection.js +1 -1
  77. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  78. package/dist/cli/pack/hook-codex-pre-tool-use.js +27 -1
  79. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +1 -1
  80. package/dist/cli/pack/hook-post-merge-gate.js +1 -1
  81. package/dist/cli/pack/hook-post-merge-gate.js.map +1 -1
  82. package/dist/cli/pack/hook-pre-tool-use.js +91 -4
  83. package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
  84. package/dist/cli/pack/hook-solution-acceptance.js +19 -2
  85. package/dist/cli/pack/hook-solution-acceptance.js.map +1 -1
  86. package/dist/cli/pack/understanding-report-schema-hint.d.ts +13 -0
  87. package/dist/cli/pack/understanding-report-schema-hint.js +52 -3
  88. package/dist/cli/pack/understanding-report-schema-hint.js.map +1 -1
  89. package/dist/cli/pause/index.js +1 -1
  90. package/dist/cli/pause/index.js.map +1 -1
  91. package/dist/cli/policy/intercept.d.ts +148 -0
  92. package/dist/cli/policy/intercept.js +379 -48
  93. package/dist/cli/policy/intercept.js.map +1 -1
  94. package/dist/cli/session-start/index.d.ts +12 -3
  95. package/dist/cli/session-start/index.js +67 -11
  96. package/dist/cli/session-start/index.js.map +1 -1
  97. package/dist/cli/session-start/stale-base-check.d.ts +71 -0
  98. package/dist/cli/session-start/stale-base-check.js +379 -0
  99. package/dist/cli/session-start/stale-base-check.js.map +1 -0
  100. package/dist/cli/session-start/toolchain-parity.d.ts +142 -8
  101. package/dist/cli/session-start/toolchain-parity.js +244 -36
  102. package/dist/cli/session-start/toolchain-parity.js.map +1 -1
  103. package/dist/cli/smoke/assertions.d.ts +9 -2
  104. package/dist/cli/smoke/assertions.js +9 -2
  105. package/dist/cli/smoke/assertions.js.map +1 -1
  106. package/dist/cli/uninstall/index.js +18 -2
  107. package/dist/cli/uninstall/index.js.map +1 -1
  108. package/dist/cli/validate/checks.d.ts +3 -1
  109. package/dist/cli/validate/checks.js +298 -3
  110. package/dist/cli/validate/checks.js.map +1 -1
  111. package/dist/io/claude-mcp.d.ts +122 -1
  112. package/dist/io/claude-mcp.js +214 -12
  113. package/dist/io/claude-mcp.js.map +1 -1
  114. package/dist/io/duration.js.map +1 -0
  115. package/dist/io/expand-home.js.map +1 -0
  116. package/dist/io/extract.js.map +1 -0
  117. package/dist/io/harness-lock.js +1 -1
  118. package/dist/io/harness-lock.js.map +1 -1
  119. package/dist/{runtime → io}/ledger-record.d.ts +1 -1
  120. package/dist/io/ledger-record.js.map +1 -0
  121. package/dist/policies/index.d.ts +2 -2
  122. package/dist/policies/index.js +2 -2
  123. package/dist/policies/index.js.map +1 -1
  124. package/dist/policies/ledger-client.js +10 -6
  125. package/dist/policies/ledger-client.js.map +1 -1
  126. package/dist/policies/producers.js +1 -1
  127. package/dist/policies/producers.js.map +1 -1
  128. package/dist/policies/requires.js +2 -2
  129. package/dist/policies/requires.js.map +1 -1
  130. package/dist/policy-packs/builtin/branch-protection.js +28 -3
  131. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  132. package/dist/policy-packs/builtin/post-merge-gate.js +45 -9
  133. package/dist/policy-packs/builtin/post-merge-gate.js.map +1 -1
  134. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +139 -8
  135. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +246 -12
  136. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +1 -1
  137. package/dist/policy-packs/builtin/solution-acceptance.js +17 -3
  138. package/dist/policy-packs/builtin/solution-acceptance.js.map +1 -1
  139. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +25 -0
  140. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +89 -0
  141. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +1 -0
  142. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +13 -0
  143. package/dist/policy-packs/builtin/understanding-before-execution/index.js +39 -0
  144. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -0
  145. package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +25 -0
  146. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +56 -0
  147. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +1 -0
  148. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +20 -0
  149. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +79 -0
  150. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +1 -0
  151. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +103 -0
  152. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +203 -0
  153. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +1 -0
  154. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +177 -0
  155. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +309 -0
  156. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +1 -0
  157. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +106 -0
  158. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +209 -0
  159. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +1 -0
  160. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +78 -0
  161. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +185 -0
  162. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +1 -0
  163. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +1 -536
  164. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +9 -1095
  165. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +1 -1
  166. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +109 -1
  167. package/dist/policy-packs/builtin/understanding-before-execution.js +183 -15
  168. package/dist/policy-packs/builtin/understanding-before-execution.js.map +1 -1
  169. package/dist/policy-packs/registry.d.ts +11 -0
  170. package/dist/policy-packs/registry.js +1 -1
  171. package/dist/policy-packs/registry.js.map +1 -1
  172. package/dist/policy-packs/runtime.d.ts +1 -1
  173. package/dist/policy-packs/runtime.js +10 -1
  174. package/dist/policy-packs/runtime.js.map +1 -1
  175. package/dist/runtime/agent-facing.js +1 -1
  176. package/dist/runtime/agent-facing.js.map +1 -1
  177. package/dist/runtime/bash-match-facts.d.ts +9 -7
  178. package/dist/runtime/bash-match-facts.js +9 -7
  179. package/dist/runtime/bash-match-facts.js.map +1 -1
  180. package/dist/runtime/bash-match-registry.d.ts +75 -38
  181. package/dist/runtime/bash-match-registry.js +279 -232
  182. package/dist/runtime/bash-match-registry.js.map +1 -1
  183. package/dist/runtime/bash-prefix-parse.d.ts +7 -0
  184. package/dist/runtime/bash-prefix-parse.js +205 -17
  185. package/dist/runtime/bash-prefix-parse.js.map +1 -1
  186. package/dist/runtime/command-normalize.d.ts +112 -0
  187. package/dist/runtime/command-normalize.js +411 -38
  188. package/dist/runtime/command-normalize.js.map +1 -1
  189. package/dist/runtime/hermetic-spawn-guard.js +6 -0
  190. package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
  191. package/dist/runtime/index.d.ts +2 -2
  192. package/dist/runtime/index.js +2 -2
  193. package/dist/runtime/index.js.map +1 -1
  194. package/dist/runtime/intercept.d.ts +78 -3
  195. package/dist/runtime/intercept.js +151 -39
  196. package/dist/runtime/intercept.js.map +1 -1
  197. package/dist/runtime/ledger-add.js +1 -1
  198. package/dist/runtime/ledger-add.js.map +1 -1
  199. package/dist/runtime/read-only-bash.js +303 -24
  200. package/dist/runtime/read-only-bash.js.map +1 -1
  201. package/dist/schema/doctor.d.ts +25 -0
  202. package/dist/schema/doctor.js +23 -0
  203. package/dist/schema/doctor.js.map +1 -0
  204. package/dist/schema/extract.js +1 -1
  205. package/dist/schema/extract.js.map +1 -1
  206. package/dist/schema/index.d.ts +76 -0
  207. package/dist/schema/index.js +9 -0
  208. package/dist/schema/index.js.map +1 -1
  209. package/dist/schema/requires.js +2 -2
  210. package/dist/schema/requires.js.map +1 -1
  211. package/dist/schema/risk.d.ts +7 -0
  212. package/dist/schema/risk.js +23 -0
  213. package/dist/schema/risk.js.map +1 -1
  214. package/dist/schema/stale-base-check.d.ts +18 -0
  215. package/dist/schema/stale-base-check.js +37 -0
  216. package/dist/schema/stale-base-check.js.map +1 -0
  217. package/dist/schema/toolchain-parity.d.ts +3 -0
  218. package/dist/schema/toolchain-parity.js +13 -0
  219. package/dist/schema/toolchain-parity.js.map +1 -1
  220. package/package.json +3 -2
  221. package/dist/policies/duration.js.map +0 -1
  222. package/dist/policies/extract.js.map +0 -1
  223. package/dist/runtime/expand-home.js.map +0 -1
  224. package/dist/runtime/ledger-record.js.map +0 -1
  225. /package/dist/{policies → io}/duration.d.ts +0 -0
  226. /package/dist/{policies → io}/duration.js +0 -0
  227. /package/dist/{runtime → io}/expand-home.d.ts +0 -0
  228. /package/dist/{runtime → io}/expand-home.js +0 -0
  229. /package/dist/{policies → io}/extract.d.ts +0 -0
  230. /package/dist/{policies → io}/extract.js +0 -0
  231. /package/dist/{runtime → io}/ledger-record.js +0 -0
@@ -81,6 +81,59 @@
81
81
  // on bash actually running the verb, per the same never-fold-into-a-zero
82
82
  // discipline.
83
83
  //
84
+ // UPDATE (task cf3dff51): closes the quoted-shell-boundary residual named
85
+ // in the "DELIBERATELY NOT SUPPORTED" list below (`VAR='a; b' git push` —
86
+ // task 13e55484's pinned gap). A HALT criterion, written before any
87
+ // implementation code, forced the closing mechanism into an ADDITIVE,
88
+ // fully independent third normalisation pass (`normalizeCommandQuoteAware`
89
+ // / `findNextBoundaryQuoteAware`, defined near `findNextBoundary` below)
90
+ // rather than an in-place edit of `BOUNDARY_RE` / `segmentAndCanonicalize`
91
+ // itself. First round of this task built and fully tested the new pass but
92
+ // left it UNWIRED (the wiring — a `policyMatchesEvent` fourth disjunct in
93
+ // `src/runtime/intercept.ts` — touched a file outside that round's
94
+ // assigned scope). The orchestrator EXTENDED the scope for a second round
95
+ // specifically to include that one wiring point (not a general re-open of
96
+ // scope), so `normalizeCommandQuoteAware` IS now `policyMatchesEvent`'s
97
+ // fourth arm, mirroring `normalizeCommandAmpAware`'s own wiring (task
98
+ // aabbad63) exactly — see that function's own doc comment and
99
+ // `src/runtime/intercept.ts`'s `policyMatchesEvent` for the wired shape.
100
+ // UPDATE (task f561e44c): `src/cli/dry-run.ts`'s own, independently-copied
101
+ // raw-OR-normalised-OR-amp-normalised matcher (the `harness dry-run` debug
102
+ // verb, which predicts `policyMatchesEvent`'s verdict without running it)
103
+ // was left out of the wiring above (named residual, not silently left
104
+ // implicit) and, as a result, under-predicted the quote-aware family. This
105
+ // follow-up task added the same fourth OR-arm to that matcher too, so
106
+ // `harness dry-run` and `policy intercept` now agree on this family — see
107
+ // that file's own comment for the fix and its parity-fixture test.
108
+ //
109
+ // THE HALT CRITERION (decided and written down before writing the first
110
+ // line of this pass's implementation, mirroring 13e55484's own "ONE-
111
+ // DIRECTIONAL GUARD" structure): quote-aware segmentation MUST be built as
112
+ // a NEW, independent function — never as an in-place edit of the EXISTING,
113
+ // already-wired primary pass (`BOUNDARY_RE` / `findNextBoundary` /
114
+ // `segmentAndCanonicalize`'s behaviour for `normalizeCommand`). If closing
115
+ // this residual is ever found to require editing that existing pass in
116
+ // place instead of adding a new one, HALT and fall back to documentation-
117
+ // only (pin the residual, as this header already does for its NOT-
118
+ // SUPPORTED siblings) rather than risk a two-directional regression. This
119
+ // is not a hypothetical: this task PROVED the trigger before writing any
120
+ // code, by reading `tests/runtime/command-normalize.test.ts`'s pinned
121
+ // "accepted over-block: a quoted assignment inside TEXT after a boundary
122
+ // char now matches" case — `echo "a; VAR='x y' git push"` — which matches
123
+ // TODAY only because `BOUNDARY_RE`'s quote-unawareness un-splits `VAR='x
124
+ // y' git push` into its own segment. An in-place quote-aware edit of the
125
+ // SAME pass would un-split it the other way (merge it back into the outer
126
+ // `echo "..."` segment, which is not itself a recognised invocation) and
127
+ // flip that already-accepted, already-WIRED pin from match to no-match —
128
+ // a genuine lost match under this module's own no-lost-matches discipline
129
+ // (see AMP_BOUNDARY_RE's own comment for why that discipline exists at
130
+ // all), not merely a lost false positive. Building a separate, additive
131
+ // pass instead means the existing primary pass — and every pin resting on
132
+ // it, including that exact one — stays byte-for-byte untouched; see
133
+ // `normalizeCommandQuoteAware`'s own comment for the differential
134
+ // measurement that confirms this by construction rather than by
135
+ // inspection alone.
136
+ //
84
137
  // THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
85
138
  // (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
86
139
  // single regex tested against the UNPARSED command string, anchored on a
@@ -265,13 +318,33 @@
265
318
  // agent writes and then executes.
266
319
  // - `pushd`/`popd`, and a `cd` inside a nested subshell
267
320
  // (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
268
- // - A shell-boundary character INSIDE a quoted assignment VALUE
269
- // (`VAR='a; b' git push` — task 13e55484's pinned residual):
270
- // `BOUNDARY_RE` splits segments BEFORE tokenisation and is quote-
271
- // unaware, so the quoted value is cut at the `;` and neither
272
- // resulting segment carries a recognisable invocation. Closing this
273
- // means a quote-aware segmenter, a different (and riskier) change
274
- // than the assignment-value continuation that task shipped.
321
+ // - CLOSED (was: a shell-boundary character INSIDE a quoted assignment
322
+ // VALUE — `VAR='a; b' git push`, task 13e55484's pinned residual, no
323
+ // longer belongs in a NOT-SUPPORTED list). Task cf3dff51: a quote-
324
+ // aware THIRD normalisation pass, `normalizeCommandQuoteAware`
325
+ // (defined near `findNextBoundary` below), built as a new, additive
326
+ // pass rather than an in-place edit of `BOUNDARY_RE` /
327
+ // `segmentAndCanonicalize` (see the module header's HALT CRITERION
328
+ // paragraph and that function's own comment for why), now wired as
329
+ // `policyMatchesEvent`'s FOURTH arm (`src/runtime/intercept.ts`,
330
+ // mirroring `normalizeCommandAmpAware`'s own wiring, task aabbad63).
331
+ // Closes `;`, `|`, `&&`, `(`, and a literal newline, each with an
332
+ // internal whitespace split inside the quoted value (the exact
333
+ // `VAR='a; b' git push` shape) — measured 12/12 at the real
334
+ // `runInterceptCli` hook entry point (isolated harness home,
335
+ // PATH-shim-verified bash execution, two gated verbs). Over-block
336
+ // surface: MEASURED, not assumed, against an adversarial probe —
337
+ // every ADDITIONAL match this pass adds beyond raw /
338
+ // `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive
339
+ // (a real invocation bash would actually execute); no NEW over-block
340
+ // CLASS distinct from `consumeAssignment`'s own, already-reviewed
341
+ // precision limits (the "master false positive" class already pinned
342
+ // elsewhere in the test file) was found. See `normalizeCommandQuoteAware`'s
343
+ // own doc comment for the full mechanism and reasoning. CLOSED (task
344
+ // f561e44c): `src/cli/dry-run.ts`'s independently-copied matcher was
345
+ // initially not extended to try this fourth form — named residual — but
346
+ // that follow-up task added the same fourth OR-arm there too, see that
347
+ // file's own comment.
275
348
  // - A quoted assignment VALUE containing the literal word `git`/`gh`/
276
349
  // `npm`/`harness` as its own token (`VAR='a git b' git push`): the
277
350
  // continuation's one-directional guard abandons rather than risk
@@ -838,6 +911,26 @@ export function segmentViewOf(command) {
838
911
  * common (already-matched) path.
839
912
  */
840
913
  export function normalizeCommandAmpAware(command) {
914
+ return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, AMP_BOUNDARY_RE, false).normalized);
915
+ }
916
+ /**
917
+ * Shared empty/length/throw guard clauses for a normalisation pass whose
918
+ * public return shape is exactly `{ normalized, truncated }` — no
919
+ * `targetDir`/`targetBase` (task cf3dff51 extraction: before this,
920
+ * `normalizeCommandAmpAware` and `normalizeCommandQuoteAware` carried
921
+ * byte-identical copies of this wrapper, which `npm run check:duplication`
922
+ * — a jscpd clone-count fitness function, see that script's own header
923
+ * comment for the precedent this follows — flagged as new duplication
924
+ * against the pinned baseline; extracted rather than raising the pin,
925
+ * per that script's own stated preference). `normalizeCommand` itself is
926
+ * NOT this shape (its return type also carries `targetDir`/`targetBase`)
927
+ * and keeps its own, separate guard clauses — deliberately not folded in
928
+ * here, since unifying THREE different return shapes behind one generic
929
+ * helper would obscure more than the ~10 duplicated lines it would save.
930
+ * `compute` is called only once `command` has passed both guards, so it
931
+ * never needs to re-check emptiness or the length bound itself.
932
+ */
933
+ function normalizeWithGuards(command, compute) {
841
934
  if (typeof command !== "string" || command.length === 0) {
842
935
  return { normalized: typeof command === "string" ? command : "", truncated: false };
843
936
  }
@@ -845,10 +938,7 @@ export function normalizeCommandAmpAware(command) {
845
938
  return { normalized: command, truncated: true };
846
939
  }
847
940
  try {
848
- return {
849
- normalized: segmentAndCanonicalize(command, AMP_BOUNDARY_RE, false).normalized,
850
- truncated: false,
851
- };
941
+ return { normalized: compute(command), truncated: false };
852
942
  }
853
943
  catch {
854
944
  return { normalized: command, truncated: false };
@@ -858,6 +948,96 @@ function normalizeCommandInner(command) {
858
948
  const { normalized, targetDir, targetBase } = segmentAndCanonicalize(command, BOUNDARY_RE, false);
859
949
  return { normalized, targetDir, targetBase, truncated: false };
860
950
  }
951
+ /**
952
+ * Quote-aware THIRD normalisation pass (task cf3dff51). Segments the
953
+ * command under `BOUNDARY_RE`'s own alphabet — same characters as the
954
+ * primary pass, no widening — but tracks single/double-quote state while
955
+ * scanning (`findNextBoundaryQuoteAware` below) so a boundary character
956
+ * sitting INSIDE a quoted assignment value is no longer treated as a real
957
+ * segment split. Closes the gap the module header's "DELIBERATELY NOT
958
+ * SUPPORTED" list and `BOUNDARY_RE`'s own comment both name: `VAR='a; b'
959
+ * git push` (task 13e55484's pinned residual) — `BOUNDARY_RE` used to cut
960
+ * the segment at the `;`, landing squarely between the two halves of one
961
+ * quoted value, so neither resulting piece carried a recognisable
962
+ * invocation and `consumeAssignment`'s own multi-token quote continuation
963
+ * (built for exactly this shape) never got the chance to run: it operates
964
+ * AFTER segmentation, on tokens WITHIN one segment, and the quoted value
965
+ * was never in one segment to begin with. Once quote-aware segmentation
966
+ * keeps the value whole, `consumeAssignment` needs no changes at all — it
967
+ * already reconstructs a multi-token quoted value correctly; this pass
968
+ * only fixes what gets handed to it as ONE segment in the first place.
969
+ *
970
+ * BUILT AS A NEW, INDEPENDENT PASS RATHER THAN AN IN-PLACE EDIT OF
971
+ * `BOUNDARY_RE` / `segmentAndCanonicalize` — this is the module header's
972
+ * HALT CRITERION, restated here at the point it governs: quote-aware
973
+ * segmentation can MERGE a segment the primary pass currently splits, and
974
+ * that merge cuts in BOTH directions — it can turn a previously-missed
975
+ * bypass into a match (the fix), but it can just as easily turn a
976
+ * previously-accepted, ALREADY-WIRED over-block into a miss (a
977
+ * regression). Measured, not hypothetical: `tests/runtime/command-
978
+ * normalize.test.ts`'s "accepted over-block: a quoted assignment inside
979
+ * TEXT after a boundary char now matches" pin — `echo "a; VAR='x y' git
980
+ * push"` — matches via `normalizeCommand` TODAY only because `BOUNDARY_RE`
981
+ * splits it at the `;`, landing `VAR='x y' git push` at a segment start
982
+ * where the assignment continuation turns it into a match. An in-place
983
+ * quote-aware edit of THAT pass would un-split it back into the outer
984
+ * `echo "..."` segment — not itself a recognised invocation — and flip
985
+ * that pin to a lost match, in violation of the raw-OR-normalised-OR-amp-
986
+ * aware-OR-(this) never-lose-a-match discipline every other pass in this
987
+ * module already honours. Building this as a SEPARATE pass instead means
988
+ * `normalizeCommand`'s own segmentation, and every test resting on it —
989
+ * including that exact one — is untouched by this task; see this file's
990
+ * own test suite for the differential proof (every case the first three
991
+ * arms already matched still matches, by construction, because none of
992
+ * those three arms' code changed at all).
993
+ *
994
+ * WIRED to `policyMatchesEvent` (`src/runtime/intercept.ts`) as its
995
+ * FOURTH arm — same shape as `normalizeCommandAmpAware`'s own third-arm
996
+ * wiring (task aabbad63): consulted only after the raw command,
997
+ * `normalizedCommand`, and the amp-aware pass have all missed, via a
998
+ * memoised thunk (`InterceptOptions.quoteNormalizedCommandThunk`,
999
+ * `src/cli/policy/intercept.ts`'s `quoteNormalizedCommandThunk`) so the
1000
+ * segmentation cost is paid at most once per event, on the common
1001
+ * (already-matched) path it costs nothing at all. Wiring landed in a
1002
+ * SECOND round of this task, after the orchestrator extended the first
1003
+ * round's file scope specifically for this one wiring point — the first
1004
+ * round shipped this function fully built and tested but genuinely
1005
+ * unwired (an inert security fix, called out explicitly as the wrong
1006
+ * shipped state in that round's own report) — `src/cli/dry-run.ts`'s own,
1007
+ * independently-copied matcher was deliberately NOT extended in the same
1008
+ * round (out of the extended scope too) and stayed a named residual until
1009
+ * task f561e44c closed it by adding the same fourth OR-arm there too — see
1010
+ * that file's own comment.
1011
+ *
1012
+ * THIS PASS'S OWN OVER-BLOCK SURFACE is measured and named in the test
1013
+ * file rather than assumed — and the measurement CORRECTS an earlier,
1014
+ * pre-measurement guess that used to sit in this comment: it does NOT
1015
+ * reproduce the `echo "a; VAR='x y' git push"` accepted over-block named
1016
+ * above. Quote-aware merging keeps that whole `echo "..."` segment
1017
+ * together, starting with the unrecognised head token `echo`, so this
1018
+ * function's output for it is byte-identical to the raw input
1019
+ * (`normalizeCommand`'s OWN, separate, unchanged-by-this-task match for
1020
+ * that same string is unaffected either way). Measured against a broader
1021
+ * adversarial probe, both at this function's own level and again at
1022
+ * `policyMatchesEvent`'s wired level (a bash-shim-verified corpus of
1023
+ * chained/prefixed/subshell-wrapped genuine invocations plus candidate
1024
+ * over-block shapes — entirely quoted `echo`/`printf` arguments that only
1025
+ * PRINT the spelling): every additional match this pass adds beyond raw /
1026
+ * `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive (a
1027
+ * real invocation bash would actually execute); no NEW over-block class
1028
+ * distinct from `consumeAssignment`'s own, already-reviewed precision
1029
+ * limits was found. This function shares `canonicalizeSegment` /
1030
+ * `consumeAssignment` completely unchanged with the other passes — only
1031
+ * the segmentation boundary decision differs — which is also the
1032
+ * structural reason a longer (merged) segment can only REVEAL a match
1033
+ * those functions would already accept from a shorter view, never
1034
+ * fabricate a new kind of one; see the module header's cf3dff51 UPDATE
1035
+ * paragraph for the fuller statement of this reasoning and its
1036
+ * "not a formal proof" caveat.
1037
+ */
1038
+ export function normalizeCommandQuoteAware(command) {
1039
+ return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, BOUNDARY_RE, false, true).normalized);
1040
+ }
861
1041
  /**
862
1042
  * The shared segmentation + canonicalisation engine behind BOTH
863
1043
  * normalisation passes (task aabbad63). Parameterised on the boundary
@@ -898,8 +1078,21 @@ function normalizeCommandInner(command) {
898
1078
  * view at all (see that function's own comment). `segments` is `null` on
899
1079
  * the returned object when `collectSegments` is `false`, an array
900
1080
  * (possibly empty) otherwise.
1081
+ *
1082
+ * `quoteAware` (task cf3dff51, default `false` — every PRE-EXISTING call
1083
+ * site is unchanged, positionally omitting this parameter): selects
1084
+ * `findNextBoundaryQuoteAware` instead of `findNextBoundary` as the one
1085
+ * thing this walk asks to find the next segment split. Everything else in
1086
+ * this function — `canonicalizeSegment`, the target-directory bookkeeping,
1087
+ * the `collectSegments` per-segment view — is IDENTICAL either way; only
1088
+ * WHERE a segment ends changes. Only `normalizeCommandQuoteAware` passes
1089
+ * `true`; see that function's own comment for why this had to be a new
1090
+ * parameter on the SHARED walk rather than a wholly separate function
1091
+ * (reuse, same reasoning `AMP_BOUNDARY_RE`'s parameterisation on
1092
+ * `boundaryRe` already established) and for the HALT CRITERION that ruled
1093
+ * out editing `findNextBoundary` itself in place instead.
901
1094
  */
902
- function segmentAndCanonicalize(command, boundaryRe, collectSegments) {
1095
+ function segmentAndCanonicalize(command, boundaryRe, collectSegments, quoteAware = false) {
903
1096
  const parts = [];
904
1097
  const explicitTargets = new Set();
905
1098
  let explicitTargetBase = null;
@@ -939,7 +1132,9 @@ function segmentAndCanonicalize(command, boundaryRe, collectSegments) {
939
1132
  // least one boundary token's length each iteration a boundary is
940
1133
  // found, and the loop stops once none remain.
941
1134
  for (;;) {
942
- const boundary = findNextBoundary(command, i, boundaryRe);
1135
+ const boundary = quoteAware
1136
+ ? findNextBoundaryQuoteAware(command, i, boundaryRe)
1137
+ : findNextBoundary(command, i, boundaryRe);
943
1138
  const segEnd = boundary ? boundary.start : n;
944
1139
  const segmentText = command.slice(i, segEnd);
945
1140
  const result = canonicalizeSegment(segmentText);
@@ -1125,6 +1320,205 @@ function findNextBoundary(s, from, boundaryRe) {
1125
1320
  const m = boundaryRe.exec(s);
1126
1321
  return m === null ? null : { start: m.index, token: m[0] };
1127
1322
  }
1323
+ /**
1324
+ * Quote-aware sibling of `findNextBoundary` (task cf3dff51): the earliest
1325
+ * boundary token at or after `from` that sits OUTSIDE any open single- or
1326
+ * double-quoted run, under the SAME `boundaryRe` alphabet the caller
1327
+ * already scans with — no new boundary characters, only quote-tracking
1328
+ * around the existing ones. `null` when no such boundary exists (either
1329
+ * none remain at all, or every remaining candidate sits inside quoting
1330
+ * that never closes before the string ends — the same conservative "no
1331
+ * further split" fallback `consumeAssignment`'s own unterminated-quote
1332
+ * case already uses, restated here for whole-string segmentation instead
1333
+ * of one token).
1334
+ *
1335
+ * TWO-PHASE, NOT a per-character regex probe (task cf3dff51, latency fix
1336
+ * round — this replaces this function's first shipped implementation,
1337
+ * which called a sticky-regex `.exec()` at EVERY character position;
1338
+ * measured ~2x `normalizeCommand`'s own cost on a large, quote-free
1339
+ * command, because a JS-to-native regex-engine round trip at every single
1340
+ * character is far more expensive than the SAME regex engine's own
1341
+ * internal forward scan across many characters in one call — exactly what
1342
+ * `findNextBoundary` already does for the primary, non-quote-aware pass).
1343
+ * This version instead COMPOSES `findNextBoundary` (unchanged, still the
1344
+ * quote-UNAWARE fast forward scanner) with `QuoteScanner` (below): repeat
1345
+ * — find the next quote-unaware CANDIDATE boundary via `findNextBoundary`
1346
+ * (one native regex scan, however far away the candidate is), then walk
1347
+ * ONLY the span between the previous position and that candidate,
1348
+ * character-by-character, through `QuoteScanner` (below) to learn whether
1349
+ * a quote is open at the candidate's position; if not, the candidate IS
1350
+ * the real boundary — return it; if so, the candidate is inside a quote
1351
+ * (exactly the `VAR='a; b' git push` shape this pass exists to see
1352
+ * through), step through the candidate token's own characters too (never
1353
+ * quote-relevant — `BOUNDARY_RE`'s alphabet contains no `'`/`"`/`\` — so
1354
+ * this is a correctness no-op, only here so the running quote/escape
1355
+ * state stays byte-for-byte identical to a full character-by-character
1356
+ * walk) and continue the search from just past it. For a typical command
1357
+ * (few boundary characters, most of them outside any quote) this pays
1358
+ * for ONE `findNextBoundary` native scan per REAL segment plus one plain
1359
+ * (non-regex) character walk of the same total length `findNextBoundary`
1360
+ * would have scanned internally anyway — no longer one regex call per
1361
+ * character. `null` when no such boundary exists (either none remain at
1362
+ * all — `findNextBoundary` itself returns `null` — or every remaining
1363
+ * candidate sits inside quoting that never closes before the string ends,
1364
+ * the same conservative "no further split" fallback `consumeAssignment`'s
1365
+ * own unterminated-quote case already uses, restated here for whole-
1366
+ * string segmentation instead of one token).
1367
+ *
1368
+ * `QuoteScanner` (below) is the SAME quote/escape state machine
1369
+ * `consumeAssignment`'s own per-token loop uses (three states — `""`,
1370
+ * `'`, `"` — same POSIX approximation: outside quotes `\` escapes the
1371
+ * next char, `'...'` closes only at an UNESCAPED-by-construction `'`
1372
+ * (single quotes never honour backslash escapes at all), `"..."` closes
1373
+ * at an unescaped `"`). Extracted into a shared class (task cf3dff51 fix
1374
+ * round: `npm run check:duplication`'s jscpd scan flagged the two
1375
+ * independently-written copies of this loop as new duplication against
1376
+ * the pinned baseline; see that script's own header comment for why
1377
+ * extraction, not raising the pin, is the stated preference) rather than
1378
+ * duplicated a second time. Generalised from per-token to whole-string
1379
+ * scope by THIS function, not by the scanner itself: `consumeAssignment`
1380
+ * only ever sees one already-whitespace-tokenised segment at a time; this
1381
+ * function walks the RAW command text directly, whitespace included, so
1382
+ * it can tell whether a `;`/`|`/`&&`/`(`/`\n` character sits inside a
1383
+ * quote that a later whitespace-delimited token will re-enter (exactly
1384
+ * the shape `VAR='a; b' git push` needs: the space after `;` is itself
1385
+ * INSIDE the quoted run, not a token boundary this function cares about
1386
+ * at all — only the eventual UNQUOTED position matters).
1387
+ *
1388
+ * Reuses the MODULE-LEVEL `BOUNDARY_QUOTE_SCANNER` instance (`.reset()`
1389
+ * at the top of each call — see that constant's and `reset()`'s own
1390
+ * comments) instead of allocating a fresh `QuoteScanner` per call, and
1391
+ * calls `findNextBoundary` with the CALLER-SUPPLIED `boundaryRe` object
1392
+ * directly rather than a sticky clone — `findNextBoundary` already resets
1393
+ * `lastIndex` itself on every call (its own comment documents exactly
1394
+ * this discipline for the primary pass sharing the SAME `BOUNDARY_RE`
1395
+ * object), so composing it here needs no extra care. Called once per
1396
+ * segment (not once per character) by `segmentAndCanonicalize`'s loop;
1397
+ * the character walk itself still visits each character in the
1398
+ * `[from, end-of-match-or-string)` span exactly once IN TOTAL across all
1399
+ * calls for one command (split across the phase-1 "verify" loop and the
1400
+ * phase-2 "step through the false candidate" loop, never both for the
1401
+ * same character), keeping the whole walk O(length), same complexity
1402
+ * class `findNextBoundary`'s own comment establishes for the primary
1403
+ * pass — this rewrite changes the CONSTANT factor, not the asymptotic
1404
+ * class.
1405
+ */
1406
+ function findNextBoundaryQuoteAware(s, from, boundaryRe) {
1407
+ BOUNDARY_QUOTE_SCANNER.reset();
1408
+ let pos = from;
1409
+ const n = s.length;
1410
+ for (;;) {
1411
+ const candidate = findNextBoundary(s, pos, boundaryRe);
1412
+ if (candidate === null)
1413
+ return null;
1414
+ // Phase 1: verify quote-safety by walking ONLY the span strictly
1415
+ // BEFORE the candidate — mirrors the original per-character scan,
1416
+ // which never steps through the boundary token's own characters
1417
+ // before deciding whether to return it.
1418
+ for (let i = pos; i < candidate.start; i++) {
1419
+ BOUNDARY_QUOTE_SCANNER.step(s[i]);
1420
+ }
1421
+ if (BOUNDARY_QUOTE_SCANNER.state === "") {
1422
+ return candidate;
1423
+ }
1424
+ // Phase 2: the candidate sits inside an open quote — not a real
1425
+ // boundary. Step through ITS OWN characters too (a correctness
1426
+ // no-op, see this function's own comment) so the running state stays
1427
+ // exactly what a full character-by-character walk would have
1428
+ // produced, then resume the search just past it.
1429
+ const tokenEnd = candidate.start + candidate.token.length;
1430
+ for (let i = candidate.start; i < tokenEnd; i++) {
1431
+ BOUNDARY_QUOTE_SCANNER.step(s[i]);
1432
+ }
1433
+ if (tokenEnd >= n)
1434
+ return null;
1435
+ pos = tokenEnd;
1436
+ }
1437
+ }
1438
+ /**
1439
+ * Mutable single-/double-quote + backslash-escape state machine (task
1440
+ * cf3dff51 extraction — see `findNextBoundaryQuoteAware`'s own comment for
1441
+ * why this was pulled out of two independently-written copies rather than
1442
+ * left duplicated). POSIX-approximated semantics, shared verbatim by both
1443
+ * consumers: outside quotes `\` escapes the next character; `'...'`
1444
+ * closes only at a literal `'` (single quotes never honour backslash
1445
+ * escapes — the `escaped` branch below is unreachable whenever
1446
+ * `state === "'"`, by construction: `step` checks `this.escaped` BEFORE
1447
+ * checking `this.state`, so an escape flagged one character earlier is
1448
+ * always consumed first, but nothing inside the `state === "'"` branch
1449
+ * itself ever SETS `escaped`, so it can never observe a `\` as anything
1450
+ * other than a literal character); `"..."` closes at an unescaped `"`. A
1451
+ * mutable class rather than a pure step function returning a new state
1452
+ * object: this module runs on every Bash/Edit/Write tool call (module
1453
+ * header) and both consumers step through EVERY character of a segment or
1454
+ * token, so one allocation per scan (not one per character) matters for
1455
+ * the same "allocation-light" reason the module header states elsewhere.
1456
+ */
1457
+ class QuoteScanner {
1458
+ state = "";
1459
+ escaped = false;
1460
+ /** Advance the state machine by exactly one character. */
1461
+ step(c) {
1462
+ if (this.escaped) {
1463
+ this.escaped = false;
1464
+ return;
1465
+ }
1466
+ if (this.state === "'") {
1467
+ if (c === "'")
1468
+ this.state = "";
1469
+ return;
1470
+ }
1471
+ if (this.state === '"') {
1472
+ if (c === "\\")
1473
+ this.escaped = true;
1474
+ else if (c === '"')
1475
+ this.state = "";
1476
+ return;
1477
+ }
1478
+ // state === ""
1479
+ if (c === "\\")
1480
+ this.escaped = true;
1481
+ else if (c === "'" || c === '"')
1482
+ this.state = c;
1483
+ }
1484
+ /**
1485
+ * Reset to the initial `""` / not-escaped state — behaviourally
1486
+ * IDENTICAL to discarding this instance and constructing a fresh one
1487
+ * (task cf3dff51, latency fix round: lets `findNextBoundaryQuoteAware`
1488
+ * reuse ONE module-level instance across every segment of a command
1489
+ * instead of allocating a new one per segment — see that function's own
1490
+ * comment for the measured latency this closes).
1491
+ */
1492
+ reset() {
1493
+ this.state = "";
1494
+ this.escaped = false;
1495
+ }
1496
+ /**
1497
+ * Clear a pending escape without consuming a character. Used only by
1498
+ * `consumeAssignment`, at a whitespace-tokenised TOKEN boundary: a
1499
+ * backslash pending at a token's end would otherwise escape the
1500
+ * separating whitespace itself (task b093911d's class, deliberately
1501
+ * NOT continued — see that function's own comment). `state` is never
1502
+ * touched by this — an open quote genuinely stays open across
1503
+ * whitespace, only a dangling escape does not.
1504
+ */
1505
+ resetEscape() {
1506
+ this.escaped = false;
1507
+ }
1508
+ }
1509
+ /**
1510
+ * Module-level `QuoteScanner` instance reused by `findNextBoundaryQuoteAware`
1511
+ * across every segment of every command (task cf3dff51, latency fix
1512
+ * round) — `.reset()` at the top of each call makes reusing this single
1513
+ * instance behaviourally IDENTICAL to `new QuoteScanner()` per call (see
1514
+ * `reset()`'s own doc comment), just without the allocation. Placed here,
1515
+ * textually AFTER `class QuoteScanner`, because a top-level `const`
1516
+ * initialiser referencing a class runs in real module-evaluation order —
1517
+ * `findNextBoundaryQuoteAware` itself sits ABOVE this point in the file
1518
+ * but is a hoisted function declaration whose BODY only runs when called,
1519
+ * by which time this line has already executed.
1520
+ */
1521
+ const BOUNDARY_QUOTE_SCANNER = new QuoteScanner();
1128
1522
  /**
1129
1523
  * Consume one leading `VAR=value` assignment starting at `idx`, returning
1130
1524
  * the index of the first token AFTER it. Task 13e55484: the VALUE may be
@@ -1185,40 +1579,19 @@ function findNextBoundary(s, from, boundaryRe) {
1185
1579
  * pinned.
1186
1580
  */
1187
1581
  function consumeAssignment(tokens, idx) {
1188
- let state = "";
1189
- let escaped = false;
1582
+ const quote = new QuoteScanner();
1190
1583
  for (let i = idx; i < tokens.length; i++) {
1191
1584
  const text = tokens[i].text;
1192
1585
  if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
1193
1586
  return idx + 1;
1194
1587
  }
1195
1588
  for (let k = 0; k < text.length; k++) {
1196
- const c = text[k];
1197
- if (escaped) {
1198
- escaped = false;
1199
- continue;
1200
- }
1201
- if (state === "'") {
1202
- if (c === "'")
1203
- state = "";
1204
- continue;
1205
- }
1206
- if (state === '"') {
1207
- if (c === "\\")
1208
- escaped = true;
1209
- else if (c === '"')
1210
- state = "";
1211
- continue;
1212
- }
1213
- if (c === "\\")
1214
- escaped = true;
1215
- else if (c === "'" || c === '"')
1216
- state = c;
1589
+ quote.step(text[k]);
1217
1590
  }
1218
1591
  // A backslash pending at a token's end would escape the separating
1219
1592
  // whitespace itself — b093911d's class, not continued here.
1220
- escaped = false;
1221
- if (state === "")
1593
+ quote.resetEscape();
1594
+ if (quote.state === "")
1222
1595
  return i + 1;
1223
1596
  }
1224
1597
  // Ran out of tokens with a quote still open: unterminated. Preserve the