@opengsd/gsd-path 1.4.0 → 1.5.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 (347) hide show
  1. package/CHANGELOG.md +325 -344
  2. package/DOCS.md +12 -2
  3. package/FULL.md +3 -2
  4. package/HOOKS.md +95 -8
  5. package/QUICK.md +1 -1
  6. package/README.md +78 -110
  7. package/RUNTIME.md +1 -1
  8. package/UPDATE.md +4 -3
  9. package/WORKFLOW.md +22 -1
  10. package/package.json +5 -2
  11. package/platforms/shared-agents/dispatch.md +13 -0
  12. package/scripts/_common.py +149 -1
  13. package/scripts/archive_milestone.py +14 -13
  14. package/scripts/build_recovery.py +96 -31
  15. package/scripts/build_state.py +79 -3
  16. package/scripts/check_docs_audit.py +77 -4
  17. package/scripts/check_handoffs.py +546 -30
  18. package/scripts/check_task_briefs.py +279 -23
  19. package/scripts/detect_project.py +243 -18
  20. package/scripts/discussion_validate.py +86 -29
  21. package/scripts/dispatch_driver.py +392 -22
  22. package/scripts/git_guard.py +3 -3
  23. package/scripts/guard_hook.py +1416 -165
  24. package/scripts/install.mjs +23 -8
  25. package/scripts/install.py +49 -9
  26. package/scripts/integration.py +146 -1
  27. package/scripts/isolation.py +392 -60
  28. package/scripts/loop_run.py +16 -4
  29. package/scripts/members.py +11 -9
  30. package/scripts/path_config.py +3 -3
  31. package/scripts/pipeline_git.py +63 -12
  32. package/scripts/pipeline_state.py +111 -33
  33. package/scripts/pipeline_undo.py +101 -8
  34. package/scripts/runtime_store.py +20 -2
  35. package/scripts/skill-resources.json +9 -2
  36. package/scripts/state_checkpoint.py +9 -27
  37. package/scripts/status_runtime.py +22 -4
  38. package/scripts/task_context.py +12 -3
  39. package/scripts/wizard.mjs +1 -0
  40. package/scripts/workflow_run.py +5 -2
  41. package/scripts/worktree_paths.py +54 -2
  42. package/skills/gsd-path/DEFINE.md +28 -3
  43. package/skills/gsd-path/PLAN.md +11 -3
  44. package/skills/gsd-path/RESEARCH.md +11 -0
  45. package/skills/gsd-path/SHIP.md +6 -1
  46. package/skills/gsd-path/references/build-native.md +16 -1
  47. package/skills/gsd-path/references/dispatch.md +13 -0
  48. package/skills/gsd-path/references/docs-auditor.md +5 -0
  49. package/skills/gsd-path/references/planner.md +14 -4
  50. package/skills/gsd-path/references/reviewer-final.md +20 -2
  51. package/skills/gsd-path/references/reviewer-wave.md +15 -4
  52. package/skills/gsd-path/references/reviewer.md +3 -1
  53. package/skills/gsd-path/references/spec-probes.md +150 -0
  54. package/skills/gsd-path/scripts/_common.py +149 -1
  55. package/skills/gsd-path/scripts/archive_milestone.py +14 -13
  56. package/skills/gsd-path/scripts/build_recovery.py +96 -31
  57. package/skills/gsd-path/scripts/build_state.py +79 -3
  58. package/skills/gsd-path/scripts/check_docs_audit.py +77 -4
  59. package/skills/gsd-path/scripts/check_handoffs.py +546 -30
  60. package/skills/gsd-path/scripts/check_task_briefs.py +279 -23
  61. package/skills/gsd-path/scripts/detect_project.py +243 -18
  62. package/skills/gsd-path/scripts/discussion_validate.py +86 -29
  63. package/skills/gsd-path/scripts/dispatch_driver.py +392 -22
  64. package/skills/gsd-path/scripts/git_guard.py +3 -3
  65. package/skills/gsd-path/scripts/integration.py +146 -1
  66. package/skills/gsd-path/scripts/isolation.py +392 -60
  67. package/skills/gsd-path/scripts/loop_run.py +16 -4
  68. package/skills/gsd-path/scripts/members.py +11 -9
  69. package/skills/gsd-path/scripts/path_config.py +3 -3
  70. package/skills/gsd-path/scripts/pipeline_git.py +63 -12
  71. package/skills/gsd-path/scripts/pipeline_state.py +111 -33
  72. package/skills/gsd-path/scripts/state_checkpoint.py +9 -27
  73. package/skills/gsd-path/scripts/status_runtime.py +22 -4
  74. package/skills/gsd-path/scripts/task_context.py +12 -3
  75. package/skills/gsd-path/scripts/workflow_run.py +5 -2
  76. package/skills/gsd-path/scripts/worktree_paths.py +54 -2
  77. package/skills/gsd-path/templates/docs-audit.md +5 -1
  78. package/skills/gsd-path/templates/final-review.md +11 -2
  79. package/skills/gsd-path/templates/intent.md +30 -0
  80. package/skills/gsd-path/templates/patch-findings.md +3 -3
  81. package/skills/gsd-path/templates/plan.md +17 -2
  82. package/skills/gsd-path/templates/research-handoff.md +14 -3
  83. package/skills/gsd-path/templates/state.md +1 -1
  84. package/skills/gsd-path/templates/wave-review.md +16 -6
  85. package/skills/gsd-path-build/references/build-native.md +16 -1
  86. package/skills/gsd-path-build/references/dispatch.md +13 -0
  87. package/skills/gsd-path-build/references/reviewer-final.md +20 -2
  88. package/skills/gsd-path-build/references/reviewer-wave.md +15 -4
  89. package/skills/gsd-path-build/references/reviewer.md +3 -1
  90. package/skills/gsd-path-build/references/spec-probes.md +150 -0
  91. package/skills/gsd-path-build/scripts/_common.py +149 -1
  92. package/skills/gsd-path-build/scripts/archive_milestone.py +14 -13
  93. package/skills/gsd-path-build/scripts/build_recovery.py +96 -31
  94. package/skills/gsd-path-build/scripts/build_state.py +79 -3
  95. package/skills/gsd-path-build/scripts/check_handoffs.py +546 -30
  96. package/skills/gsd-path-build/scripts/check_task_briefs.py +279 -23
  97. package/skills/gsd-path-build/scripts/detect_project.py +243 -18
  98. package/skills/gsd-path-build/scripts/discussion_validate.py +86 -29
  99. package/skills/gsd-path-build/scripts/dispatch_driver.py +392 -22
  100. package/skills/gsd-path-build/scripts/integration.py +146 -1
  101. package/skills/gsd-path-build/scripts/isolation.py +392 -60
  102. package/skills/gsd-path-build/scripts/loop_run.py +16 -4
  103. package/skills/gsd-path-build/scripts/members.py +11 -9
  104. package/skills/gsd-path-build/scripts/path_config.py +3 -3
  105. package/skills/gsd-path-build/scripts/pipeline_git.py +63 -12
  106. package/skills/gsd-path-build/scripts/pipeline_state.py +111 -33
  107. package/skills/gsd-path-build/scripts/state_checkpoint.py +9 -27
  108. package/skills/gsd-path-build/scripts/task_context.py +12 -3
  109. package/skills/gsd-path-build/scripts/workflow_run.py +5 -2
  110. package/skills/gsd-path-build/scripts/worktree_paths.py +54 -2
  111. package/skills/gsd-path-build/templates/wave-review.md +16 -6
  112. package/skills/gsd-path-decide/references/dispatch.md +13 -0
  113. package/skills/gsd-path-decide/scripts/_common.py +149 -1
  114. package/skills/gsd-path-decide/scripts/archive_milestone.py +14 -13
  115. package/skills/gsd-path-decide/scripts/build_recovery.py +96 -31
  116. package/skills/gsd-path-decide/scripts/check_handoffs.py +546 -30
  117. package/skills/gsd-path-decide/scripts/check_task_briefs.py +279 -23
  118. package/skills/gsd-path-decide/scripts/detect_project.py +243 -18
  119. package/skills/gsd-path-decide/scripts/discussion_validate.py +86 -29
  120. package/skills/gsd-path-decide/scripts/integration.py +146 -1
  121. package/skills/gsd-path-decide/scripts/isolation.py +392 -60
  122. package/skills/gsd-path-decide/scripts/loop_run.py +16 -4
  123. package/skills/gsd-path-decide/scripts/members.py +11 -9
  124. package/skills/gsd-path-decide/scripts/path_config.py +3 -3
  125. package/skills/gsd-path-decide/scripts/pipeline_git.py +63 -12
  126. package/skills/gsd-path-decide/scripts/pipeline_state.py +111 -33
  127. package/skills/gsd-path-decide/scripts/state_checkpoint.py +9 -27
  128. package/skills/gsd-path-decide/scripts/worktree_paths.py +54 -2
  129. package/skills/gsd-path-define/SKILL.md +28 -3
  130. package/skills/gsd-path-define/references/spec-probes.md +150 -0
  131. package/skills/gsd-path-define/scripts/_common.py +149 -1
  132. package/skills/gsd-path-define/scripts/archive_milestone.py +14 -13
  133. package/skills/gsd-path-define/scripts/build_recovery.py +96 -31
  134. package/skills/gsd-path-define/scripts/check_handoffs.py +546 -30
  135. package/skills/gsd-path-define/scripts/check_task_briefs.py +279 -23
  136. package/skills/gsd-path-define/scripts/detect_project.py +243 -18
  137. package/skills/gsd-path-define/scripts/discussion_validate.py +86 -29
  138. package/skills/gsd-path-define/scripts/integration.py +146 -1
  139. package/skills/gsd-path-define/scripts/isolation.py +392 -60
  140. package/skills/gsd-path-define/scripts/loop_run.py +16 -4
  141. package/skills/gsd-path-define/scripts/members.py +11 -9
  142. package/skills/gsd-path-define/scripts/path_config.py +3 -3
  143. package/skills/gsd-path-define/scripts/pipeline_git.py +63 -12
  144. package/skills/gsd-path-define/scripts/pipeline_state.py +111 -33
  145. package/skills/gsd-path-define/scripts/state_checkpoint.py +9 -27
  146. package/skills/gsd-path-define/scripts/worktree_paths.py +54 -2
  147. package/skills/gsd-path-define/templates/intent.md +30 -0
  148. package/skills/gsd-path-define/templates/state.md +1 -1
  149. package/skills/gsd-path-discuss/scripts/_common.py +149 -1
  150. package/skills/gsd-path-discuss/scripts/loop_run.py +16 -4
  151. package/skills/gsd-path-discuss/scripts/path_config.py +3 -3
  152. package/skills/gsd-path-discuss/scripts/worktree_paths.py +54 -2
  153. package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
  154. package/skills/gsd-path-docs-audit/references/docs-auditor.md +5 -0
  155. package/skills/gsd-path-docs-audit/scripts/_common.py +149 -1
  156. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +14 -13
  157. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +96 -31
  158. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +77 -4
  159. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +546 -30
  160. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +279 -23
  161. package/skills/gsd-path-docs-audit/scripts/detect_project.py +243 -18
  162. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +86 -29
  163. package/skills/gsd-path-docs-audit/scripts/integration.py +146 -1
  164. package/skills/gsd-path-docs-audit/scripts/isolation.py +392 -60
  165. package/skills/gsd-path-docs-audit/scripts/loop_run.py +16 -4
  166. package/skills/gsd-path-docs-audit/scripts/members.py +11 -9
  167. package/skills/gsd-path-docs-audit/scripts/path_config.py +3 -3
  168. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +63 -12
  169. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +111 -33
  170. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +9 -27
  171. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +54 -2
  172. package/skills/gsd-path-docs-audit/templates/docs-audit.md +5 -1
  173. package/skills/gsd-path-forensics/scripts/_common.py +149 -1
  174. package/skills/gsd-path-forensics/scripts/loop_run.py +16 -4
  175. package/skills/gsd-path-forensics/scripts/path_config.py +3 -3
  176. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +101 -8
  177. package/skills/gsd-path-forensics/scripts/worktree_paths.py +54 -2
  178. package/skills/gsd-path-inspect/references/dispatch.md +13 -0
  179. package/skills/gsd-path-inspect/references/docs-auditor.md +5 -0
  180. package/skills/gsd-path-inspect/scripts/_common.py +149 -1
  181. package/skills/gsd-path-inspect/scripts/archive_milestone.py +14 -13
  182. package/skills/gsd-path-inspect/scripts/build_recovery.py +96 -31
  183. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +77 -4
  184. package/skills/gsd-path-inspect/scripts/check_handoffs.py +546 -30
  185. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +279 -23
  186. package/skills/gsd-path-inspect/scripts/detect_project.py +243 -18
  187. package/skills/gsd-path-inspect/scripts/discussion_validate.py +86 -29
  188. package/skills/gsd-path-inspect/scripts/integration.py +146 -1
  189. package/skills/gsd-path-inspect/scripts/isolation.py +392 -60
  190. package/skills/gsd-path-inspect/scripts/loop_run.py +16 -4
  191. package/skills/gsd-path-inspect/scripts/members.py +11 -9
  192. package/skills/gsd-path-inspect/scripts/path_config.py +3 -3
  193. package/skills/gsd-path-inspect/scripts/pipeline_git.py +63 -12
  194. package/skills/gsd-path-inspect/scripts/pipeline_state.py +111 -33
  195. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +9 -27
  196. package/skills/gsd-path-inspect/scripts/workflow_run.py +5 -2
  197. package/skills/gsd-path-inspect/scripts/worktree_paths.py +54 -2
  198. package/skills/gsd-path-inspect/templates/docs-audit.md +5 -1
  199. package/skills/gsd-path-inspect/templates/state.md +1 -1
  200. package/skills/gsd-path-loop/scripts/_common.py +149 -1
  201. package/skills/gsd-path-loop/scripts/loop_run.py +16 -4
  202. package/skills/gsd-path-loop/scripts/path_config.py +3 -3
  203. package/skills/gsd-path-loop/scripts/worktree_paths.py +54 -2
  204. package/skills/gsd-path-migrate/scripts/_common.py +149 -1
  205. package/skills/gsd-path-migrate/scripts/loop_run.py +16 -4
  206. package/skills/gsd-path-migrate/scripts/path_config.py +3 -3
  207. package/skills/gsd-path-plan/SKILL.md +11 -3
  208. package/skills/gsd-path-plan/references/dispatch.md +13 -0
  209. package/skills/gsd-path-plan/references/planner.md +14 -4
  210. package/skills/gsd-path-plan/references/spec-probes.md +150 -0
  211. package/skills/gsd-path-plan/scripts/_common.py +149 -1
  212. package/skills/gsd-path-plan/scripts/archive_milestone.py +14 -13
  213. package/skills/gsd-path-plan/scripts/build_recovery.py +96 -31
  214. package/skills/gsd-path-plan/scripts/check_handoffs.py +546 -30
  215. package/skills/gsd-path-plan/scripts/check_task_briefs.py +279 -23
  216. package/skills/gsd-path-plan/scripts/detect_project.py +243 -18
  217. package/skills/gsd-path-plan/scripts/discussion_validate.py +86 -29
  218. package/skills/gsd-path-plan/scripts/integration.py +146 -1
  219. package/skills/gsd-path-plan/scripts/isolation.py +392 -60
  220. package/skills/gsd-path-plan/scripts/loop_run.py +16 -4
  221. package/skills/gsd-path-plan/scripts/members.py +11 -9
  222. package/skills/gsd-path-plan/scripts/path_config.py +3 -3
  223. package/skills/gsd-path-plan/scripts/pipeline_git.py +63 -12
  224. package/skills/gsd-path-plan/scripts/pipeline_state.py +111 -33
  225. package/skills/gsd-path-plan/scripts/state_checkpoint.py +9 -27
  226. package/skills/gsd-path-plan/scripts/workflow_run.py +5 -2
  227. package/skills/gsd-path-plan/scripts/worktree_paths.py +54 -2
  228. package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
  229. package/skills/gsd-path-plan/templates/plan.md +17 -2
  230. package/skills/gsd-path-research/SKILL.md +11 -0
  231. package/skills/gsd-path-research/references/dispatch.md +13 -0
  232. package/skills/gsd-path-research/scripts/_common.py +149 -1
  233. package/skills/gsd-path-research/scripts/archive_milestone.py +14 -13
  234. package/skills/gsd-path-research/scripts/build_recovery.py +96 -31
  235. package/skills/gsd-path-research/scripts/check_handoffs.py +546 -30
  236. package/skills/gsd-path-research/scripts/check_task_briefs.py +279 -23
  237. package/skills/gsd-path-research/scripts/detect_project.py +243 -18
  238. package/skills/gsd-path-research/scripts/discussion_validate.py +86 -29
  239. package/skills/gsd-path-research/scripts/integration.py +146 -1
  240. package/skills/gsd-path-research/scripts/isolation.py +392 -60
  241. package/skills/gsd-path-research/scripts/loop_run.py +16 -4
  242. package/skills/gsd-path-research/scripts/members.py +11 -9
  243. package/skills/gsd-path-research/scripts/path_config.py +3 -3
  244. package/skills/gsd-path-research/scripts/pipeline_git.py +63 -12
  245. package/skills/gsd-path-research/scripts/pipeline_state.py +111 -33
  246. package/skills/gsd-path-research/scripts/state_checkpoint.py +9 -27
  247. package/skills/gsd-path-research/scripts/worktree_paths.py +54 -2
  248. package/skills/gsd-path-research/templates/research-handoff.md +14 -3
  249. package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
  250. package/skills/gsd-path-roadmap/scripts/_common.py +149 -1
  251. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +14 -13
  252. package/skills/gsd-path-roadmap/scripts/build_recovery.py +96 -31
  253. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +546 -30
  254. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +279 -23
  255. package/skills/gsd-path-roadmap/scripts/detect_project.py +243 -18
  256. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +86 -29
  257. package/skills/gsd-path-roadmap/scripts/integration.py +146 -1
  258. package/skills/gsd-path-roadmap/scripts/isolation.py +392 -60
  259. package/skills/gsd-path-roadmap/scripts/loop_run.py +16 -4
  260. package/skills/gsd-path-roadmap/scripts/members.py +11 -9
  261. package/skills/gsd-path-roadmap/scripts/path_config.py +3 -3
  262. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +63 -12
  263. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +111 -33
  264. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +9 -27
  265. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +54 -2
  266. package/skills/gsd-path-ship/SKILL.md +6 -1
  267. package/skills/gsd-path-ship/references/dispatch.md +13 -0
  268. package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
  269. package/skills/gsd-path-ship/references/reviewer-wave.md +15 -4
  270. package/skills/gsd-path-ship/references/reviewer.md +3 -1
  271. package/skills/gsd-path-ship/references/spec-probes.md +150 -0
  272. package/skills/gsd-path-ship/scripts/_common.py +149 -1
  273. package/skills/gsd-path-ship/scripts/archive_milestone.py +14 -13
  274. package/skills/gsd-path-ship/scripts/build_recovery.py +96 -31
  275. package/skills/gsd-path-ship/scripts/build_state.py +79 -3
  276. package/skills/gsd-path-ship/scripts/check_handoffs.py +546 -30
  277. package/skills/gsd-path-ship/scripts/check_task_briefs.py +279 -23
  278. package/skills/gsd-path-ship/scripts/detect_project.py +243 -18
  279. package/skills/gsd-path-ship/scripts/discussion_validate.py +86 -29
  280. package/skills/gsd-path-ship/scripts/integration.py +146 -1
  281. package/skills/gsd-path-ship/scripts/isolation.py +392 -60
  282. package/skills/gsd-path-ship/scripts/loop_run.py +16 -4
  283. package/skills/gsd-path-ship/scripts/members.py +11 -9
  284. package/skills/gsd-path-ship/scripts/path_config.py +3 -3
  285. package/skills/gsd-path-ship/scripts/pipeline_git.py +63 -12
  286. package/skills/gsd-path-ship/scripts/pipeline_state.py +111 -33
  287. package/skills/gsd-path-ship/scripts/state_checkpoint.py +9 -27
  288. package/skills/gsd-path-ship/scripts/workflow_run.py +5 -2
  289. package/skills/gsd-path-ship/scripts/worktree_paths.py +54 -2
  290. package/skills/gsd-path-ship/templates/final-review.md +11 -2
  291. package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
  292. package/skills/gsd-path-ship/templates/wave-review.md +16 -6
  293. package/skills/gsd-path-undo/scripts/_common.py +149 -1
  294. package/skills/gsd-path-undo/scripts/loop_run.py +16 -4
  295. package/skills/gsd-path-undo/scripts/path_config.py +3 -3
  296. package/skills/gsd-path-undo/scripts/pipeline_undo.py +101 -8
  297. package/skills/gsd-path-undo/scripts/worktree_paths.py +54 -2
  298. package/skills/path/DEFINE.md +28 -3
  299. package/skills/path/PLAN.md +11 -3
  300. package/skills/path/RESEARCH.md +11 -0
  301. package/skills/path/SHIP.md +6 -1
  302. package/skills/path/references/build-native.md +16 -1
  303. package/skills/path/references/dispatch.md +13 -0
  304. package/skills/path/references/docs-auditor.md +5 -0
  305. package/skills/path/references/planner.md +14 -4
  306. package/skills/path/references/reviewer-final.md +20 -2
  307. package/skills/path/references/reviewer-wave.md +15 -4
  308. package/skills/path/references/reviewer.md +3 -1
  309. package/skills/path/references/spec-probes.md +150 -0
  310. package/skills/path/scripts/_common.py +149 -1
  311. package/skills/path/scripts/archive_milestone.py +14 -13
  312. package/skills/path/scripts/build_recovery.py +96 -31
  313. package/skills/path/scripts/build_state.py +79 -3
  314. package/skills/path/scripts/check_docs_audit.py +77 -4
  315. package/skills/path/scripts/check_handoffs.py +546 -30
  316. package/skills/path/scripts/check_task_briefs.py +279 -23
  317. package/skills/path/scripts/detect_project.py +243 -18
  318. package/skills/path/scripts/discussion_validate.py +86 -29
  319. package/skills/path/scripts/dispatch_driver.py +392 -22
  320. package/skills/path/scripts/git_guard.py +3 -3
  321. package/skills/path/scripts/integration.py +146 -1
  322. package/skills/path/scripts/isolation.py +392 -60
  323. package/skills/path/scripts/loop_run.py +16 -4
  324. package/skills/path/scripts/members.py +11 -9
  325. package/skills/path/scripts/path_config.py +3 -3
  326. package/skills/path/scripts/pipeline_git.py +63 -12
  327. package/skills/path/scripts/pipeline_state.py +111 -33
  328. package/skills/path/scripts/state_checkpoint.py +9 -27
  329. package/skills/path/scripts/status_runtime.py +22 -4
  330. package/skills/path/scripts/task_context.py +12 -3
  331. package/skills/path/scripts/workflow_run.py +5 -2
  332. package/skills/path/scripts/worktree_paths.py +54 -2
  333. package/skills/path/templates/docs-audit.md +5 -1
  334. package/skills/path/templates/final-review.md +11 -2
  335. package/skills/path/templates/intent.md +30 -0
  336. package/skills/path/templates/patch-findings.md +3 -3
  337. package/skills/path/templates/plan.md +17 -2
  338. package/skills/path/templates/research-handoff.md +14 -3
  339. package/skills/path/templates/state.md +1 -1
  340. package/skills/path/templates/wave-review.md +16 -6
  341. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  342. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  343. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  344. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  345. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  346. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  347. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
