@lannguyensi/harness 0.63.0 → 1.0.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 (702) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +46 -42
  3. package/dist/cli/add/index.js +17 -21
  4. package/dist/cli/add/index.js.map +1 -1
  5. package/dist/cli/adopt/derive.d.ts +2 -2
  6. package/dist/cli/adopt/derive.js +2 -19
  7. package/dist/cli/adopt/derive.js.map +1 -1
  8. package/dist/cli/adopt/index.d.ts +1 -1
  9. package/dist/cli/adopt/index.js +1 -12
  10. package/dist/cli/adopt/index.js.map +1 -1
  11. package/dist/cli/apply/apply.d.ts +20 -35
  12. package/dist/cli/apply/apply.js +61 -136
  13. package/dist/cli/apply/apply.js.map +1 -1
  14. package/dist/cli/apply/generate-codex-config.js +10 -58
  15. package/dist/cli/apply/generate-codex-config.js.map +1 -1
  16. package/dist/cli/apply/generate-memory-index.js +1 -2
  17. package/dist/cli/apply/generate-memory-index.js.map +1 -1
  18. package/dist/cli/apply/generate-opencode-config.d.ts +2 -10
  19. package/dist/cli/apply/generate-opencode-config.js +74 -113
  20. package/dist/cli/apply/generate-opencode-config.js.map +1 -1
  21. package/dist/cli/apply/generate-settings.d.ts +11 -64
  22. package/dist/cli/apply/generate-settings.js +27 -132
  23. package/dist/cli/apply/generate-settings.js.map +1 -1
  24. package/dist/cli/apply/index.d.ts +1 -1
  25. package/dist/cli/apply/index.js +1 -1
  26. package/dist/cli/apply/index.js.map +1 -1
  27. package/dist/cli/apply/install-codex-config.d.ts +153 -1
  28. package/dist/cli/apply/install-codex-config.js +445 -70
  29. package/dist/cli/apply/install-codex-config.js.map +1 -1
  30. package/dist/cli/apply/next-steps.d.ts +7 -0
  31. package/dist/cli/apply/next-steps.js +13 -1
  32. package/dist/cli/apply/next-steps.js.map +1 -1
  33. package/dist/cli/bounded-stdin.d.ts +0 -23
  34. package/dist/cli/bounded-stdin.js +2 -49
  35. package/dist/cli/bounded-stdin.js.map +1 -1
  36. package/dist/cli/describe.d.ts +1 -1
  37. package/dist/cli/describe.js +0 -3
  38. package/dist/cli/describe.js.map +1 -1
  39. package/dist/cli/diff/engine.d.ts +0 -11
  40. package/dist/cli/diff/engine.js +1 -17
  41. package/dist/cli/diff/engine.js.map +1 -1
  42. package/dist/cli/diff/index.js +14 -25
  43. package/dist/cli/diff/index.js.map +1 -1
  44. package/dist/cli/doctor/claude-mcp.d.ts +4 -19
  45. package/dist/cli/doctor/claude-mcp.js +11 -22
  46. package/dist/cli/doctor/claude-mcp.js.map +1 -1
  47. package/dist/cli/doctor/codex.d.ts +12 -14
  48. package/dist/cli/doctor/codex.js +31 -144
  49. package/dist/cli/doctor/codex.js.map +1 -1
  50. package/dist/cli/doctor/format.js +23 -338
  51. package/dist/cli/doctor/format.js.map +1 -1
  52. package/dist/cli/doctor/hook-liveness.d.ts +56 -0
  53. package/dist/cli/doctor/hook-liveness.js +455 -0
  54. package/dist/cli/doctor/hook-liveness.js.map +1 -0
  55. package/dist/cli/doctor/index.d.ts +13 -87
  56. package/dist/cli/doctor/index.js +57 -705
  57. package/dist/cli/doctor/index.js.map +1 -1
  58. package/dist/cli/doctor/opencode.d.ts +2 -1
  59. package/dist/cli/doctor/opencode.js.map +1 -1
  60. package/dist/cli/doctor/types.d.ts +21 -378
  61. package/dist/cli/doctor/types.js.map +1 -1
  62. package/dist/cli/drain-stdio.d.ts +31 -0
  63. package/dist/cli/drain-stdio.js +76 -0
  64. package/dist/cli/drain-stdio.js.map +1 -0
  65. package/dist/cli/export.d.ts +3 -1
  66. package/dist/cli/export.js +7 -16
  67. package/dist/cli/export.js.map +1 -1
  68. package/dist/cli/gate/codex-disable.d.ts +29 -0
  69. package/dist/cli/gate/codex-disable.js +195 -0
  70. package/dist/cli/gate/codex-disable.js.map +1 -0
  71. package/dist/cli/gate/codex-enable.d.ts +41 -0
  72. package/dist/cli/gate/codex-enable.js +127 -0
  73. package/dist/cli/gate/codex-enable.js.map +1 -0
  74. package/dist/cli/gate/codex-snapshot.d.ts +208 -0
  75. package/dist/cli/gate/codex-snapshot.js +402 -0
  76. package/dist/cli/gate/codex-snapshot.js.map +1 -0
  77. package/dist/cli/gate/disable.d.ts +9 -0
  78. package/dist/cli/gate/disable.js +263 -28
  79. package/dist/cli/gate/disable.js.map +1 -1
  80. package/dist/cli/gate/enable.d.ts +26 -6
  81. package/dist/cli/gate/enable.js +152 -80
  82. package/dist/cli/gate/enable.js.map +1 -1
  83. package/dist/cli/gate/link-chain.d.ts +53 -0
  84. package/dist/cli/gate/link-chain.js +122 -0
  85. package/dist/cli/gate/link-chain.js.map +1 -0
  86. package/dist/cli/gate/snapshot.d.ts +289 -12
  87. package/dist/cli/gate/snapshot.js +481 -22
  88. package/dist/cli/gate/snapshot.js.map +1 -1
  89. package/dist/cli/index.d.ts +0 -9
  90. package/dist/cli/index.js +15 -2607
  91. package/dist/cli/index.js.map +1 -1
  92. package/dist/cli/init/composer.d.ts +3 -6
  93. package/dist/cli/init/composer.js +6 -370
  94. package/dist/cli/init/composer.js.map +1 -1
  95. package/dist/cli/init/dependencies.d.ts +1 -2
  96. package/dist/cli/init/dependencies.js +4 -80
  97. package/dist/cli/init/dependencies.js.map +1 -1
  98. package/dist/cli/init/detect.js +5 -4
  99. package/dist/cli/init/detect.js.map +1 -1
  100. package/dist/cli/init/interactive.d.ts +1 -23
  101. package/dist/cli/init/interactive.js +33 -247
  102. package/dist/cli/init/interactive.js.map +1 -1
  103. package/dist/cli/init/profiles.d.ts +2 -2
  104. package/dist/cli/init/profiles.js +51 -243
  105. package/dist/cli/init/profiles.js.map +1 -1
  106. package/dist/cli/init/templates.d.ts +2 -37
  107. package/dist/cli/init/templates.js +25 -1237
  108. package/dist/cli/init/templates.js.map +1 -1
  109. package/dist/cli/list.d.ts +1 -1
  110. package/dist/cli/list.js +1 -38
  111. package/dist/cli/list.js.map +1 -1
  112. package/dist/cli/loader.d.ts +15 -1
  113. package/dist/cli/loader.js +56 -39
  114. package/dist/cli/loader.js.map +1 -1
  115. package/dist/cli/main.js +7 -2
  116. package/dist/cli/main.js.map +1 -1
  117. package/dist/cli/migrate-home/index.js +2 -2
  118. package/dist/cli/migrate-home/index.js.map +1 -1
  119. package/dist/cli/pack/add.js +1 -1
  120. package/dist/cli/pack/add.js.map +1 -1
  121. package/dist/cli/pack/hook-bootstrap.d.ts +5 -164
  122. package/dist/cli/pack/hook-bootstrap.js +15 -291
  123. package/dist/cli/pack/hook-bootstrap.js.map +1 -1
  124. package/dist/cli/pack/hook-branch-protection.d.ts +28 -21
  125. package/dist/cli/pack/hook-branch-protection.js +365 -340
  126. package/dist/cli/pack/hook-branch-protection.js.map +1 -1
  127. package/dist/cli/pack/index.d.ts +0 -1
  128. package/dist/cli/pack/index.js +0 -1
  129. package/dist/cli/pack/index.js.map +1 -1
  130. package/dist/cli/pack/list.js +1 -6
  131. package/dist/cli/pack/list.js.map +1 -1
  132. package/dist/cli/pack/mutate.d.ts +7 -9
  133. package/dist/cli/pack/mutate.js +6 -9
  134. package/dist/cli/pack/mutate.js.map +1 -1
  135. package/dist/cli/pack/reseed.d.ts +2 -2
  136. package/dist/cli/pack/reseed.js +16 -22
  137. package/dist/cli/pack/reseed.js.map +1 -1
  138. package/dist/cli/register-gate-group.d.ts +5 -0
  139. package/dist/cli/register-gate-group.js +348 -0
  140. package/dist/cli/register-gate-group.js.map +1 -0
  141. package/dist/cli/register-inspect-group.d.ts +6 -0
  142. package/dist/cli/register-inspect-group.js +189 -0
  143. package/dist/cli/register-inspect-group.js.map +1 -0
  144. package/dist/cli/register-operator-lifecycle.d.ts +5 -0
  145. package/dist/cli/register-operator-lifecycle.js +20 -0
  146. package/dist/cli/register-operator-lifecycle.js.map +1 -0
  147. package/dist/cli/register-pack-group.d.ts +5 -0
  148. package/dist/cli/register-pack-group.js +134 -0
  149. package/dist/cli/register-pack-group.js.map +1 -0
  150. package/dist/cli/register-setup-group.d.ts +5 -0
  151. package/dist/cli/register-setup-group.js +495 -0
  152. package/dist/cli/register-setup-group.js.map +1 -0
  153. package/dist/cli/remove/index.d.ts +0 -14
  154. package/dist/cli/remove/index.js +2 -115
  155. package/dist/cli/remove/index.js.map +1 -1
  156. package/dist/cli/remove/mutate.d.ts +0 -2
  157. package/dist/cli/remove/mutate.js +1 -19
  158. package/dist/cli/remove/mutate.js.map +1 -1
  159. package/dist/cli/remove/register.d.ts +5 -0
  160. package/dist/cli/remove/register.js +25 -0
  161. package/dist/cli/remove/register.js.map +1 -0
  162. package/dist/cli/shell-quote.d.ts +7 -0
  163. package/dist/cli/shell-quote.js +17 -0
  164. package/dist/cli/shell-quote.js.map +1 -0
  165. package/dist/cli/uninstall/index.d.ts +4 -2
  166. package/dist/cli/uninstall/index.js +15 -7
  167. package/dist/cli/uninstall/index.js.map +1 -1
  168. package/dist/cli/uninstall/register.d.ts +5 -0
  169. package/dist/cli/uninstall/register.js +171 -0
  170. package/dist/cli/uninstall/register.js.map +1 -0
  171. package/dist/cli/uninstall/snapshot.d.ts +15 -0
  172. package/dist/cli/uninstall/snapshot.js +38 -0
  173. package/dist/cli/uninstall/snapshot.js.map +1 -1
  174. package/dist/cli/validate/checks.d.ts +0 -77
  175. package/dist/cli/validate/checks.js +3 -1049
  176. package/dist/cli/validate/checks.js.map +1 -1
  177. package/dist/cli/validate/index.js +19 -15
  178. package/dist/cli/validate/index.js.map +1 -1
  179. package/dist/index.d.ts +0 -3
  180. package/dist/index.js +0 -3
  181. package/dist/index.js.map +1 -1
  182. package/dist/io/claude-mcp.d.ts +1 -1
  183. package/dist/io/claude-mcp.js +27 -14
  184. package/dist/io/claude-mcp.js.map +1 -1
  185. package/dist/io/harness-lock.js +2 -2
  186. package/dist/io/harness-lock.js.map +1 -1
  187. package/dist/io/lock.d.ts +0 -26
  188. package/dist/io/lock.js +10 -27
  189. package/dist/io/lock.js.map +1 -1
  190. package/dist/io/merge-settings.js +6 -4
  191. package/dist/io/merge-settings.js.map +1 -1
  192. package/dist/io/project-name.d.ts +22 -39
  193. package/dist/io/project-name.js +25 -42
  194. package/dist/io/project-name.js.map +1 -1
  195. package/dist/io/read-regular-file.d.ts +103 -40
  196. package/dist/io/read-regular-file.js +196 -35
  197. package/dist/io/read-regular-file.js.map +1 -1
  198. package/dist/io/restart-hints.js +3 -6
  199. package/dist/io/restart-hints.js.map +1 -1
  200. package/dist/io/version-compare.d.ts +1 -3
  201. package/dist/io/version-compare.js +1 -3
  202. package/dist/io/version-compare.js.map +1 -1
  203. package/dist/io/write-regular-file.d.ts +30 -0
  204. package/dist/io/write-regular-file.js +85 -0
  205. package/dist/io/write-regular-file.js.map +1 -0
  206. package/dist/overrides/index.d.ts +1 -0
  207. package/dist/overrides/index.js +1 -0
  208. package/dist/overrides/index.js.map +1 -1
  209. package/dist/overrides/merge-layers.d.ts +43 -0
  210. package/dist/overrides/merge-layers.js +76 -0
  211. package/dist/overrides/merge-layers.js.map +1 -0
  212. package/dist/overrides/merge.d.ts +16 -0
  213. package/dist/overrides/merge.js +21 -8
  214. package/dist/overrides/merge.js.map +1 -1
  215. package/dist/policy-packs/builtin/branch-protection-runtime.d.ts +4 -69
  216. package/dist/policy-packs/builtin/branch-protection-runtime.js +12 -84
  217. package/dist/policy-packs/builtin/branch-protection-runtime.js.map +1 -1
  218. package/dist/policy-packs/builtin/branch-protection.d.ts +7 -30
  219. package/dist/policy-packs/builtin/branch-protection.js +56 -89
  220. package/dist/policy-packs/builtin/branch-protection.js.map +1 -1
  221. package/dist/policy-packs/config-check.d.ts +7 -5
  222. package/dist/policy-packs/config-check.js +2 -2
  223. package/dist/policy-packs/config-check.js.map +1 -1
  224. package/dist/policy-packs/expand.d.ts +1 -3
  225. package/dist/policy-packs/expand.js +5 -42
  226. package/dist/policy-packs/expand.js.map +1 -1
  227. package/dist/policy-packs/index.d.ts +3 -3
  228. package/dist/policy-packs/index.js +2 -2
  229. package/dist/policy-packs/index.js.map +1 -1
  230. package/dist/policy-packs/registry.d.ts +9 -38
  231. package/dist/policy-packs/registry.js +5 -65
  232. package/dist/policy-packs/registry.js.map +1 -1
  233. package/dist/policy-packs/runtime.d.ts +0 -4
  234. package/dist/policy-packs/runtime.js +9 -22
  235. package/dist/policy-packs/runtime.js.map +1 -1
  236. package/dist/policy-packs/types.d.ts +0 -19
  237. package/dist/policy-packs/ux-compare.d.ts +1 -11
  238. package/dist/policy-packs/ux-compare.js +6 -42
  239. package/dist/policy-packs/ux-compare.js.map +1 -1
  240. package/dist/policy-packs/ux-drift-check.d.ts +5 -5
  241. package/dist/policy-packs/ux-drift-check.js +17 -22
  242. package/dist/policy-packs/ux-drift-check.js.map +1 -1
  243. package/dist/policy-packs/version-check.d.ts +5 -26
  244. package/dist/policy-packs/version-check.js +19 -74
  245. package/dist/policy-packs/version-check.js.map +1 -1
  246. package/dist/probes/memory.d.ts +1 -1
  247. package/dist/probes/memory.js +1 -1
  248. package/dist/probes/memory.js.map +1 -1
  249. package/dist/runtime/agent-facing.d.ts +9 -0
  250. package/dist/runtime/agent-facing.js +28 -14
  251. package/dist/runtime/agent-facing.js.map +1 -1
  252. package/dist/runtime/bounded-text.d.ts +15 -0
  253. package/dist/runtime/bounded-text.js +27 -0
  254. package/dist/runtime/bounded-text.js.map +1 -0
  255. package/dist/runtime/git-branch.d.ts +125 -0
  256. package/dist/runtime/git-branch.js +381 -0
  257. package/dist/runtime/git-branch.js.map +1 -0
  258. package/dist/runtime/hermetic-spawn-guard.d.ts +2 -2
  259. package/dist/runtime/hermetic-spawn-guard.js +24 -34
  260. package/dist/runtime/hermetic-spawn-guard.js.map +1 -1
  261. package/dist/runtime/home-dir.js +3 -2
  262. package/dist/runtime/home-dir.js.map +1 -1
  263. package/dist/runtime/tool-name-aliases.d.ts +0 -6
  264. package/dist/runtime/tool-name-aliases.js +5 -18
  265. package/dist/runtime/tool-name-aliases.js.map +1 -1
  266. package/dist/schema/grounding.d.ts +7 -54
  267. package/dist/schema/grounding.js +10 -12
  268. package/dist/schema/grounding.js.map +1 -1
  269. package/dist/schema/hooks.d.ts +39 -136
  270. package/dist/schema/hooks.js +3 -6
  271. package/dist/schema/hooks.js.map +1 -1
  272. package/dist/schema/index.d.ts +114 -3828
  273. package/dist/schema/index.js +41 -80
  274. package/dist/schema/index.js.map +1 -1
  275. package/dist/schema/memory.d.ts +54 -131
  276. package/dist/schema/memory.js +2 -2
  277. package/dist/schema/memory.js.map +1 -1
  278. package/dist/schema/policy-packs.d.ts +3 -45
  279. package/dist/schema/policy-packs.js +7 -13
  280. package/dist/schema/policy-packs.js.map +1 -1
  281. package/dist/schema/policy-ux.d.ts +7 -0
  282. package/dist/schema/policy-ux.js +18 -0
  283. package/dist/schema/policy-ux.js.map +1 -0
  284. package/dist/schema/removed-keys.d.ts +185 -0
  285. package/dist/schema/removed-keys.js +531 -0
  286. package/dist/schema/removed-keys.js.map +1 -0
  287. package/dist/schema/tools.d.ts +30 -268
  288. package/dist/schema/tools.js +4 -4
  289. package/dist/schema/tools.js.map +1 -1
  290. package/package.json +5 -16
  291. package/dist/cli/approve/branch-protection.d.ts +0 -69
  292. package/dist/cli/approve/branch-protection.js +0 -144
  293. package/dist/cli/approve/branch-protection.js.map +0 -1
  294. package/dist/cli/approve/risk.d.ts +0 -112
  295. package/dist/cli/approve/risk.js +0 -197
  296. package/dist/cli/approve/risk.js.map +0 -1
  297. package/dist/cli/approve/stdin-report.d.ts +0 -57
  298. package/dist/cli/approve/stdin-report.js +0 -145
  299. package/dist/cli/approve/stdin-report.js.map +0 -1
  300. package/dist/cli/approve/understanding.d.ts +0 -410
  301. package/dist/cli/approve/understanding.js +0 -996
  302. package/dist/cli/approve/understanding.js.map +0 -1
  303. package/dist/cli/audit.d.ts +0 -80
  304. package/dist/cli/audit.js +0 -252
  305. package/dist/cli/audit.js.map +0 -1
  306. package/dist/cli/delegate/index.d.ts +0 -76
  307. package/dist/cli/delegate/index.js +0 -443
  308. package/dist/cli/delegate/index.js.map +0 -1
  309. package/dist/cli/doctor/auto-approve-mode.d.ts +0 -37
  310. package/dist/cli/doctor/auto-approve-mode.js +0 -80
  311. package/dist/cli/doctor/auto-approve-mode.js.map +0 -1
  312. package/dist/cli/doctor/bypass-without-auto-approve.d.ts +0 -38
  313. package/dist/cli/doctor/bypass-without-auto-approve.js +0 -95
  314. package/dist/cli/doctor/bypass-without-auto-approve.js.map +0 -1
  315. package/dist/cli/doctor/codex-config-drift.d.ts +0 -28
  316. package/dist/cli/doctor/codex-config-drift.js +0 -240
  317. package/dist/cli/doctor/codex-config-drift.js.map +0 -1
  318. package/dist/cli/doctor/rogue-ledger.d.ts +0 -53
  319. package/dist/cli/doctor/rogue-ledger.js +0 -153
  320. package/dist/cli/doctor/rogue-ledger.js.map +0 -1
  321. package/dist/cli/doctor/session-start-preflight-setup-version.d.ts +0 -83
  322. package/dist/cli/doctor/session-start-preflight-setup-version.js +0 -114
  323. package/dist/cli/doctor/session-start-preflight-setup-version.js.map +0 -1
  324. package/dist/cli/doctor/settings-drift.d.ts +0 -25
  325. package/dist/cli/doctor/settings-drift.js +0 -213
  326. package/dist/cli/doctor/settings-drift.js.map +0 -1
  327. package/dist/cli/doctor/toolchain-parity.d.ts +0 -63
  328. package/dist/cli/doctor/toolchain-parity.js +0 -154
  329. package/dist/cli/doctor/toolchain-parity.js.map +0 -1
  330. package/dist/cli/doctor/ug-auto-approvals.d.ts +0 -40
  331. package/dist/cli/doctor/ug-auto-approvals.js +0 -91
  332. package/dist/cli/doctor/ug-auto-approvals.js.map +0 -1
  333. package/dist/cli/doctor/ug-delegations.d.ts +0 -26
  334. package/dist/cli/doctor/ug-delegations.js +0 -121
  335. package/dist/cli/doctor/ug-delegations.js.map +0 -1
  336. package/dist/cli/doctor/ug-inflight.d.ts +0 -35
  337. package/dist/cli/doctor/ug-inflight.js +0 -57
  338. package/dist/cli/doctor/ug-inflight.js.map +0 -1
  339. package/dist/cli/doctor/understanding-mode-env.d.ts +0 -51
  340. package/dist/cli/doctor/understanding-mode-env.js +0 -98
  341. package/dist/cli/doctor/understanding-mode-env.js.map +0 -1
  342. package/dist/cli/dry-run.d.ts +0 -46
  343. package/dist/cli/dry-run.js +0 -250
  344. package/dist/cli/dry-run.js.map +0 -1
  345. package/dist/cli/event-input.d.ts +0 -28
  346. package/dist/cli/event-input.js +0 -73
  347. package/dist/cli/event-input.js.map +0 -1
  348. package/dist/cli/explain-action.d.ts +0 -20
  349. package/dist/cli/explain-action.js +0 -27
  350. package/dist/cli/explain-action.js.map +0 -1
  351. package/dist/cli/explain-policy.d.ts +0 -135
  352. package/dist/cli/explain-policy.js +0 -266
  353. package/dist/cli/explain-policy.js.map +0 -1
  354. package/dist/cli/explain.d.ts +0 -31
  355. package/dist/cli/explain.js +0 -159
  356. package/dist/cli/explain.js.map +0 -1
  357. package/dist/cli/gc/index.d.ts +0 -63
  358. package/dist/cli/gc/index.js +0 -566
  359. package/dist/cli/gc/index.js.map +0 -1
  360. package/dist/cli/pack/approve-escape.d.ts +0 -19
  361. package/dist/cli/pack/approve-escape.js +0 -190
  362. package/dist/cli/pack/approve-escape.js.map +0 -1
  363. package/dist/cli/pack/auto-approve-path.d.ts +0 -146
  364. package/dist/cli/pack/auto-approve-path.js +0 -462
  365. package/dist/cli/pack/auto-approve-path.js.map +0 -1
  366. package/dist/cli/pack/hook-codex-post-tool-use.d.ts +0 -25
  367. package/dist/cli/pack/hook-codex-post-tool-use.js +0 -160
  368. package/dist/cli/pack/hook-codex-post-tool-use.js.map +0 -1
  369. package/dist/cli/pack/hook-codex-pre-tool-use.d.ts +0 -48
  370. package/dist/cli/pack/hook-codex-pre-tool-use.js +0 -474
  371. package/dist/cli/pack/hook-codex-pre-tool-use.js.map +0 -1
  372. package/dist/cli/pack/hook-codex-stop.d.ts +0 -35
  373. package/dist/cli/pack/hook-codex-stop.js +0 -329
  374. package/dist/cli/pack/hook-codex-stop.js.map +0 -1
  375. package/dist/cli/pack/hook-codex-user-prompt-submit.d.ts +0 -24
  376. package/dist/cli/pack/hook-codex-user-prompt-submit.js +0 -93
  377. package/dist/cli/pack/hook-codex-user-prompt-submit.js.map +0 -1
  378. package/dist/cli/pack/hook-post-merge-gate-record.d.ts +0 -37
  379. package/dist/cli/pack/hook-post-merge-gate-record.js +0 -190
  380. package/dist/cli/pack/hook-post-merge-gate-record.js.map +0 -1
  381. package/dist/cli/pack/hook-post-merge-gate.d.ts +0 -34
  382. package/dist/cli/pack/hook-post-merge-gate.js +0 -277
  383. package/dist/cli/pack/hook-post-merge-gate.js.map +0 -1
  384. package/dist/cli/pack/hook-post-tool-use.d.ts +0 -45
  385. package/dist/cli/pack/hook-post-tool-use.js +0 -137
  386. package/dist/cli/pack/hook-post-tool-use.js.map +0 -1
  387. package/dist/cli/pack/hook-pre-tool-use.d.ts +0 -92
  388. package/dist/cli/pack/hook-pre-tool-use.js +0 -1328
  389. package/dist/cli/pack/hook-pre-tool-use.js.map +0 -1
  390. package/dist/cli/pack/hook-runtime-reality.d.ts +0 -63
  391. package/dist/cli/pack/hook-runtime-reality.js +0 -210
  392. package/dist/cli/pack/hook-runtime-reality.js.map +0 -1
  393. package/dist/cli/pack/hook-solution-acceptance-writeguard.d.ts +0 -32
  394. package/dist/cli/pack/hook-solution-acceptance-writeguard.js +0 -337
  395. package/dist/cli/pack/hook-solution-acceptance-writeguard.js.map +0 -1
  396. package/dist/cli/pack/hook-solution-acceptance.d.ts +0 -157
  397. package/dist/cli/pack/hook-solution-acceptance.js +0 -530
  398. package/dist/cli/pack/hook-solution-acceptance.js.map +0 -1
  399. package/dist/cli/pack/hook-stay-in-scope.d.ts +0 -37
  400. package/dist/cli/pack/hook-stay-in-scope.js +0 -128
  401. package/dist/cli/pack/hook-stay-in-scope.js.map +0 -1
  402. package/dist/cli/pack/hook-subagent-start.d.ts +0 -23
  403. package/dist/cli/pack/hook-subagent-start.js +0 -88
  404. package/dist/cli/pack/hook-subagent-start.js.map +0 -1
  405. package/dist/cli/pack/hook-subagent-stop.d.ts +0 -19
  406. package/dist/cli/pack/hook-subagent-stop.js +0 -41
  407. package/dist/cli/pack/hook-subagent-stop.js.map +0 -1
  408. package/dist/cli/pack/hook-track-active-claim.d.ts +0 -27
  409. package/dist/cli/pack/hook-track-active-claim.js +0 -275
  410. package/dist/cli/pack/hook-track-active-claim.js.map +0 -1
  411. package/dist/cli/pack/transcript-report-scan.d.ts +0 -71
  412. package/dist/cli/pack/transcript-report-scan.js +0 -359
  413. package/dist/cli/pack/transcript-report-scan.js.map +0 -1
  414. package/dist/cli/pack/understanding-report-schema-hint.d.ts +0 -26
  415. package/dist/cli/pack/understanding-report-schema-hint.js +0 -120
  416. package/dist/cli/pack/understanding-report-schema-hint.js.map +0 -1
  417. package/dist/cli/pack/upgrade.d.ts +0 -32
  418. package/dist/cli/pack/upgrade.js +0 -273
  419. package/dist/cli/pack/upgrade.js.map +0 -1
  420. package/dist/cli/pause/index.d.ts +0 -113
  421. package/dist/cli/pause/index.js +0 -251
  422. package/dist/cli/pause/index.js.map +0 -1
  423. package/dist/cli/pause-check.d.ts +0 -22
  424. package/dist/cli/pause-check.js +0 -54
  425. package/dist/cli/pause-check.js.map +0 -1
  426. package/dist/cli/policy/intercept.d.ts +0 -233
  427. package/dist/cli/policy/intercept.js +0 -1020
  428. package/dist/cli/policy/intercept.js.map +0 -1
  429. package/dist/cli/policy/risk-envelope-enrichment.d.ts +0 -40
  430. package/dist/cli/policy/risk-envelope-enrichment.js +0 -108
  431. package/dist/cli/policy/risk-envelope-enrichment.js.map +0 -1
  432. package/dist/cli/record/index.d.ts +0 -81
  433. package/dist/cli/record/index.js +0 -348
  434. package/dist/cli/record/index.js.map +0 -1
  435. package/dist/cli/resolve-env.d.ts +0 -32
  436. package/dist/cli/resolve-env.js +0 -47
  437. package/dist/cli/resolve-env.js.map +0 -1
  438. package/dist/cli/session-export/index.d.ts +0 -46
  439. package/dist/cli/session-export/index.js +0 -169
  440. package/dist/cli/session-export/index.js.map +0 -1
  441. package/dist/cli/session-export/redact.d.ts +0 -22
  442. package/dist/cli/session-export/redact.js +0 -47
  443. package/dist/cli/session-export/redact.js.map +0 -1
  444. package/dist/cli/session-export/transcript.d.ts +0 -24
  445. package/dist/cli/session-export/transcript.js +0 -164
  446. package/dist/cli/session-export/transcript.js.map +0 -1
  447. package/dist/cli/session-start/branch-check.d.ts +0 -18
  448. package/dist/cli/session-start/branch-check.js +0 -140
  449. package/dist/cli/session-start/branch-check.js.map +0 -1
  450. package/dist/cli/session-start/index.d.ts +0 -121
  451. package/dist/cli/session-start/index.js +0 -591
  452. package/dist/cli/session-start/index.js.map +0 -1
  453. package/dist/cli/session-start/shared-options.d.ts +0 -103
  454. package/dist/cli/session-start/shared-options.js +0 -107
  455. package/dist/cli/session-start/shared-options.js.map +0 -1
  456. package/dist/cli/session-start/stale-base-check.d.ts +0 -46
  457. package/dist/cli/session-start/stale-base-check.js +0 -352
  458. package/dist/cli/session-start/stale-base-check.js.map +0 -1
  459. package/dist/cli/session-start/toolchain-parity.d.ts +0 -267
  460. package/dist/cli/session-start/toolchain-parity.js +0 -797
  461. package/dist/cli/session-start/toolchain-parity.js.map +0 -1
  462. package/dist/cli/smoke/assertions.d.ts +0 -46
  463. package/dist/cli/smoke/assertions.js +0 -170
  464. package/dist/cli/smoke/assertions.js.map +0 -1
  465. package/dist/cli/smoke/index.d.ts +0 -76
  466. package/dist/cli/smoke/index.js +0 -245
  467. package/dist/cli/smoke/index.js.map +0 -1
  468. package/dist/cli/smoke/runner.d.ts +0 -55
  469. package/dist/cli/smoke/runner.js +0 -134
  470. package/dist/cli/smoke/runner.js.map +0 -1
  471. package/dist/cli/smoke/stream-parser.d.ts +0 -65
  472. package/dist/cli/smoke/stream-parser.js +0 -115
  473. package/dist/cli/smoke/stream-parser.js.map +0 -1
  474. package/dist/cli/test-risk.d.ts +0 -26
  475. package/dist/cli/test-risk.js +0 -34
  476. package/dist/cli/test-risk.js.map +0 -1
  477. package/dist/io/display-path.d.ts +0 -11
  478. package/dist/io/display-path.js +0 -29
  479. package/dist/io/display-path.js.map +0 -1
  480. package/dist/io/duration.d.ts +0 -5
  481. package/dist/io/duration.js +0 -50
  482. package/dist/io/duration.js.map +0 -1
  483. package/dist/io/extract.d.ts +0 -79
  484. package/dist/io/extract.js +0 -226
  485. package/dist/io/extract.js.map +0 -1
  486. package/dist/io/ledger-record.d.ts +0 -83
  487. package/dist/io/ledger-record.js +0 -301
  488. package/dist/io/ledger-record.js.map +0 -1
  489. package/dist/io/read-json-dir-entries.d.ts +0 -36
  490. package/dist/io/read-json-dir-entries.js +0 -48
  491. package/dist/io/read-json-dir-entries.js.map +0 -1
  492. package/dist/io/safe-json-parse.d.ts +0 -5
  493. package/dist/io/safe-json-parse.js +0 -21
  494. package/dist/io/safe-json-parse.js.map +0 -1
  495. package/dist/policies/index.d.ts +0 -5
  496. package/dist/policies/index.js +0 -6
  497. package/dist/policies/index.js.map +0 -1
  498. package/dist/policies/ledger-client.d.ts +0 -113
  499. package/dist/policies/ledger-client.js +0 -500
  500. package/dist/policies/ledger-client.js.map +0 -1
  501. package/dist/policies/producers.d.ts +0 -12
  502. package/dist/policies/producers.js +0 -61
  503. package/dist/policies/producers.js.map +0 -1
  504. package/dist/policies/requires.d.ts +0 -75
  505. package/dist/policies/requires.js +0 -240
  506. package/dist/policies/requires.js.map +0 -1
  507. package/dist/policies/timestamp.d.ts +0 -14
  508. package/dist/policies/timestamp.js +0 -36
  509. package/dist/policies/timestamp.js.map +0 -1
  510. package/dist/policy-packs/builtin/permission-profiles.d.ts +0 -11
  511. package/dist/policy-packs/builtin/permission-profiles.js +0 -74
  512. package/dist/policy-packs/builtin/permission-profiles.js.map +0 -1
  513. package/dist/policy-packs/builtin/post-merge-gate-runtime.d.ts +0 -286
  514. package/dist/policy-packs/builtin/post-merge-gate-runtime.js +0 -397
  515. package/dist/policy-packs/builtin/post-merge-gate-runtime.js.map +0 -1
  516. package/dist/policy-packs/builtin/post-merge-gate.d.ts +0 -56
  517. package/dist/policy-packs/builtin/post-merge-gate.js +0 -347
  518. package/dist/policy-packs/builtin/post-merge-gate.js.map +0 -1
  519. package/dist/policy-packs/builtin/solution-acceptance-reconnect.d.ts +0 -50
  520. package/dist/policy-packs/builtin/solution-acceptance-reconnect.js +0 -151
  521. package/dist/policy-packs/builtin/solution-acceptance-reconnect.js.map +0 -1
  522. package/dist/policy-packs/builtin/solution-acceptance-runtime.d.ts +0 -323
  523. package/dist/policy-packs/builtin/solution-acceptance-runtime.js +0 -672
  524. package/dist/policy-packs/builtin/solution-acceptance-runtime.js.map +0 -1
  525. package/dist/policy-packs/builtin/solution-acceptance.d.ts +0 -45
  526. package/dist/policy-packs/builtin/solution-acceptance.js +0 -226
  527. package/dist/policy-packs/builtin/solution-acceptance.js.map +0 -1
  528. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.d.ts +0 -25
  529. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js +0 -94
  530. package/dist/policy-packs/builtin/understanding-before-execution/active-claim.js.map +0 -1
  531. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve-default.d.ts +0 -35
  532. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve-default.js +0 -86
  533. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve-default.js.map +0 -1
  534. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.d.ts +0 -210
  535. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.js +0 -361
  536. package/dist/policy-packs/builtin/understanding-before-execution/auto-approve.js.map +0 -1
  537. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.d.ts +0 -304
  538. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +0 -734
  539. package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +0 -1
  540. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.d.ts +0 -36
  541. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js +0 -73
  542. package/dist/policy-packs/builtin/understanding-before-execution/expiry-reason.js.map +0 -1
  543. package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +0 -19
  544. package/dist/policy-packs/builtin/understanding-before-execution/index.js +0 -83
  545. package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +0 -1
  546. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.d.ts +0 -135
  547. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js +0 -618
  548. package/dist/policy-packs/builtin/understanding-before-execution/inflight-records.js.map +0 -1
  549. package/dist/policy-packs/builtin/understanding-before-execution/ledger.d.ts +0 -25
  550. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js +0 -56
  551. package/dist/policy-packs/builtin/understanding-before-execution/ledger.js.map +0 -1
  552. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.d.ts +0 -20
  553. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js +0 -93
  554. package/dist/policy-packs/builtin/understanding-before-execution/lifecycle.js.map +0 -1
  555. package/dist/policy-packs/builtin/understanding-before-execution/markers.d.ts +0 -126
  556. package/dist/policy-packs/builtin/understanding-before-execution/markers.js +0 -245
  557. package/dist/policy-packs/builtin/understanding-before-execution/markers.js.map +0 -1
  558. package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.d.ts +0 -29
  559. package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.js +0 -82
  560. package/dist/policy-packs/builtin/understanding-before-execution/measured-permission-modes.js.map +0 -1
  561. package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.d.ts +0 -71
  562. package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js +0 -175
  563. package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js.map +0 -1
  564. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.d.ts +0 -620
  565. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js +0 -1047
  566. package/dist/policy-packs/builtin/understanding-before-execution/persisted-reports.js.map +0 -1
  567. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.d.ts +0 -128
  568. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js +0 -213
  569. package/dist/policy-packs/builtin/understanding-before-execution/post-tool-use-boundary.js.map +0 -1
  570. package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.d.ts +0 -129
  571. package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.js +0 -166
  572. package/dist/policy-packs/builtin/understanding-before-execution/stay-in-scope-config.js.map +0 -1
  573. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.d.ts +0 -164
  574. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js +0 -275
  575. package/dist/policy-packs/builtin/understanding-before-execution/task-markers.js.map +0 -1
  576. package/dist/policy-packs/builtin/understanding-before-execution-runtime.d.ts +0 -1
  577. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js +0 -10
  578. package/dist/policy-packs/builtin/understanding-before-execution-runtime.js.map +0 -1
  579. package/dist/policy-packs/builtin/understanding-before-execution.d.ts +0 -526
  580. package/dist/policy-packs/builtin/understanding-before-execution.js +0 -1074
  581. package/dist/policy-packs/builtin/understanding-before-execution.js.map +0 -1
  582. package/dist/policy-packs/permission-translator.d.ts +0 -9
  583. package/dist/policy-packs/permission-translator.js +0 -76
  584. package/dist/policy-packs/permission-translator.js.map +0 -1
  585. package/dist/runtime/action-envelope.d.ts +0 -64
  586. package/dist/runtime/action-envelope.js +0 -46
  587. package/dist/runtime/action-envelope.js.map +0 -1
  588. package/dist/runtime/approval-signing.d.ts +0 -111
  589. package/dist/runtime/approval-signing.js +0 -384
  590. package/dist/runtime/approval-signing.js.map +0 -1
  591. package/dist/runtime/bash-match-facts.d.ts +0 -60
  592. package/dist/runtime/bash-match-facts.js +0 -196
  593. package/dist/runtime/bash-match-facts.js.map +0 -1
  594. package/dist/runtime/bash-match-registry.d.ts +0 -112
  595. package/dist/runtime/bash-match-registry.js +0 -406
  596. package/dist/runtime/bash-match-registry.js.map +0 -1
  597. package/dist/runtime/bash-prefix-parse.d.ts +0 -31
  598. package/dist/runtime/bash-prefix-parse.js +0 -576
  599. package/dist/runtime/bash-prefix-parse.js.map +0 -1
  600. package/dist/runtime/command-normalize.d.ts +0 -658
  601. package/dist/runtime/command-normalize.js +0 -2410
  602. package/dist/runtime/command-normalize.js.map +0 -1
  603. package/dist/runtime/deletion-target-resolve.d.ts +0 -28
  604. package/dist/runtime/deletion-target-resolve.js +0 -1078
  605. package/dist/runtime/deletion-target-resolve.js.map +0 -1
  606. package/dist/runtime/destructive-shell-floor.d.ts +0 -19
  607. package/dist/runtime/destructive-shell-floor.js +0 -611
  608. package/dist/runtime/destructive-shell-floor.js.map +0 -1
  609. package/dist/runtime/environment-resolver.d.ts +0 -36
  610. package/dist/runtime/environment-resolver.js +0 -138
  611. package/dist/runtime/environment-resolver.js.map +0 -1
  612. package/dist/runtime/git-context.d.ts +0 -201
  613. package/dist/runtime/git-context.js +0 -422
  614. package/dist/runtime/git-context.js.map +0 -1
  615. package/dist/runtime/index.d.ts +0 -12
  616. package/dist/runtime/index.js +0 -13
  617. package/dist/runtime/index.js.map +0 -1
  618. package/dist/runtime/intercept.d.ts +0 -494
  619. package/dist/runtime/intercept.js +0 -1313
  620. package/dist/runtime/intercept.js.map +0 -1
  621. package/dist/runtime/kube-context.d.ts +0 -16
  622. package/dist/runtime/kube-context.js +0 -63
  623. package/dist/runtime/kube-context.js.map +0 -1
  624. package/dist/runtime/kubectl-target-parse.d.ts +0 -31
  625. package/dist/runtime/kubectl-target-parse.js +0 -249
  626. package/dist/runtime/kubectl-target-parse.js.map +0 -1
  627. package/dist/runtime/ledger-add.d.ts +0 -16
  628. package/dist/runtime/ledger-add.js +0 -150
  629. package/dist/runtime/ledger-add.js.map +0 -1
  630. package/dist/runtime/ledger-writer.d.ts +0 -27
  631. package/dist/runtime/ledger-writer.js +0 -46
  632. package/dist/runtime/ledger-writer.js.map +0 -1
  633. package/dist/runtime/pause-sentinel.d.ts +0 -60
  634. package/dist/runtime/pause-sentinel.js +0 -156
  635. package/dist/runtime/pause-sentinel.js.map +0 -1
  636. package/dist/runtime/pending-approval.d.ts +0 -23
  637. package/dist/runtime/pending-approval.js +0 -67
  638. package/dist/runtime/pending-approval.js.map +0 -1
  639. package/dist/runtime/read-only-bash.d.ts +0 -141
  640. package/dist/runtime/read-only-bash.js +0 -2204
  641. package/dist/runtime/read-only-bash.js.map +0 -1
  642. package/dist/runtime/recovery-git-commit.d.ts +0 -22
  643. package/dist/runtime/recovery-git-commit.js +0 -284
  644. package/dist/runtime/recovery-git-commit.js.map +0 -1
  645. package/dist/runtime/reject-malformed-session-id.d.ts +0 -8
  646. package/dist/runtime/reject-malformed-session-id.js +0 -16
  647. package/dist/runtime/reject-malformed-session-id.js.map +0 -1
  648. package/dist/runtime/risk-classifier.d.ts +0 -38
  649. package/dist/runtime/risk-classifier.js +0 -311
  650. package/dist/runtime/risk-classifier.js.map +0 -1
  651. package/dist/runtime/session-id.d.ts +0 -113
  652. package/dist/runtime/session-id.js +0 -193
  653. package/dist/runtime/session-id.js.map +0 -1
  654. package/dist/runtime/shell-word.d.ts +0 -62
  655. package/dist/runtime/shell-word.js +0 -410
  656. package/dist/runtime/shell-word.js.map +0 -1
  657. package/dist/runtime/task-providers/agent-tasks.d.ts +0 -103
  658. package/dist/runtime/task-providers/agent-tasks.js +0 -245
  659. package/dist/runtime/task-providers/agent-tasks.js.map +0 -1
  660. package/dist/runtime/when-eval.d.ts +0 -62
  661. package/dist/runtime/when-eval.js +0 -212
  662. package/dist/runtime/when-eval.js.map +0 -1
  663. package/dist/runtime/workflow-policies.d.ts +0 -207
  664. package/dist/runtime/workflow-policies.js +0 -596
  665. package/dist/runtime/workflow-policies.js.map +0 -1
  666. package/dist/schema/audit.d.ts +0 -71
  667. package/dist/schema/audit.js +0 -32
  668. package/dist/schema/audit.js.map +0 -1
  669. package/dist/schema/doctor.d.ts +0 -25
  670. package/dist/schema/doctor.js +0 -23
  671. package/dist/schema/doctor.js.map +0 -1
  672. package/dist/schema/environments.d.ts +0 -215
  673. package/dist/schema/environments.js +0 -101
  674. package/dist/schema/environments.js.map +0 -1
  675. package/dist/schema/extract.d.ts +0 -9
  676. package/dist/schema/extract.js +0 -86
  677. package/dist/schema/extract.js.map +0 -1
  678. package/dist/schema/permission-profiles.d.ts +0 -2292
  679. package/dist/schema/permission-profiles.js +0 -60
  680. package/dist/schema/permission-profiles.js.map +0 -1
  681. package/dist/schema/policies.d.ts +0 -974
  682. package/dist/schema/policies.js +0 -298
  683. package/dist/schema/policies.js.map +0 -1
  684. package/dist/schema/requires.d.ts +0 -131
  685. package/dist/schema/requires.js +0 -88
  686. package/dist/schema/requires.js.map +0 -1
  687. package/dist/schema/risk.d.ts +0 -144
  688. package/dist/schema/risk.js +0 -194
  689. package/dist/schema/risk.js.map +0 -1
  690. package/dist/schema/session-start-preflight.d.ts +0 -10
  691. package/dist/schema/session-start-preflight.js +0 -106
  692. package/dist/schema/session-start-preflight.js.map +0 -1
  693. package/dist/schema/stale-base-check.d.ts +0 -18
  694. package/dist/schema/stale-base-check.js +0 -37
  695. package/dist/schema/stale-base-check.js.map +0 -1
  696. package/dist/schema/toolchain-parity.d.ts +0 -21
  697. package/dist/schema/toolchain-parity.js +0 -48
  698. package/dist/schema/toolchain-parity.js.map +0 -1
  699. package/dist/schema/workflows.d.ts +0 -519
  700. package/dist/schema/workflows.js +0 -81
  701. package/dist/schema/workflows.js.map +0 -1
  702. package/scripts/runtime-reality-docker-probe.mjs +0 -71