@@ -121,6 +121,10 @@ ARCHIVE_REASON = (
121
121
  "committed GSD Path archives under .project/archive/ are read-only; "
122
122
  "only the bundled pipeline helpers may write there during a ship transaction"
123
123
  )
124
+ ARCHIVE_UNPROVEN_READ_REASON = (
125
+ "commands run in archive context must be provably read-only; only "
126
+ "recognized read commands and the bundled pipeline helpers may run there"
127
+ )
124
128
  DESTRUCTIVE_SHAPE_REASON = (
125
129
  "destructive commands must run alone with literal paths; split compound, "
126
130
  "substituted, or expanded destructive commands into single commands"
@@ -182,7 +186,7 @@ STATUS_STATE_FIELDS = frozenset(
182
186
  "integration_source",
183
187
  }
184
188
  )
185
- STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
189
+ STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request", "external-landing"})
186
190
  STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
187
191
  STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
188
192
  STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
@@ -206,6 +210,7 @@ ARCHIVE_READ_COMMANDS = frozenset(
206
210
  "tail",
207
211
  "grep",
208
212
  "rg",
213
+ "sed",
209
214
  "ls",
210
215
  "stat",
211
216
  "wc",
@@ -233,6 +238,147 @@ CLOSED_LISTING_GIT_OPTIONS = {
233
238
  "symbolic-ref": frozenset({"-q", "--quiet", "--short"}),
234
239
  }
235
240
  GIT_READ_WRITE_OPTIONS = frozenset({"--output", "--ext-diff", "--textconv"})
241
+ # Options whose value git writes to or runs as a command: one unknown word is
242
+ # already enough, so a parameter in or after them is never accepted.
243
+ GIT_PARAMETER_DENIED_OPTIONS = GIT_READ_WRITE_OPTIONS | {
244
+ "--upload-pack", "--receive-pack", "--exec", "-c", "--config-env",
245
+ }
246
+
247
+
248
+ # -c/--config keys whose value git executes as a program. A literal value is
249
+ # as dangerous as a parameter here: git -c core.fsmonitor=<path> status runs
250
+ # the configured program while the subcommand stays nominally read-only.
251
+ GIT_EXECUTED_CONFIG_KEYS = frozenset({
252
+ "core.fsmonitor", "core.editor", "sequence.editor", "core.sshcommand",
253
+ "core.askpass", "core.pager", "core.gitproxy", "interactive.difffilter",
254
+ })
255
+ GIT_EXECUTED_CONFIG_SECTIONS = {
256
+ "filter": (".clean", ".smudge", ".process", ".command"),
257
+ "diff": (".command", ".textconv", ".external"),
258
+ "merge": (".driver",),
259
+ "remote": (".uploadpack", ".receivepack"),
260
+ "gpg": (".program",),
261
+ "credential": (".helper",),
262
+ "pager": ("",),
263
+ }
264
+ # Keys whose value is a path or URL git loads code through: an included file
265
+ # can set every key above, and a rewritten URL can retarget the transport.
266
+ # No value of these is a disabling form.
267
+ GIT_LOADED_CONFIG_KEYS = frozenset({"core.hookspath", "include.path"})
268
+ GIT_LOADED_CONFIG_SECTIONS = {
269
+ "includeif": (".path",),
270
+ "url": (".insteadof", ".pushinsteadof"),
271
+ }
272
+ GIT_CONFIG_DISABLING_VALUES = frozenset({"", "false"})
273
+
274
+
275
+ def config_key_in(key, keys, sections):
276
+ if key in keys:
277
+ return True
278
+ section, _, remainder = key.partition(".")
279
+ return bool(remainder) and key.endswith(tuple(sections.get(section, ())))
280
+
281
+
282
+ def loaded_config_key(key):
283
+ """Whether a config key's value is a path or URL git loads code through."""
284
+ return config_key_in(key, GIT_LOADED_CONFIG_KEYS, GIT_LOADED_CONFIG_SECTIONS)
285
+
286
+
287
+ def executed_config_key(key):
288
+ """Whether a config key's value is a program git runs or code it loads."""
289
+ return loaded_config_key(key) or config_key_in(
290
+ key, GIT_EXECUTED_CONFIG_KEYS, GIT_EXECUTED_CONFIG_SECTIONS
291
+ )
292
+
293
+
294
+ # Real read-only git options that are a strict prefix of a denied option name.
295
+ GIT_SAFE_PREFIX_OPTIONS = frozenset({"--text"})
296
+
297
+
298
+ def git_option_in(token, options):
299
+ """Whether a token names one of the options, by full name or any long-option prefix.
300
+
301
+ git accepts each unique prefix of a long option; matching every prefix is
302
+ stricter than git and so only fails closed. A prefix that is itself the
303
+ exact name of a read-only option is that option, not an abbreviation.
304
+ """
305
+ name = token.split("=", 1)[0]
306
+ if name in GIT_SAFE_PREFIX_OPTIONS and name not in options:
307
+ return False
308
+ if name.startswith("--") and len(name) > 2:
309
+ return any(option.startswith(name) for option in options)
310
+ return name in options
311
+ GIT_CONFIG_READ_MODES = frozenset({
312
+ "--get", "--get-all", "--get-regexp", "--get-urlmatch", "--get-color",
313
+ "--get-colorbool", "-l", "--list", "--unset", "--unset-all",
314
+ })
315
+ GIT_CONFIG_READ_SUBCOMMANDS = frozenset({"get", "unset", "list"})
316
+ GIT_CONFIG_DENIED_MODES = frozenset({"--rename-section", "--remove-section", "--edit"})
317
+ GIT_CONFIG_DENIED_SUBCOMMANDS = frozenset({"rename-section", "remove-section", "edit"})
318
+ GIT_CONFIG_VALUE_OPTIONS = frozenset({
319
+ "--file", "--blob", "--type", "--default", "--comment", "--value", "--url",
320
+ })
321
+ GIT_CONFIG_EDIT_SHORT_OPTION = re.compile(r"-[A-Za-z]*e[A-Za-z]*")
322
+ # An unquoted word the shell can turn into several arguments: a glob, or a
323
+ # brace list or range.
324
+ PLAIN_QUOTED_SEGMENT = re.compile(r"'[^']*'|\"[^\"]*\"")
325
+ ANSI_QUOTE_START = re.compile(r"\$['\"]")
326
+ GIT_CONFIG_EXPANDING_WORD = re.compile(r"[*?\[]|\{.*(?:,|\.\.)")
327
+
328
+
329
+ def denied_git_config_argument(arguments):
330
+ """The git config argument that persists or runs a program git executes.
331
+
332
+ A section rename can carry a key into an executed section and --edit runs
333
+ the editor, so both are denied outright. Otherwise only a set denies: an
334
+ executed key with a value operand after it and no read or unset mode.
335
+ Only a trailing redirection is dropped: the operator, its target and a
336
+ file descriptor digit written against it. A separate digit (5 >file) and a
337
+ quoted operator are operands.
338
+ """
339
+ arguments = list(arguments)
340
+ while (
341
+ len(arguments) >= 2
342
+ and getattr(arguments[-2], "redirection", False)
343
+ and not arguments[-2].isdigit()
344
+ ):
345
+ del arguments[-2:]
346
+ if (
347
+ arguments
348
+ and getattr(arguments[-1], "redirection", False)
349
+ and arguments[-1].isdigit()
350
+ ):
351
+ del arguments[-1]
352
+ separator = arguments.index("--") if "--" in arguments else len(arguments)
353
+ options = arguments[:separator]
354
+ for token in options:
355
+ if (
356
+ git_option_in(token, GIT_CONFIG_DENIED_MODES)
357
+ or GIT_CONFIG_EDIT_SHORT_OPTION.fullmatch(token)
358
+ or token.casefold() in GIT_CONFIG_DENIED_SUBCOMMANDS
359
+ ):
360
+ return token
361
+ if options[:1] and options[0].casefold() in GIT_CONFIG_READ_SUBCOMMANDS:
362
+ return None
363
+ for position, token in enumerate(options):
364
+ # A mode word that is the value of the option before it is no mode.
365
+ if token in GIT_CONFIG_READ_MODES and not (
366
+ position
367
+ and (
368
+ options[position - 1] == "-f"
369
+ or git_option_in(options[position - 1], GIT_CONFIG_VALUE_OPTIONS)
370
+ )
371
+ ):
372
+ return None
373
+ operands = [
374
+ token for token in options if not token.startswith("-")
375
+ ] + arguments[separator + 1:]
376
+ for token in operands[:-1]:
377
+ if executed_config_key(token.casefold()):
378
+ return token
379
+ return None
380
+
381
+
236
382
  ARCHIVE_READ_EXECUTION_OPTIONS = {"rg": frozenset({"--pre"})}
237
383
  AMBIGUOUS_SHELL_SYNTAX = re.compile(r"[\r\n|;&<>`]|\$\(|@\(")
238
384
  SUBSTITUTION_PLACEHOLDER = "COMMAND_SUBSTITUTION_"
@@ -240,10 +386,6 @@ SUBSTITUTION_PLACEHOLDER_SYNTAX = re.compile(
240
386
  r"\$\{" + SUBSTITUTION_PLACEHOLDER + r"(\d+)\}"
241
387
  )
242
388
  LINE_CONTINUATION = re.compile(r"(?<!\\)((?:\\\\)*)\\\r?\n")
243
- HEREDOC_PATTERN = re.compile(
244
- r"<<(?P<dash>-?)\s*(?:'(?P<single>[^']+)'|\"(?P<double>[^\"]+)\""
245
- r"|\\?(?P<bare>[A-Za-z_][A-Za-z0-9_]*))"
246
- )
247
389
  SHELL_WRITE_REDIRECTION_CHARS = frozenset("<>&|")
248
390
  SHELL_PARAMETER_SYNTAX = re.compile(
249
391
  r"\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[-*@#?$!]|\{[^}\r\n]+\})"
@@ -251,9 +393,12 @@ SHELL_PARAMETER_SYNTAX = re.compile(
251
393
  NAMED_SHELL_PARAMETER_SYNTAX = re.compile(
252
394
  r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})"
253
395
  )
396
+ ZSH_EXPANSION_FLAG_SYNTAX = re.compile(r"\$[=~^+]")
254
397
  CMD_PARAMETER_SYNTAX = re.compile(
255
398
  r"%([A-Za-z_][A-Za-z0-9_]*)%|!([A-Za-z_][A-Za-z0-9_]*)!"
256
399
  )
400
+ # The shell reassigns these on every cd, so a tracked value is stale.
401
+ SHELL_DIRECTORY_VARIABLES = frozenset({"PWD", "OLDPWD"})
257
402
  DIRECTORY_CHANGE_COMMANDS = frozenset(
258
403
  {"cd", "chdir", "pushd", "set-location", "sl"}
259
404
  )
@@ -306,6 +451,8 @@ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
306
451
  UNVALIDATED_EXECUTION_REASONS = {
307
452
  ".": SCRIPT_FILE_REASON,
308
453
  "source": SCRIPT_FILE_REASON,
454
+ "readonly": "readonly changes shell assignment behavior the guard cannot validate; "
455
+ "avoid readonly declarations",
309
456
  "start": "start launches a detached process the guard cannot inspect; run the program directly",
310
457
  "setenv": ENV_BUILTIN_REASON,
311
458
  "unsetenv": ENV_BUILTIN_REASON,
@@ -370,7 +517,64 @@ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
370
517
  }
371
518
  )
372
519
  IN_PLACE_EDITORS = frozenset({"perl", "sed"})
520
+ SED_SAFE_OPTIONS = frozenset(
521
+ {
522
+ "--debug",
523
+ "--help",
524
+ "--null-data",
525
+ "--posix",
526
+ "--quiet",
527
+ "--regexp-extended",
528
+ "--sandbox",
529
+ "--separate",
530
+ "--silent",
531
+ "--unbuffered",
532
+ "--version",
533
+ "-E",
534
+ "-n",
535
+ "-r",
536
+ "-s",
537
+ "-u",
538
+ }
539
+ )
373
540
  FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
541
+ # Programs that run the command in their arguments, with the options of each
542
+ # that take a separate value. The guard checks the command they run.
543
+ EXEC_PREFIX_VALUE_OPTIONS = {
544
+ "nice": frozenset({"-n", "--adjustment"}),
545
+ "nohup": frozenset(),
546
+ "setsid": frozenset(),
547
+ "stdbuf": frozenset({"-i", "-o", "-e", "--input", "--output", "--error"}),
548
+ "sudo": frozenset({
549
+ "-u", "-g", "-h", "-p", "-C", "-T", "-U", "-r", "-t",
550
+ "--user", "--group", "--host", "--prompt", "--close-from",
551
+ "--command-timeout", "--other-user", "--role", "--type",
552
+ }),
553
+ "time": frozenset({"-f", "--format"}),
554
+ "timeout": frozenset({"-s", "-k", "--signal", "--kill-after"}),
555
+ "watch": frozenset({"-n", "-q", "--interval", "--equexit"}),
556
+ }
557
+ # Runner options that write, edit or enter a file or directory by themselves.
558
+ EXEC_PREFIX_FILE_OPTIONS = {
559
+ "sudo": frozenset({"-e", "-D", "-R", "--edit", "--chdir", "--chroot"}),
560
+ "time": frozenset({"-o", "--output"}),
561
+ "watch": frozenset({"-s", "--shotsdir"}),
562
+ }
563
+ # The short option letters of each runner that take no value.
564
+ EXEC_PREFIX_FLAG_LETTERS = {
565
+ "nice": "",
566
+ "nohup": "",
567
+ "setsid": "cfw",
568
+ "stdbuf": "",
569
+ "sudo": "AbBEHiKklnNPSsVv",
570
+ "time": "ahlpqvV",
571
+ "timeout": "fpv",
572
+ "watch": "bcCdegprtwx",
573
+ }
574
+ EXEC_PREFIX_INTERVAL_OPTIONS = frozenset({"-n", "--interval"})
575
+ RUNNER_DURATION = re.compile(
576
+ r"(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?)[smhd]?", re.IGNORECASE
577
+ )
374
578
  SHELL_CONTROL_WORDS = frozenset(
375
579
  {
376
580
  "!",
@@ -925,30 +1129,29 @@ def closed_target_reason(targets):
925
1129
  return None
926
1130
 
927
1131
 
928
- def enforce_pipeline_reentry(paths, working_directories):
929
- reason = closed_target_reason((path, working_directories) for path in paths)
930
- if reason:
931
- deny(reason)
932
- classified = pipeline_target_kinds((path, working_directories) for path in paths)
1132
+ def pipeline_product_phase_reason(classified):
1133
+ """Why product writes are denied outside the routed build phase, or None when allowed."""
933
1134
  if classified is None:
934
- return
1135
+ return None
935
1136
  repo, state, kinds = classified
936
1137
  for path, kind in kinds:
937
1138
  if kind == "protected":
938
- deny(f"{CONTROL_FILE_REASON} {path}")
939
- kinds = [kind for _, kind in kinds]
940
- if all(kind == "external" for kind in kinds):
941
- return
1139
+ return f"{CONTROL_FILE_REASON} {path}"
1140
+ kind_values = [kind for _, kind in kinds]
1141
+ if all(kind == "external" for kind in kind_values):
1142
+ return None
1143
+ if not any(kind == "product" for kind in kind_values):
1144
+ return None
942
1145
  try:
943
1146
  status = project_status(repo)
944
1147
  except (OSError, ValueError, json.JSONDecodeError):
945
- deny(REENTRY_FAILURE_REASON)
1148
+ return REENTRY_FAILURE_REASON
946
1149
  route = status.get("route")
947
1150
  state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
948
1151
  completion = status.get("completion")
949
1152
  if (state_data.get("phase") == "shipped"
950
1153
  and isinstance(completion, dict) and completion.get("status") == "verified"):
951
- return
1154
+ return None
952
1155
  routed_build = (
953
1156
  state_data.get("phase") == "build"
954
1157
  and isinstance(route, dict)
@@ -956,20 +1159,31 @@ def enforce_pipeline_reentry(paths, working_directories):
956
1159
  and route.get("phase") == "build"
957
1160
  )
958
1161
  if routed_build:
959
- return
960
- if all(kind in {"external", "artifact"} for kind in kinds):
961
- return
1162
+ return None
1163
+ if all(kind in {"external", "artifact"} for kind in kind_values):
1164
+ return None
962
1165
  phase = state_data.get("phase", "unknown")
963
1166
  if isinstance(route, dict) and route.get("action") != "run-phase":
964
1167
  next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
965
1168
  else:
966
1169
  next_step = status.get("next_skill") or "gsd-path"
967
- deny(
1170
+ return (
968
1171
  f"GSD Path is {phase}; direct product-file changes require the routed "
969
1172
  f"build phase. Review {status.get('path', state)}; next: {next_step}"
970
1173
  )
971
1174
 
972
1175
 
1176
+ def enforce_pipeline_reentry(paths, working_directories):
1177
+ reason = closed_target_reason((path, working_directories) for path in paths)
1178
+ if reason:
1179
+ deny(reason)
1180
+ reason = pipeline_product_phase_reason(
1181
+ pipeline_target_kinds((path, working_directories) for path in paths)
1182
+ )
1183
+ if reason:
1184
+ deny(reason)
1185
+
1186
+
973
1187
  def in_archive(path):
974
1188
  # Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
975
1189
  normalized = normalize_posix(path).casefold()
@@ -1147,10 +1361,10 @@ def directory_change_target(arguments):
1147
1361
  return operands[-1]
1148
1362
 
1149
1363
 
1150
- def segment_directories(tokens, working_directories):
1364
+ def segment_directories(tokens, working_directories, contexts=None):
1151
1365
  """Yield each command segment with the working directories in effect for it."""
1152
1366
  current_directories = list(working_directories)
1153
- for segment in command_segments(tokens):
1367
+ for position, segment in enumerate(command_segments(tokens)):
1154
1368
  yield segment, current_directories
1155
1369
  invocation = command_invocation(segment)
1156
1370
  if invocation is None:
@@ -1163,16 +1377,31 @@ def segment_directories(tokens, working_directories):
1163
1377
  if command not in DIRECTORY_CHANGE_COMMANDS:
1164
1378
  continue
1165
1379
  target = directory_change_target(arguments)
1166
- if SHELL_PARAMETER_SYNTAX.search(target):
1380
+ tracked = {}
1381
+ if contexts is not None and position < len(contexts):
1382
+ tracked = {
1383
+ name: value
1384
+ for name, value in contexts[position].items()
1385
+ if name not in SHELL_DIRECTORY_VARIABLES
1386
+ }
1387
+ expanded = expand_environment_parameters(target, tracked, tracked_only=True)
1388
+ if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(expanded):
1167
1389
  raise ValueError(
1168
1390
  f"cd target {target} cannot be resolved by the guard; "
1169
1391
  "run that command first and cd to the literal result"
1170
1392
  )
1171
- if is_absolute_path(target):
1172
- current_directories = [target]
1393
+ if (
1394
+ (SHELL_PARAMETER_SYNTAX.search(target) or CMD_PARAMETER_SYNTAX.search(target))
1395
+ and any(character.isspace() or character in "*?[]" for character in expanded)
1396
+ ):
1397
+ raise ValueError(
1398
+ f"cd target {target} may split or glob; pass a literal path"
1399
+ )
1400
+ if is_absolute_path(expanded):
1401
+ current_directories = [expanded]
1173
1402
  else:
1174
1403
  bases = current_directories or ["."]
1175
- current_directories = [f"{base}/{target}" for base in bases]
1404
+ current_directories = [f"{base}/{expanded}" for base in bases]
1176
1405
 
1177
1406
 
1178
1407
  def command_can_destroy_files(invocation):
@@ -1188,9 +1417,11 @@ def command_can_destroy_files(invocation):
1188
1417
  def literal_path(operand):
1189
1418
  if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
1190
1419
  operand = operand[1:-1]
1191
- pattern = r"[A-Za-z0-9._/-]+"
1420
+ # A space is safe here: the operand is one shlex unit, so it cannot carry a
1421
+ # second word; injection-relevant characters stay excluded.
1422
+ pattern = r"[A-Za-z0-9._/ -]+"
1192
1423
  if os.name == "nt":
1193
- pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\-]+"
1424
+ pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\ -]+"
1194
1425
  return operand if re.fullmatch(pattern, operand) is not None else None
1195
1426
 
1196
1427
 
@@ -1203,19 +1434,27 @@ def literal_destructive_operand(operand):
1203
1434
  return literal
1204
1435
 
1205
1436
 
1206
- def command_references_archive(tokens, working_directories):
1207
- for segment, directories in segment_directories(tokens, working_directories):
1208
- invocation = command_invocation(segment)
1209
- ancestors = command_can_destroy_files(invocation)
1210
- operands = segment
1211
- if ancestors:
1212
- operands = [literal_destructive_operand(operand) for operand in invocation[1]]
1213
- if any(path_in_archive(operand, directories, ancestors) for operand in operands):
1214
- return True
1215
- wrapped = wrapped_command_tokens(segment, expand_parameters=False)
1216
- if wrapped is not None and command_references_archive(wrapped, directories):
1217
- return True
1218
- return False
1437
+ def segment_references_archive(segment, directories, assignments=None):
1438
+ invocation = command_invocation(segment)
1439
+ ancestors = command_can_destroy_files(invocation)
1440
+ # A here-document body survives tokenization only inside a wrapped payload
1441
+ # (bash -c '...'), where it is data, not commands; keep its text from
1442
+ # flipping the segment into archive context.
1443
+ def searchable(token):
1444
+ return strip_heredoc_bodies(token) if "\n" in token else token
1445
+
1446
+ operands = [
1447
+ expand_environment_parameters(searchable(token), assignments)
1448
+ for token in segment
1449
+ ]
1450
+ if ancestors:
1451
+ resolved = command_invocation(operands)
1452
+ operands = [literal_destructive_operand(operand) for operand in resolved[1]]
1453
+ if any(path_in_archive(operand, directories, ancestors) for operand in operands):
1454
+ return True
1455
+ return unresolved_archive_expansion(
1456
+ " ".join(searchable(token) for token in segment), assignments
1457
+ )
1219
1458
 
1220
1459
 
1221
1460
  def copy_destinations(arguments, directories, assignments=None):
@@ -1286,43 +1525,258 @@ def copy_destinations(arguments, directories, assignments=None):
1286
1525
  yield str(output / entry.relative_to(source_path))
1287
1526
 
1288
1527
 
1528
+ def sed_delimited_end(script, index, delimiter):
1529
+ while index < len(script):
1530
+ if script[index] == "\\":
1531
+ index += 2
1532
+ elif script[index] == delimiter:
1533
+ return index + 1
1534
+ else:
1535
+ index += 1
1536
+ return None
1537
+
1538
+
1539
+ def sed_address_end(script, index):
1540
+ if index >= len(script):
1541
+ return index
1542
+ if script[index].isdigit():
1543
+ while index < len(script) and script[index].isdigit():
1544
+ index += 1
1545
+ if index < len(script) and script[index] == "~":
1546
+ index += 1
1547
+ while index < len(script) and script[index].isdigit():
1548
+ index += 1
1549
+ elif script[index] == "$":
1550
+ index += 1
1551
+ elif script[index] == "/":
1552
+ end = sed_delimited_end(script, index + 1, "/")
1553
+ return len(script) if end is None else end
1554
+ elif script[index] == "\\" and index + 1 < len(script):
1555
+ end = sed_delimited_end(script, index + 2, script[index + 1])
1556
+ return len(script) if end is None else end
1557
+ return index
1558
+
1559
+
1560
+ def sed_script_is_read_only(script):
1561
+ """Accept only sed scripts whose commands cannot write or execute."""
1562
+ # Sed blocks require recursively validating their nested command grammar;
1563
+ # reject them until that grammar is modeled explicitly.
1564
+ safe_commands = frozenset("aAcCdDgGhHiIlLnNpPqQrstTxv=:")
1565
+ index = 0
1566
+ while index < len(script):
1567
+ while index < len(script) and (script[index].isspace() or script[index] == ";"):
1568
+ index += 1
1569
+ if index >= len(script):
1570
+ break
1571
+ address_start = index
1572
+ index = sed_address_end(script, index)
1573
+ if index < len(script) and script[index] == ",":
1574
+ index = sed_address_end(script, index + 1)
1575
+ if index > address_start and index < len(script) and script[index] == "!":
1576
+ index += 1
1577
+ while index < len(script) and script[index].isspace():
1578
+ index += 1
1579
+ if index >= len(script):
1580
+ break
1581
+ command = script[index]
1582
+ index += 1
1583
+ if command in {"w", "W", "e"}:
1584
+ return False
1585
+ if command == "#":
1586
+ newline = script.find("\n", index)
1587
+ index = len(script) if newline < 0 else newline + 1
1588
+ continue
1589
+ if command == "s":
1590
+ if index >= len(script):
1591
+ return False
1592
+ delimiter = script[index]
1593
+ index = sed_delimited_end(script, index + 1, delimiter)
1594
+ if index is None:
1595
+ return False
1596
+ index = sed_delimited_end(script, index, delimiter)
1597
+ if index is None:
1598
+ return False
1599
+ end = index
1600
+ while end < len(script) and script[end] not in ";\n":
1601
+ end += 1
1602
+ if any(flag in "wWe" for flag in script[index:end]):
1603
+ return False
1604
+ index = end
1605
+ continue
1606
+ if command == "y":
1607
+ if index >= len(script):
1608
+ return False
1609
+ delimiter = script[index]
1610
+ index = sed_delimited_end(script, index + 1, delimiter)
1611
+ if index is None:
1612
+ return False
1613
+ index = sed_delimited_end(script, index, delimiter)
1614
+ if index is None:
1615
+ return False
1616
+ continue
1617
+ if command not in safe_commands:
1618
+ return False
1619
+ while index < len(script) and script[index] not in ";\n":
1620
+ index += 1
1621
+ return True
1622
+
1623
+
1624
+ def sed_arguments(arguments):
1625
+ """Return (in_place, input_files, script_is_read_only) for sed arguments."""
1626
+ in_place = False
1627
+ scripts = []
1628
+ inputs = []
1629
+ script_seen = False
1630
+ scripts_are_literal = True
1631
+ index = 0
1632
+ while index < len(arguments):
1633
+ argument = arguments[index]
1634
+ index += 1
1635
+ if argument == "--":
1636
+ positional = arguments[index:]
1637
+ if not script_seen and positional:
1638
+ scripts.append(positional[0])
1639
+ positional = positional[1:]
1640
+ script_seen = True
1641
+ inputs.extend(positional)
1642
+ break
1643
+ if argument in {"-e", "--expression"}:
1644
+ if index >= len(arguments):
1645
+ return in_place, inputs, False
1646
+ scripts.append(arguments[index])
1647
+ script_seen = True
1648
+ index += 1
1649
+ continue
1650
+ if argument == "-f" or argument == "--file":
1651
+ if index >= len(arguments):
1652
+ return in_place, inputs, False
1653
+ script_seen = True
1654
+ scripts_are_literal = False
1655
+ index += 1
1656
+ continue
1657
+ if argument.startswith("--expression="):
1658
+ scripts.append(argument.split("=", 1)[1])
1659
+ script_seen = True
1660
+ continue
1661
+ if argument.startswith("--file="):
1662
+ script_seen = True
1663
+ scripts_are_literal = False
1664
+ continue
1665
+ if argument == "--in-place" or argument.startswith("--in-place="):
1666
+ in_place = True
1667
+ continue
1668
+ if argument.startswith("--"):
1669
+ if argument not in SED_SAFE_OPTIONS:
1670
+ scripts_are_literal = False
1671
+ continue
1672
+ if argument.startswith("-") and argument != "-":
1673
+ options = argument[1:]
1674
+ cursor = 0
1675
+ while cursor < len(options):
1676
+ option = options[cursor]
1677
+ cursor += 1
1678
+ if option == "i":
1679
+ in_place = True
1680
+ break
1681
+ if option == "e":
1682
+ if cursor < len(options):
1683
+ scripts.append(options[cursor:])
1684
+ script_seen = True
1685
+ elif index < len(arguments):
1686
+ scripts.append(arguments[index])
1687
+ script_seen = True
1688
+ index += 1
1689
+ else:
1690
+ return in_place, inputs, False
1691
+ break
1692
+ if option not in "nErsu":
1693
+ scripts_are_literal = False
1694
+ continue
1695
+ if not script_seen:
1696
+ scripts.append(argument)
1697
+ script_seen = True
1698
+ else:
1699
+ inputs.append(argument)
1700
+ if not scripts or not scripts_are_literal:
1701
+ return in_place, inputs, False
1702
+ return in_place, inputs, all(sed_script_is_read_only(script) for script in scripts)
1703
+
1704
+
1289
1705
  def shell_write_targets(tokens, working_directories, assignments=None):
1290
- """Yield (target, directories) for every path a shell command may write."""
1291
- assignments = {**(assignments or {}), **shell_assignment_values(tokens)}
1292
- for segment, directories in segment_directories(tokens, working_directories):
1706
+ """Yield (target, directories, variables) for every path a shell command may write."""
1707
+ for segment, directories, segment_assignments, _ in shell_segment_contexts(
1708
+ tokens, working_directories, assignments
1709
+ ):
1293
1710
  for index, token in enumerate(segment[:-1]):
1294
1711
  if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
1295
1712
  continue
1296
1713
  target = segment[index + 1]
1297
1714
  if token.endswith("&") and (target.isdigit() or target == "-"):
1298
1715
  continue
1299
- yield target, directories
1716
+ yield target, directories, segment_assignments
1300
1717
  wrapped = wrapped_command_tokens(segment)
1301
1718
  if wrapped is not None:
1302
- yield from shell_write_targets(wrapped, directories, assignments)
1719
+ yield from shell_write_targets(wrapped, directories, segment_assignments)
1303
1720
  continue
1304
1721
  invocation = command_invocation(segment)
1305
1722
  if invocation is None:
1306
1723
  continue
1307
1724
  command, arguments = invocation
1725
+ command = command.removesuffix(".exe")
1726
+ if command == "sed":
1727
+ in_place, inputs, _ = sed_arguments(arguments)
1728
+ if in_place:
1729
+ for target in inputs:
1730
+ yield target, directories, segment_assignments
1731
+ continue
1308
1732
  in_place = command in IN_PLACE_EDITORS and (
1309
1733
  has_short_option(arguments, "i")
1310
1734
  or any(argument.startswith("--in-place") for argument in arguments)
1311
1735
  )
1312
- if command.removesuffix(".exe") == "cp":
1313
- for destination in copy_destinations(arguments, directories, assignments):
1314
- yield destination, directories
1736
+ if command == "cp":
1737
+ for destination in copy_destinations(arguments, directories, segment_assignments):
1738
+ yield destination, directories, segment_assignments
1315
1739
  continue
1316
- if command.removesuffix(".exe") in SHELL_WRITE_COMMANDS or in_place:
1740
+ if command in SHELL_WRITE_COMMANDS or in_place:
1317
1741
  for argument in arguments:
1318
1742
  if argument and not argument.startswith("-"):
1319
- yield argument, directories
1743
+ yield argument, directories, segment_assignments
1744
+
1745
+
1746
+ def shell_write_needs_build_phase_gate(targets):
1747
+ """Phase-gate shell product writes only on bound milestone branches in-tree."""
1748
+ repo = repository_root()
1749
+ branch = subprocess.run(
1750
+ ["git", "-C", str(repo), "branch", "--show-current"],
1751
+ capture_output=True,
1752
+ encoding="utf-8",
1753
+ errors="replace",
1754
+ )
1755
+ if branch.returncode or not STATUS_BRANCH.fullmatch(branch.stdout.strip()):
1756
+ return False
1757
+ for path, working_directories in targets:
1758
+ for target in dict.fromkeys(target_paths(path, working_directories, repo)):
1759
+ directory = (
1760
+ target
1761
+ if target.is_dir() and not is_link_like(target)
1762
+ else target.parent
1763
+ )
1764
+ while not directory.exists() and directory != directory.parent:
1765
+ directory = directory.parent
1766
+ root = subprocess.run(
1767
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
1768
+ capture_output=True,
1769
+ encoding="utf-8",
1770
+ errors="replace",
1771
+ )
1772
+ if root.returncode != 0 or Path(root.stdout.strip()) != repo:
1773
+ return False
1774
+ return True
1320
1775
 
1321
1776
 
1322
1777
  def protected_shell_write_reason(tokens, working_directories):
1323
- assignments = shell_assignment_values(tokens)
1324
1778
  targets = []
1325
- for target, directories in shell_write_targets(tokens, working_directories):
1779
+ for target, directories, assignments in shell_write_targets(tokens, working_directories):
1326
1780
  expanded = expand_environment_parameters(target, assignments)
1327
1781
  if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
1328
1782
  expanded
@@ -1342,44 +1796,141 @@ def protected_shell_write_reason(tokens, working_directories):
1342
1796
  for target, kind in classified[2]:
1343
1797
  if kind == "protected":
1344
1798
  return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
1799
+ if shell_write_needs_build_phase_gate(targets):
1800
+ return pipeline_product_phase_reason(classified)
1345
1801
  return None
1346
1802
 
1347
1803
 
1348
- def environment_parameter_value(match, assignments):
1804
+ def environment_parameter_value(match, assignments, tracked_only=False):
1349
1805
  name = match.group(1) or match.group(2)
1350
1806
  if name.startswith(SUBSTITUTION_PLACEHOLDER):
1351
1807
  return match.group(0) # command substitution output is never resolved
1352
- return assignments.get(name, os.environ.get(name, ""))
1808
+ if name in assignments:
1809
+ return assignments[name]
1810
+ if tracked_only:
1811
+ return match.group(0)
1812
+ return os.environ.get(name, match.group(0))
1353
1813
 
1354
1814
 
1355
- def expand_environment_parameters(command, assignments=None):
1815
+ def expand_environment_parameters(command, assignments=None, tracked_only=False):
1356
1816
  values = assignments or {}
1357
1817
 
1358
1818
  def substitute(match):
1359
- return environment_parameter_value(match, values)
1819
+ return environment_parameter_value(match, values, tracked_only)
1360
1820
 
1361
1821
  expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
1362
1822
  return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
1363
1823
 
1364
1824
 
1365
- def shell_assignment_values(tokens):
1366
- values = {}
1367
- for segment in command_segments(tokens):
1368
- variable_command = segment[0].casefold() in VARIABLE_COMMANDS
1825
+ def shell_segment_flow(tokens):
1826
+ """Return whether each segment is conditional or runs in a subshell."""
1827
+ flows = []
1828
+ segment = []
1829
+ conditional = False
1830
+ subshell_depth = 0
1831
+ control_depth = 0
1832
+ separators = set("|;&()\n\r")
1833
+ for token in tokens:
1834
+ if token and set(token) <= separators:
1835
+ if segment:
1836
+ flows.append(
1837
+ (conditional or control_depth > 0, subshell_depth > 0 or token in {"|", "&"})
1838
+ )
1839
+ segment = []
1840
+ if token == "(":
1841
+ subshell_depth += 1
1842
+ conditional = True
1843
+ elif token == ")":
1844
+ subshell_depth = max(0, subshell_depth - 1)
1845
+ conditional = False
1846
+ elif token in {"&&", "||", "|", "&"}:
1847
+ conditional = True
1848
+ else:
1849
+ conditional = False
1850
+ continue
1851
+ word = token.casefold()
1852
+ if not segment and word in SHELL_CONTROL_WORDS:
1853
+ if word in {"if", "while", "until", "for", "select", "case", "coproc"}:
1854
+ control_depth += 1
1855
+ elif word in {"fi", "done", "esac"}:
1856
+ control_depth = max(0, control_depth - 1)
1857
+ if word in {"then", "else", "elif", "do", "in"}:
1858
+ conditional = True
1859
+ continue
1860
+ segment.append(token)
1861
+ if segment:
1862
+ flows.append((conditional or control_depth > 0, subshell_depth > 0))
1863
+ return flows
1864
+
1865
+
1866
+ def shell_assignment_changes(segment, values):
1867
+ """Return variables changed by a persistent assignment or variable builtin."""
1868
+ index = 0
1869
+ prefix = []
1870
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1871
+ prefix.append(segment[index])
1872
+ index += 1
1873
+ executable = segment[index].casefold() if index < len(segment) else None
1874
+ updates = {}
1875
+ removes = set()
1876
+ if executable is None:
1877
+ assignments = prefix
1878
+ elif executable in {"export", "declare", "typeset"}:
1879
+ assignments = [*prefix, *segment[index + 1 :]]
1880
+ else:
1881
+ assignments = []
1882
+ for assignment in assignments:
1883
+ if SHELL_ASSIGNMENT_PATTERN.match(assignment):
1884
+ name, value = assignment.split("=", 1)
1885
+ updates[name] = expand_environment_parameters(value, {**values, **updates})
1886
+ if executable == "unset":
1887
+ removes = {
1888
+ name for name in segment[index + 1 :] if not name.startswith("-")
1889
+ }
1890
+ return updates, removes
1891
+
1892
+
1893
+ def shell_assignment_contexts(tokens, initial=None):
1894
+ """Return the persistent shell variables visible before each segment."""
1895
+ values = dict(initial or {})
1896
+ contexts = []
1897
+ segments = list(command_segments(tokens))
1898
+ flows = shell_segment_flow(tokens)
1899
+ if len(segments) != len(flows):
1900
+ raise ValueError("shell command segments cannot be aligned")
1901
+ for segment, (conditional, subshell) in zip(segments, flows):
1902
+ contexts.append(dict(values))
1903
+ updates, removes = shell_assignment_changes(segment, values)
1904
+ if subshell:
1905
+ continue
1906
+ for name in removes:
1907
+ if conditional:
1908
+ values[name] = f"${name}"
1909
+ else:
1910
+ values.pop(name, None)
1911
+ for name, value in updates.items():
1912
+ values[name] = f"${name}" if conditional else value
1913
+ return contexts, values
1914
+
1915
+
1916
+ def shell_segment_contexts(tokens, working_directories, initial=None):
1917
+ """Yield each segment with its working directory and prior shell variables."""
1918
+ contexts, _ = shell_assignment_contexts(tokens, initial)
1919
+ segments = list(segment_directories(tokens, working_directories, contexts))
1920
+ if len(segments) != len(contexts):
1921
+ raise ValueError("shell command segments cannot be aligned")
1922
+ for (segment, directories), assignments in zip(segments, contexts):
1923
+ prefix = {}
1369
1924
  for token in segment:
1370
1925
  if not SHELL_ASSIGNMENT_PATTERN.match(token):
1371
- if variable_command:
1372
- continue
1373
1926
  break
1374
1927
  name, value = token.split("=", 1)
1375
- if name in values:
1376
- raise ValueError(f"shell variable {name} is reassigned; split the commands")
1377
- values[name] = expand_environment_parameters(value, values)
1378
- return values
1928
+ prefix[name] = expand_environment_parameters(value, assignments)
1929
+ yield segment, directories, assignments, prefix
1379
1930
 
1380
1931
 
1381
- def unresolved_archive_expansion(command, tokens, working_directories):
1382
- expanded = expand_environment_parameters(command, shell_assignment_values(tokens))
1932
+ def unresolved_archive_expansion(command, assignments=None):
1933
+ expanded = expand_environment_parameters(command, assignments)
1383
1934
  return ARCHIVE_REFERENCE.search(expanded) is not None
1384
1935
 
1385
1936
 
@@ -1408,32 +1959,194 @@ def substitution_end(command, start):
1408
1959
  raise ValueError("command substitution $( is not closed")
1409
1960
 
1410
1961
 
1962
+ def heredoc_requests(line):
1963
+ """Find literal here-document delimiters, excluding here-strings and quotes."""
1964
+ requests = []
1965
+ quote = None
1966
+ index = 0
1967
+ while index < len(line):
1968
+ character = line[index]
1969
+ if quote:
1970
+ if character == "\\" and quote == '"':
1971
+ index += 2
1972
+ continue
1973
+ if character == quote:
1974
+ quote = None
1975
+ index += 1
1976
+ continue
1977
+ if character == "\\":
1978
+ index += 2
1979
+ continue
1980
+ if character == "#" and (
1981
+ index == 0
1982
+ or line[index - 1].isspace()
1983
+ or line[index - 1] in ";|&()<>"
1984
+ ):
1985
+ break
1986
+ if character in "'\"":
1987
+ quote = character
1988
+ index += 1
1989
+ continue
1990
+ if line.startswith("$(", index):
1991
+ index = substitution_end(line, index + 2) + 1
1992
+ continue
1993
+ if character == "`":
1994
+ end = index + 1
1995
+ while end < len(line) and line[end] != "`":
1996
+ end += 2 if line[end] == "\\" else 1
1997
+ index = min(end + 1, len(line))
1998
+ continue
1999
+ if line.startswith("<<<", index):
2000
+ index += 3
2001
+ continue
2002
+ if not line.startswith("<<", index):
2003
+ index += 1
2004
+ continue
2005
+ cursor = index + 2
2006
+ strip_tabs = cursor < len(line) and line[cursor] == "-"
2007
+ if strip_tabs:
2008
+ cursor += 1
2009
+ while cursor < len(line) and line[cursor] in " \t":
2010
+ cursor += 1
2011
+ delimiter = []
2012
+ delimiter_quote = None
2013
+ quoted = False
2014
+ while cursor < len(line):
2015
+ value = line[cursor]
2016
+ if delimiter_quote:
2017
+ if value == delimiter_quote:
2018
+ delimiter_quote = None
2019
+ else:
2020
+ delimiter.append(value)
2021
+ cursor += 1
2022
+ continue
2023
+ if value in "'\"":
2024
+ delimiter_quote = value
2025
+ quoted = True
2026
+ cursor += 1
2027
+ continue
2028
+ if value == "\\" and cursor + 1 < len(line):
2029
+ quoted = True
2030
+ delimiter.append(line[cursor + 1])
2031
+ cursor += 2
2032
+ continue
2033
+ if value.isspace() or value in ";|&()<>":
2034
+ break
2035
+ delimiter.append(value)
2036
+ cursor += 1
2037
+ if not delimiter:
2038
+ raise ValueError("here-document delimiter cannot be validated")
2039
+ requests.append(("".join(delimiter), strip_tabs, quoted))
2040
+ index = cursor
2041
+ return requests
2042
+
2043
+
2044
+ def heredoc_modes(command):
2045
+ """Mark here-document bodies as data or shell-expanded input."""
2046
+ lines = command.splitlines(keepends=True)
2047
+ modes = ["shell"] * len(command)
2048
+ offsets = []
2049
+ offset = 0
2050
+ for line in lines:
2051
+ offsets.append(offset)
2052
+ offset += len(line)
2053
+ line_index = 0
2054
+ while line_index < len(lines):
2055
+ requests = heredoc_requests(lines[line_index].rstrip("\r\n"))
2056
+ line_index += 1
2057
+ for delimiter, strip_tabs, quoted in requests:
2058
+ found = False
2059
+ while line_index < len(lines):
2060
+ line = lines[line_index]
2061
+ content = line.rstrip("\r\n")
2062
+ if strip_tabs:
2063
+ content = content.lstrip("\t")
2064
+ start = offsets[line_index]
2065
+ stop = start + len(line)
2066
+ mode = "literal" if quoted or content == delimiter else "heredoc"
2067
+ modes[start:stop] = [mode] * (stop - start)
2068
+ line_index += 1
2069
+ if content == delimiter:
2070
+ found = True
2071
+ break
2072
+ if not found:
2073
+ raise ValueError(f"here-document {delimiter} is not terminated")
2074
+ return modes
2075
+
2076
+
1411
2077
  def split_command_substitutions(command):
1412
- """Replace every $(...) and `...` with a placeholder; return (outer, inner)."""
1413
- if "@(" in command:
1414
- raise ValueError(
1415
- "@( expansion cannot be validated; write the list as literal arguments"
1416
- )
1417
- if "$(" not in command and "`" not in command:
1418
- return command, []
1419
- outer, inner, index = [], [], 0
2078
+ """Replace executable substitutions, leaving quoted text as shell data."""
2079
+ modes = heredoc_modes(command)
2080
+ outer, inner, index, quote = [], [], 0, None
1420
2081
  while index < len(command):
2082
+ mode = modes[index]
2083
+ character = command[index]
2084
+ if mode == "literal":
2085
+ outer.append(character)
2086
+ index += 1
2087
+ continue
2088
+ if mode == "heredoc":
2089
+ if character == "\\" and index + 1 < len(command) and command[index + 1] in "\\$`":
2090
+ outer.append(command[index : index + 2])
2091
+ index += 2
2092
+ continue
2093
+ if command.startswith("$(", index):
2094
+ end = substitution_end(command, index + 2)
2095
+ inner.append(command[index + 2 : end])
2096
+ elif character == "`":
2097
+ end = index + 1
2098
+ while end < len(command) and command[end] != "`":
2099
+ end += 2 if command[end] == "\\" else 1
2100
+ if end >= len(command):
2101
+ raise ValueError("backtick command substitution is not closed")
2102
+ inner.append(command[index + 1 : end])
2103
+ elif command.startswith("@(", index):
2104
+ raise ValueError(
2105
+ "@( expansion cannot be validated; write the list as literal arguments"
2106
+ )
2107
+ else:
2108
+ outer.append(character)
2109
+ index += 1
2110
+ continue
2111
+ outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
2112
+ index = end + 1
2113
+ continue
2114
+ if quote == "'":
2115
+ outer.append(character)
2116
+ if character == "'":
2117
+ quote = None
2118
+ index += 1
2119
+ continue
2120
+ if character == "\\":
2121
+ outer.append(command[index : index + 2])
2122
+ index += 2
2123
+ continue
2124
+ if quote is None and character == "'":
2125
+ quote = character
2126
+ outer.append(character)
2127
+ index += 1
2128
+ continue
2129
+ if character == '"':
2130
+ quote = None if quote == '"' else '"'
2131
+ outer.append(character)
2132
+ index += 1
2133
+ continue
1421
2134
  if command.startswith("$(", index):
1422
2135
  end = substitution_end(command, index + 2)
1423
2136
  inner.append(command[index + 2 : end])
1424
- elif command[index] == "`":
2137
+ elif character == "`":
1425
2138
  end = index + 1
1426
2139
  while end < len(command) and command[end] != "`":
1427
2140
  end += 2 if command[end] == "\\" else 1
1428
2141
  if end >= len(command):
1429
2142
  raise ValueError("backtick command substitution is not closed")
1430
2143
  inner.append(command[index + 1 : end])
1431
- elif command[index] == "\\":
1432
- outer.append(command[index : index + 2])
1433
- index += 2
1434
- continue
2144
+ elif command.startswith("@(", index):
2145
+ raise ValueError(
2146
+ "@( expansion cannot be validated; write the list as literal arguments"
2147
+ )
1435
2148
  else:
1436
- outer.append(command[index])
2149
+ outer.append(character)
1437
2150
  index += 1
1438
2151
  continue
1439
2152
  outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
@@ -1454,11 +2167,8 @@ def strip_heredoc_bodies(command):
1454
2167
  line = lines[index]
1455
2168
  kept.append(line)
1456
2169
  index += 1
1457
- for match in HEREDOC_PATTERN.finditer(line):
1458
- delimiter = (
1459
- match.group("single") or match.group("double") or match.group("bare")
1460
- )
1461
- strip = "\t\r" if match.group("dash") else "\r"
2170
+ for delimiter, strip_tabs, _ in heredoc_requests(line):
2171
+ strip = "\t\r" if strip_tabs else "\r"
1462
2172
  while index < len(lines) and lines[index].strip(strip) != delimiter:
1463
2173
  index += 1
1464
2174
  if index >= len(lines):
@@ -1497,14 +2207,222 @@ def decode_patch_path(raw_path, strip_prefix):
1497
2207
  return path
1498
2208
 
1499
2209
 
1500
- def shell_tokens(command):
1501
- command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2210
+ PARAMETER_MARKERS = frozenset("$%!")
2211
+
2212
+
2213
+ class ShellToken(str):
2214
+ """A token that keeps, per parameter marker character, whether double quotes enclosed it.
2215
+
2216
+ `redirection` is true for an unquoted redirection operator and for one
2217
+ ASCII file descriptor digit written against one (the 2 of 2>file, not of
2218
+ 2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
2219
+ `literal` is "bare" for a word written with no quote or escape, "quoted"
2220
+ for a word inside one pair of plain quotes, "mixed" for plain quoted and
2221
+ unquoted parts where no unquoted part can expand, and None when the shell
2222
+ may build the word in another way (escapes, an expanding unquoted part, a
2223
+ line continuation in the word or one that joins it to the next word).
2224
+ """
2225
+
2226
+ double_quoted = ()
2227
+ redirection = False
2228
+ literal = None
2229
+
2230
+
2231
+ def double_quoted_markers(command):
2232
+ """Return, per parameter marker in order, whether plain double quotes enclose it.
2233
+
2234
+ None means the command uses quoting outside the plain constructs this scan
2235
+ models (plain single quotes, plain double quotes, unquoted text), so no
2236
+ marker in it can be proven quoted.
2237
+ """
2238
+ flags = []
2239
+ quote = None
2240
+ comment = False
2241
+ braces = 0
2242
+ previous = " "
2243
+ index = 0
2244
+ while index < len(command):
2245
+ character = command[index]
2246
+ following = command[index + 1 : index + 2]
2247
+ if comment:
2248
+ if character in "\r\n":
2249
+ comment = False
2250
+ elif character in "'\"\\`":
2251
+ return None
2252
+ elif quote == "'":
2253
+ if character == "'":
2254
+ quote = None
2255
+ elif character == "\\":
2256
+ if following and following in PARAMETER_MARKERS:
2257
+ # The shell executes the escaped marker as literal text; the
2258
+ # token keeps the bare marker, so the pair is never provable.
2259
+ flags.append(False)
2260
+ previous = character
2261
+ index += 2
2262
+ continue
2263
+ elif character == "`" or (character == "$" and following == "("):
2264
+ return None
2265
+ elif character == "$" and following == "{":
2266
+ braces += 1
2267
+ elif character == "}" and braces:
2268
+ braces -= 1
2269
+ elif character in "'\"" and braces:
2270
+ return None
2271
+ elif character == '"':
2272
+ quote = None if quote else '"'
2273
+ elif quote is None:
2274
+ if character == "'":
2275
+ quote = "'"
2276
+ elif character == "$" and following and following in "'\"":
2277
+ return None
2278
+ elif character == "<" and following == "<":
2279
+ return None
2280
+ elif character == "#" and previous in " \t\r\n;&|()<>":
2281
+ comment = True
2282
+ if character in PARAMETER_MARKERS:
2283
+ flags.append(quote == '"' and not comment)
2284
+ previous = character
2285
+ index += 1
2286
+ return flags if quote is None and not braces else None
2287
+
2288
+
2289
+ def zsh_expansion_flag(text):
2290
+ """Whether the text holds an unbraced zsh expansion flag ($=x, $~x, $^x, $+x).
2291
+
2292
+ These match no parameter syntax, so without this check they never enter
2293
+ the parameter proof at all — while zsh word-splits them exactly like the
2294
+ forms the proof denies. Any other unmatched $ is inert literal text.
2295
+ """
2296
+ return ZSH_EXPANSION_FLAG_SYNTAX.search(text)
2297
+
2298
+
2299
+ def parameters_double_quoted(token):
2300
+ """Whether every parameter in the token expands inside double quotes to one word.
2301
+
2302
+ Only plain $name and ${name} qualify: flags, operators, subscripts,
2303
+ positional and special parameters can split even inside double quotes.
2304
+ A backslash-escaped marker stays one flag short of proven: the shell
2305
+ executes the literal text while the guard's token still shows the bare
2306
+ marker, so nothing about its expansion is provable.
2307
+ """
2308
+ flags = getattr(token, "double_quoted", ())
2309
+ positions = [
2310
+ index for index, character in enumerate(token) if character in PARAMETER_MARKERS
2311
+ ]
2312
+ if len(flags) != len(positions):
2313
+ return False
2314
+ quoted = dict(zip(positions, flags))
2315
+ plain = {
2316
+ match.start(): match.end() for match in NAMED_SHELL_PARAMETER_SYNTAX.finditer(token)
2317
+ }
2318
+ if any(
2319
+ character == "$" and index not in plain for index, character in enumerate(token)
2320
+ ) or any(token[end : end + 1] == "[" for end in plain.values()):
2321
+ return False
2322
+ return all(
2323
+ quoted[match.start()] and quoted.get(match.end() - 1, True)
2324
+ for syntax in (SHELL_PARAMETER_SYNTAX, CMD_PARAMETER_SYNTAX)
2325
+ for match in syntax.finditer(token)
2326
+ )
2327
+
2328
+
2329
+ def shell_tokens(command, direct=True):
2330
+ """Tokenize a command; `direct` is False for a string that a second shell parses.
2331
+
2332
+ Only a directly parsed command keeps quote flags: an outer shell expands
2333
+ parameters before the inner parse, so inner quotes prove nothing.
2334
+ """
2335
+ breaks = set()
2336
+ if LINE_CONTINUATION.search(command):
2337
+ mark = next(
2338
+ character for character in map(chr, range(0xE000, 0xF900))
2339
+ if character not in command
2340
+ )
2341
+ marked = strip_heredoc_bodies(
2342
+ LINE_CONTINUATION.sub(lambda match: match.group(1) + mark, command)
2343
+ )
2344
+ command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2345
+ breaks = (
2346
+ {index for index, character in enumerate(marked) if character == mark}
2347
+ if marked.replace(mark, " ") == command
2348
+ else None
2349
+ )
2350
+ else:
2351
+ command = strip_heredoc_bodies(command)
2352
+ joins = set()
2353
+ for index in breaks or ():
2354
+ left, right = index - 1, index + 1
2355
+ while left >= 0 and marked[left] == mark:
2356
+ left -= 1
2357
+ while right < len(marked) and marked[right] == mark:
2358
+ right += 1
2359
+ if left >= 0 and marked[left].strip() and marked[right:right + 1].strip():
2360
+ joins.add(index)
1502
2361
  lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
1503
2362
  lexer.whitespace = " \t"
1504
2363
  lexer.whitespace_split = True
1505
2364
  lexer.commenters = ""
2365
+ tokens = []
2366
+ previous_end = 0
1506
2367
  try:
1507
- tokens = list(lexer)
2368
+ for token in lexer:
2369
+ end = lexer.instream.tell() - len(lexer._pushback_chars)
2370
+ if command[end - 1:end] in (" ", "\t"):
2371
+ end -= 1
2372
+ word_start = previous_end
2373
+ while word_start < end and command[word_start] in " \t":
2374
+ word_start += 1
2375
+ previous_end = end
2376
+ word = command[word_start:end]
2377
+ literal = None
2378
+ if (
2379
+ breaks is not None
2380
+ and "\\" not in word
2381
+ and not ANSI_QUOTE_START.search(word)
2382
+ and breaks.isdisjoint(range(word_start, end))
2383
+ and end not in joins
2384
+ and word_start - 1 not in joins
2385
+ and PLAIN_QUOTED_SEGMENT.sub(lambda match: match.group(0)[1:-1], word) == token
2386
+ ):
2387
+ unquoted = PLAIN_QUOTED_SEGMENT.sub("", word)
2388
+ if (
2389
+ "'" not in unquoted
2390
+ and '"' not in unquoted
2391
+ and not GIT_CONFIG_EXPANDING_WORD.search(unquoted)
2392
+ ):
2393
+ literal = "mixed"
2394
+ for quote in ("", "'", '"'):
2395
+ start = end - len(token) - 2 * len(quote)
2396
+ if (
2397
+ breaks is not None
2398
+ and start >= 0
2399
+ and command[start:end] == quote + token + quote
2400
+ and (start == 0 or command[start - 1] in " \t|;&()<>\n\r")
2401
+ and breaks.isdisjoint(range(start, end))
2402
+ and end not in joins
2403
+ and start - 1 not in joins
2404
+ ):
2405
+ literal = "quoted" if quote else "bare"
2406
+ break
2407
+ start = end - len(token)
2408
+ before = command[start - 1:start] if start > 0 else ""
2409
+ redirection = command[start:end] == token and (
2410
+ (
2411
+ set(token) <= SHELL_WRITE_REDIRECTION_CHARS
2412
+ and ("<" in token or ">" in token)
2413
+ and before != "\\"
2414
+ )
2415
+ or (
2416
+ len(token) == 1
2417
+ and token in "0123456789"
2418
+ and command[end:end + 1] in ("<", ">")
2419
+ and (not before or before in " \t|;&()<>\n\r")
2420
+ )
2421
+ )
2422
+ token = ShellToken(token)
2423
+ token.redirection = redirection
2424
+ token.literal = literal
2425
+ tokens.append(token)
1508
2426
  except ValueError as error:
1509
2427
  raise ValueError(
1510
2428
  f"shell command cannot be tokenized ({error}); balance the quotes "
@@ -1512,7 +2430,20 @@ def shell_tokens(command):
1512
2430
  ) from None
1513
2431
  if not tokens:
1514
2432
  raise ValueError("shell command is empty")
1515
- return tokens
2433
+ flags = double_quoted_markers(command) if direct else None
2434
+ if flags is None or len(flags) != sum(
2435
+ character in PARAMETER_MARKERS for token in tokens for character in token
2436
+ ):
2437
+ return tokens
2438
+ marked = []
2439
+ for token in tokens:
2440
+ count = sum(character in PARAMETER_MARKERS for character in token)
2441
+ redirection, literal = token.redirection, token.literal
2442
+ token = ShellToken(token)
2443
+ token.redirection, token.literal = redirection, literal
2444
+ token.double_quoted, flags = tuple(flags[:count]), flags[count:]
2445
+ marked.append(token)
2446
+ return marked
1516
2447
 
1517
2448
 
1518
2449
  def command_segments(tokens):
@@ -1572,56 +2503,163 @@ def xargs_command(arguments):
1572
2503
 
1573
2504
 
1574
2505
  def require_read_command(wrapper, wrapped):
1575
- if wrapped and not archive_command_is_read_only(" ".join(wrapped), wrapped, True):
2506
+ segments = list(command_segments(wrapped))
2507
+ if wrapped and (
2508
+ not segments
2509
+ or not all(
2510
+ archive_command_is_read_only(shlex.join(segment), segment, True)
2511
+ for segment in segments
2512
+ )
2513
+ ):
1576
2514
  raise ValueError(
1577
2515
  f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
1578
2516
  f"only read commands may follow {wrapper}, or pass the paths as literals"
1579
2517
  )
1580
2518
 
1581
2519
 
1582
- def command_invocation(segment):
2520
+ def runner_value_option(prefix, token):
2521
+ """The option in a runner option word whose value is the next word, if any.
2522
+
2523
+ Raises for an option that writes, edits or enters a file or directory. A
2524
+ long option matches by each prefix, as getopt accepts it. A short word is
2525
+ read as getopt reads it: flag letters, then the first value option letter,
2526
+ whose value is the rest of the word or, with no rest, the next word.
2527
+ """
2528
+ value_options = EXEC_PREFIX_VALUE_OPTIONS[prefix]
2529
+ file_options = EXEC_PREFIX_FILE_OPTIONS.get(prefix, frozenset())
2530
+ denial = ValueError(
2531
+ f"{prefix} {token} writes, edits or enters a file or directory that the "
2532
+ "guard cannot check; run the command without this option and redirect "
2533
+ "its output instead"
2534
+ )
2535
+ if token.startswith("--"):
2536
+ name = token.split("=", 1)[0]
2537
+ if any(option.startswith(name) for option in file_options):
2538
+ raise denial
2539
+ option = next((option for option in value_options if option.startswith(name)), None)
2540
+ return option if "=" not in token else None
2541
+ for position, letter in enumerate(token[1:], start=1):
2542
+ option = f"-{letter}"
2543
+ if option in file_options:
2544
+ raise denial
2545
+ if option in value_options:
2546
+ return option if position == len(token) - 1 else None
2547
+ if letter not in EXEC_PREFIX_FLAG_LETTERS[prefix]:
2548
+ return None
2549
+ return None
2550
+
2551
+
2552
+ def nonliteral_runner_option(token):
2553
+ literal = getattr(token, "literal", "bare")
2554
+ return (
2555
+ SHELL_PARAMETER_SYNTAX.search(token)
2556
+ or CMD_PARAMETER_SYNTAX.search(token)
2557
+ or zsh_expansion_flag(token)
2558
+ or literal is None
2559
+ or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(token))
2560
+ )
2561
+
2562
+
2563
+ def unresolved_runner_word(token):
2564
+ return (
2565
+ SHELL_PARAMETER_SYNTAX.search(token)
2566
+ or CMD_PARAMETER_SYNTAX.search(token)
2567
+ or zsh_expansion_flag(token)
2568
+ ) and not parameters_double_quoted(token)
2569
+
2570
+
2571
+ def command_invocation(segment, unwrap_runners=True):
2572
+ """`unwrap_runners` is False where a command runner itself must be judged."""
1583
2573
  index = 0