@@ -1,2410 +0,0 @@
1
- // Trigger-matching normaliser for Bash command strings.
2
- //
3
- // STATUS (read this before wiring anything new to `targetDir`/
4
- // `targetBase`): this module has two independent outputs with different
5
- // consumption status. `normalized` — the command with wrapper prefixes
6
- // peeled and a git invocation's own global options collapsed — is LIVE:
7
- // `policyMatchesEvent` (`src/runtime/intercept.ts`) tests every
8
- // `bash_match` regex against the raw command first and, only on a raw
9
- // miss, against this normalised form too (raw-OR-normalised, never raw-
10
- // replaced-by-normalised), closing a class of silent trigger bypasses
11
- // (see below) — and, only on a further miss of THIS form, against a
12
- // SECOND, independent, ampersand-aware normalisation pass (`AMP_BOUNDARY_RE`
13
- // / `normalizeCommandAmpAware` below, task aabbad63) that this header
14
- // otherwise never mentions; see that function's own comment for its scope
15
- // and why it carries no `targetDir`/`targetBase` of its own (fix round 1,
16
- // finding F5 — added so a reader of this header, the module's index,
17
- // learns a second pass exists at all instead of finding it only by
18
- // reading past this point). `targetDir` / `targetBase` — the effective target
19
- // directory of the command's own git invocation(s), when one can be
20
- // named unambiguously — are NOT WIRED TO ANY GATE. An earlier version of
21
- // this run used them to resolve the `${REPO}`/`${BRANCH}` policy
22
- // builtins (`src/cli/policy/intercept.ts`) to a command's named target
23
- // instead of the hook's own cwd; that wiring produced three consecutive
24
- // review-round security regressions (Risk Gate risk declassification, a
25
- // push-gate multi-invocation fail-open, and a non-git gated verb leaking
26
- // a decoy repo's target, first via `&&`, still open via `|`/`||`), each
27
- // only partially closeable because a single per-event `targetDir` cannot
28
- // express "this invocation targets repo B, but the gated verb after it
29
- // runs in the caller's cwd." The wiring was removed before shipping (see
30
- // `src/cli/policy/intercept.ts` and `CHANGELOG.md`); the extraction and
31
- // its own ambiguity rules stay here, fully tested, as the foundation for
32
- // a redesign that attributes a target to the SEGMENT satisfying a
33
- // policy's own trigger instead of to the whole event — follow-up task
34
- // `98ad072f`. Nothing below describes gate wiring; it describes what
35
- // THIS MODULE computes, live or not.
36
- //
37
- // UPDATE (task `98ad072f`, run `2026-08-02-per-repo-gate-scoping-
38
- // redesign`, T-002): the per-segment foundation named above now EXISTS as
39
- // a real export — `segmentViewOf` / `CommandSegment`, defined below
40
- // `normalizeCommand` — instead of being only a design intention. It
41
- // exposes, per boundary-delimited segment, the canonicalised text, the
42
- // segment's OWN explicit target, and an EFFECTIVE target composed with the
43
- // last preceding `cd <path>` segment (see `CommandSegment`'s own doc
44
- // comment for the composition rules and its own not-covered list, which
45
- // is narrower in places than `targetDir`/`targetBase` above — e.g. no
46
- // `VAR=value cd <path>` tolerance). `normalizeCommand`'s own `targetDir`/
47
- // `targetBase` are UNCHANGED by this addition — byte-identical in every
48
- // pre-existing test.
49
- //
50
- // UPDATE (task `98ad072f`, T-003, this run): `segmentViewOf` /
51
- // `CommandSegment` — NOT `normalizeCommand`'s own `targetDir`/`targetBase`
52
- // above, which stay exactly as unwired as before this slice — IS now
53
- // wired to a gate: `src/runtime/intercept.ts`'s `attributeTriggerSegments`
54
- // re-tests a matched policy's own `bash_match` regex against each
55
- // segment's `text`, and, for a policy whose `requires:` uses
56
- // `${REPO}`/`${BRANCH}`/`at_head`, resolves those builtins (and
57
- // `currentHeadSha`) from the trigger-satisfying segment's
58
- // `effectiveTarget` instead of the event's cwd — lazily, memoised per
59
- // resolved path, never touching `resolverGit`/`riskContext`. Trust in
60
- // `effectiveTarget` is UNIFORM — no distinction between a segment's own
61
- // explicit target and one inherited from a preceding `cd` (orchestrator
62
- // decision D-010: an initial revision distinguished the two and was
63
- // rejected — bash itself draws no such distinction, a `cd <B> && <verb>`
64
- // chain genuinely runs `<verb>` inside B). See that module's own comments
65
- // (`attributeTriggerSegments`, `resolveAttributedContexts`) for the
66
- // consumption rules; this module's OWN extraction and ambiguity rules
67
- // below are unchanged by being consumed.
68
- //
69
- // MEASUREMENT RULE (task 47297478): before measuring THIS module's
70
- // extraction across builds with an ad-hoc corpus, read the per-arm-gate
71
- // design in scripts/measure-bash-prefix-parse.mjs — a corpus arm whose
72
- // baseline never hit proves nothing, and an ungated zero from an ad-hoc
73
- // corpus has already been wrong three times in a row (b093911d run).
74
- // Note the tool itself measures ONLY bash-prefix-parse.ts's `cdTarget`
75
- // extraction; this module's `normalized`/`targetDir`/`targetBase`
76
- // outputs have no instrument yet, so cross-build claims about them need
77
- // their own per-arm-gated measurement — that instrument now exists:
78
- // scripts/measure-command-normalize.mjs (groundwork for task aabbad63, the
79
- // BOUNDARY_RE bare-`&` fix below). It rebuilds a faithful superset of the
80
- // reverted fix's 140-form quoted-value regression corpus and gates arm A
81
- // on bash actually running the verb, per the same never-fold-into-a-zero
82
- // discipline.
83
- //
84
- // 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
- //
137
- // THE TRIGGER-MATCHING GAP THIS CLOSES: every `bash_match` policy trigger
138
- // (`src/cli/init/templates.ts`, `docs/examples/full-manifest.yaml`) is a
139
- // single regex tested against the UNPARSED command string, anchored on a
140
- // shell boundary (`^ \n ; | && (`) followed by optional `VAR=value`
141
- // tokens and a literal head token — `git` (most triggers), `gh` (`gh pr
142
- // merge`/`gh pr create`), `npm` (`npm publish`), or `harness` (the
143
- // kill-switch verbs). That anchor is exact-spelling brittle: a wrapper
144
- // binary, a git global option other than `-C`, or even a second space
145
- // between the head token and its subcommand defeats the match silently —
146
- // the policy is skipped, no ledger query happens, no audit signal at all.
147
- // Measured bypasses closed by this module (task `432db3d3`, run
148
- // `2026-07-28-nongit-trigger-wrappers`, D-001): `env gh pr merge`, `env -C
149
- // <dir> gh pr merge`, `nice gh pr merge`, `gh pr merge` (double space),
150
- // `env gh pr create`, `env npm publish`, `nice npm publish`, `env harness
151
- // pause`, `nice harness pause`, `command harness pause`, `env -C <dir>
152
- // harness pause` — the exact one-word-wrapper class already fixed for
153
- // `git` in the prior run, reproduced against `gh`/`npm`/`harness` because
154
- // the head-token condition at the end of `canonicalizeSegment` checked
155
- // literal equality with `git` and nothing else.
156
- //
157
- // CURRENT RULES — trigger normalisation (peeled/canonicalised into
158
- // `normalized`):
159
- // - Leading `VAR=value` tokens (any number, in sequence).
160
- // - `env`, including `-C <dir>` / `--chdir <dir>` / `-C<dir>` /
161
- // `--chdir=<dir>` and its own `VAR=value` arguments. The flag-NAME
162
- // enumeration (which flags exist, not their VALUES) is IMPORTED from
163
- // `read-only-bash.ts`'s `ENV_LEADING_FLAGS` / `ENV_VALUE_FLAGS` /
164
- // `ENV_SPLIT_STRING_FLAGS` rather than hand-rolled here a second
165
- // time, so the two peelers cannot independently drift on which
166
- // flags exist; `read-only-bash.ts`'s generic "any OTHER glued
167
- // `--long=value` flag" catch-all is mirrored here too. Each module
168
- // keeps its own decision about what a recognised flag MEANS: this
169
- // module additionally needs the VALUE of `-C`/`--chdir` to extract
170
- // `targetDir`, which `read-only-bash.ts` never needs, so that
171
- // glued-value parsing (`-C<dir>`, `--chdir=<dir>`) stays local here.
172
- // - `command` (its own flags, e.g. `-p`/`-v`/`-V`, and `--`).
173
- // - `nice`, including `-n <n>` / `-n<n>` / `--adjustment=<n>`, and the
174
- // bare `-<n>` / `+<n>` forms — `nice -10 cmd` (increment glued
175
- // straight to the leading dash, no `n`) is `nice(1)`'s PRIMARY
176
- // documented spelling.
177
- // - `sudo` / `doas` (their own user/group/config flags — see
178
- // `SUDO_VALUE_FLAGS` / `DOAS_VALUE_FLAGS`), `time` (`-o`/`-f`/
179
- // `--output`/`--format` plus boolean flags), `timeout` (its own flags
180
- // PLUS the mandatory leading DURATION positional — see
181
- // `peelTimeout`), `stdbuf` (`-i`/`-o`/`-e`, always value-taking), and
182
- // `setsid` (`-w`/`-c`/`-f`, boolean). NOT `xargs` — deliberately
183
- // excluded, see NOT SUPPORTED below.
184
- // - A git invocation's own global options: `-C <dir>`, `-c <k=v>`,
185
- // `--git-dir[= ]<dir>`, `--work-tree[= ]<dir>`, `--no-pager`,
186
- // `-p`/`--paginate`, `--exec-path[=<path>]`, `--namespace[= ]<ns>`,
187
- // `--literal-pathspecs`, `--no-replace-objects` — dropped so the
188
- // subcommand becomes adjacent to the literal `git` token, and the
189
- // whitespace between them collapses to exactly one space.
190
- // - The `git` token itself may be path-qualified (`/usr/bin/git`,
191
- // `./git`, any `\S*/git`), matched by BASENAME (mirrors the existing
192
- // `deny-kill-switch-bypass` regex's own `(?:npx\s+|\S*/)?harness`
193
- // shape). The canonicalised output always writes the literal `git`,
194
- // regardless of how the invocation spelled the binary.
195
- // - THE CLOSED HEAD-TOKEN SET (D-001, run
196
- // 2026-07-28-nongit-trigger-wrappers): after the SAME wrapper-peeling
197
- // loop above, the head token is checked against `git` OR the three
198
- // other literal head tokens this module covers — `gh` (`gh pr merge`
199
- // / `gh pr create`), `npm` (`npm publish`), and `harness` (the
200
- // kill-switch verbs). `git` alone gets the git-specific global-option
201
- // dropping described above; the other three get wrapper peeling PLUS
202
- // whitespace collapsing across the REST OF THE SEGMENT (fix round 2,
203
- // finding F2 — collapsing only the head-to-next-token gap, the
204
- // original scope, still let an interior whitespace run defeat a
205
- // multi-word trigger like `gh pr merge`), no option-dropping (`gh
206
- // -R`, `npm --loglevel`, and any other tool-specific flag stay
207
- // UNSUPPORTED — see below — because a flag inserted between the head
208
- // token and its subcommand tokens is not looked past). Matched by
209
- // EXACT literal equality, not basename like `git` — a path-qualified
210
- // `gh`/`npm`/`harness` invocation is out of scope for this set (named
211
- // residual below); `harness`'s own trigger regex already covers a
212
- // path-qualified spelling at the RAW-match level (`(?:npx\s+|\S*/)?
213
- // harness` in `deny-kill-switch-bypass`), so nothing is lost there
214
- // specifically. This is a CLOSED set, not a general "peel wrappers
215
- // for any head token" rule.
216
- // - SHIPPED BUT NOT COVERED (fix round 2, finding F1 — corrects an
217
- // inaccurate claim in the prior version of this comment, which said
218
- // this module's four head tokens were "the head tokens shipped
219
- // policies actually gate today"): a shipped `bash_match` trigger
220
- // actually keys on EIGHT distinct COMMAND-NAME head tokens, not four —
221
- // plus one HEADLESS alternative with no command name at all:
222
- // `deny-session-env-strip`'s bare `<SESSION_VAR>=` empty-assignment
223
- // form, whose wrapped spelling (`nice CLAUDE_SESSION_ID= npm publish`)
224
- // is equally out of this module's reach, because the peel loop consumes
225
- // the assignment as an ordinary `VAR=value` prefix (measured
226
- // 2026-07-28, pinned alongside the four below).
227
- // `deny-session-env-strip` also keys on `env` (`env ... -u <VAR>` /
228
- // `env ... --unset <VAR>`) and `unset` (`unset <VAR>...`);
229
- // `deny-pause-sentinel-forgery` also keys on `tee` and `cp`. None of
230
- // these four are in the closed set above, and adding them is not
231
- // simply "the same kind of change again":
232
- // - `env` is STRUCTURALLY unreachable by this module's own
233
- // architecture, not merely unimplemented: the wrapper-peeling
234
- // loop treats `env` ONLY as a pass-through wrapper hunting for a
235
- // `git`/`gh`/`npm`/`harness` invocation behind it, so it ALWAYS
236
- // calls `peelEnv` and consumes `env`'s OWN `-u`/`--unset` flags
237
- // (and their VALUES) as part of that hunt — by the time `headTok`
238
- // is checked, the very `-u <VAR>` text `deny-session-env-strip`'s
239
- // trigger keys on has already been peeled away. Recognising
240
- // `env` as ITSELF a head token would need the SAME literal token
241
- // to get two different treatments depending on WHY it is there —
242
- // an architecture change, not a set-membership change — and is
243
- // out of scope for this closed-set run. Measured, still-open
244
- // bypass: `nice env -u CLAUDE_SESSION_ID ls` (pinned as a
245
- // documented ceiling, not silently left to a comment).
246
- // - `unset` is simply not one of the wrapper-peeling loop's
247
- // recognised names at all (unlike `env`, there is no
248
- // `peelUnset`), so a wrapped `unset` invocation is not looked
249
- // past either. Measured, still-open bypass: `nice unset
250
- // CLAUDE_SESSION_ID`.
251
- // - `tee` / `cp` are never wrapper-peeled or recognised as a head
252
- // token by this module at all; `deny-pause-sentinel-forgery`'s
253
- // own raw regex is the only thing that ever catches them.
254
- // Measured, still-open bypasses: `nice tee /tmp/.harness-paused`,
255
- // `nice cp a /tmp/.harness-paused`.
256
- // A future policy gating some OTHER head token (one of these four, or
257
- // any other) stays uncovered by this module too — named residual, no
258
- // follow-up filed unless a real consumer appears. See
259
- // `tests/runtime/bash-match-head-token-drift.test.ts` for the guard
260
- // that couples this module's covered set to what FULL_TEMPLATE's
261
- // shipped `bash_match` policies actually key on today.
262
- //
263
- // CURRENT RULES — target-directory extraction (`targetDir`/`targetBase`;
264
- // UNCONSUMED — see STATUS above):
265
- // - The effective target directory of the command's git invocation(s),
266
- // from (in priority order per invocation) the git invocation's own
267
- // `-C` / `--work-tree` / `--git-dir` (parent directory when the path
268
- // ends in `.git`), the wrapping `env -C` / `--chdir` on the SAME
269
- // invocation, or — only when NO invocation named one explicitly — a
270
- // leading `cd <dir> &&|;` prefix (delegated to `bash-prefix-
271
- // parse.ts`, which already parses that idiom). A `~`-prefixed value
272
- // is treated as though no target were named AT ALL: this module does
273
- // not expand `~`, and letting it resolve against the caller's cwd
274
- // would produce a CONFIDENTLY WRONG answer (an unrelated repo found
275
- // by walking up from a bogus path) instead of the documented "no
276
- // target" fallback.
277
- // - `targetBase`: when exactly one git invocation named an explicit
278
- // target (or several agree on the SAME one) and it is a RELATIVE
279
- // path, this names the directory it is relative to, when this
280
- // module saw one — the wrapping `env -C` on the SAME invocation
281
- // (highest priority), else a leading `cd <dir> &&|;` prefix (`cd
282
- // <repo> && git -C src status` resolves `src` against `<repo>`, not
283
- // a caller's own cwd). `null` when neither is present — a
284
- // hypothetical future consumer resolves `targetDir` against its own
285
- // cwd instead.
286
- // - When a command names MORE THAN ONE git invocation, `targetDir` (and
287
- // `targetBase`) are populated only when every invocation AGREES: all
288
- // of them name the SAME explicit target, or NONE of them do (falling
289
- // through to the leading-`cd` case above). A command mixing a bare
290
- // invocation with an explicitly-targeted one — `git -C <B> log &&
291
- // git push` — or naming two DIFFERENT explicit targets, is AMBIGUOUS
292
- // and `targetDir` is `null`.
293
- // - "Every invocation agrees" is ALSO broken by a non-git command that
294
- // shares the chain: a command-starting segment (follows
295
- // `&&`/`;`/`\n`/`(`, or is the first segment — never a bare `|`,
296
- // which stays in the SAME directory) that is NEITHER a git
297
- // invocation NOR the recognised leading `cd` prefix forces
298
- // `targetDir` to `null` too, the same conservative fallback used for
299
- // git-vs-git disagreement — measured case: `git -C <B> rev-parse
300
- // HEAD && gh pr merge`, where `-C` scopes only the ONE git call it
301
- // decorates and `gh pr merge` genuinely runs at the real, unaffected
302
- // cwd. The leading `cd` prefix stays exempt on purpose: unlike `-C`,
303
- // `cd` genuinely persists for the rest of the chain, so `cd <B> &&
304
- // gh pr create` really does run against B — only a PER-INVOCATION
305
- // override (`-C`, `--work-tree`, `--git-dir`, wrapping `env -C`) can
306
- // leak across an unrelated later command; a persistent `cd` cannot,
307
- // by construction.
308
- //
309
- // DELIBERATELY NOT SUPPORTED, and out of reach of ANY string-level
310
- // approach (see `CHANGELOG.md` task `2cc73f55`, decision D-005):
311
- // - `sh -c '...'` / `bash -c '...'`: the wrapped command lives inside a
312
- // single string argument. This module does not parse into it — note
313
- // that such a command usually still matches a `bash_match` regex
314
- // anyway, because the regex scans the RAW command string without
315
- // respecting quoting, so a shell boundary (`&&`, `;`, ...) inside the
316
- // quoted string is still "seen".
317
- // - Heredocs, `eval`, base64-decoded payloads, or a script file the
318
- // agent writes and then executes.
319
- // - `pushd`/`popd`, and a `cd` inside a nested subshell
320
- // (`(cd X && ...)`) — mirrors `bash-prefix-parse.ts`'s own scope.
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.
348
- // - A quoted assignment VALUE containing the literal word `git`/`gh`/
349
- // `npm`/`harness` as its own token (`VAR='a git b' git push`): the
350
- // continuation's one-directional guard abandons rather than risk
351
- // swallowing a real head token (see `consumeAssignment`), leaving
352
- // the byte-exact pre-task behaviour on those spellings.
353
- // - Quoted directory arguments containing whitespace (e.g. `git -C
354
- // '/path with spaces' status`): the tokeniser splits on whitespace
355
- // without quote-awareness, so such a case falls through to "no git
356
- // invocation found here" and the segment is left unchanged. Safe
357
- // (never a false positive), just not one of the covered shapes.
358
- // - `xargs git status`: unlike `env`/`command`/`nice`/`sudo`/`doas`/...,
359
- // `xargs`'s own argv is not simply "the command to run" — it appends
360
- // stdin lines as trailing arguments and may invoke the wrapped
361
- // command MULTIPLE times, or zero, depending on stdin. Different
362
- // enough semantics that peeling it the same way would be misleading,
363
- // not just incomplete, so it is deliberately excluded, not merely
364
- // missed.
365
- // - A quoted git subcommand (`git "status"`): the tokeniser sees the
366
- // literal token `"status"` (quotes included) and canonicalises to
367
- // `git "status"`, which does not satisfy a `bash_match` regex
368
- // expecting a bare word boundary. The same class of problem as the
369
- // whitespace-in-quoted-path gap above.
370
- // - Backtick command substitution wrapping the real invocation, e.g.
371
- // `` echo `env -C /tmp git status` ``: this module canonicalises the
372
- // OUTER command only and does not recurse into a substitution's
373
- // contents — the same boundary `sh -c` stops at, above.
374
- // - `$(...)` command substitution is NOT in this list — see the
375
- // "accidentally covered" note right below. Do not read its absence
376
- // here as "also open"; it genuinely blocks today, pinned by a test.
377
- // - CLOSED (task d03af8f6, review round 3): `exec git status` and
378
- // `nohup git status` used to sit in this list. Both are now peeled
379
- // the same boolean-flag-only way as `setsid` (`peelExec`/`peelNohup`,
380
- // part of the shared `peelWrapperPrefixes` loop) — added while
381
- // rebuilding `src/runtime/deletion-target-resolve.ts`'s own
382
- // recognition on top of this module's peelers so the deletion gate
383
- // could see past `exec rm -rf ...` / `nohup rm -rf ... &`, and added
384
- // HERE (not only there) so this module's own `git`/`gh`/`npm`/
385
- // `harness` trigger recognition benefits too, per the orchestrator
386
- // decision for that task. `exec`'s own rare value-taking flag (`-a
387
- // name`) is NOT modelled — treated as boolean like every other
388
- // unrecognised flag this loop's generic branch consumes, the same
389
- // approximation `setsid`'s three flags already use.
390
- // - Eight more measured-bypass spellings, none of them peeled wrapper
391
- // prefixes or a recognised `git` spelling: `ionice -c3 git status`,
392
- // `flock /tmp/l git status`, `script -q /dev/null git status`, `chrt
393
- // -b 0 git status`, `taskset -c 0 git status`, `\git status` (the
394
- // backslash defeats `GIT_TOKEN_RE`, which requires the bare
395
- // basename), `"git" status` (a QUOTED binary name — the mirror image
396
- // of the quoted-subcommand gap above), and `env -S "git status"` (the
397
- // split-string flag hands the rest of the argv to a fresh, opaque
398
- // re-parse this module deliberately does not follow, per `peelEnv`'s
399
- // own comment).
400
- // - `gh`'s / `npm`'s own tool-specific flags inserted BETWEEN the head
401
- // token and its subcommand: `gh -R owner/repo pr merge`, `gh --repo
402
- // owner/repo pr create`, `npm --loglevel=silent publish`, `npm
403
- // --registry <url> publish`. Only wrapper prefixes (`env`, `nice`,
404
- // `command`, ...) and whitespace collapsing across the rest of the
405
- // segment (see the closed head-token-set rule above) are peeled for
406
- // these three heads — a flag belonging to `gh`/`npm` itself is never
407
- // recognised or skipped, so it defeats the match the same way an
408
- // unrecognised git flag would if `peelGitGlobalOptions` did not know
409
- // its name.
410
- // - A path-qualified `gh`/`npm`/`harness` invocation (`/usr/local/bin/gh
411
- // pr merge`, `./node_modules/.bin/npm publish`): the closed
412
- // head-token-set check is EXACT literal equality, not basename like
413
- // `GIT_TOKEN_RE`. `harness`'s own trigger regex already covers a
414
- // path-qualified spelling at the raw-match level, so nothing is lost
415
- // there specifically; `gh`/`npm` genuinely have no coverage for this
416
- // shape.
417
- //
418
- // KNOWN OVER-MATCHING (FALSE POSITIVE) CLASS — `command -v NAME` / `sudo
419
- // -l [NAME]` (fix round 2, finding F5; doc-only, no behaviour change):
420
- // both are INTROSPECTION forms — `command -v NAME` prints what `NAME`
421
- // resolves to without running it, `sudo -l [NAME]` lists (without
422
- // running) whether `NAME` is permitted — but this module's wrapper
423
- // peeling treats `-v` (an otherwise-unrecognised `command` flag) and `-l`
424
- // (an otherwise-unrecognised `sudo` flag) as ordinary BOOLEAN flags to
425
- // skip past, exactly like any other flag either wrapper legitimately
426
- // takes before its real, EXECUTED payload. `command -v harness pause`
427
- // therefore canonicalises to `harness pause` and DENIES via
428
- // `deny-kill-switch-bypass` even though nothing was actually paused
429
- // (measured). This predates this run — `sudo -l git status` was already
430
- // a false positive under the git-only normaliser — this run only widens
431
- // it to also cover `gh`/`npm`/`harness`. Fail-CLOSED direction (an
432
- // operator gets an unnecessary deny, never a missed real gate), so left
433
- // as-is rather than special-cased; noted here so it is not mistaken for
434
- // a security hole if reported.
435
- //
436
- // SAME CLASS, second member (task 13e55484, review round 1): a QUOTED
437
- // ASSIGNMENT INSIDE TEXT at a segment-start position now over-matches.
438
- // `BOUNDARY_RE` splits segments with no quote awareness, so in
439
- // `echo "a; VAR='x y' git push"` the text after the `;` becomes its own
440
- // segment, the assignment continuation normalises it to `git push`, and
441
- // the push gate DENIES a command that only PRINTS the spelling
442
- // (measured; master allowed it). Fail-closed direction, accepted and
443
- // pinned in the test file as a boundary assertion — the practical cost
444
- // is that documenting or testing that bypass class through a Bash call
445
- // trips the gate, the same cost the dbc6d303 quoted/heredoc class
446
- // already carries.
447
- //
448
- // SAME CLASS, third member (task aabbad63, this run's own amp-aware
449
- // second pass): a WIDENING of the second member above, not a new class —
450
- // `AMP_BOUNDARY_RE` is quote-unaware in exactly the same way `BOUNDARY_RE`
451
- // is, so a bare `&` INSIDE a quoted string is now also a segment
452
- // boundary, and a wrapper spelling sitting behind it can be peeled and
453
- // canonicalised as though it were a real invocation. Measured on a
454
- // 160-form quoted-text corpus arbitrated by real bash (PATH-shimmed
455
- // verbs + a marker file the verb would touch if it actually ran): 144
456
- // spellings of `<text> & <wrapper> <gated verb>` INSIDE a quoted string
457
- // now match where the base (pre-aabbad63) build did not, and bash ran
458
- // the gated verb in 0 of them — a false alarm on a command that only
459
- // PRINTS the spelling, never a missed real gate. The wrapper-LESS
460
- // variant of the same shape (`echo 'a & git push'`) already false-
461
- // alarmed on the base build 16/16 via the RAW regex alone, because every
462
- // shipped `bash_match` trigger already anchors on a bare `&` (task
463
- // `d834a065`) — so this is a WIDENING of an already-accepted over-block
464
- // class to the wrapped spellings of it, not a new false-positive class
465
- // this task introduces. The opposite direction, measured on the general
466
- // (non-adversarial) corpus rather than this quoted-text one: of 276
467
- // matches this pass gains there, all 151 classifiable ones were TRUE
468
- // positives, 0 false alarms.
469
- //
470
- // `$(...)` command substitution is ACCIDENTALLY covered — not a
471
- // deliberate feature, and distinct from the backtick case above, which
472
- // is a genuine, deliberate gap. `BOUNDARY_RE` treats a bare `(` as a
473
- // shell-boundary token (needed for the leading-command case, `(cd X &&
474
- // ...)`), and `$(...)`'s opening paren is, character-for-character, the
475
- // same `(`. So `echo $(env -C /tmp git status)` splits into a segment
476
- // starting exactly at that `(`, `canonicalizeSegment` finds a real git
477
- // invocation inside it, and the close paren rides along harmlessly as
478
- // trailing text after the subcommand (`git status)` still satisfies a
479
- // `...status\b` regex, since `)` is a non-word character). This is
480
- // coincidental: `BOUNDARY_RE` was never designed with `$(...)` in mind,
481
- // and an unrelated future change to it could silently drop this coverage
482
- // with no test noticing — pinned by a dedicated test for exactly that
483
- // reason.
484
- //
485
- // Above `MAX_NORMALIZE_LENGTH` characters, normalisation is skipped
486
- // entirely and the command is returned unchanged: `harness policy
487
- // intercept` runs on every Bash/Edit/Write tool call and `require-
488
- // preflight-evidence` declares `budget_ms: 1000`, so command SIZE must
489
- // never be able to drive the hook past its own timeout budget. The RAW
490
- // command is still tested by `policyMatchesEvent` regardless (raw-OR-
491
- // normalised construction), so an oversized command only loses the
492
- // ADDITIONAL normalised-form coverage, never the baseline one.
493
- // `NormalizedCommand.truncated` reports the skip back to the caller;
494
- // `runInterceptCli` (`src/cli/policy/intercept.ts`) writes exactly one
495
- // stderr line when it sees `truncated: true`, keeping this module itself
496
- // pure and I/O-free (see below) while making the skip observable at the
497
- // one place that already owns a stderr stream for this event.
498
- //
499
- // Bounded, allocation-light, pure string work: no `fs`, no
500
- // `child_process`, no network — `node:path` is used ONLY for
501
- // `path.isAbsolute`, a synchronous string check with no I/O. This runs on
502
- // every Bash/Edit/Write tool call, so it must stay cheap: `findNextBoundary`
503
- // is a single combined-alternation regex scan rather than one `indexOf`
504
- // per boundary token (a command with many segments and at least one
505
- // boundary kind that never occurs anywhere would otherwise degenerate to
506
- // O(segments × length) — see `findNextBoundary`'s own comment). Never
507
- // throws: a malformed or unparseable command falls through cleanly and
508
- // `normalized` is the input unchanged.
509
- import * as path from "node:path";
510
- import { parseBashPrefix } from "./bash-prefix-parse.js";
511
- import { ENV_LEADING_FLAGS, ENV_SPLIT_STRING_FLAGS, ENV_VALUE_FLAGS, } from "./read-only-bash.js";
512
- const VAR_ASSIGN_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
513
- /**
514
- * The `git` token itself, matched by BASENAME so a path-qualified
515
- * invocation (`/usr/bin/git`, `./git`) is recognised too (F4 fix).
516
- * Anchored to the WHOLE token (not a substring) so `mygit` / `git-foo`
517
- * still correctly fail to match — `\S*` only ever contributes characters
518
- * immediately before a literal `/`, never before `git` directly.
519
- * Exported (fix round 2, finding F3) so
520
- * `tests/runtime/bash-match-head-token-drift.test.ts` can couple this
521
- * module's covered set to what FULL_TEMPLATE's shipped `bash_match`
522
- * policies actually key on today, instead of a test-owned duplicate.
523
- */
524
- export const GIT_TOKEN_RE = /^(?:\S*\/)?git$/;
525
- /**
526
- * git global options that this module's `peelGitGlobalOptions` skips over
527
- * to find the subcommand. Exported so `read-only-bash.ts` can share the same
528
- * source of truth; each module keeps its own decision about what a matched
529
- * option means.
530
- *
531
- * Flags that consume a value (separate token or glued via `=`): `-C`,
532
- * `--git-dir`, `--work-tree`, `-c`, `--namespace`.
533
- *
534
- * Flags without a value: `--no-pager`, `-p`, `--paginate`, `--exec-path`,
535
- * `--literal-pathspecs`, `--no-replace-objects`.
536
- */
537
- /**
538
- * git global options that take a value, excluding `-c` for security.
539
- * `-c KEY=VALUE` can inject arbitrary git config that executes code
540
- * (core.fsmonitor, core.pager, core.editor, protocol.*.allow run
541
- * arbitrary programs); fail-closed by not skipping it. read-only-bash.ts
542
- * uses this set to skip only the safe value-taking options.
543
- *
544
- * Flags: `-C`, `--git-dir`, `--work-tree`, `--namespace` (recognized both
545
- * standalone and glued with `=`).
546
- */
547
- export const GIT_GLOBAL_VALUE_TAKING_FLAGS = new Set([
548
- "-C", "--git-dir", "--work-tree", "--namespace",
549
- ]);
550
- /**
551
- * git global options that take no value.
552
- * Flags: `--no-pager`, `-p`, `--paginate`, `--exec-path`,
553
- * `--literal-pathspecs`, `--no-replace-objects`.
554
- */
555
- export const GIT_GLOBAL_NO_VALUE_FLAGS = new Set([
556
- "--no-pager", "-p", "--paginate",
557
- "--exec-path",
558
- "--literal-pathspecs", "--no-replace-objects",
559
- ]);
560
- /**
561
- * The three OTHER head tokens this module covers (D-001, run
562
- * 2026-07-28-nongit-trigger-wrappers) — `gh` (`gh pr merge` / `gh pr
563
- * create`), `npm` (`npm publish`), `harness` (the kill-switch verbs). A
564
- * closed set, deliberately NOT a general "any head token" rule (see the
565
- * module header): matched by EXACT literal equality, unlike `GIT_TOKEN_RE`'s
566
- * basename match, so a path-qualified spelling of any of these three stays
567
- * out of scope (named residual, module header NOT-SUPPORTED list). This is
568
- * NOT the complete set of head tokens a shipped `bash_match` trigger keys
569
- * on — see the module header's "SHIPPED BUT NOT COVERED" paragraph (fix
570
- * round 2, finding F1) for the four that are NOT in this set (`env`,
571
- * `unset`, `tee`, `cp`) and why. Exported (fix round 2, finding F3) for the
572
- * same drift-guard reason as `GIT_TOKEN_RE` above.
573
- */
574
- export const NON_GIT_HEAD_TOKENS = new Set(["gh", "npm", "harness"]);
575
- /**
576
- * Above this length, normalisation is skipped entirely and the command
577
- * is returned unchanged (F3 fix — see the module header for why command
578
- * SIZE must be bounded even after `findNextBoundary` was made linear).
579
- * 100k characters comfortably covers any command a human or agent would
580
- * plausibly type, while keeping the worst case a small constant instead
581
- * of scaling with whatever ends up pasted into a single Bash call.
582
- * Exported (G4 fix, review round 2) so a caller reporting the skip (see
583
- * `NormalizedCommand.truncated`) can reference the same number instead of
584
- * hand-copying it into a diagnostic message.
585
- */
586
- export const MAX_NORMALIZE_LENGTH = 100_000;
587
- /**
588
- * Shell boundary tokens a `bash_match` regex can anchor on, expressed as
589
- * ONE alternation so `findNextBoundary` does a single regex scan per
590
- * remaining span instead of one `indexOf` per token (F3 fix, review
591
- * round 2026-07-27). Order among the alternatives does not affect
592
- * correctness: no two of these tokens can start at the same string
593
- * position (only `&&` is multi-character, and no other alternative
594
- * starts with `&`), so there is no tie for the regex engine's leftmost-
595
- * match rule to break.
596
- *
597
- * DELIBERATELY MISSING, STILL: single `&`, even though bash starts a new
598
- * command after it. Adding it HERE was tried and REVERTED after
599
- * measurement: this regex is quote-unaware, `&` inside a quoted
600
- * assignment value is common, and the split lands mid-quote, so the
601
- * value continuation never engages and the wrapper is never peeled.
602
- * Measured against the pre-change build with a passing positive
603
- * control: 140 of 140 sampled `<wrapper> FOO='a&b' <gated verb>`
604
- * spellings LOST a gate they previously held, including the
605
- * `operator_only` kill-switch deny. That evidence is why THIS alphabet
606
- * stays byte-identical rather than growing a bare `&` alternative
607
- * directly — see `scripts/measure-command-normalize.mjs`'s arm A for the
608
- * regression corpus that keeps proving it stays byte-identical.
609
- *
610
- * TASK aabbad63 (this run): the bare-`&` family this alphabet cannot see
611
- * (`A=x&env -C /tmp git status`, `echo hi & nice git status`) is now
612
- * handled WITHOUT touching this regex at all — by a SECOND, independent
613
- * boundary alphabet (`AMP_BOUNDARY_RE` below) driving a SECOND
614
- * normalisation pass (`normalizeCommandAmpAware`) that
615
- * `policyMatchesEvent` (`src/runtime/intercept.ts`) tries ONLY as a
616
- * third, additional raw-OR-normalised-OR-amp-normalised disjunct — never
617
- * a replacement for either of the first two arms. That construction is
618
- * exactly what avoids re-triggering the 140/140 regression above: every
619
- * command that matched via the raw test or via THIS (BOUNDARY_RE)
620
- * normalised form keeps matching through those same two arms, byte-for-
621
- * byte unchanged; the amp-aware arm can only ADD a match a spelling
622
- * would otherwise have missed, so a quoted `FOO='a&b'` value that this
623
- * alphabet already normalises correctly is never at risk of losing that
624
- * gate to the new arm. See `AMP_BOUNDARY_RE`'s own comment for why it
625
- * has to be a distinct regex object rather than a mutation of this one,
626
- * and `normalizeCommandAmpAware`'s comment for why its return type
627
- * carries no `targetDir`/`targetBase`.
628
- */
629
- const BOUNDARY_RE = /\n|&&|;|\||\(/g;
630
- /**
631
- * Second boundary alphabet (task aabbad63): identical to `BOUNDARY_RE`
632
- * plus a bare `&` — bash's OWN "start a new command here" token, which
633
- * every shipped `bash_match` trigger regex has treated as one of its own
634
- * anchor characters since task `d834a065`, but which `BOUNDARY_RE` above
635
- * has never recognised as a NORMALISATION-time boundary (see its comment
636
- * for the reverted, measured-140/140-regression attempt to add it there
637
- * directly).
638
- *
639
- * ALTERNATION ORDER IS LOAD-BEARING: `&&` MUST be listed to the LEFT of
640
- * the bare `&` alternative. JS regex alternation is leftmost-FIRST, not
641
- * leftmost-longest — with `&` tried before `&&`, every `&&` in a command
642
- * would tokenise as TWO consecutive one-character `&` boundary MATCHES
643
- * instead of a single two-character `&&` boundary match.
644
- *
645
- * MEASURED, NOT ASSUMED (do not "simplify" the pin below to an
646
- * end-to-end `.normalized` string assertion): for THIS module's segment-
647
- * and-rejoin architecture specifically, that reordering is, perhaps
648
- * counter-intuitively, INVISIBLE in `normalizeCommandAmpAware`'s own
649
- * output string. Two adjacent single-`&` matches sandwich a genuinely
650
- * zero-length segment between them (the two `&` characters of a real
651
- * `&&` are, by definition, adjacent), `canonicalizeSegment("")` is a
652
- * verbatim no-op, and `parts.join("")` reassembles the exact same bytes
653
- * either way — verified directly (not merely reasoned about) by
654
- * temporarily swapping this alternation's order, rebuilding, and
655
- * diffing `normalizeCommandAmpAware(...).normalized` across several
656
- * `&&`-bearing commands (including this comment's own `git -C
657
- * /tmp/repoB status && git -C /tmp/repoB log` example): byte-identical
658
- * output both ways. So a test asserting on the OUTPUT STRING alone
659
- * cannot distinguish the two orderings for this architecture — the pin
660
- * in `tests/runtime/command-normalize.test.ts` therefore execs this
661
- * regex object DIRECTLY against a `&&`-bearing string and asserts the
662
- * match text is `"&&"`, not `"&"`, which DOES fail under a swap. The
663
- * order is still specified exactly as documented above (leftmost-first
664
- * is real JS semantics, and the internal `segmentAndCanonicalize`
665
- * bookkeeping this alphabet ALSO drives — `hasAmbiguousNonGitSegment`,
666
- * discarded by `normalizeCommandAmpAware` but not by a hypothetical
667
- * future consumer of `segmentAndCanonicalize` under this alphabet —
668
- * would still see a spurious extra "non-git segment" under the wrong
669
- * order), it just cannot be pinned through this module's ONE exposed
670
- * string alone.
671
- *
672
- * Used ONLY by `normalizeCommandAmpAware` below, which is consulted ONLY
673
- * as `policyMatchesEvent`'s third, additional matching arm — see that
674
- * function's own comment for why its result never carries a `targetDir`/
675
- * `targetBase`, and `src/runtime/intercept.ts` for the memoised-thunk
676
- * threading that keeps this off the common (raw-or-normalised-already-
677
- * matched) path. Exported (mirrors `GIT_TOKEN_RE` / `NON_GIT_HEAD_TOKENS`
678
- * / `MAX_NORMALIZE_LENGTH` above) solely so the ordering pin test can
679
- * exec it directly instead of re-declaring an equivalent regex literal
680
- * that would test JS semantics in general, not THIS source's actual
681
- * alternation.
682
- */
683
- export const AMP_BOUNDARY_RE = /\n|&&|&|;|\||\(/g;
684
- /** A target value starting with `~` — not expanded, treated as unparseable (F5). */
685
- function isTildeTarget(dir) {
686
- return dir.startsWith("~");
687
- }
688
- /**
689
- * A target value `segmentViewOf`'s `CommandSegment.ownTarget` (task
690
- * `98ad072f` groundwork) treats as unattributable even where the
691
- * pre-existing per-segment machinery below (`canonicalizeSegment` /
692
- * `peelGitGlobalOptions` / `peelEnv`) still captures SOME string for the
693
- * OLD aggregate's `targetDir` — that extraction is UNCHANGED by this check
694
- * (see the module header's STATUS/UPDATE note): the tokeniser is not
695
- * quote-aware, so a quoted `-C` / `--work-tree` / `--git-dir` / `env -C`
696
- * value is captured WITH its quote character(s) still attached
697
- * (`git -C "/tmp/repoB" status` captures `"/tmp/repoB"`, quotes included —
698
- * not a real path, and not `null` either under the pre-existing
699
- * extraction) rather than being rejected outright. The same is true of a
700
- * value containing a command-substitution marker (`$(` or a backtick) —
701
- * this module never resolves substitutions, so a value carrying one names
702
- * nothing this module can vouch for as a literal path. A
703
- * whitespace-containing path needs no separate check here: the
704
- * whitespace-splitting tokeniser never captures a value containing
705
- * whitespace as ONE token in the first place — it becomes a different,
706
- * unrecognised token sequence instead (see the module header's own
707
- * "quoted directory arguments containing whitespace" note), which for
708
- * `segmentViewOf` purposes simply fails to produce a recognised own target
709
- * at all (see `parseCdSegmentTarget` below for the `cd` case).
710
- *
711
- * A value ENDING in a bare `$` is ALSO rejected, even though it does not
712
- * literally contain the two-character `$(` marker: `BOUNDARY_RE` (this
713
- * function is only ever reached through the primary, non-amp-aware pass —
714
- * `segmentViewOf` never runs under `AMP_BOUNDARY_RE`) treats a bare `(` as
715
- * a shell boundary UNCONDITIONALLY (needed for the leading-command case,
716
- * module header), so `$(` can never survive as a substring WITHIN one
717
- * token or segment in the first place — the `(` always splits it into its
718
- * own boundary match first. A `cd` target ending in `$` is the tell-tale
719
- * leftover of exactly that split (`cd $(pwd)/T && ...` tokenises this
720
- * function's caller's OWN segment as literal `cd $`, a syntactically
721
- * valid-LOOKING two-token `cd <path>` with `<path>` being the single
722
- * character `$` — measured live, not hypothetical). Rejecting it here
723
- * closes that gap without needing `parseCdSegmentTarget` (which has no
724
- * visibility into what boundary token follows its own segment) to
725
- * special-case it itself. The `-C`/`--work-tree`/`--git-dir`/`env -C` side
726
- * of this module never has this specific gap: a value-capturing git
727
- * global option additionally requires a SUBCOMMAND token after its value
728
- * (`peelGitGlobalOptions`'s caller checks `gitOpts.idx >= tokens.length`),
729
- * so `git -C $(pwd) status` already comes back `isGit: false` (no
730
- * subcommand token survives the split) before this function is ever
731
- * consulted for it — this extra check is `cd`-specific, `cd <path>` having
732
- * no analogous "and then a subcommand" requirement.
733
- */
734
- function isUnattributableTargetValue(value) {
735
- return (value.includes("'") ||
736
- value.includes('"') ||
737
- value.includes("$(") ||
738
- value.includes("`") ||
739
- value.endsWith("$"));
740
- }
741
- function classifyCdSegment(segmentText) {
742
- const tokens = tokenizeWithOffsets(segmentText);
743
- if (tokens.length === 0)
744
- return { kind: "none" };
745
- const head = tokens[0].text;
746
- if (head === "pushd" || head === "popd")
747
- return { kind: "reset" };
748
- if (head !== "cd")
749
- return { kind: "none" };
750
- if (tokens.length === 1)
751
- return { kind: "reset" }; // bare `cd`, no argument
752
- if (tokens.length > 2)
753
- return { kind: "reset" }; // flagged cd: `cd -P /tmp/x`
754
- const arg = tokens[1].text;
755
- if (arg.length === 0 || arg.startsWith("-"))
756
- return { kind: "reset" }; // `cd -`, `cd -P` alone
757
- return { kind: "cd", arg };
758
- }
759
- /**
760
- * Compute one segment's `ownTarget` / `effectiveTarget` (task `98ad072f`
761
- * groundwork, `segmentViewOf` / `CommandSegment`) and the cd-basis to
762
- * carry into the NEXT segment, from: this segment's own canonicalisation
763
- * result (already computed by the shared walk — `identityTargetDir` /
764
- * `identityTargetBase` / `isGit`, unmodified here; NOT the OLD aggregate's
765
- * `targetDir`/`targetBase`, which still includes `--work-tree` — D-017,
766
- * fix round 2), whether THIS segment is itself a recognised `cd <path>`
767
- * segment, and the cd-basis carried in from whatever preceded it.
768
- * Implements `CommandSegment.effectiveTarget`'s doc comment verbatim; see
769
- * that comment for the consumer-facing statement of these rules.
770
- */
771
- function computeSegmentTarget(segmentText, canon, incomingCdBasis) {
772
- // Same-invocation `env -C`/`--chdir` base + a RELATIVE own git
773
- // REPO-RELOCATING target (`-C`/`--git-dir` — orchestrator follow-up
774
- // after the initial T-002 round, still task `98ad072f` groundwork):
775
- // `canon.identityTargetBase` is non-null EXACTLY in this mixed case —
776
- // see `canonicalizeSegment`'s own `identityTargetBase` computation,
777
- // reused here unmodified, never when there is no wrapping `env -C` on
778
- // the SAME invocation. `env -C /tmp/base git -C sub status` must NOT
779
- // hand a future consumer `ownTarget`/`effectiveTarget: "sub"` (raw,
780
- // relative): a naive consumer resolving a relative value against ITS
781
- // OWN cwd (this module's documented fallback for the "no known basis"
782
- // branch below) would land on a CONFIDENTLY WRONG repository whenever
783
- // the caller's cwd differs from `/tmp/base` — precisely the "relative
784
- // and `~` target dirs resolve to a confidently wrong repo" class named
785
- // in the 07-27 review. Composing correctly to `/tmp/base/sub` would ALSO
786
- // be correct but is deliberately NOT this module's job (real
787
- // filesystem-shaped path joining — the same reason the cd-basis
788
- // composition rule below refuses to compose `cd T && git -C sub` into
789
- // `T/sub`); noted here as a possible LATER precision gain, not built in
790
- // this slice. A bare git invocation with an `env -C` base and no
791
- // relative own REPO-RELOCATING target (`env -C /abs git status`) is
792
- // UNAFFECTED — the ordinary `canon.identityTargetDir` extraction below
793
- // already folds that in (`gitOpts.relocateTargetDir ?? envTargetDir`),
794
- // and `canon.identityTargetBase` stays `null` for that shape (no
795
- // relative own repo-relocating target to be relative TO). A `--work-
796
- // tree`-only invocation (no `-C`/`--git-dir` on the same call) is NEVER
797
- // "mixed" here even when wrapped by `env -C`: `relocateTargetDir` is
798
- // `null` for it, so `identityTargetDir` falls through to `envTargetDir`
799
- // (or stays `null`) and `identityTargetBase` stays `null` — D-017, this
800
- // is the fix. A `cd`-shaped segment never reaches this branch: it never
801
- // matches `GIT_TOKEN_RE`, so `canon.isGit` is always `false` for one.
802
- if (canon.isGit && canon.identityTargetBase !== null) {
803
- return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: incomingCdBasis };
804
- }
805
- const cdClass = classifyCdSegment(segmentText);
806
- // D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign): a
807
- // segment that GENUINELY changes directory to somewhere this module
808
- // cannot name (bare `cd`, `cd -`, a flagged `cd`, `pushd`, `popd`)
809
- // RESETS the running basis to `null` for every later segment, rather
810
- // than the pre-fix behaviour of leaving it unchanged (silently
811
- // INHERITING whatever preceded it, as though the shell had not moved at
812
- // all). This segment names nothing itself either. A PRECISION fix, not
813
- // the security fix: `src/runtime/intercept.ts`'s D-011 additive
814
- // attribution rule means an inherited target always also demands the
815
- // cwd context, so an over-cautious reset here can only make a LATER
816
- // segment fall back to cwd-only evaluation — identical to the shipped
817
- // baseline for an unattributable form (D-003), never a fail-open.
818
- if (cdClass.kind === "reset") {
819
- return { ownTarget: null, effectiveTarget: null, outgoingCdBasis: null };
820
- }
821
- const cdArg = cdClass.kind === "cd" ? cdClass.arg : null;
822
- const rawOwn = cdArg !== null ? cdArg : canon.isGit ? canon.identityTargetDir : null;
823
- const ownTarget = rawOwn !== null && !isTildeTarget(rawOwn) && !isUnattributableTargetValue(rawOwn)
824
- ? rawOwn
825
- : null;
826
- let effectiveTarget;
827
- if (ownTarget === null) {
828
- // D-014: a recognised `cd <path>` segment whose OWN value came out
829
- // unattributable (tilde/quoted/substitution) RESETS rather than
830
- // inherits — the shell genuinely changed directory to somewhere this
831
- // module cannot name, so silently keeping an earlier basis in place
832
- // would be a confidently-wrong carry-forward, exactly the class this
833
- // whole module exists to avoid. A segment that is NOT cd-shaped at
834
- // all (`cdArg === null`, `cdClass.kind === "none"`) still inherits —
835
- // this module has no evidence the shell moved, so it does not guess.
836
- effectiveTarget = cdArg !== null ? null : incomingCdBasis;
837
- }
838
- else if (path.isAbsolute(ownTarget)) {
839
- effectiveTarget = ownTarget;
840
- }
841
- else {
842
- effectiveTarget = incomingCdBasis === null ? ownTarget : null;
843
- }
844
- // Only a recognised `cd <path>` segment updates the outgoing basis
845
- // (whether or not its OWN value came out attributable — see
846
- // `CommandSegment.effectiveTarget`'s doc comment for why an
847
- // unattributable `cd` must still RESET the basis to `null`, not leave a
848
- // stale earlier one in place). Every other segment — gated or not —
849
- // never itself changes the shell's directory, so the incoming basis
850
- // passes through unchanged.
851
- const outgoingCdBasis = cdArg !== null ? effectiveTarget : incomingCdBasis;
852
- return { ownTarget, effectiveTarget, outgoingCdBasis };
853
- }
854
- /**
855
- * Given a Bash command string, return its normalised form and the target
856
- * directory (and base, if relative) of its git invocation. Never throws:
857
- * any internal failure (there should be none — this is a defensive
858
- * backstop matching the fail-safe shape used elsewhere in this codebase,
859
- * e.g. `policyMatchesEvent`'s `try/catch` around `new RegExp`) falls back
860
- * to the input unchanged.
861
- */
862
- export function normalizeCommand(command) {
863
- if (typeof command !== "string" || command.length === 0) {
864
- return {
865
- normalized: typeof command === "string" ? command : "",
866
- targetDir: null,
867
- targetBase: null,
868
- truncated: false,
869
- };
870
- }
871
- if (command.length > MAX_NORMALIZE_LENGTH) {
872
- return { normalized: command, targetDir: null, targetBase: null, truncated: true };
873
- }
874
- try {
875
- return normalizeCommandInner(command);
876
- }
877
- catch {
878
- return { normalized: command, targetDir: null, targetBase: null, truncated: false };
879
- }
880
- }
881
- /**
882
- * Given a Bash command string, return its per-segment view (task
883
- * `98ad072f` groundwork, T-002) — see `CommandSegment`'s own doc comment
884
- * for the field-level composition rules. `null` when the input exceeded
885
- * `MAX_NORMALIZE_LENGTH` (mirrors `NormalizedCommand.truncated`, but as
886
- * the ABSENCE of a view rather than a boolean flag alongside one, since
887
- * there is no meaningful per-segment view to return for an input whose
888
- * normalisation itself was skipped). `[]` for an empty or non-string
889
- * command, and as the never-throws fallback (mirrors `normalizeCommand`'s
890
- * own defensive backstop — see its doc comment above).
891
- *
892
- * THIS function's OWN call into `segmentAndCanonicalize` (`collectSegments:
893
- * true`) is one walk, O(length) — the "no additional pass" claim is scoped
894
- * to that ONE call, not to a caller's total work. CORRECTED (D-015, fix
895
- * round, run 2026-08-02-per-repo-gate-scoping-redesign) — the prior wording
896
- * here read "no additional pass over the command string" without that
897
- * scoping, which a reader could take as "calling this costs nothing beyond
898
- * `normalizeCommand`'s own walk." Measured false: `normalizeCommand(cmd)`
899
- * and `segmentViewOf(cmd)` are two SEPARATE top-level calls, each running
900
- * its own full `segmentAndCanonicalize` pass over the SAME string — a
901
- * caller needing both (as `src/cli/policy/intercept.ts` does, for
902
- * `normalized`/`truncated` and the segment view respectively) pays TWO
903
- * walks, not one; measured +206% at the `MAX_NORMALIZE_LENGTH` bound
904
- * (2.73 → 8.36 ms), harmless against the 1000 ms hook budget but not free.
905
- * As of the same fix round, the one production caller only pays this
906
- * second walk lazily, when some matching policy actually needs it (see
907
- * `InterceptOptions.commandSegmentsThunk` in `src/runtime/intercept.ts`) —
908
- * but that laziness lives in the CALLER, not in this function, which still
909
- * does exactly one walk every time it runs. The ampersand-aware second pass
910
- * (`normalizeCommandAmpAware`) NEVER collects a segment view (always calls
911
- * `segmentAndCanonicalize` with `collectSegments: false`) — see that
912
- * function's own comment for why a directory extracted under the
913
- * ampersand-aware alphabet would be unreliable in the first place; the
914
- * same reasoning applies here, so this module offers no amp-aware variant
915
- * of this function at all.
916
- */
917
- export function segmentViewOf(command) {
918
- if (typeof command !== "string" || command.length === 0)
919
- return [];
920
- if (command.length > MAX_NORMALIZE_LENGTH)
921
- return null;
922
- try {
923
- return segmentAndCanonicalize(command, BOUNDARY_RE, true).segments ?? [];
924
- }
925
- catch {
926
- return [];
927
- }
928
- }
929
- /**
930
- * Ampersand-aware sibling of `segmentViewOf` (task d03af8f6, review
931
- * round 3): the same per-segment view, but segmented under
932
- * `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead of
933
- * `BOUNDARY_RE` — closing the same bare-`&` gap `normalizeCommandAmpAware`
934
- * closes for trigger matching (`echo hi & rm -rf /home/x`), but as a
935
- * segment VIEW rather than a rejoined string, for
936
- * `src/runtime/deletion-target-resolve.ts`'s multi-segment scan. Uses the
937
- * SAME shared walk (`segmentAndCanonicalize`, parameterised on
938
- * `boundaryRe` since task aabbad63) as every other pass in this module —
939
- * no new segmentation logic. `CommandSegment.effectiveTarget`'s own
940
- * composition rules are UNCHANGED by which alphabet drove the split; a
941
- * caller that only needs `.text` per segment (as the deletion resolver
942
- * does) is unaffected by the same directory-extraction reliability
943
- * caveat `normalizeCommandAmpAware`'s own comment names for `targetDir`/
944
- * `targetBase` — this function returns the FULL `CommandSegment` shape,
945
- * `effectiveTarget` included, so a future caller relying on THAT field
946
- * under this alphabet should read `normalizeCommandAmpAware`'s comment
947
- * first. `null` under the same `MAX_NORMALIZE_LENGTH` condition
948
- * `segmentViewOf` uses; `[]` for an empty/non-string command or the
949
- * never-throws fallback.
950
- */
951
- export function segmentViewOfAmpAware(command) {
952
- if (typeof command !== "string" || command.length === 0)
953
- return [];
954
- if (command.length > MAX_NORMALIZE_LENGTH)
955
- return null;
956
- try {
957
- return segmentAndCanonicalize(command, AMP_BOUNDARY_RE, true).segments ?? [];
958
- }
959
- catch {
960
- return [];
961
- }
962
- }
963
- /**
964
- * Ampersand-aware SECOND normalisation pass (task aabbad63). Segments the
965
- * command under `AMP_BOUNDARY_RE` (`BOUNDARY_RE` plus a bare `&`) instead
966
- * of `BOUNDARY_RE`, then canonicalises exactly like the primary pass
967
- * (same `canonicalizeSegment`, same wrapper-peeling rules, same
968
- * `MAX_NORMALIZE_LENGTH` bound). Closes the gap `BOUNDARY_RE`'s own
969
- * comment names: `A=x&env -C /tmp git status` and `echo hi & nice git
970
- * status` were ungated because a wrapper can only be peeled after a
971
- * recognised boundary, and a bare `&` was not one.
972
- *
973
- * Return type deliberately has NO `targetDir`/`targetBase` — not merely
974
- * "always null under this alphabet", genuinely ABSENT from the type, so
975
- * no consumer can read one by mistake. Reason: a bare `&` is ALSO how
976
- * bash spells a background job (`git -C /x push &`) and how a redirect
977
- * merges streams (`git -C /x log 2>&1`, `... &> out`). Under THIS
978
- * alphabet each of those splits into a spurious extra "segment", which
979
- * `segmentAndCanonicalize`'s own non-git-segment ambiguity rule (a
980
- * segment that starts a new command and is not itself a git invocation
981
- * forces `targetDir` to `null` — see its G1 comment) would turn into a
982
- * wrong-shaped loss for a case the PRIMARY pass still resolves correctly
983
- * today. Pinned in `tests/runtime/command-normalize.test.ts`'s "aabbad63
984
- * groundwork" describe block, which exercises ONLY `normalizeCommand`
985
- * (the primary pass), never this function — `targetDir`/`targetBase`
986
- * stay the primary pass's property alone.
987
- *
988
- * Only consulted by `policyMatchesEvent`'s third arm
989
- * (`src/runtime/intercept.ts`), and only when a policy's `bash_match`
990
- * regex has already missed BOTH the raw command and the primary
991
- * normalised form — see that module and `src/cli/policy/intercept.ts`
992
- * for the memoised-thunk threading that keeps this computation off the
993
- * common (already-matched) path.
994
- */
995
- export function normalizeCommandAmpAware(command) {
996
- return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, AMP_BOUNDARY_RE, false).normalized);
997
- }
998
- /**
999
- * Shared empty/length/throw guard clauses for a normalisation pass whose
1000
- * public return shape is exactly `{ normalized, truncated }` — no
1001
- * `targetDir`/`targetBase` (task cf3dff51 extraction: before this,
1002
- * `normalizeCommandAmpAware` and `normalizeCommandQuoteAware` carried
1003
- * byte-identical copies of this wrapper, which `npm run check:duplication`
1004
- * — a jscpd clone-count fitness function, see that script's own header
1005
- * comment for the precedent this follows — flagged as new duplication
1006
- * against the pinned baseline; extracted rather than raising the pin,
1007
- * per that script's own stated preference). `normalizeCommand` itself is
1008
- * NOT this shape (its return type also carries `targetDir`/`targetBase`)
1009
- * and keeps its own, separate guard clauses — deliberately not folded in
1010
- * here, since unifying THREE different return shapes behind one generic
1011
- * helper would obscure more than the ~10 duplicated lines it would save.
1012
- * `compute` is called only once `command` has passed both guards, so it
1013
- * never needs to re-check emptiness or the length bound itself.
1014
- */
1015
- function normalizeWithGuards(command, compute) {
1016
- if (typeof command !== "string" || command.length === 0) {
1017
- return { normalized: typeof command === "string" ? command : "", truncated: false };
1018
- }
1019
- if (command.length > MAX_NORMALIZE_LENGTH) {
1020
- return { normalized: command, truncated: true };
1021
- }
1022
- try {
1023
- return { normalized: compute(command), truncated: false };
1024
- }
1025
- catch {
1026
- return { normalized: command, truncated: false };
1027
- }
1028
- }
1029
- function normalizeCommandInner(command) {
1030
- const { normalized, targetDir, targetBase } = segmentAndCanonicalize(command, BOUNDARY_RE, false);
1031
- return { normalized, targetDir, targetBase, truncated: false };
1032
- }
1033
- /**
1034
- * Quote-aware THIRD normalisation pass (task cf3dff51). Segments the
1035
- * command under `BOUNDARY_RE`'s own alphabet — same characters as the
1036
- * primary pass, no widening — but tracks single/double-quote state while
1037
- * scanning (`findNextBoundaryQuoteAware` below) so a boundary character
1038
- * sitting INSIDE a quoted assignment value is no longer treated as a real
1039
- * segment split. Closes the gap the module header's "DELIBERATELY NOT
1040
- * SUPPORTED" list and `BOUNDARY_RE`'s own comment both name: `VAR='a; b'
1041
- * git push` (task 13e55484's pinned residual) — `BOUNDARY_RE` used to cut
1042
- * the segment at the `;`, landing squarely between the two halves of one
1043
- * quoted value, so neither resulting piece carried a recognisable
1044
- * invocation and `consumeAssignment`'s own multi-token quote continuation
1045
- * (built for exactly this shape) never got the chance to run: it operates
1046
- * AFTER segmentation, on tokens WITHIN one segment, and the quoted value
1047
- * was never in one segment to begin with. Once quote-aware segmentation
1048
- * keeps the value whole, `consumeAssignment` needs no changes at all — it
1049
- * already reconstructs a multi-token quoted value correctly; this pass
1050
- * only fixes what gets handed to it as ONE segment in the first place.
1051
- *
1052
- * BUILT AS A NEW, INDEPENDENT PASS RATHER THAN AN IN-PLACE EDIT OF
1053
- * `BOUNDARY_RE` / `segmentAndCanonicalize` — this is the module header's
1054
- * HALT CRITERION, restated here at the point it governs: quote-aware
1055
- * segmentation can MERGE a segment the primary pass currently splits, and
1056
- * that merge cuts in BOTH directions — it can turn a previously-missed
1057
- * bypass into a match (the fix), but it can just as easily turn a
1058
- * previously-accepted, ALREADY-WIRED over-block into a miss (a
1059
- * regression). Measured, not hypothetical: `tests/runtime/command-
1060
- * normalize.test.ts`'s "accepted over-block: a quoted assignment inside
1061
- * TEXT after a boundary char now matches" pin — `echo "a; VAR='x y' git
1062
- * push"` — matches via `normalizeCommand` TODAY only because `BOUNDARY_RE`
1063
- * splits it at the `;`, landing `VAR='x y' git push` at a segment start
1064
- * where the assignment continuation turns it into a match. An in-place
1065
- * quote-aware edit of THAT pass would un-split it back into the outer
1066
- * `echo "..."` segment — not itself a recognised invocation — and flip
1067
- * that pin to a lost match, in violation of the raw-OR-normalised-OR-amp-
1068
- * aware-OR-(this) never-lose-a-match discipline every other pass in this
1069
- * module already honours. Building this as a SEPARATE pass instead means
1070
- * `normalizeCommand`'s own segmentation, and every test resting on it —
1071
- * including that exact one — is untouched by this task; see this file's
1072
- * own test suite for the differential proof (every case the first three
1073
- * arms already matched still matches, by construction, because none of
1074
- * those three arms' code changed at all).
1075
- *
1076
- * WIRED to `policyMatchesEvent` (`src/runtime/intercept.ts`) as its
1077
- * FOURTH arm — same shape as `normalizeCommandAmpAware`'s own third-arm
1078
- * wiring (task aabbad63): consulted only after the raw command,
1079
- * `normalizedCommand`, and the amp-aware pass have all missed, via a
1080
- * memoised thunk (`InterceptOptions.quoteNormalizedCommandThunk`,
1081
- * `src/cli/policy/intercept.ts`'s `quoteNormalizedCommandThunk`) so the
1082
- * segmentation cost is paid at most once per event, on the common
1083
- * (already-matched) path it costs nothing at all. Wiring landed in a
1084
- * SECOND round of this task, after the orchestrator extended the first
1085
- * round's file scope specifically for this one wiring point — the first
1086
- * round shipped this function fully built and tested but genuinely
1087
- * unwired (an inert security fix, called out explicitly as the wrong
1088
- * shipped state in that round's own report) — `src/cli/dry-run.ts`'s own,
1089
- * independently-copied matcher was deliberately NOT extended in the same
1090
- * round (out of the extended scope too) and stayed a named residual until
1091
- * task f561e44c closed it by adding the same fourth OR-arm there too — see
1092
- * that file's own comment.
1093
- *
1094
- * THIS PASS'S OWN OVER-BLOCK SURFACE is measured and named in the test
1095
- * file rather than assumed — and the measurement CORRECTS an earlier,
1096
- * pre-measurement guess that used to sit in this comment: it does NOT
1097
- * reproduce the `echo "a; VAR='x y' git push"` accepted over-block named
1098
- * above. Quote-aware merging keeps that whole `echo "..."` segment
1099
- * together, starting with the unrecognised head token `echo`, so this
1100
- * function's output for it is byte-identical to the raw input
1101
- * (`normalizeCommand`'s OWN, separate, unchanged-by-this-task match for
1102
- * that same string is unaffected either way). Measured against a broader
1103
- * adversarial probe, both at this function's own level and again at
1104
- * `policyMatchesEvent`'s wired level (a bash-shim-verified corpus of
1105
- * chained/prefixed/subshell-wrapped genuine invocations plus candidate
1106
- * over-block shapes — entirely quoted `echo`/`printf` arguments that only
1107
- * PRINT the spelling): every additional match this pass adds beyond raw /
1108
- * `normalizeCommand` / `normalizeCommandAmpAware` is a TRUE positive (a
1109
- * real invocation bash would actually execute); no NEW over-block class
1110
- * distinct from `consumeAssignment`'s own, already-reviewed precision
1111
- * limits was found. This function shares `canonicalizeSegment` /
1112
- * `consumeAssignment` completely unchanged with the other passes — only
1113
- * the segmentation boundary decision differs — which is also the
1114
- * structural reason a longer (merged) segment can only REVEAL a match
1115
- * those functions would already accept from a shorter view, never
1116
- * fabricate a new kind of one; see the module header's cf3dff51 UPDATE
1117
- * paragraph for the fuller statement of this reasoning and its
1118
- * "not a formal proof" caveat.
1119
- */
1120
- export function normalizeCommandQuoteAware(command) {
1121
- return normalizeWithGuards(command, (c) => segmentAndCanonicalize(c, BOUNDARY_RE, false, true).normalized);
1122
- }
1123
- /**
1124
- * The shared segmentation + canonicalisation engine behind BOTH
1125
- * normalisation passes (task aabbad63). Parameterised on the boundary
1126
- * alphabet (`boundaryRe`) instead of closing over a single module-level
1127
- * regex, so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
1128
- * second pass (`AMP_BOUNDARY_RE`) never share regex scan state: each
1129
- * call is handed its OWN regex object, and `findNextBoundary` resets
1130
- * that object's own `lastIndex` before every use, so the two passes
1131
- * (even if a future change nested one inside the other, which nothing
1132
- * here does today) can never observe or clobber each other's position.
1133
- * `targetDir`/`targetBase` ARE still computed here regardless of which
1134
- * alphabet drove the segmentation — `normalizeCommandAmpAware` below
1135
- * simply never returns them to its own caller; see that function's
1136
- * comment for why exposing them would be wrong under the amp alphabet
1137
- * specifically. Fix round 1, finding F10: under the amp alphabet
1138
- * specifically, ALL of this function's target bookkeeping
1139
- * (`explicitTargets`, `explicitTargetBase`, `bareGitSegmentCount`,
1140
- * `leadingCd`'s full `parseBashPrefix` pass, and the whole G1 ambiguity
1141
- * resolution below) is knowingly computed and then discarded by
1142
- * `normalizeCommandAmpAware` ITSELF, which returns only
1143
- * `{ normalized, truncated }` — its own caller never sees these fields at
1144
- * all. This is the containment design's cost, not an oversight, so a
1145
- * future reader should not "fix" it by exposing these fields to a
1146
- * hypothetical amp-alphabet consumer.
1147
- *
1148
- * `collectSegments` (task `98ad072f` groundwork, T-002): when `true`, ALSO
1149
- * builds the per-segment view `segmentViewOf` returns (`CommandSegment[]`)
1150
- * inside this SAME walk — no SECOND walk INSIDE this one function call,
1151
- * just a bounded amount of extra work per segment already being visited
1152
- * (see `computeSegmentTarget`). Scoped claim only (D-015 correction, fix
1153
- * round, run 2026-08-02-per-repo-gate-scoping-redesign): this says nothing
1154
- * about a CALLER that invokes this function twice with different
1155
- * `collectSegments` values on the same string — see `segmentViewOf`'s own
1156
- * doc comment, corrected in the same fix round, for the measured cost of
1157
- * exactly that. `normalizeCommandInner` and
1158
- * `normalizeCommandAmpAware` both pass `false`: the OLD aggregate never
1159
- * needed this, and the amp-aware pass deliberately never exposes a segment
1160
- * view at all (see that function's own comment). `segments` is `null` on
1161
- * the returned object when `collectSegments` is `false`, an array
1162
- * (possibly empty) otherwise.
1163
- *
1164
- * `quoteAware` (task cf3dff51, default `false` — every PRE-EXISTING call
1165
- * site is unchanged, positionally omitting this parameter): selects
1166
- * `findNextBoundaryQuoteAware` instead of `findNextBoundary` as the one
1167
- * thing this walk asks to find the next segment split. Everything else in
1168
- * this function — `canonicalizeSegment`, the target-directory bookkeeping,
1169
- * the `collectSegments` per-segment view — is IDENTICAL either way; only
1170
- * WHERE a segment ends changes. Only `normalizeCommandQuoteAware` passes
1171
- * `true`; see that function's own comment for why this had to be a new
1172
- * parameter on the SHARED walk rather than a wholly separate function
1173
- * (reuse, same reasoning `AMP_BOUNDARY_RE`'s parameterisation on
1174
- * `boundaryRe` already established) and for the HALT CRITERION that ruled
1175
- * out editing `findNextBoundary` itself in place instead.
1176
- */
1177
- function segmentAndCanonicalize(command, boundaryRe, collectSegments, quoteAware = false) {
1178
- const parts = [];
1179
- const explicitTargets = new Set();
1180
- let explicitTargetBase = null;
1181
- let sawExplicitBase = false;
1182
- let bareGitSegmentCount = 0;
1183
- let i = 0;
1184
- const n = command.length;
1185
- // task `98ad072f` groundwork (T-002): the per-segment view's own,
1186
- // independent bookkeeping — a running cd-basis (see
1187
- // `computeSegmentTarget`) and the accumulated segments themselves.
1188
- // Entirely separate from `explicitTargets` / `bareGitSegmentCount` /
1189
- // `hasAmbiguousNonGitSegment` above, which stay exactly as they were:
1190
- // this never reads or writes them, and they never read or write this.
1191
- const segments = collectSegments ? [] : null;
1192
- let cdBasis = null;
1193
- // A leading `cd <dir> &&|;` prefix, parsed ONCE up front (G1 fix,
1194
- // review round 2, 2026-07-27) instead of the two separate calls the
1195
- // size===1 and size===0 branches below each used to make on their own.
1196
- // Also needed here now to exempt the recognised leading-cd segment
1197
- // itself from the new non-git-command ambiguity check just below: that
1198
- // segment is not "some other command sharing the chain" — it IS the
1199
- // directory every later bare command in the chain actually runs
1200
- // inside.
1201
- const leadingCd = parseBashPrefix(command).cdTarget;
1202
- // Boundary token immediately preceding the segment currently being
1203
- // examined (`null` for the very first segment). Tracked so the G1
1204
- // check below can tell a real "new command" boundary (`&&`/`;`/`\n`/
1205
- // `(`) apart from a bare `|`, which keeps running in the SAME
1206
- // directory as whatever precedes it.
1207
- let precedingBoundaryToken = null;
1208
- let hasAmbiguousNonGitSegment = false;
1209
- // Walk the command as alternating (segment, boundary) pairs. Each
1210
- // segment is handed to `canonicalizeSegment`, which rewrites ONLY its
1211
- // head (wrapper prefixes + a git invocation's global options, if any
1212
- // are found) and leaves the remainder of the segment — and every
1213
- // boundary token — untouched. Bounded: `i` strictly increases by at
1214
- // least one boundary token's length each iteration a boundary is
1215
- // found, and the loop stops once none remain.
1216
- for (;;) {
1217
- const boundary = quoteAware
1218
- ? findNextBoundaryQuoteAware(command, i, boundaryRe)
1219
- : findNextBoundary(command, i, boundaryRe);
1220
- const segEnd = boundary ? boundary.start : n;
1221
- const segmentText = command.slice(i, segEnd);
1222
- const result = canonicalizeSegment(segmentText);
1223
- parts.push(result.text);
1224
- if (segments !== null) {
1225
- // task `98ad072f` groundwork (T-002): O(1) extra work per segment
1226
- // already being visited — see `segmentAndCanonicalize`'s own doc
1227
- // comment for why this is not a second pass over `command`.
1228
- //
1229
- // D-014 (fix round, run 2026-08-02-per-repo-gate-scoping-redesign),
1230
- // two bash-truthfulness corrections applied around the call, not
1231
- // inside `computeSegmentTarget` itself (both are about WHICH basis
1232
- // crosses a BOUNDARY, not about one segment's own composition):
1233
- //
1234
- // - a running cd-basis never crosses a bare `|` — each side of a
1235
- // pipeline is bash's own subshell, so a `cd` on one side cannot
1236
- // affect the other (measured bypass: `cd <forged> | git push`
1237
- // really runs `git push` at the real cwd, never `<forged>`).
1238
- // The segment immediately after a `|` is handed `null` as its
1239
- // incoming basis, as though nothing preceded it.
1240
- // - the OUTGOING basis resets to `null` whenever THIS segment's
1241
- // own text contains a `)` (a subshell close riding along after
1242
- // `BOUNDARY_RE`'s bare `(` boundary — this alphabet has no `)`
1243
- // boundary of its own, see its header comment — so a subshell's
1244
- // `cd` genuinely stops applying once the subshell exits: `(cd
1245
- // <forged> ; echo hi) && git push` must not let `<forged>` reach
1246
- // `git push`). THIS segment's own `effectiveTarget` is computed
1247
- // from the UNCHANGED incoming basis first — a segment ending in
1248
- // `)` (e.g. the `git status)` tail of `(cd A && git status)`)
1249
- // still correctly attributes to A for itself, exactly as it
1250
- // should while still inside the subshell; only what carries
1251
- // FORWARD to the next segment is affected. A simple substring
1252
- // check, not paren-depth tracking: conservative, precision-only
1253
- // under D-011 (see `computeSegmentTarget`'s own D-014 note) — an
1254
- // over-eager reset here can only make a later segment fall back
1255
- // to cwd-only, never a fail-open.
1256
- const incomingBasis = precedingBoundaryToken === "|" ? null : cdBasis;
1257
- const seg = computeSegmentTarget(segmentText, result, incomingBasis);
1258
- segments.push({
1259
- text: result.text,
1260
- ownTarget: seg.ownTarget,
1261
- effectiveTarget: seg.effectiveTarget,
1262
- });
1263
- cdBasis = segmentText.includes(")") ? null : seg.outgoingCdBasis;
1264
- }
1265
- if (result.isGit) {
1266
- if (result.targetDir === null) {
1267
- bareGitSegmentCount += 1;
1268
- }
1269
- else {
1270
- explicitTargets.add(result.targetDir);
1271
- if (!sawExplicitBase && result.targetBase !== null) {
1272
- explicitTargetBase = result.targetBase;
1273
- sawExplicitBase = true;
1274
- }
1275
- }
1276
- }
1277
- else if (precedingBoundaryToken !== "|" &&
1278
- !(precedingBoundaryToken === null && leadingCd !== null)) {
1279
- // G1 fix (HIGH, review round 2, 2026-07-27): a segment that STARTS
1280
- // a new command (follows `&&`/`;`/`\n`/`(` — or is the very first
1281
- // segment in the command — never a bare `|`, which keeps running
1282
- // in the SAME directory as whatever it reads from) and is NOT
1283
- // itself a git invocation is a genuinely DIFFERENT command that
1284
- // does not necessarily run wherever some OTHER git invocation
1285
- // elsewhere in the chain explicitly pointed. Measured case: `git
1286
- // -C <B> rev-parse HEAD && gh pr merge` — `-C` scopes only the ONE
1287
- // git call it decorates, so `gh pr merge` actually runs at the
1288
- // real (unaffected) cwd; resolving ${REPO}/${BRANCH} from B for it
1289
- // let a fact recorded against a decoy repo satisfy
1290
- // `review-before-merge-bash` / `review-subagent-before-pr-create-
1291
- // bash` for a merge/PR that never touched B. The one exemption is
1292
- // the recognised LEADING `cd` prefix itself: unlike `-C`, `cd`
1293
- // genuinely persists for the rest of the chain, so `cd <B> && git
1294
- // status` (or any other command after it) really does run in B —
1295
- // that stays the untouched `explicitTargets.size === 0` branch
1296
- // below, not this one.
1297
- hasAmbiguousNonGitSegment = true;
1298
- }
1299
- if (!boundary)
1300
- break;
1301
- parts.push(boundary.token);
1302
- i = boundary.start + boundary.token.length;
1303
- precedingBoundaryToken = boundary.token;
1304
- }
1305
- let targetDir;
1306
- let targetBase = null;
1307
- if (explicitTargets.size === 1 &&
1308
- bareGitSegmentCount === 0 &&
1309
- !hasAmbiguousNonGitSegment) {
1310
- // Every git invocation the command names agrees on ONE repository —
1311
- // safe to use it even when there are several (e.g. two `git -C <same
1312
- // dir>` calls chained together) — AND no OTHER, non-git command
1313
- // shares the chain that this single target does not actually apply
1314
- // to (G1 fix, review round 2).
1315
- targetDir = [...explicitTargets][0];
1316
- targetBase = explicitTargetBase;
1317
- if (targetBase === null && !path.isAbsolute(targetDir)) {
1318
- // F5 fix: a RELATIVE git-level target with no more specific base
1319
- // (no wrapping `env -C` on the SAME invocation) is relative to
1320
- // wherever the shell's cwd was at that point in the chain, which a
1321
- // LEADING `cd <dir> &&|;` prefix moves for every later command.
1322
- // Only the leading form is consulted — this module does not
1323
- // simulate the chain deeply enough to attribute a NON-leading `cd`
1324
- // to a specific later invocation, same ceiling as the leading-only
1325
- // fallback immediately below.
1326
- if (leadingCd !== null && !isTildeTarget(leadingCd))
1327
- targetBase = leadingCd;
1328
- }
1329
- }
1330
- else if (explicitTargets.size === 0) {
1331
- // No git invocation named an explicit target of its own (whether
1332
- // there were zero git invocations, or one or more entirely bare
1333
- // ones). A leading `cd <dir> &&|;` prefix, if present, names the
1334
- // target DIRECTLY here — every bare invocation in the chain runs
1335
- // inside it, so this is not a base for something else, it IS the
1336
- // answer — delegated to the existing leading-prefix parser rather
1337
- // than reimplementing its quote-handling. Unaffected by the G1
1338
- // ambiguity flag above: a non-git command after a leading `cd` is
1339
- // exactly the case that flag exempts, and a non-git command with NO
1340
- // git invocation anywhere in the chain never disagrees with
1341
- // anything, since there is no explicit git target to disagree with
1342
- // in the first place.
1343
- targetDir = leadingCd !== null && !isTildeTarget(leadingCd) ? leadingCd : null;
1344
- }
1345
- else {
1346
- // Ambiguous: either more than one git invocation names a DIFFERENT
1347
- // repository, at least one invocation names a repo explicitly while
1348
- // another runs bare (which may be a THIRD repo — whatever the
1349
- // shell's cwd happens to be at that point), or (G1 fix, review round
1350
- // 2) a non-git, non-leading-cd command elsewhere in the chain that
1351
- // the single explicit git target does not actually apply to. None of
1352
- // these has one unambiguous answer, so — per the "when in doubt,
1353
- // fall back to cwd" rule this whole run was built on — report no
1354
- // target at all rather than silently picking one invocation's answer
1355
- // for the whole command (F2 fix; see the module header for the
1356
- // push-gate fail-open this closes).
1357
- targetDir = null;
1358
- }
1359
- return { normalized: parts.join(""), targetDir, targetBase, segments };
1360
- }
1361
- /**
1362
- * Find the earliest shell boundary token at or after `from`, under the
1363
- * CALLER-SUPPLIED boundary alphabet. Takes `boundaryRe` as an explicit
1364
- * parameter rather than closing over a single module-level regex (task
1365
- * aabbad63) so the primary pass (`BOUNDARY_RE`) and the ampersand-aware
1366
- * second pass (`AMP_BOUNDARY_RE`) each carry their own `lastIndex` state.
1367
- *
1368
- * Fix round 1, finding F8 (corrects the prior wording here): resetting
1369
- * `lastIndex = from` on every call is NOT what stops the two passes from
1370
- * clobbering each other's scan position — they are two DISTINCT regex
1371
- * objects, so there is nothing shared for either to clobber, and
1372
- * `segmentAndCanonicalize`'s own loop always calls this with `from`
1373
- * already equal to where the previous match's `lastIndex` would have
1374
- * landed anyway. That is why removing this reset measured 0 output
1375
- * differences across the corpus and left the suite green (see
1376
- * `tests/runtime/command-normalize.test.ts`'s F8 note — the mutation was
1377
- * applied and reverted to confirm this, not merely reasoned about). The
1378
- * line is still a legitimate guard, just for a DIFFERENT reason:
1379
- * `BOUNDARY_RE` and `AMP_BOUNDARY_RE` are module-level `/g` objects
1380
- * shared by every call in the process, so their `lastIndex` outlives any
1381
- * single scan. If `canonicalizeSegment` THROWS partway through a scan,
1382
- * the top-level `catch` in `normalizeCommand` /
1383
- * `normalizeCommandAmpAware` returns a fallback but leaves that shared
1384
- * object's `lastIndex` wherever the last successful match landed — and
1385
- * the NEXT call, on a completely different command, would then start
1386
- * scanning from that stale offset. This reset re-anchors it.
1387
- *
1388
- * DO NOT DELETE THIS LINE ON THE STRENGTH OF ITS MUTATION-INERTNESS.
1389
- * Measured (fix round 2): with the reset removed and a throw forced into
1390
- * `canonicalizeSegment`, the FOLLOWING call to
1391
- * `normalizeCommandAmpAware("a | env git push origin master")` returned
1392
- * the un-normalised string, and `preflight-before-push` went from MATCH
1393
- * to NO-MATCH. That is a gate-losing fail-open, not a stylistic nit.
1394
- * The honest caveat, stated rather than implied: no throw is KNOWN to be
1395
- * reachable from real input — the leak above was demonstrated with a
1396
- * forced throw, which is why the line measures inert. The module's own
1397
- * top-level `catch` exists because a throw is treated as possible, and
1398
- * this reset is the other half of that same posture.
1399
- */
1400
- function findNextBoundary(s, from, boundaryRe) {
1401
- boundaryRe.lastIndex = from;
1402
- const m = boundaryRe.exec(s);
1403
- return m === null ? null : { start: m.index, token: m[0] };
1404
- }
1405
- /**
1406
- * Quote-aware sibling of `findNextBoundary` (task cf3dff51): the earliest
1407
- * boundary token at or after `from` that sits OUTSIDE any open single- or
1408
- * double-quoted run, under the SAME `boundaryRe` alphabet the caller
1409
- * already scans with — no new boundary characters, only quote-tracking
1410
- * around the existing ones. `null` when no such boundary exists (either
1411
- * none remain at all, or every remaining candidate sits inside quoting
1412
- * that never closes before the string ends — the same conservative "no
1413
- * further split" fallback `consumeAssignment`'s own unterminated-quote
1414
- * case already uses, restated here for whole-string segmentation instead
1415
- * of one token).
1416
- *
1417
- * TWO-PHASE, NOT a per-character regex probe (task cf3dff51, latency fix
1418
- * round — this replaces this function's first shipped implementation,
1419
- * which called a sticky-regex `.exec()` at EVERY character position;
1420
- * measured ~2x `normalizeCommand`'s own cost on a large, quote-free
1421
- * command, because a JS-to-native regex-engine round trip at every single
1422
- * character is far more expensive than the SAME regex engine's own
1423
- * internal forward scan across many characters in one call — exactly what
1424
- * `findNextBoundary` already does for the primary, non-quote-aware pass).
1425
- * This version instead COMPOSES `findNextBoundary` (unchanged, still the
1426
- * quote-UNAWARE fast forward scanner) with `QuoteScanner` (below): repeat
1427
- * — find the next quote-unaware CANDIDATE boundary via `findNextBoundary`
1428
- * (one native regex scan, however far away the candidate is), then walk
1429
- * ONLY the span between the previous position and that candidate,
1430
- * character-by-character, through `QuoteScanner` (below) to learn whether
1431
- * a quote is open at the candidate's position; if not, the candidate IS
1432
- * the real boundary — return it; if so, the candidate is inside a quote
1433
- * (exactly the `VAR='a; b' git push` shape this pass exists to see
1434
- * through), step through the candidate token's own characters too (never
1435
- * quote-relevant — `BOUNDARY_RE`'s alphabet contains no `'`/`"`/`\` — so
1436
- * this is a correctness no-op, only here so the running quote/escape
1437
- * state stays byte-for-byte identical to a full character-by-character
1438
- * walk) and continue the search from just past it. For a typical command
1439
- * (few boundary characters, most of them outside any quote) this pays
1440
- * for ONE `findNextBoundary` native scan per REAL segment plus one plain
1441
- * (non-regex) character walk of the same total length `findNextBoundary`
1442
- * would have scanned internally anyway — no longer one regex call per
1443
- * character. `null` when no such boundary exists (either none remain at
1444
- * all — `findNextBoundary` itself returns `null` — or every remaining
1445
- * candidate sits inside quoting that never closes before the string ends,
1446
- * the same conservative "no further split" fallback `consumeAssignment`'s
1447
- * own unterminated-quote case already uses, restated here for whole-
1448
- * string segmentation instead of one token).
1449
- *
1450
- * `QuoteScanner` (below) is the SAME quote/escape state machine
1451
- * `consumeAssignment`'s own per-token loop uses (three states — `""`,
1452
- * `'`, `"` — same POSIX approximation: outside quotes `\` escapes the
1453
- * next char, `'...'` closes only at an UNESCAPED-by-construction `'`
1454
- * (single quotes never honour backslash escapes at all), `"..."` closes
1455
- * at an unescaped `"`). Extracted into a shared class (task cf3dff51 fix
1456
- * round: `npm run check:duplication`'s jscpd scan flagged the two
1457
- * independently-written copies of this loop as new duplication against
1458
- * the pinned baseline; see that script's own header comment for why
1459
- * extraction, not raising the pin, is the stated preference) rather than
1460
- * duplicated a second time. Generalised from per-token to whole-string
1461
- * scope by THIS function, not by the scanner itself: `consumeAssignment`
1462
- * only ever sees one already-whitespace-tokenised segment at a time; this
1463
- * function walks the RAW command text directly, whitespace included, so
1464
- * it can tell whether a `;`/`|`/`&&`/`(`/`\n` character sits inside a
1465
- * quote that a later whitespace-delimited token will re-enter (exactly
1466
- * the shape `VAR='a; b' git push` needs: the space after `;` is itself
1467
- * INSIDE the quoted run, not a token boundary this function cares about
1468
- * at all — only the eventual UNQUOTED position matters).
1469
- *
1470
- * Reuses the MODULE-LEVEL `BOUNDARY_QUOTE_SCANNER` instance (`.reset()`
1471
- * at the top of each call — see that constant's and `reset()`'s own
1472
- * comments) instead of allocating a fresh `QuoteScanner` per call, and
1473
- * calls `findNextBoundary` with the CALLER-SUPPLIED `boundaryRe` object
1474
- * directly rather than a sticky clone — `findNextBoundary` already resets
1475
- * `lastIndex` itself on every call (its own comment documents exactly
1476
- * this discipline for the primary pass sharing the SAME `BOUNDARY_RE`
1477
- * object), so composing it here needs no extra care. Called once per
1478
- * segment (not once per character) by `segmentAndCanonicalize`'s loop;
1479
- * the character walk itself still visits each character in the
1480
- * `[from, end-of-match-or-string)` span exactly once IN TOTAL across all
1481
- * calls for one command (split across the phase-1 "verify" loop and the
1482
- * phase-2 "step through the false candidate" loop, never both for the
1483
- * same character), keeping the whole walk O(length), same complexity
1484
- * class `findNextBoundary`'s own comment establishes for the primary
1485
- * pass — this rewrite changes the CONSTANT factor, not the asymptotic
1486
- * class.
1487
- */
1488
- function findNextBoundaryQuoteAware(s, from, boundaryRe) {
1489
- BOUNDARY_QUOTE_SCANNER.reset();
1490
- let pos = from;
1491
- const n = s.length;
1492
- for (;;) {
1493
- const candidate = findNextBoundary(s, pos, boundaryRe);
1494
- if (candidate === null)
1495
- return null;
1496
- // Phase 1: verify quote-safety by walking ONLY the span strictly
1497
- // BEFORE the candidate — mirrors the original per-character scan,
1498
- // which never steps through the boundary token's own characters
1499
- // before deciding whether to return it.
1500
- for (let i = pos; i < candidate.start; i++) {
1501
- BOUNDARY_QUOTE_SCANNER.step(s[i]);
1502
- }
1503
- if (BOUNDARY_QUOTE_SCANNER.state === "") {
1504
- return candidate;
1505
- }
1506
- // Phase 2: the candidate sits inside an open quote — not a real
1507
- // boundary. Step through ITS OWN characters too (a correctness
1508
- // no-op, see this function's own comment) so the running state stays
1509
- // exactly what a full character-by-character walk would have
1510
- // produced, then resume the search just past it.
1511
- const tokenEnd = candidate.start + candidate.token.length;
1512
- for (let i = candidate.start; i < tokenEnd; i++) {
1513
- BOUNDARY_QUOTE_SCANNER.step(s[i]);
1514
- }
1515
- if (tokenEnd >= n)
1516
- return null;
1517
- pos = tokenEnd;
1518
- }
1519
- }
1520
- /**
1521
- * Mutable single-/double-quote + backslash-escape state machine (task
1522
- * cf3dff51 extraction — see `findNextBoundaryQuoteAware`'s own comment for
1523
- * why this was pulled out of two independently-written copies rather than
1524
- * left duplicated). POSIX-approximated semantics, shared verbatim by both
1525
- * consumers: outside quotes `\` escapes the next character; `'...'`
1526
- * closes only at a literal `'` (single quotes never honour backslash
1527
- * escapes — the `escaped` branch below is unreachable whenever
1528
- * `state === "'"`, by construction: `step` checks `this.escaped` BEFORE
1529
- * checking `this.state`, so an escape flagged one character earlier is
1530
- * always consumed first, but nothing inside the `state === "'"` branch
1531
- * itself ever SETS `escaped`, so it can never observe a `\` as anything
1532
- * other than a literal character); `"..."` closes at an unescaped `"`. A
1533
- * mutable class rather than a pure step function returning a new state
1534
- * object: this module runs on every Bash/Edit/Write tool call (module
1535
- * header) and both consumers step through EVERY character of a segment or
1536
- * token, so one allocation per scan (not one per character) matters for
1537
- * the same "allocation-light" reason the module header states elsewhere.
1538
- */
1539
- class QuoteScanner {
1540
- state = "";
1541
- escaped = false;
1542
- /** Advance the state machine by exactly one character. */
1543
- step(c) {
1544
- if (this.escaped) {
1545
- this.escaped = false;
1546
- return;
1547
- }
1548
- if (this.state === "'") {
1549
- if (c === "'")
1550
- this.state = "";
1551
- return;
1552
- }
1553
- if (this.state === '"') {
1554
- if (c === "\\")
1555
- this.escaped = true;
1556
- else if (c === '"')
1557
- this.state = "";
1558
- return;
1559
- }
1560
- // state === ""
1561
- if (c === "\\")
1562
- this.escaped = true;
1563
- else if (c === "'" || c === '"')
1564
- this.state = c;
1565
- }
1566
- /**
1567
- * Reset to the initial `""` / not-escaped state — behaviourally
1568
- * IDENTICAL to discarding this instance and constructing a fresh one
1569
- * (task cf3dff51, latency fix round: lets `findNextBoundaryQuoteAware`
1570
- * reuse ONE module-level instance across every segment of a command
1571
- * instead of allocating a new one per segment — see that function's own
1572
- * comment for the measured latency this closes).
1573
- */
1574
- reset() {
1575
- this.state = "";
1576
- this.escaped = false;
1577
- }
1578
- /**
1579
- * Clear a pending escape without consuming a character. Used only by
1580
- * `consumeAssignment`, at a whitespace-tokenised TOKEN boundary: a
1581
- * backslash pending at a token's end would otherwise escape the
1582
- * separating whitespace itself (task b093911d's class, deliberately
1583
- * NOT continued — see that function's own comment). `state` is never
1584
- * touched by this — an open quote genuinely stays open across
1585
- * whitespace, only a dangling escape does not.
1586
- */
1587
- resetEscape() {
1588
- this.escaped = false;
1589
- }
1590
- }
1591
- /**
1592
- * Module-level `QuoteScanner` instance reused by `findNextBoundaryQuoteAware`
1593
- * across every segment of every command (task cf3dff51, latency fix
1594
- * round) — `.reset()` at the top of each call makes reusing this single
1595
- * instance behaviourally IDENTICAL to `new QuoteScanner()` per call (see
1596
- * `reset()`'s own doc comment), just without the allocation. Placed here,
1597
- * textually AFTER `class QuoteScanner`, because a top-level `const`
1598
- * initialiser referencing a class runs in real module-evaluation order —
1599
- * `findNextBoundaryQuoteAware` itself sits ABOVE this point in the file
1600
- * but is a hoisted function declaration whose BODY only runs when called,
1601
- * by which time this line has already executed.
1602
- */
1603
- const BOUNDARY_QUOTE_SCANNER = new QuoteScanner();
1604
- /**
1605
- * Consume one leading `VAR=value` assignment starting at `idx`, returning
1606
- * the index of the first token AFTER it. Task 13e55484: the VALUE may be
1607
- * quoted and span multiple whitespace-split tokens (`VAR='hello world'`),
1608
- * which used to leave a dangling `world'` token that aborted the peel
1609
- * loop — the one measured spelling where BOTH matching layers failed on
1610
- * the same character. The continuation engages ONLY while an opening
1611
- * quote from the assignment stays unbalanced at a token's end and a
1612
- * matching close exists in a later token; every other shape keeps the
1613
- * exact pre-task one-token consume:
1614
- * - an UNTERMINATED quote (`VAR='a git push`) falls back to consuming
1615
- * one token, because that spelling normalised to `git push` before
1616
- * this task and must keep doing so (never-unmatch);
1617
- * - backslash-escaped whitespace without quotes (`VAR=a\ b`) is task
1618
- * b093911d's escape-handling class and is deliberately NOT continued
1619
- * here (pinned as a still-open bypass in the test file).
1620
- * Quote semantics per POSIX: outside quotes `\` escapes the next char,
1621
- * single quotes close only at `'`, double quotes close at an unescaped
1622
- * `"`. A quote run may chain (`VAR='a b'"c d"`); the assignment ends at
1623
- * the first token that finishes outside any quote.
1624
- *
1625
- * ONE-DIRECTIONAL GUARD (review round 1, CRITICAL): the continuation
1626
- * ABANDONS (returns the pre-task one-token consume) the moment the token
1627
- * it would consume next is a recognised head token (`GIT_TOKEN_RE` or
1628
- * `NON_GIT_HEAD_TOKENS`). Without this, a quote model diverging from
1629
- * bash on even ONE spelling lets the continuation swallow the gated
1630
- * command word and turn a previously-BLOCKED command into a bypass —
1631
- * measured live with ANSI-C quoting, where bash escapes `\'` inside
1632
- * `$'...'` but this scanner (correctly for plain `'...'`) treats the
1633
- * backslash as literal: `A=$'don\'t' env harness pause # '` produced a
1634
- * phantom-open state that consumed the whole segment, and NEITHER layer
1635
- * matched where master's one-token consume blocked. The guard closes
1636
- * the HEAD-SWALLOW channel specifically: a diverging quote state can
1637
- * never re-target or swallow a recognised head token (measured — see
1638
- * the differential pins in the test file). It is NOT a universal
1639
- * monotonicity guarantee (round-2 review, measured): when the
1640
- * continuation closes on a WRAPPER'S glued flag token, the peel resumes
1641
- * at that wrapper's next ARGUMENT (a positional like timeout's
1642
- * duration, or a second flag), which is neither head nor wrapper, so
1643
- * the segment comes back unchanged where the pre-continuation peel
1644
- * completed — e.g. `A='x timeout --signal=INT' 5 git push` matched on
1645
- * master and does not here. Every found member of that class (550 of
1646
- * 41,440 adversarial cases) is a MASTER FALSE POSITIVE, PATH-shim-
1647
- * verified: bash treats the quoted run as one assignment word and then
1648
- * executes the next word (`5`, `-e0`), never the gated verb — 110 are
1649
- * outright `bash -n` syntax errors — so no gated invocation is lost and
1650
- * this side is the more bash-accurate one; the class is pinned in the
1651
- * test file so it cannot silently widen. The honest cost of the guard,
1652
- * measured and named rather than implied: a quoted VALUE containing the
1653
- * literal word `git`/`gh`/`npm`/`harness` as its own token
1654
- * (`VAR='a git b' git push`) is not continued — the pre-task consume
1655
- * applies, byte-identical to master's behaviour, so nothing regresses.
1656
- * The guard checks EXACT head spellings only: a path-qualified head
1657
- * glued to the closing quote (`VAR='a /usr/bin/git' git push` — the
1658
- * token is `/usr/bin/git'`, which GIT_TOKEN_RE rejects) is NOT
1659
- * abandoned, the continuation completes, and the REAL invocation behind
1660
- * it now matches — a measured fail-closed gain over master, also
1661
- * pinned.
1662
- */
1663
- function consumeAssignment(tokens, idx) {
1664
- const quote = new QuoteScanner();
1665
- for (let i = idx; i < tokens.length; i++) {
1666
- const text = tokens[i].text;
1667
- if (i > idx && (GIT_TOKEN_RE.test(text) || NON_GIT_HEAD_TOKENS.has(text))) {
1668
- return idx + 1;
1669
- }
1670
- for (let k = 0; k < text.length; k++) {
1671
- quote.step(text[k]);
1672
- }
1673
- // A backslash pending at a token's end would escape the separating
1674
- // whitespace itself — b093911d's class, not continued here.
1675
- quote.resetEscape();
1676
- if (quote.state === "")
1677
- return i + 1;
1678
- }
1679
- // Ran out of tokens with a quote still open: unterminated. Preserve the
1680
- // pre-task behaviour exactly (consume only the assignment's first token).
1681
- return idx + 1;
1682
- }
1683
- /** Split a segment into whitespace-delimited tokens with their offsets. */
1684
- function tokenizeWithOffsets(s) {
1685
- const out = [];
1686
- const re = /\S+/g;
1687
- let m;
1688
- while ((m = re.exec(s)) !== null) {
1689
- out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
1690
- }
1691
- return out;
1692
- }
1693
- /**
1694
- * Peel a leading wrapper-command chain: `VAR=value` tokens (any number,
1695
- * in sequence), `env` (+ its own flags/assignments, capturing `-C`/
1696
- * `--chdir`), `command` (+ its own flags), `nice` (+ its own flags),
1697
- * `sudo` / `doas` / `time` / `timeout` / `stdbuf` / `setsid` / `exec` /
1698
- * `nohup` (+ each one's own flags — F4 fix; `exec`/`nohup` added task
1699
- * d03af8f6, review round 3). Bounded by `tokens.length` so a
1700
- * pathological input cannot spin. Returns the cursor position of
1701
- * whatever token (if any) survives the peel — the caller decides what to
1702
- * do with it; this function never itself inspects the survivor.
1703
- *
1704
- * SHARED (task d03af8f6, review round 3) between `canonicalizeSegment`
1705
- * below — this module's own `git`/`gh`/`npm`/`harness` trigger
1706
- * recognition — and `src/runtime/deletion-target-resolve.ts`'s deletion-
1707
- * verb recognition (`rm`/`find`/`git clean`), which used to hand-roll an
1708
- * independent, narrower copy of this exact peeling vocabulary
1709
- * (`peelWrapperHeads`, missing `-u`/`-i`/`-C` on `env`, the duration-
1710
- * consuming shape of `timeout`, `nice`'s glued/`--adjustment=` forms,
1711
- * and `exec`/`nohup` entirely). Reusing this ONE loop instead of
1712
- * maintaining two means a future wrapper added here (or a flag fix to an
1713
- * existing one) benefits both gates automatically, and
1714
- * `npm run check:duplication`'s clone-count fitness function has one
1715
- * fewer near-duplicate block to flag. `deletion-target-resolve.ts` hands
1716
- * this a decoded token array built from ITS OWN quote-aware tokenizer
1717
- * (`WrapperPeelToken[]`, `.text` only — see that interface's own
1718
- * comment for why the offset-carrying `Token[]` this module's own
1719
- * `tokenizeWithOffsets` produces is not required here), then layers its
1720
- * own `xargs` handling on top (this loop deliberately does NOT recognise
1721
- * `xargs` — see the module header's NOT-SUPPORTED list for why `xargs`
1722
- * is architecturally different from every wrapper peeled here: its own
1723
- * argv is not simply "the command to run").
1724
- */
1725
- export function peelWrapperPrefixes(tokens, startIdx = 0) {
1726
- let idx = startIdx;
1727
- let envTargetDir = null;
1728
- for (let guard = 0; guard < tokens.length; guard++) {
1729
- const head = tokens[idx]?.text;
1730
- if (head === undefined)
1731
- break;
1732
- if (VAR_ASSIGN_RE.test(head)) {
1733
- idx = consumeAssignment(tokens, idx);
1734
- continue;
1735
- }
1736
- if (head === "env") {
1737
- const r = peelEnv(tokens, idx + 1);
1738
- idx = r.idx;
1739
- if (envTargetDir === null)
1740
- envTargetDir = r.targetDir;
1741
- continue;
1742
- }
1743
- if (head === "command") {
1744
- idx = peelCommand(tokens, idx + 1);
1745
- continue;
1746
- }
1747
- if (head === "nice") {
1748
- idx = peelNice(tokens, idx + 1);
1749
- continue;
1750
- }
1751
- if (head === "sudo") {
1752
- idx = peelSudo(tokens, idx + 1);
1753
- continue;
1754
- }
1755
- if (head === "doas") {
1756
- idx = peelDoas(tokens, idx + 1);
1757
- continue;
1758
- }
1759
- if (head === "time") {
1760
- idx = peelTime(tokens, idx + 1);
1761
- continue;
1762
- }
1763
- if (head === "timeout") {
1764
- idx = peelTimeout(tokens, idx + 1);
1765
- continue;
1766
- }
1767
- if (head === "stdbuf") {
1768
- idx = peelStdbuf(tokens, idx + 1);
1769
- continue;
1770
- }
1771
- if (head === "setsid") {
1772
- idx = peelSetsid(tokens, idx + 1);
1773
- continue;
1774
- }
1775
- if (head === "exec") {
1776
- idx = peelExec(tokens, idx + 1);
1777
- continue;
1778
- }
1779
- if (head === "nohup") {
1780
- idx = peelNohup(tokens, idx + 1);
1781
- continue;
1782
- }
1783
- break;
1784
- }
1785
- return { idx, envTargetDir };
1786
- }
1787
- /**
1788
- * Rewrite one boundary-delimited segment: peel wrapper prefixes to look
1789
- * for an invocation of one of the closed head-token set's four members
1790
- * (`git`, `gh`, `npm`, `harness` — D-001, run
1791
- * 2026-07-28-nongit-trigger-wrappers), and — ONLY if one is found —
1792
- * canonicalise it. `git` is canonicalised to `git <subcommand>` with its
1793
- * OWN global options dropped, same as before this run. The other three
1794
- * are canonicalised by wrapper peeling plus whitespace collapsing across
1795
- * the REST OF THE SEGMENT (fix round 2, finding F2 — rejoining every
1796
- * token from the head onward with a single space, not just the head-to-
1797
- * next-token gap), no option-dropping (see the module header). When no
1798
- * recognised head token is found (wrong binary, ran out of tokens, or a
1799
- * malformed git global option), the segment is returned COMPLETELY
1800
- * UNCHANGED: peeling is tentative, and nothing is stripped from a
1801
- * segment that turns out not to be a recognised call (so `digit=1 foo`,
1802
- * `env -C X ls`, `ghx pr merge`, etc. are never touched).
1803
- */
1804
- function canonicalizeSegment(segmentText) {
1805
- const tokens = tokenizeWithOffsets(segmentText);
1806
- if (tokens.length === 0) {
1807
- return {
1808
- text: segmentText,
1809
- targetDir: null,
1810
- targetBase: null,
1811
- identityTargetDir: null,
1812
- identityTargetBase: null,
1813
- isGit: false,
1814
- };
1815
- }
1816
- const peeled = peelWrapperPrefixes(tokens);
1817
- let idx = peeled.idx;
1818
- const envTargetDir = peeled.envTargetDir;
1819
- const headTok = tokens[idx]?.text;
1820
- if (headTok === undefined) {
1821
- return {
1822
- text: segmentText,
1823
- targetDir: null,
1824
- targetBase: null,
1825
- identityTargetDir: null,
1826
- identityTargetBase: null,
1827
- isGit: false,
1828
- };
1829
- }
1830
- if (GIT_TOKEN_RE.test(headTok)) {
1831
- idx += 1; // consume the git token (whatever spelling matched GIT_TOKEN_RE)
1832
- const gitOpts = peelGitGlobalOptions(tokens, idx);
1833
- if (gitOpts.malformed || gitOpts.idx >= tokens.length) {
1834
- // Malformed global option (missing required value) or no subcommand
1835
- // token left: nothing safe to canonicalise. Leave untouched.
1836
- return {
1837
- text: segmentText,
1838
- targetDir: null,
1839
- targetBase: null,
1840
- identityTargetDir: null,
1841
- identityTargetBase: null,
1842
- isGit: false,
1843
- };
1844
- }
1845
- const subcommandTok = tokens[gitOpts.idx];
1846
- const rewritten = `git ${subcommandTok.text}${segmentText.slice(subcommandTok.end)}`;
1847
- const targetDir = gitOpts.targetDir ?? envTargetDir;
1848
- // F5: the git invocation's OWN target wins (already the priority
1849
- // order above), but when it's RELATIVE and this same segment is
1850
- // wrapped by `env -C`, that env target is the base it's relative to —
1851
- // only meaningful when git's own target is the one actually used.
1852
- const targetBase = gitOpts.targetDir !== null &&
1853
- envTargetDir !== null &&
1854
- !path.isAbsolute(gitOpts.targetDir)
1855
- ? envTargetDir
1856
- : null;
1857
- // D-017: the identity pair mirrors targetDir/targetBase's composition
1858
- // exactly, but is fed by `relocateTargetDir` (repo-relocating flags
1859
- // only — `-C`/`--git-dir`, never `--work-tree`) instead of
1860
- // `gitOpts.targetDir` (which still includes `--work-tree`).
1861
- //
1862
- // D-018 (fix round 3): a same-invocation wrapping `env -C`/`--chdir`
1863
- // PLUS the git invocation's OWN absolute repo-relocating flag are two
1864
- // repo-relocating options on one invocation, the same "more than one"
1865
- // class `noteRelocatingOption` (in `peelGitGlobalOptions`) closes for
1866
- // git's own flags — extended here across the `env -C` wrap. When they
1867
- // diverge (different resolved directories), git's own flag DOES
1868
- // deterministically win in reality, but this module deliberately does
1869
- // not encode that composition rule (`computeSegmentTarget`'s own
1870
- // comment on why path composition is out of scope) — `null` (cwd
1871
- // fallback, D-003) rather than keep preferring git's own flag as
1872
- // before this fix. An identical pair (`env -C X git -C X ...`) is the
1873
- // same idempotent-repeat allowance as `noteRelocatingOption`'s single-
1874
- // function case, so it stays `X`, not `null`. The pre-existing
1875
- // RELATIVE-own-target + env-base mix is untouched by this check —
1876
- // `identityTargetBase` below already resolves that class fully to
1877
- // `null` via `computeSegmentTarget`'s early return; this new check
1878
- // only has any effect when `gitOpts.relocateTargetDir` is ABSOLUTE
1879
- // (`path.isAbsolute` guards it out for the relative case, the same
1880
- // condition `identityTargetBase` below already uses).
1881
- const relocateEnvDiverges = gitOpts.relocateTargetDir !== null &&
1882
- envTargetDir !== null &&
1883
- path.isAbsolute(gitOpts.relocateTargetDir) &&
1884
- gitOpts.relocateTargetDir !== envTargetDir;
1885
- const identityTargetDir = relocateEnvDiverges
1886
- ? null
1887
- : (gitOpts.relocateTargetDir ?? envTargetDir);
1888
- const identityTargetBase = gitOpts.relocateTargetDir !== null &&
1889
- envTargetDir !== null &&
1890
- !path.isAbsolute(gitOpts.relocateTargetDir)
1891
- ? envTargetDir
1892
- : null;
1893
- return {
1894
- text: rewritten,
1895
- targetDir,
1896
- targetBase,
1897
- identityTargetDir,
1898
- identityTargetBase,
1899
- isGit: true,
1900
- };
1901
- }
1902
- if (NON_GIT_HEAD_TOKENS.has(headTok)) {
1903
- // D-001 + fix round 2 (finding F2): `gh` / `npm` / `harness` get
1904
- // wrapper peeling (already applied above, same loop as `git`'s) plus
1905
- // whitespace collapsing ACROSS THE REST OF THE SEGMENT — rejoining
1906
- // every token from the head token onward with exactly one space
1907
- // between each. The original scope (collapse only between the head
1908
- // token and the SINGLE token immediately following it) left an
1909
- // INTERIOR whitespace run further into a multi-word trigger able to
1910
- // defeat the match: `gh pr merge` (double space between `pr` and
1911
- // `merge`, not `gh` and `pr`), `gh pr<TAB>merge`, `gh pr create`.
1912
- // Full-segment rejoin closes any such run anywhere after the head,
1913
- // not just immediately after it. Safe because this is additive-only
1914
- // matching (raw-OR-normalised, module header) and byte-identity is an
1915
- // acceptance criterion only for heads NOT in this set (see the
1916
- // byte-identity negative tests below) — a RECOGNISED head's own tail
1917
- // may be freely rewritten. Still no option-dropping (`gh -R`, `npm
1918
- // --loglevel`, etc. stay unsupported — module header): a tool-specific
1919
- // flag between the head and its subcommand still isn't recognised or
1920
- // skipped, it just no longer needs an interior whitespace run to
1921
- // survive the rejoin. `targetDir`/`targetBase` stay `null`: these are
1922
- // never git invocations, so no per-invocation target is extracted for
1923
- // them (unaffected by the STATUS note above — that extraction was
1924
- // never wired to a gate either way).
1925
- if (idx + 1 >= tokens.length) {
1926
- // Head token alone, no further token to canonicalise toward — no
1927
- // shipped trigger matches a bare head token anyway, and rewriting to
1928
- // just the head would silently drop whatever wrapper preceded it for
1929
- // no benefit. Leave untouched, same fail-safe shape as the git
1930
- // malformed/no-subcommand case above.
1931
- return {
1932
- text: segmentText,
1933
- targetDir: null,
1934
- targetBase: null,
1935
- identityTargetDir: null,
1936
- identityTargetBase: null,
1937
- isGit: false,
1938
- };
1939
- }
1940
- const rewritten = tokens
1941
- .slice(idx)
1942
- .map((t) => t.text)
1943
- .join(" ");
1944
- return {
1945
- text: rewritten,
1946
- targetDir: null,
1947
- targetBase: null,
1948
- identityTargetDir: null,
1949
- identityTargetBase: null,
1950
- isGit: false,
1951
- };
1952
- }
1953
- return {
1954
- text: segmentText,
1955
- targetDir: null,
1956
- targetBase: null,
1957
- identityTargetDir: null,
1958
- identityTargetBase: null,
1959
- isGit: false,
1960
- };
1961
- }
1962
- /**
1963
- * Peel `env`'s own leading flags and `VAR=value` assignments. The flag-
1964
- * NAME sets are shared with `read-only-bash.ts` (F6 fix — see the module
1965
- * header); the VALUE extraction for `-C`/`--chdir` (needed for
1966
- * `targetDir`, not needed by that module) stays local. Returns the
1967
- * cursor after the last recognised token and the `-C`/`--chdir` target
1968
- * directory, if any (excluding a `~`-prefixed value — F5, treated as if
1969
- * absent).
1970
- */
1971
- function peelEnv(tokens, startIdx) {
1972
- let idx = startIdx;
1973
- let targetDir = null;
1974
- while (idx < tokens.length) {
1975
- const t = tokens[idx].text;
1976
- // `-S`/`--split-string` re-parses a single string argument into a
1977
- // fresh argv — we cannot safely see through it. Stop peeling here;
1978
- // whatever follows is opaque, and since it is not the literal `git`
1979
- // token the caller's git-check below fails harmlessly.
1980
- if (ENV_SPLIT_STRING_FLAGS.test(t))
1981
- break;
1982
- if (t === "--") {
1983
- idx += 1;
1984
- break;
1985
- }
1986
- if (t === "-C" || t === "--chdir") {
1987
- const dir = tokens[idx + 1]?.text;
1988
- if (dir === undefined)
1989
- break;
1990
- if (targetDir === null && !isTildeTarget(dir))
1991
- targetDir = dir;
1992
- idx += 2;
1993
- continue;
1994
- }
1995
- if (ENV_VALUE_FLAGS.has(t)) {
1996
- // Reached only for `-u` / `--unset` — `-C` / `--chdir` are handled
1997
- // above (with value capture) before this generic check runs.
1998
- idx += 2;
1999
- continue;
2000
- }
2001
- if (ENV_LEADING_FLAGS.has(t)) {
2002
- idx += 1;
2003
- continue;
2004
- }
2005
- if (t.startsWith("-C") && t.length > 2) {
2006
- if (targetDir === null && !isTildeTarget(t.slice(2)))
2007
- targetDir = t.slice(2);
2008
- idx += 1;
2009
- continue;
2010
- }
2011
- if (t.startsWith("--chdir=")) {
2012
- const dir = t.slice("--chdir=".length);
2013
- if (targetDir === null && !isTildeTarget(dir))
2014
- targetDir = dir;
2015
- idx += 1;
2016
- continue;
2017
- }
2018
- if (t.startsWith("--unset=") || /^-u./.test(t)) {
2019
- idx += 1;
2020
- continue;
2021
- }
2022
- // Generic glued long flag with a value (`--default-signal=INT`, or
2023
- // any OTHER `env` long option this module does not need the VALUE
2024
- // of): single token, skip it (G6 fix, review round 2, 2026-07-27 —
2025
- // mirrors `read-only-bash.ts`'s own generic catch-all, so the two
2026
- // peelers' coverage cannot drift again the way `--default-signal=INT`
2027
- // once did; see the module header). Placed AFTER the named `-C` /
2028
- // `--chdir` / `--unset` forms above so it only ever catches a flag
2029
- // this module has no more specific handling for.
2030
- if (t.startsWith("--") && t.includes("=")) {
2031
- idx += 1;
2032
- continue;
2033
- }
2034
- if (VAR_ASSIGN_RE.test(t)) {
2035
- idx = consumeAssignment(tokens, idx);
2036
- continue;
2037
- }
2038
- break;
2039
- }
2040
- return { idx, targetDir };
2041
- }
2042
- /** Peel `command`'s own flags (e.g. `-p`, `-v`, `-V`, `--`). */
2043
- function peelCommand(tokens, startIdx) {
2044
- let idx = startIdx;
2045
- while (idx < tokens.length) {
2046
- const t = tokens[idx].text;
2047
- if (t === "--") {
2048
- idx += 1;
2049
- break;
2050
- }
2051
- if (t.startsWith("-") && t !== "-") {
2052
- idx += 1;
2053
- continue;
2054
- }
2055
- break;
2056
- }
2057
- return idx;
2058
- }
2059
- /**
2060
- * Peel `nice`'s own flags: `-n <n>`, `-n<n>`, `--adjustment=<n>`, and the
2061
- * bare `-<n>` / `+<n>` forms (G3 fix, review round 2, 2026-07-27):
2062
- * `nice(1)` documents `nice -10 cmd` (increment glued directly to the
2063
- * leading dash, no `n`) as its PRIMARY spelling, and some BSD variants
2064
- * also accept a leading `+` for a positive increment — `nice -10 git
2065
- * status` bypassed even though `nice` was already in the SUPPORTED list,
2066
- * because only the `-n`-prefixed spellings were recognised.
2067
- */
2068
- function peelNice(tokens, startIdx) {
2069
- let idx = startIdx;
2070
- while (idx < tokens.length) {
2071
- const t = tokens[idx].text;
2072
- if (t === "-n" || t === "--adjustment") {
2073
- if (tokens[idx + 1] === undefined)
2074
- break;
2075
- idx += 2;
2076
- continue;
2077
- }
2078
- if (/^-n-?\d+$/.test(t)) {
2079
- idx += 1;
2080
- continue;
2081
- }
2082
- if (t.startsWith("--adjustment=")) {
2083
- idx += 1;
2084
- continue;
2085
- }
2086
- if (/^[-+]\d+$/.test(t)) {
2087
- idx += 1;
2088
- continue;
2089
- }
2090
- break;
2091
- }
2092
- return idx;
2093
- }
2094
- /**
2095
- * Generic "peel this wrapper's own flags" loop shared by the small,
2096
- * bounded-vocabulary runners added for F4 (`sudo`, `doas`, `time`,
2097
- * `stdbuf`, `setsid`, `exec`, `nohup`): consume `--`, any flag in
2098
- * `valueFlags` (+ its following token), any glued short/long form
2099
- * matching `gluedRe`, then any OTHER `-`-prefixed token treated as
2100
- * boolean (mirrors `peelCommand` above — safe because an unrecognised
2101
- * flag is either genuinely boolean, in which case this is correct, or
2102
- * value-taking, in which case its VALUE token is left for the next
2103
- * iteration and — not being `git`, a known wrapper, or another flag —
2104
- * simply causes a clean, safe bail-out a token late rather than a false
2105
- * match). Does NOT capture flag VALUES: none of these wrappers have a
2106
- * "run in a different directory" semantic this module tracks as a
2107
- * target directory — only `env -C`/`--chdir` does, and that stays
2108
- * hand-written in `peelEnv`. Exported (task d03af8f6, review round 3) so
2109
- * `src/runtime/deletion-target-resolve.ts` can reuse this SAME loop for
2110
- * `xargs`'s own flags (`-0`, `-n1`, ...) instead of a third
2111
- * independently-drifting copy — that module is the reason this needs no
2112
- * value-flag vocabulary of its own either: `xargs`'s own value-taking
2113
- * flags are not modelled there, matching this function's existing
2114
- * effort level for the boolean-only wrappers above.
2115
- */
2116
- export function peelGenericFlags(tokens, startIdx, valueFlags, gluedRe) {
2117
- let idx = startIdx;
2118
- while (idx < tokens.length) {
2119
- const t = tokens[idx].text;
2120
- if (t === "--") {
2121
- idx += 1;
2122
- break;
2123
- }
2124
- if (valueFlags.has(t)) {
2125
- if (tokens[idx + 1] === undefined)
2126
- break;
2127
- idx += 2;
2128
- continue;
2129
- }
2130
- if (t.startsWith("--") && t.includes("=")) {
2131
- idx += 1;
2132
- continue;
2133
- }
2134
- if (gluedRe !== null && gluedRe.test(t)) {
2135
- idx += 1;
2136
- continue;
2137
- }
2138
- if (t.startsWith("-") && t !== "-") {
2139
- idx += 1;
2140
- continue;
2141
- }
2142
- break;
2143
- }
2144
- return idx;
2145
- }
2146
- /**
2147
- * `sudo`'s own value-taking flags (user/group/prompt/chroot/role/type/
2148
- * timeout/close-from). Every other `-`-prefixed `sudo` flag (`-E`, `-H`,
2149
- * `-i`, `-k`, `-n`, `-S`, `-v`, ...) is boolean and falls through
2150
- * `peelGenericFlags`'s generic "-flag consumes one token" branch.
2151
- */
2152
- const SUDO_VALUE_FLAGS = new Set([
2153
- "-g", "-h", "-p", "-R", "-r", "-t", "-T", "-u", "-C",
2154
- "--group", "--host", "--prompt", "--chroot", "--role",
2155
- "--type", "--command-timeout", "--user", "--close-from",
2156
- ]);
2157
- /** `sudo`'s glued short-value forms: `-uroot`, `-Cbastion`, etc. */
2158
- const SUDO_GLUED_RE = /^-[ghpRrtTuC]./;
2159
- function peelSudo(tokens, startIdx) {
2160
- return peelGenericFlags(tokens, startIdx, SUDO_VALUE_FLAGS, SUDO_GLUED_RE);
2161
- }
2162
- /** `doas`'s own value-taking flags: `-u user`, `-C configfile`. */
2163
- const DOAS_VALUE_FLAGS = new Set(["-u", "-C"]);
2164
- const DOAS_GLUED_RE = /^-[uC]./;
2165
- function peelDoas(tokens, startIdx) {
2166
- return peelGenericFlags(tokens, startIdx, DOAS_VALUE_FLAGS, DOAS_GLUED_RE);
2167
- }
2168
- /** `time`'s own value-taking flags: `-o`/`--output`, `-f`/`--format`. */
2169
- const TIME_VALUE_FLAGS = new Set(["-o", "-f", "--output", "--format"]);
2170
- const TIME_GLUED_RE = /^-[of]./;
2171
- function peelTime(tokens, startIdx) {
2172
- return peelGenericFlags(tokens, startIdx, TIME_VALUE_FLAGS, TIME_GLUED_RE);
2173
- }
2174
- /** `timeout`'s own value-taking flags: `-k`/`--kill-after`, `-s`/`--signal`. */
2175
- const TIMEOUT_VALUE_FLAGS = new Set([
2176
- "-k", "-s", "--kill-after", "--signal",
2177
- ]);
2178
- const TIMEOUT_GLUED_RE = /^-[ks]./;
2179
- /**
2180
- * `timeout DURATION command...` requires a DURATION positional BEFORE
2181
- * the wrapped command — unlike the other four runners, so it cannot
2182
- * share `peelGenericFlags` unmodified. Peel `timeout`'s own flags first,
2183
- * then consume the mandatory duration token. A missing duration
2184
- * (malformed/truncated command) leaves `idx` where the flag loop left
2185
- * it; the caller's "is this token `git`?" check bails safely on
2186
- * whatever token is actually there.
2187
- */
2188
- function peelTimeout(tokens, startIdx) {
2189
- let idx = peelGenericFlags(tokens, startIdx, TIMEOUT_VALUE_FLAGS, TIMEOUT_GLUED_RE);
2190
- if (tokens[idx] !== undefined)
2191
- idx += 1;
2192
- return idx;
2193
- }
2194
- /** `stdbuf`'s flags are ALWAYS value-taking: `-i`/`-o`/`-e` (buffering mode). */
2195
- const STDBUF_VALUE_FLAGS = new Set([
2196
- "-i", "-o", "-e", "--input", "--output", "--error",
2197
- ]);
2198
- const STDBUF_GLUED_RE = /^-[ioe]./;
2199
- function peelStdbuf(tokens, startIdx) {
2200
- return peelGenericFlags(tokens, startIdx, STDBUF_VALUE_FLAGS, STDBUF_GLUED_RE);
2201
- }
2202
- /** `setsid`'s flags (`-w`/`--wait`, `-c`/`--ctty`, `-f`/`--fork`) are all boolean. */
2203
- function peelSetsid(tokens, startIdx) {
2204
- return peelGenericFlags(tokens, startIdx, new Set(), null);
2205
- }
2206
- /**
2207
- * `exec`'s own flags (bash builtin: `-a name`, `-c`, `-l`) are rare
2208
- * enough that this module treats it as a boolean-only wrapper like
2209
- * `setsid` above — an unrecognised flag (including `-a`, whose VALUE
2210
- * this does not capture) simply consumes one token via
2211
- * `peelGenericFlags`'s generic `-flag` branch (task d03af8f6, review
2212
- * round 3 — closes the `exec git status` / `exec rm -rf ...` bypass
2213
- * named in the module header's NOT-SUPPORTED list).
2214
- */
2215
- function peelExec(tokens, startIdx) {
2216
- return peelGenericFlags(tokens, startIdx, new Set(), null);
2217
- }
2218
- /** `nohup` takes no flags of its own (GNU coreutils: only `--help`/`--version`,
2219
- * neither of which precedes a real wrapped command). Boolean-only, same
2220
- * shape as `peelSetsid`/`peelExec` (task d03af8f6, review round 3 — closes
2221
- * the `nohup git status` / `nohup rm -rf ... &` bypass). */
2222
- function peelNohup(tokens, startIdx) {
2223
- return peelGenericFlags(tokens, startIdx, new Set(), null);
2224
- }
2225
- /** Strip a path that ends in a `.git` directory down to its parent. */
2226
- function parentIfDotGit(dir) {
2227
- const trimmed = dir.replace(/\/+$/, "");
2228
- if (trimmed === ".git")
2229
- return ".";
2230
- if (trimmed.endsWith("/.git"))
2231
- return trimmed.slice(0, -"/.git".length) || "/";
2232
- return dir;
2233
- }
2234
- /**
2235
- * Peel git's own global options (dropping them so the subcommand becomes
2236
- * adjacent to `git`), tracking the first `-C` / `--work-tree` /
2237
- * `--git-dir` target directory encountered (excluding a `~`-prefixed
2238
- * value — F5, treated as if absent, but still consumed as an option so
2239
- * parsing continues past it) as `targetDir` — the OLD, UNCONSUMED
2240
- * aggregate's own extraction (module header STATUS note), UNCHANGED by
2241
- * the fix below. `malformed: true` means a value-requiring option was
2242
- * missing its value — the caller bails without rewriting rather than
2243
- * guess.
2244
- *
2245
- * `relocateTargetDir` (D-017, fix round 2, run `2026-08-02-per-repo-
2246
- * gate-scoping-redesign`): a SEPARATE tracker, fed ONLY by the
2247
- * repo-relocating flags `-C` and `--git-dir` — NEVER `--work-tree`.
2248
- * `--work-tree` sets the working tree but does NOT relocate git's
2249
- * `--git-dir` search; `git push`/`log`/`status` under `--work-tree=<B>`
2250
- * alone still operate on whatever repo the CURRENT directory (or an
2251
- * actual `-C`/`--git-dir`) names, not `<B>`. Pass-2 review measured this
2252
- * as a live cross-repo fail-open once `--work-tree` was folded into the
2253
- * same `targetDir` bucket as `-C`/`--git-dir` and that bucket fed
2254
- * `CommandSegment.ownTarget`'s REPLACE attribution (`computeSegmentTarget`
2255
- * below, `src/runtime/intercept.ts`'s `resolveAttributedContexts`,
2256
- * D-011): `git --work-tree=<B> push`, cwd = A, was attributed to B alone,
2257
- * dropping the cwd demand a real `git push` there still has to satisfy.
2258
- * `relocateTargetDir` is what `computeSegmentTarget` now consults for a
2259
- * git segment's `ownTarget` — see that function and `canonicalizeSegment`
2260
- * below. Kept as a distinct field (a whitelist of repo-relocating flags),
2261
- * not a `--work-tree` special-case, so a future path-valued-but-repo-
2262
- * neutral git global option cannot silently reopen this class by being
2263
- * folded into `targetDir` the way `--work-tree` originally was.
2264
- *
2265
- * D-018 (fix round 3, pass-3 CRITICAL): `relocateTargetDir` is set ONLY
2266
- * when the invocation carries EXACTLY ONE recognised, fully-resolved
2267
- * repo-relocating option (a single `-C` or a single `--git-dir`/
2268
- * `--git-dir=`). Pass-3 review measured (against real git 2.34.1) that
2269
- * `git -C <forge> -C <realA> push` attributes to the FIRST `-C` here
2270
- * while git composes ALL of them cumulatively and actually operates in
2271
- * `<realA>` — the same first-token-wins gap spans `-C` combined with
2272
- * `--git-dir`. `noteRelocatingOption` below counts every RESOLVED
2273
- * (non-`~`) occurrence of either flag across the whole invocation; the
2274
- * second (or later) occurrence forces `relocateTargetDir` to `null` and
2275
- * LOCKS it there for the rest of this call — UNLESS its value is an
2276
- * exact repeat of the one already recorded (idempotent, e.g. `-C X -C
2277
- * X`), which is not new information and does not trigger the lock. This
2278
- * is a deliberate under-approximation of git's real `-C`-chains-relative-
2279
- * to-the-previous-`-C` composition rule (the module has never encoded
2280
- * that, by design — see `computeSegmentTarget`'s own comment on why
2281
- * composing paths is not this module's job): ANY invocation this module
2282
- * cannot resolve to a single, unambiguous flag falls back to `null` here,
2283
- * which downstream (`computeSegmentTarget`) means "no own target" and the
2284
- * cwd context is demanded instead — exactly the shipped, pre-this-run
2285
- * semantics, never a forged first-token guess.
2286
- *
2287
- * D-021 (UNIVERSAL-ADDITIVE, operator decision, run 2026-08-02-per-repo-
2288
- * gate-scoping-redesign): both the D-017 `--work-tree` exclusion and the
2289
- * D-018 multi-option lock above were originally the SAFETY mechanism
2290
- * (the thing standing between an attacker-composed invocation and a
2291
- * forged REPLACE attribution); under D-021, `src/runtime/intercept.ts`'s
2292
- * `resolveAttributedContexts` never replaces the cwd demand at all, so
2293
- * neither exclusion can be bypassed into a weaker-than-shipped gate any
2294
- * more — the cwd demand is never dropped regardless of what
2295
- * `relocateTargetDir` resolves to. Both stay in place as PRECISION
2296
- * (avoiding a spurious extra demand for an ambiguous or non-relocating
2297
- * target), not as the security boundary; see `resolveAttributedContexts`
2298
- * itself for where that boundary now lives.
2299
- */
2300
- function peelGitGlobalOptions(tokens, startIdx) {
2301
- let idx = startIdx;
2302
- let targetDir = null;
2303
- let relocateTargetDir = null;
2304
- let relocateResolvedCount = 0;
2305
- let relocateAmbiguous = false;
2306
- // D-018: record one repo-relocating flag's resolved value (`null` for a
2307
- // `~`-prefixed value, which — same as the pre-fix code — never sets or
2308
- // counts against `relocateTargetDir` at all, D-003's "treated as if
2309
- // absent"). The SECOND (or later) resolved occurrence nulls and LOCKS
2310
- // `relocateTargetDir`, unless it is an exact repeat of the value
2311
- // already recorded (idempotent, e.g. `-C X -C X` or `-C X --git-dir
2312
- // X/.git`), which stays a single value rather than tripping the lock —
2313
- // "im Zweifel null" still applies to every OTHER two-or-more case.
2314
- function noteRelocatingOption(resolved) {
2315
- if (resolved === null)
2316
- return;
2317
- relocateResolvedCount += 1;
2318
- if (relocateAmbiguous)
2319
- return;
2320
- if (relocateResolvedCount === 1) {
2321
- relocateTargetDir = resolved;
2322
- return;
2323
- }
2324
- if (resolved === relocateTargetDir)
2325
- return; // idempotent repeat
2326
- relocateTargetDir = null;
2327
- relocateAmbiguous = true;
2328
- }
2329
- while (idx < tokens.length) {
2330
- const t = tokens[idx].text;
2331
- if (t === "-C") {
2332
- const dir = tokens[idx + 1]?.text;
2333
- if (dir === undefined)
2334
- return { idx, targetDir, relocateTargetDir, malformed: true };
2335
- if (targetDir === null && !isTildeTarget(dir))
2336
- targetDir = dir;
2337
- noteRelocatingOption(isTildeTarget(dir) ? null : dir);
2338
- idx += 2;
2339
- continue;
2340
- }
2341
- if (t === "-c") {
2342
- if (tokens[idx + 1] === undefined)
2343
- return { idx, targetDir, relocateTargetDir, malformed: true };
2344
- idx += 2;
2345
- continue;
2346
- }
2347
- if (t === "--git-dir") {
2348
- const dir = tokens[idx + 1]?.text;
2349
- if (dir === undefined)
2350
- return { idx, targetDir, relocateTargetDir, malformed: true };
2351
- if (targetDir === null && !isTildeTarget(dir))
2352
- targetDir = parentIfDotGit(dir);
2353
- noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
2354
- idx += 2;
2355
- continue;
2356
- }
2357
- if (t.startsWith("--git-dir=")) {
2358
- const dir = t.slice("--git-dir=".length);
2359
- if (targetDir === null && !isTildeTarget(dir))
2360
- targetDir = parentIfDotGit(dir);
2361
- noteRelocatingOption(isTildeTarget(dir) ? null : parentIfDotGit(dir));
2362
- idx += 1;
2363
- continue;
2364
- }
2365
- if (t === "--work-tree") {
2366
- // D-017: `--work-tree` still feeds the OLD `targetDir` aggregate
2367
- // (unchanged, unconsumed by any gate) but deliberately NEVER
2368
- // `relocateTargetDir` — see this function's own doc comment.
2369
- const dir = tokens[idx + 1]?.text;
2370
- if (dir === undefined)
2371
- return { idx, targetDir, relocateTargetDir, malformed: true };
2372
- if (targetDir === null && !isTildeTarget(dir))
2373
- targetDir = dir;
2374
- idx += 2;
2375
- continue;
2376
- }
2377
- if (t.startsWith("--work-tree=")) {
2378
- const dir = t.slice("--work-tree=".length);
2379
- if (targetDir === null && !isTildeTarget(dir))
2380
- targetDir = dir;
2381
- idx += 1;
2382
- continue;
2383
- }
2384
- if (t === "--no-pager" || t === "-p" || t === "--paginate") {
2385
- idx += 1;
2386
- continue;
2387
- }
2388
- if (t === "--exec-path" || t.startsWith("--exec-path=")) {
2389
- idx += 1;
2390
- continue;
2391
- }
2392
- if (t === "--namespace") {
2393
- if (tokens[idx + 1] === undefined)
2394
- return { idx, targetDir, relocateTargetDir, malformed: true };
2395
- idx += 2;
2396
- continue;
2397
- }
2398
- if (t.startsWith("--namespace=")) {
2399
- idx += 1;
2400
- continue;
2401
- }
2402
- if (t === "--literal-pathspecs" || t === "--no-replace-objects") {
2403
- idx += 1;
2404
- continue;
2405
- }
2406
- break;
2407
- }
2408
- return { idx, targetDir, relocateTargetDir, malformed: false };
2409
- }
2410
- //# sourceMappingURL=command-normalize.js.map