1584
- while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1585
- validate_shell_assignment(segment[index])
2574
+ # The shell word `time` is already removed; its only option can remain.
2575
+ while unwrap_runners and index < len(segment) and segment[index] == "-p":
1586
2576
  index += 1
1587
- if index >= len(segment):
1588
- return None
1589
- executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1590
- if executable == "env":
1591
- index += 1
1592
- while index < len(segment):
1593
- token = segment[index]
1594
- if token == "--":
1595
- index += 1
2577
+ while True:
2578
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
2579
+ validate_shell_assignment(segment[index])
2580
+ index += 1
2581
+ if index >= len(segment):
2582
+ return None
2583
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
2584
+ prefix = executable.removesuffix(".exe")
2585
+ if executable == "env":
2586
+ index += 1
2587
+ while index < len(segment):
2588
+ token = segment[index]
2589
+ if token == "--":
2590
+ index += 1
2591
+ break
2592
+ if SHELL_ASSIGNMENT_PATTERN.match(token):
2593
+ validate_shell_assignment(token)
2594
+ index += 1
2595
+ continue
2596
+ option = token.split("=", 1)[0]
2597
+ if option in ENV_OPTIONS_WITHOUT_VALUES:
2598
+ index += 1
2599
+ continue
2600
+ if option in ENV_OPTIONS_WITH_VALUES:
2601
+ if option in {"-C", "--chdir"}:
2602
+ raise ValueError(
2603
+ f"env {option} changes the working directory the guard "
2604
+ "tracks; use cd with a literal path instead"
2605
+ )
2606
+ index += 1
2607
+ if "=" not in token:
2608
+ if index >= len(segment):
2609
+ raise ValueError(f"env {option} lacks a value")
2610
+ index += 1
2611
+ continue
2612
+ if token.startswith("-"):
2613
+ raise ValueError(
2614
+ f"env {token} cannot be validated; use env NAME=VALUE <command>"
2615
+ )
2616
+ if SHELL_PARAMETER_SYNTAX.search(token) or zsh_expansion_flag(token):
2617
+ raise ValueError(
2618
+ f"env word {token} cannot be resolved by the guard; env "
2619
+ "assignments must be literal NAME=VALUE words and the "
2620
+ "program a literal name"
2621
+ )
1596
2622
  break
1597
- if SHELL_ASSIGNMENT_PATTERN.match(token):
1598
- validate_shell_assignment(token)
2623
+ elif unwrap_runners and prefix in EXEC_PREFIX_VALUE_OPTIONS:
2624
+ index += 1
2625
+ while index < len(segment):
2626
+ token = segment[index]
2627
+ if token == "--":
2628
+ index += 1
2629
+ break
2630
+ if not token.startswith("-") or token == "-":
2631
+ break
1599
2632
  index += 1
1600
- continue
1601
- option = token.split("=", 1)[0]
1602
- if option in ENV_OPTIONS_WITHOUT_VALUES:
2633
+ if nonliteral_runner_option(token):
2634
+ raise ValueError(
2635
+ f"{prefix} option {token} cannot be resolved by the guard; "
2636
+ f"{prefix} options must be literal words"
2637
+ )
2638
+ option = runner_value_option(prefix, token)
2639
+ if option is None or index >= len(segment):
2640
+ continue
2641
+ value = segment[index]
1603
2642
  index += 1
1604
- continue
1605
- if option in ENV_OPTIONS_WITH_VALUES:
1606
- if option in {"-C", "--chdir"}:
2643
+ if prefix == "watch" and option in EXEC_PREFIX_INTERVAL_OPTIONS:
2644
+ if not RUNNER_DURATION.fullmatch(value):
2645
+ raise ValueError(
2646
+ f"watch interval {value} cannot be resolved by the guard; "
2647
+ "the interval must be a literal number"
2648
+ )
2649
+ elif unresolved_runner_word(value):
2650
+ raise ValueError(
2651
+ f"{prefix} option value {value} cannot be resolved by the "
2652
+ f"guard; {prefix} option values must be literal words"
2653
+ )
2654
+ if prefix == "timeout" and index < len(segment):
2655
+ if not RUNNER_DURATION.fullmatch(segment[index]):
1607
2656
  raise ValueError(
1608
- f"env {option} changes the working directory the guard "
1609
- "tracks; use cd with a literal path instead"
2657
+ f"timeout duration {segment[index]} cannot be resolved by the "
2658
+ "guard; the duration must be a literal number"
1610
2659
  )
1611
2660
  index += 1
1612
- if "=" not in token:
1613
- if index >= len(segment):
1614
- raise ValueError(f"env {option} lacks a value")
1615
- index += 1
1616
- continue
1617
- if token.startswith("-"):
1618
- raise ValueError(
1619
- f"env {token} cannot be validated; use env NAME=VALUE <command>"
1620
- )
2661
+ else:
1621
2662
  break
1622
- if index >= len(segment):
1623
- return None
1624
- executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1625
2663
  arguments = segment[index + 1 :]
1626
2664
  if SHELL_PARAMETER_SYNTAX.search(executable):
1627
2665
  raise ValueError(
@@ -1645,19 +2683,103 @@ def command_invocation(segment):
1645
2683
  return executable, arguments
1646
2684
 
1647
2685
 
1648
- def git_command(segment):
2686
+ def require_resolved_git_word(word, mixed=True):
2687
+ """Deny a git word the shell can turn into other or several arguments."""
2688
+ literal = getattr(word, "literal", "bare")
2689
+ if (
2690
+ literal is None
2691
+ or (literal == "mixed" and not mixed)
2692
+ or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(word))
2693
+ ):
2694
+ raise ValueError(
2695
+ f"git argument {word} cannot be resolved by the guard; "
2696
+ "pass a literal value"
2697
+ )
2698
+
2699
+
2700
+ def git_command(segment, assignments=None):
1649
2701
  invocation = command_invocation(segment)
1650
2702
  if invocation is None:
1651
2703
  return None
1652
2704
  executable, arguments = invocation
1653
2705
  if executable not in {"git", "git.exe"}:
1654
2706
  return None
1655
- for argument in arguments:
1656
- if SHELL_PARAMETER_SYNTAX.search(argument):
1657
- raise ValueError(
1658
- f"git argument {argument} cannot be resolved by the guard; pass a "
1659
- "literal value (for commit messages use -F <file>)"
2707
+ resolved_arguments = []
2708
+ parameter_arguments = []
2709
+ unproven_parameters = []
2710
+ argument_index = 0
2711
+ while argument_index < len(arguments):
2712
+ argument = arguments[argument_index]
2713
+ attached_directory = argument.startswith("-C") and len(argument) > 2
2714
+ if argument == "-C" and argument_index + 1 < len(arguments):
2715
+ argument_index += 1
2716
+ directory = arguments[argument_index]
2717
+ require_resolved_git_word(directory)
2718
+ expanded = expand_environment_parameters(directory, assignments)
2719
+ if (
2720
+ SHELL_PARAMETER_SYNTAX.search(expanded)
2721
+ or CMD_PARAMETER_SYNTAX.search(expanded)
2722
+ or zsh_expansion_flag(expanded)
2723
+ ):
2724
+ raise ValueError(
2725
+ f"git argument {directory} cannot be resolved by the guard; "
2726
+ "pass a literal path"
2727
+ )
2728
+ if (
2729
+ (SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
2730
+ and any(character.isspace() or character in "*?[]" for character in expanded)
2731
+ ):
2732
+ raise ValueError(
2733
+ f"git -C path {directory} may split or glob; pass a literal path"
2734
+ )
2735
+ resolved_arguments.extend((argument, expanded))
2736
+ elif attached_directory:
2737
+ require_resolved_git_word(argument)
2738
+ directory = argument[2:]
2739
+ expanded = expand_environment_parameters(directory, assignments)
2740
+ if (
2741
+ SHELL_PARAMETER_SYNTAX.search(expanded)
2742
+ or CMD_PARAMETER_SYNTAX.search(expanded)
2743
+ or zsh_expansion_flag(expanded)
2744
+ ):
2745
+ raise ValueError(
2746
+ f"git argument {directory} cannot be resolved by the guard; "
2747
+ "pass a literal path"
2748
+ )
2749
+ if (
2750
+ (SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
2751
+ and any(character.isspace() or character in "*?[]" for character in expanded)
2752
+ ):
2753
+ raise ValueError(
2754
+ f"git -C path {directory} may split or glob; pass a literal path"
2755
+ )
2756
+ resolved_arguments.append(f"-C{expanded}")
2757
+ else:
2758
+ parameter = (
2759
+ SHELL_PARAMETER_SYNTAX.search(argument)
2760
+ or CMD_PARAMETER_SYNTAX.search(argument)
2761
+ or zsh_expansion_flag(argument)
1660
2762
  )
2763
+ if parameter:
2764
+ parameter_arguments.append(argument)
2765
+ literal = re.split(r"[$%!]", argument, maxsplit=1)[0]
2766
+ expanded = expand_environment_parameters(argument, assignments)
2767
+ if (
2768
+ not literal
2769
+ or (literal.startswith("-") and "=" not in literal)
2770
+ or git_option_in(literal, GIT_PARAMETER_DENIED_OPTIONS)
2771
+ or git_option_in(arguments[argument_index - 1], GIT_PARAMETER_DENIED_OPTIONS)
2772
+ or (literal.startswith("-c") and literal != "-c")
2773
+ or "::" in literal
2774
+ or not parameters_double_quoted(argument)
2775
+ or SUBSTITUTION_PLACEHOLDER in argument
2776
+ or SUBSTITUTION_PLACEHOLDER in expanded
2777
+ ):
2778
+ unproven_parameters.append(argument)
2779
+ expanded = expand_environment_parameters(argument, assignments)
2780
+ resolved_arguments.append(argument if expanded == argument else expanded)
2781
+ argument_index += 1
2782
+ arguments = resolved_arguments
1661
2783
  index = 0
1662
2784
  git_options = []
1663
2785
  while index < len(arguments) and arguments[index].startswith("-"):
@@ -1668,18 +2790,32 @@ def git_command(segment):
1668
2790
  option = token.split("=", 1)[0]
1669
2791
  value = token.split("=", 1)[1] if "=" in token else None
1670
2792
  index += 1
2793
+ if option != "-C":
2794
+ require_resolved_git_word(token)
1671
2795
  if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
1672
2796
  if index >= len(arguments):
1673
2797
  raise ValueError(f"git option {option} lacks a value")
1674
2798
  value = arguments[index]
2799
+ if option != "-C":
2800
+ require_resolved_git_word(value)
1675
2801
  index += 1
1676
2802
  if option in {"-c", "--config-env"}:
1677
- config_key = str(value).split("=", 1)[0].casefold()
2803
+ config_key, _, config_value = str(value).partition("=")
2804
+ config_key = config_key.casefold()
1678
2805
  if config_key.startswith("alias."):
1679
2806
  raise ValueError(
1680
2807
  f"git -c {config_key} defines an alias the guard cannot inspect; "
1681
2808
  "run the underlying git command directly"
1682
2809
  )
2810
+ if executed_config_key(config_key) and not (
2811
+ option == "-c"
2812
+ and not loaded_config_key(config_key)
2813
+ and config_value.casefold() in GIT_CONFIG_DISABLING_VALUES
2814
+ ):
2815
+ raise ValueError(
2816
+ f"git -c {config_key} configures a program git executes; "
2817
+ "the guard cannot allow it"
2818
+ )
1683
2819
  if config_key == "clean.requireforce":
1684
2820
  raise ValueError(
1685
2821
  "git -c clean.requireForce disables the git clean safety check; "
@@ -1688,8 +2824,47 @@ def git_command(segment):
1688
2824
  if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
1689
2825
  git_options.extend((option, str(value)))
1690
2826
  if index >= len(arguments):
2827
+ if parameter_arguments:
2828
+ raise ValueError(
2829
+ f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
2830
+ "pass a literal value (for commit messages use -F <file>)"
2831
+ )
1691
2832
  return None
1692
- return arguments[index].casefold(), arguments[index + 1:], git_options
2833
+ require_resolved_git_word(arguments[index], mixed=False)
2834
+ subcommand = arguments[index].casefold()
2835
+ if subcommand == "config":
2836
+ for argument in arguments[index + 1:]:
2837
+ literal = getattr(argument, "literal", None)
2838
+ if not getattr(argument, "redirection", False) and (
2839
+ literal in (None, "mixed")
2840
+ or (
2841
+ literal == "bare"
2842
+ and (argument.startswith("#") or GIT_CONFIG_EXPANDING_WORD.search(argument))
2843
+ )
2844
+ ):
2845
+ raise ValueError(
2846
+ f"git argument {argument} cannot be resolved by the guard; "
2847
+ "pass a literal value"
2848
+ )
2849
+ denied = denied_git_config_argument(arguments[index + 1:])
2850
+ if denied is not None:
2851
+ raise ValueError(
2852
+ f"git config {denied} persists a program git executes; "
2853
+ "the guard cannot allow it"
2854
+ )
2855
+ read_only_subcommands = ARCHIVE_READ_GIT_COMMANDS | CLOSED_READ_GIT_COMMANDS
2856
+ if subcommand in read_only_subcommands:
2857
+ # Read-only subcommands still write through --output. A parameter
2858
+ # passes only inside plain double quotes, where its expansion stays
2859
+ # one word; an unquoted one can word-split into an option, and
2860
+ # command substitution output is never known.
2861
+ parameter_arguments = unproven_parameters
2862
+ if parameter_arguments:
2863
+ raise ValueError(
2864
+ f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
2865
+ "pass a literal value (for commit messages use -F <file>)"
2866
+ )
2867
+ return subcommand, arguments[index + 1:], git_options
1693
2868
 
1694
2869
 
1695
2870
  def wrapped_command_tokens(segment, expand_parameters=True):
@@ -1698,7 +2873,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1698
2873
  return None
1699
2874
  executable, arguments = invocation
1700
2875
  if executable == "eval":
1701
- return shell_tokens(" ".join(arguments)) if arguments else None
2876
+ return shell_tokens(" ".join(arguments), direct=False) if arguments else None
1702
2877
  if executable in COMMAND_WRAPPERS:
1703
2878
  if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
1704
2879
  return None
@@ -1723,7 +2898,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1723
2898
  executable=executable, option=argument
1724
2899
  )
1725
2900
  )
1726
- return shell_tokens(arguments[index + 1])
2901
+ return shell_tokens(arguments[index + 1], direct=False)
1727
2902
  raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1728
2903
  if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
1729
2904
  switches = {"-c", "-command", "/c", "/k"}
@@ -1742,8 +2917,8 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1742
2917
  for argument in payload
1743
2918
  ]
1744
2919
  if len(payload) == 1:
1745
- return shell_tokens(payload[0])
1746
- return payload
2920
+ return shell_tokens(payload[0], direct=False)
2921
+ return [str(argument) for argument in payload]
1747
2922
  raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1748
2923
  return None
1749
2924
 
@@ -1798,14 +2973,18 @@ def update_ref_deletes(arguments):
1798
2973
  )
1799
2974
 
1800
2975
 
1801
- def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1802
- for segment in command_segments(tokens):
2976
+ def destructive_git_reason(
2977
+ tokens, resolved_aliases=frozenset(), initial_assignments=None
2978
+ ):
2979
+ segments = list(command_segments(tokens))
2980
+ contexts, _ = shell_assignment_contexts(tokens, initial_assignments)
2981
+ for segment, assignments in zip(segments, contexts):
1803
2982
  wrapped = wrapped_command_tokens(segment)
1804
2983
  if wrapped is not None:
1805
- reason = destructive_git_reason(wrapped, resolved_aliases)
2984
+ reason = destructive_git_reason(wrapped, resolved_aliases, assignments)
1806
2985
  if reason is not None:
1807
2986
  return reason
1808
- invocation = git_command(segment)
2987
+ invocation = git_command(segment, assignments)
1809
2988
  if invocation is None:
1810
2989
  continue
1811
2990
  command, arguments, git_options = invocation
@@ -1828,8 +3007,9 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1828
3007
  "git command directly"
1829
3008
  )
1830
3009
  reason = destructive_git_reason(
1831
- ["git", *git_options, *shell_tokens(alias), *arguments],
3010
+ ["git", *git_options, *shell_tokens(alias, direct=False), *arguments],
1832
3011
  resolved_aliases | {command},
3012
+ assignments,
1833
3013
  )
1834
3014
  if reason is not None:
1835
3015
  return reason
@@ -1983,28 +3163,76 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
1983
3163
  return False
1984
3164
 
1985
3165
 
1986
- def archive_command_is_read_only(command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS):
3166
+ def archive_command_is_read_only(
3167
+ command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS,
3168
+ source=None,
3169
+ ):
3170
+ """`source` is the unexpanded (segment, assignments) whose tokens keep their quote flags."""
1987
3171
  if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
1988
3172
  return False
1989
3173
  if not archive_context:
1990
3174
  return True
1991
- executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold()
3175
+ invocation = command_invocation(tokens, unwrap_runners=False)
3176
+ if invocation is None:
3177
+ return True
3178
+ executable, arguments = invocation
3179
+ executable = executable.removesuffix(".exe")
3180
+ if executable in DIRECTORY_CHANGE_COMMANDS:
3181
+ return True
3182
+ if executable in {"export", "declare", "typeset", "unset"}:
3183
+ return True
1992
3184
  denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
1993
3185
  if any(
1994
3186
  token == option or token.startswith(f"{option}=")
1995
- for token in tokens[1:]
3187
+ for token in arguments
1996
3188
  for option in denied_options
1997
3189
  ):
1998
3190
  return False
3191
+ if executable == "sed":
3192
+ in_place, _inputs, script_is_read_only = sed_arguments(arguments)
3193
+ return not in_place and script_is_read_only
1999
3194
  if executable in ARCHIVE_READ_COMMANDS:
2000
3195
  return True
2001
- if executable != "git" or len(tokens) < 2:
3196
+ if executable != "git":
3197
+ return False
3198
+ git = git_command(*(source or (tokens,)))
3199
+ if git is None:
2002
3200
  return False
2003
- if tokens[1].casefold() not in git_commands:
3201
+ subcommand, git_arguments, _options = git
3202
+ if subcommand not in git_commands:
2004
3203
  return False
2005
3204
  return not any(
2006
- token in GIT_READ_WRITE_OPTIONS or token.startswith("--output=")
2007
- for token in tokens[2:]
3205
+ git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
3206
+ )
3207
+
3208
+
3209
+ def archive_write_attempt(segment, source=None):
3210
+ """Whether a segment denied in archive context attempts a write there."""
3211
+ if any(
3212
+ token
3213
+ and (set(token) <= SHELL_WRITE_REDIRECTION_CHARS
3214
+ or re.fullmatch(r"\d*[<>]+&?", token))
3215
+ for token in segment
3216
+ ):
3217
+ return True
3218
+ invocation = command_invocation(segment)
3219
+ if invocation is None:
3220
+ return False
3221
+ executable, arguments = invocation
3222
+ executable = executable.removesuffix(".exe")
3223
+ if executable in SHELL_WRITE_COMMANDS:
3224
+ return True
3225
+ if executable in IN_PLACE_EDITORS and (
3226
+ has_short_option(arguments, "i")
3227
+ or any(argument.startswith("--in-place") for argument in arguments)
3228
+ ):
3229
+ return True
3230
+ git = git_command(*(source or (segment,)))
3231
+ if git is None:
3232
+ return False
3233
+ subcommand, git_arguments, _options = git
3234
+ return subcommand not in ARCHIVE_READ_GIT_COMMANDS or any(
3235
+ git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
2008
3236
  )
2009
3237
 
2010
3238
 
@@ -2126,22 +3354,28 @@ def closed_git_listing(tokens):
2126
3354
  return not positionals
2127
3355
 
2128
3356
 
2129
- def closed_shell_execution_reason(command, tokens, working_directories):
3357
+ def closed_shell_execution_reason(
3358
+ command, tokens, working_directories, initial_assignments=None
3359
+ ):
2130
3360
  helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
2131
3361
  rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
2132
3362
  if rest[:1] == ["pending"]:
2133
3363
  helpers = helpers | {"discussion_records.py"}
2134
3364
  if bundled_helper_invocation(command, tokens, working_directories, helpers):
2135
3365
  return None
2136
- for segment, directories in segment_directories(tokens, working_directories):
3366
+ for segment, directories, assignments, _prefix in shell_segment_contexts(
3367
+ tokens, working_directories, initial_assignments
3368
+ ):
2137
3369
  wrapped = wrapped_command_tokens(segment)
2138
3370
  if wrapped is not None:
2139
- reason = closed_shell_execution_reason(shlex.join(wrapped), wrapped, directories)
3371
+ reason = closed_shell_execution_reason(
3372
+ shlex.join(wrapped), wrapped, directories, assignments
3373
+ )
2140
3374
  if reason:
2141
3375
  return reason
2142
3376
  continue
2143
3377
  metadata_closed = None
2144
- git = git_command(segment)
3378
+ git = git_command(segment, assignments)
2145
3379
  checked_segment = segment
2146
3380
  if git is not None:
2147
3381
  subcommand, arguments, options = git
@@ -2180,6 +3414,7 @@ def closed_shell_execution_reason(command, tokens, working_directories):
2180
3414
  def command_denial(command, working_directories, allow_destructive=True):
2181
3415
  """Return why a shell command is denied, or None when it may run."""
2182
3416
  working_directories = working_directories or [os.getcwd()]
3417
+ command = re.sub(r"(?i:(\.project)\\(archive))", r"\1/\2", command)
2183
3418
  outer, substitutions = split_command_substitutions(command)
2184
3419
  try:
2185
3420
  tokens = shell_tokens(outer)
@@ -2188,6 +3423,7 @@ def command_denial(command, working_directories, allow_destructive=True):
2188
3423
  for interpreter in ("python3", "python", "py -3")
2189
3424
  ):
2190
3425
  return None
3426
+ bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
2191
3427
  destructive = list(destructive_shell_invocations(tokens))
2192
3428
  if destructive and (
2193
3429
  not allow_destructive
@@ -2208,16 +3444,31 @@ def command_denial(command, working_directories, allow_destructive=True):
2208
3444
  reason = command_denial(inner, working_directories, allow_destructive=False)
2209
3445
  if reason is not None:
2210
3446
  return reason
2211
- archive_context = (
2212
- any(working_directory_in_archive(path) for path in working_directories)
2213
- or bool(ARCHIVE_REFERENCE.search(outer))
2214
- or command_references_archive(tokens, working_directories)
2215
- or unresolved_archive_expansion(outer, tokens, working_directories)
2216
- )
2217
- if not archive_command_is_read_only(command, tokens, archive_context) and not (
2218
- archive_context and bundled_helper_invocation(command, tokens, working_directories)
3447
+ for segment, directories, assignments, prefix in shell_segment_contexts(
3448
+ tokens, working_directories
2219
3449
  ):
2220
- return ARCHIVE_REASON
3450
+ resolved_segment = [
3451
+ expand_environment_parameters(token, assignments) for token in segment
3452
+ ]
3453
+ archive_context = (
3454
+ any(working_directory_in_archive(path) for path in directories)
3455
+ or segment_references_archive(segment, directories, assignments)
3456
+ or any(
3457
+ ARCHIVE_REFERENCE.search(value)
3458
+ or path_in_archive(value, directories, ancestors=False)
3459
+ for value in prefix.values()
3460
+ )
3461
+ )
3462
+ if not archive_command_is_read_only(
3463
+ shlex.join(resolved_segment), resolved_segment, archive_context,
3464
+ source=(segment, assignments),
3465
+ ) and not (
3466
+ archive_context
3467
+ and bundled_helper
3468
+ ):
3469
+ if archive_write_attempt(resolved_segment, (segment, assignments)):
3470
+ return ARCHIVE_REASON
3471
+ return ARCHIVE_UNPROVEN_READ_REASON
2221
3472
  reason = destructive_git_reason(tokens) or protected_shell_write_reason(
2222
3473
  tokens, working_directories
2223
3474
  )