@opengsd/gsd-path 1.4.0 → 1.6.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 (476) hide show
  1. package/CHANGELOG.md +460 -341
  2. package/DOCS.md +16 -5
  3. package/FULL.md +67 -6
  4. package/HOOKS.md +158 -10
  5. package/MIGRATE.md +12 -0
  6. package/QUICK.md +4 -2
  7. package/README.md +136 -116
  8. package/RUNTIME.md +24 -6
  9. package/UPDATE.md +39 -3
  10. package/WORKFLOW.md +80 -4
  11. package/package.json +5 -2
  12. package/platforms/shared-agents/dispatch.md +13 -0
  13. package/scripts/_common.py +256 -2
  14. package/scripts/archive_milestone.py +14 -13
  15. package/scripts/build_recovery.py +155 -58
  16. package/scripts/build_state.py +274 -8
  17. package/scripts/check_docs_audit.py +77 -4
  18. package/scripts/check_handoffs.py +635 -57
  19. package/scripts/check_task_briefs.py +293 -32
  20. package/scripts/detect_project.py +260 -20
  21. package/scripts/discussion_records.py +9 -1
  22. package/scripts/discussion_validate.py +93 -35
  23. package/scripts/dispatch_driver.py +549 -53
  24. package/scripts/git_guard.py +27 -8
  25. package/scripts/guard_hook.py +2630 -210
  26. package/scripts/install.mjs +99 -11
  27. package/scripts/install.py +231 -46
  28. package/scripts/integration.py +244 -65
  29. package/scripts/isolation.py +539 -70
  30. package/scripts/loop_run.py +16 -4
  31. package/scripts/members.py +77 -32
  32. package/scripts/migrate_core.py +2 -0
  33. package/scripts/model_policy.py +5 -1
  34. package/scripts/path_config.py +3 -3
  35. package/scripts/pipeline_diagnose.py +17 -2
  36. package/scripts/pipeline_git.py +131 -42
  37. package/scripts/pipeline_state.py +139 -39
  38. package/scripts/pipeline_undo.py +118 -16
  39. package/scripts/promote_lookahead.py +3 -3
  40. package/scripts/review_findings.py +180 -44
  41. package/scripts/roadmap.py +9 -1
  42. package/scripts/runtime_store.py +21 -2
  43. package/scripts/skill-resources.json +9 -2
  44. package/scripts/state_checkpoint.py +39 -36
  45. package/scripts/state_promote.py +15 -4
  46. package/scripts/status_runtime.py +22 -4
  47. package/scripts/task_context.py +14 -3
  48. package/scripts/wizard.mjs +1 -0
  49. package/scripts/workflow_run.py +14 -4
  50. package/scripts/worktree_paths.py +54 -2
  51. package/skills/gsd-path/DEFINE.md +34 -5
  52. package/skills/gsd-path/PLAN.md +14 -3
  53. package/skills/gsd-path/RESEARCH.md +11 -0
  54. package/skills/gsd-path/ROADMAP.md +16 -6
  55. package/skills/gsd-path/SHIP.md +32 -5
  56. package/skills/gsd-path/SKILL.md +52 -24
  57. package/skills/gsd-path/references/build-native.md +112 -10
  58. package/skills/gsd-path/references/build-recovery.md +13 -0
  59. package/skills/gsd-path/references/coder.md +7 -3
  60. package/skills/gsd-path/references/dispatch.md +13 -0
  61. package/skills/gsd-path/references/docs-auditor.md +5 -0
  62. package/skills/gsd-path/references/operating-rules.md +11 -3
  63. package/skills/gsd-path/references/planner.md +26 -4
  64. package/skills/gsd-path/references/reviewer-final.md +20 -2
  65. package/skills/gsd-path/references/reviewer-wave.md +56 -12
  66. package/skills/gsd-path/references/reviewer.md +3 -1
  67. package/skills/gsd-path/references/runtime-selection.md +9 -0
  68. package/skills/gsd-path/references/spec-probes.md +150 -0
  69. package/skills/gsd-path/scripts/_common.py +256 -2
  70. package/skills/gsd-path/scripts/archive_milestone.py +14 -13
  71. package/skills/gsd-path/scripts/build_recovery.py +155 -58
  72. package/skills/gsd-path/scripts/build_state.py +274 -8
  73. package/skills/gsd-path/scripts/check_docs_audit.py +77 -4
  74. package/skills/gsd-path/scripts/check_handoffs.py +635 -57
  75. package/skills/gsd-path/scripts/check_task_briefs.py +293 -32
  76. package/skills/gsd-path/scripts/detect_project.py +260 -20
  77. package/skills/gsd-path/scripts/discussion_records.py +9 -1
  78. package/skills/gsd-path/scripts/discussion_validate.py +93 -35
  79. package/skills/gsd-path/scripts/dispatch_driver.py +549 -53
  80. package/skills/gsd-path/scripts/git_guard.py +27 -8
  81. package/skills/gsd-path/scripts/integration.py +244 -65
  82. package/skills/gsd-path/scripts/isolation.py +539 -70
  83. package/skills/gsd-path/scripts/loop_run.py +16 -4
  84. package/skills/gsd-path/scripts/members.py +77 -32
  85. package/skills/gsd-path/scripts/model_policy.py +5 -1
  86. package/skills/gsd-path/scripts/path_config.py +3 -3
  87. package/skills/gsd-path/scripts/pipeline_git.py +131 -42
  88. package/skills/gsd-path/scripts/pipeline_state.py +139 -39
  89. package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
  90. package/skills/gsd-path/scripts/review_findings.py +180 -44
  91. package/skills/gsd-path/scripts/roadmap.py +9 -1
  92. package/skills/gsd-path/scripts/state_checkpoint.py +39 -36
  93. package/skills/gsd-path/scripts/state_promote.py +15 -4
  94. package/skills/gsd-path/scripts/status_runtime.py +22 -4
  95. package/skills/gsd-path/scripts/task_context.py +14 -3
  96. package/skills/gsd-path/scripts/workflow_run.py +14 -4
  97. package/skills/gsd-path/scripts/worktree_paths.py +54 -2
  98. package/skills/gsd-path/templates/docs-audit.md +5 -1
  99. package/skills/gsd-path/templates/final-review.md +11 -2
  100. package/skills/gsd-path/templates/intent.md +30 -0
  101. package/skills/gsd-path/templates/patch-findings.md +3 -3
  102. package/skills/gsd-path/templates/plan.md +17 -2
  103. package/skills/gsd-path/templates/research-handoff.md +14 -3
  104. package/skills/gsd-path/templates/state.md +2 -1
  105. package/skills/gsd-path/templates/task.md +1 -0
  106. package/skills/gsd-path/templates/wave-review.md +28 -9
  107. package/skills/gsd-path-build/references/build-native.md +112 -10
  108. package/skills/gsd-path-build/references/build-recovery.md +13 -0
  109. package/skills/gsd-path-build/references/coder.md +7 -3
  110. package/skills/gsd-path-build/references/dispatch.md +13 -0
  111. package/skills/gsd-path-build/references/operating-rules.md +11 -3
  112. package/skills/gsd-path-build/references/reviewer-final.md +20 -2
  113. package/skills/gsd-path-build/references/reviewer-wave.md +56 -12
  114. package/skills/gsd-path-build/references/reviewer.md +3 -1
  115. package/skills/gsd-path-build/references/runtime-selection.md +9 -0
  116. package/skills/gsd-path-build/references/spec-probes.md +150 -0
  117. package/skills/gsd-path-build/scripts/_common.py +256 -2
  118. package/skills/gsd-path-build/scripts/archive_milestone.py +14 -13
  119. package/skills/gsd-path-build/scripts/build_recovery.py +155 -58
  120. package/skills/gsd-path-build/scripts/build_state.py +274 -8
  121. package/skills/gsd-path-build/scripts/check_handoffs.py +635 -57
  122. package/skills/gsd-path-build/scripts/check_task_briefs.py +293 -32
  123. package/skills/gsd-path-build/scripts/detect_project.py +260 -20
  124. package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
  125. package/skills/gsd-path-build/scripts/discussion_validate.py +93 -35
  126. package/skills/gsd-path-build/scripts/dispatch_driver.py +549 -53
  127. package/skills/gsd-path-build/scripts/integration.py +244 -65
  128. package/skills/gsd-path-build/scripts/isolation.py +539 -70
  129. package/skills/gsd-path-build/scripts/loop_run.py +16 -4
  130. package/skills/gsd-path-build/scripts/members.py +77 -32
  131. package/skills/gsd-path-build/scripts/model_policy.py +5 -1
  132. package/skills/gsd-path-build/scripts/path_config.py +3 -3
  133. package/skills/gsd-path-build/scripts/pipeline_git.py +131 -42
  134. package/skills/gsd-path-build/scripts/pipeline_state.py +139 -39
  135. package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
  136. package/skills/gsd-path-build/scripts/review_findings.py +180 -44
  137. package/skills/gsd-path-build/scripts/roadmap.py +9 -1
  138. package/skills/gsd-path-build/scripts/state_checkpoint.py +39 -36
  139. package/skills/gsd-path-build/scripts/state_promote.py +15 -4
  140. package/skills/gsd-path-build/scripts/task_context.py +14 -3
  141. package/skills/gsd-path-build/scripts/workflow_run.py +14 -4
  142. package/skills/gsd-path-build/scripts/worktree_paths.py +54 -2
  143. package/skills/gsd-path-build/templates/task.md +1 -0
  144. package/skills/gsd-path-build/templates/wave-review.md +28 -9
  145. package/skills/gsd-path-decide/references/dispatch.md +13 -0
  146. package/skills/gsd-path-decide/references/operating-rules.md +11 -3
  147. package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
  148. package/skills/gsd-path-decide/scripts/_common.py +256 -2
  149. package/skills/gsd-path-decide/scripts/archive_milestone.py +14 -13
  150. package/skills/gsd-path-decide/scripts/build_recovery.py +155 -58
  151. package/skills/gsd-path-decide/scripts/check_handoffs.py +635 -57
  152. package/skills/gsd-path-decide/scripts/check_task_briefs.py +293 -32
  153. package/skills/gsd-path-decide/scripts/detect_project.py +260 -20
  154. package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
  155. package/skills/gsd-path-decide/scripts/discussion_validate.py +93 -35
  156. package/skills/gsd-path-decide/scripts/integration.py +244 -65
  157. package/skills/gsd-path-decide/scripts/isolation.py +539 -70
  158. package/skills/gsd-path-decide/scripts/loop_run.py +16 -4
  159. package/skills/gsd-path-decide/scripts/members.py +77 -32
  160. package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
  161. package/skills/gsd-path-decide/scripts/path_config.py +3 -3
  162. package/skills/gsd-path-decide/scripts/pipeline_git.py +131 -42
  163. package/skills/gsd-path-decide/scripts/pipeline_state.py +139 -39
  164. package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
  165. package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
  166. package/skills/gsd-path-decide/scripts/state_checkpoint.py +39 -36
  167. package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
  168. package/skills/gsd-path-decide/scripts/worktree_paths.py +54 -2
  169. package/skills/gsd-path-define/SKILL.md +34 -5
  170. package/skills/gsd-path-define/references/build-recovery.md +13 -0
  171. package/skills/gsd-path-define/references/operating-rules.md +11 -3
  172. package/skills/gsd-path-define/references/runtime-selection.md +9 -0
  173. package/skills/gsd-path-define/references/spec-probes.md +150 -0
  174. package/skills/gsd-path-define/scripts/_common.py +256 -2
  175. package/skills/gsd-path-define/scripts/archive_milestone.py +14 -13
  176. package/skills/gsd-path-define/scripts/build_recovery.py +155 -58
  177. package/skills/gsd-path-define/scripts/check_handoffs.py +635 -57
  178. package/skills/gsd-path-define/scripts/check_task_briefs.py +293 -32
  179. package/skills/gsd-path-define/scripts/detect_project.py +260 -20
  180. package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
  181. package/skills/gsd-path-define/scripts/discussion_validate.py +93 -35
  182. package/skills/gsd-path-define/scripts/integration.py +244 -65
  183. package/skills/gsd-path-define/scripts/isolation.py +539 -70
  184. package/skills/gsd-path-define/scripts/loop_run.py +16 -4
  185. package/skills/gsd-path-define/scripts/members.py +77 -32
  186. package/skills/gsd-path-define/scripts/model_policy.py +5 -1
  187. package/skills/gsd-path-define/scripts/path_config.py +3 -3
  188. package/skills/gsd-path-define/scripts/pipeline_git.py +131 -42
  189. package/skills/gsd-path-define/scripts/pipeline_state.py +139 -39
  190. package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
  191. package/skills/gsd-path-define/scripts/roadmap.py +9 -1
  192. package/skills/gsd-path-define/scripts/state_checkpoint.py +39 -36
  193. package/skills/gsd-path-define/scripts/state_promote.py +15 -4
  194. package/skills/gsd-path-define/scripts/worktree_paths.py +54 -2
  195. package/skills/gsd-path-define/templates/intent.md +30 -0
  196. package/skills/gsd-path-define/templates/state.md +2 -1
  197. package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
  198. package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
  199. package/skills/gsd-path-discuss/scripts/_common.py +256 -2
  200. package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
  201. package/skills/gsd-path-discuss/scripts/loop_run.py +16 -4
  202. package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
  203. package/skills/gsd-path-discuss/scripts/path_config.py +3 -3
  204. package/skills/gsd-path-discuss/scripts/worktree_paths.py +54 -2
  205. package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
  206. package/skills/gsd-path-docs-audit/references/docs-auditor.md +5 -0
  207. package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
  208. package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
  209. package/skills/gsd-path-docs-audit/scripts/_common.py +256 -2
  210. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +14 -13
  211. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +155 -58
  212. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +77 -4
  213. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +635 -57
  214. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +293 -32
  215. package/skills/gsd-path-docs-audit/scripts/detect_project.py +260 -20
  216. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
  217. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +93 -35
  218. package/skills/gsd-path-docs-audit/scripts/integration.py +244 -65
  219. package/skills/gsd-path-docs-audit/scripts/isolation.py +539 -70
  220. package/skills/gsd-path-docs-audit/scripts/loop_run.py +16 -4
  221. package/skills/gsd-path-docs-audit/scripts/members.py +77 -32
  222. package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
  223. package/skills/gsd-path-docs-audit/scripts/path_config.py +3 -3
  224. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +131 -42
  225. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +139 -39
  226. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
  227. package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
  228. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +39 -36
  229. package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
  230. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +54 -2
  231. package/skills/gsd-path-docs-audit/templates/docs-audit.md +5 -1
  232. package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
  233. package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
  234. package/skills/gsd-path-forensics/scripts/_common.py +256 -2
  235. package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
  236. package/skills/gsd-path-forensics/scripts/loop_run.py +16 -4
  237. package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
  238. package/skills/gsd-path-forensics/scripts/path_config.py +3 -3
  239. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
  240. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +118 -16
  241. package/skills/gsd-path-forensics/scripts/worktree_paths.py +54 -2
  242. package/skills/gsd-path-inspect/references/dispatch.md +13 -0
  243. package/skills/gsd-path-inspect/references/docs-auditor.md +5 -0
  244. package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
  245. package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
  246. package/skills/gsd-path-inspect/scripts/_common.py +256 -2
  247. package/skills/gsd-path-inspect/scripts/archive_milestone.py +14 -13
  248. package/skills/gsd-path-inspect/scripts/build_recovery.py +155 -58
  249. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +77 -4
  250. package/skills/gsd-path-inspect/scripts/check_handoffs.py +635 -57
  251. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +293 -32
  252. package/skills/gsd-path-inspect/scripts/detect_project.py +260 -20
  253. package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
  254. package/skills/gsd-path-inspect/scripts/discussion_validate.py +93 -35
  255. package/skills/gsd-path-inspect/scripts/integration.py +244 -65
  256. package/skills/gsd-path-inspect/scripts/isolation.py +539 -70
  257. package/skills/gsd-path-inspect/scripts/loop_run.py +16 -4
  258. package/skills/gsd-path-inspect/scripts/members.py +77 -32
  259. package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
  260. package/skills/gsd-path-inspect/scripts/path_config.py +3 -3
  261. package/skills/gsd-path-inspect/scripts/pipeline_git.py +131 -42
  262. package/skills/gsd-path-inspect/scripts/pipeline_state.py +139 -39
  263. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
  264. package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
  265. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +39 -36
  266. package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
  267. package/skills/gsd-path-inspect/scripts/workflow_run.py +14 -4
  268. package/skills/gsd-path-inspect/scripts/worktree_paths.py +54 -2
  269. package/skills/gsd-path-inspect/templates/docs-audit.md +5 -1
  270. package/skills/gsd-path-inspect/templates/state.md +2 -1
  271. package/skills/gsd-path-loop/references/operating-rules.md +11 -3
  272. package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
  273. package/skills/gsd-path-loop/scripts/_common.py +256 -2
  274. package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
  275. package/skills/gsd-path-loop/scripts/loop_run.py +16 -4
  276. package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
  277. package/skills/gsd-path-loop/scripts/path_config.py +3 -3
  278. package/skills/gsd-path-loop/scripts/worktree_paths.py +54 -2
  279. package/skills/gsd-path-migrate/SKILL.md +8 -0
  280. package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
  281. package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
  282. package/skills/gsd-path-migrate/scripts/_common.py +256 -2
  283. package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
  284. package/skills/gsd-path-migrate/scripts/loop_run.py +16 -4
  285. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
  286. package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
  287. package/skills/gsd-path-migrate/scripts/path_config.py +3 -3
  288. package/skills/gsd-path-plan/SKILL.md +14 -3
  289. package/skills/gsd-path-plan/references/build-recovery.md +13 -0
  290. package/skills/gsd-path-plan/references/dispatch.md +13 -0
  291. package/skills/gsd-path-plan/references/operating-rules.md +11 -3
  292. package/skills/gsd-path-plan/references/planner.md +26 -4
  293. package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
  294. package/skills/gsd-path-plan/references/spec-probes.md +150 -0
  295. package/skills/gsd-path-plan/scripts/_common.py +256 -2
  296. package/skills/gsd-path-plan/scripts/archive_milestone.py +14 -13
  297. package/skills/gsd-path-plan/scripts/build_recovery.py +155 -58
  298. package/skills/gsd-path-plan/scripts/check_handoffs.py +635 -57
  299. package/skills/gsd-path-plan/scripts/check_task_briefs.py +293 -32
  300. package/skills/gsd-path-plan/scripts/detect_project.py +260 -20
  301. package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
  302. package/skills/gsd-path-plan/scripts/discussion_validate.py +93 -35
  303. package/skills/gsd-path-plan/scripts/integration.py +244 -65
  304. package/skills/gsd-path-plan/scripts/isolation.py +539 -70
  305. package/skills/gsd-path-plan/scripts/loop_run.py +16 -4
  306. package/skills/gsd-path-plan/scripts/members.py +77 -32
  307. package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
  308. package/skills/gsd-path-plan/scripts/path_config.py +3 -3
  309. package/skills/gsd-path-plan/scripts/pipeline_git.py +131 -42
  310. package/skills/gsd-path-plan/scripts/pipeline_state.py +139 -39
  311. package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
  312. package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
  313. package/skills/gsd-path-plan/scripts/state_checkpoint.py +39 -36
  314. package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
  315. package/skills/gsd-path-plan/scripts/workflow_run.py +14 -4
  316. package/skills/gsd-path-plan/scripts/worktree_paths.py +54 -2
  317. package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
  318. package/skills/gsd-path-plan/templates/plan.md +17 -2
  319. package/skills/gsd-path-plan/templates/task.md +1 -0
  320. package/skills/gsd-path-research/SKILL.md +11 -0
  321. package/skills/gsd-path-research/references/dispatch.md +13 -0
  322. package/skills/gsd-path-research/references/operating-rules.md +11 -3
  323. package/skills/gsd-path-research/references/runtime-selection.md +9 -0
  324. package/skills/gsd-path-research/scripts/_common.py +256 -2
  325. package/skills/gsd-path-research/scripts/archive_milestone.py +14 -13
  326. package/skills/gsd-path-research/scripts/build_recovery.py +155 -58
  327. package/skills/gsd-path-research/scripts/check_handoffs.py +635 -57
  328. package/skills/gsd-path-research/scripts/check_task_briefs.py +293 -32
  329. package/skills/gsd-path-research/scripts/detect_project.py +260 -20
  330. package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
  331. package/skills/gsd-path-research/scripts/discussion_validate.py +93 -35
  332. package/skills/gsd-path-research/scripts/integration.py +244 -65
  333. package/skills/gsd-path-research/scripts/isolation.py +539 -70
  334. package/skills/gsd-path-research/scripts/loop_run.py +16 -4
  335. package/skills/gsd-path-research/scripts/members.py +77 -32
  336. package/skills/gsd-path-research/scripts/model_policy.py +5 -1
  337. package/skills/gsd-path-research/scripts/path_config.py +3 -3
  338. package/skills/gsd-path-research/scripts/pipeline_git.py +131 -42
  339. package/skills/gsd-path-research/scripts/pipeline_state.py +139 -39
  340. package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
  341. package/skills/gsd-path-research/scripts/roadmap.py +9 -1
  342. package/skills/gsd-path-research/scripts/state_checkpoint.py +39 -36
  343. package/skills/gsd-path-research/scripts/state_promote.py +15 -4
  344. package/skills/gsd-path-research/scripts/worktree_paths.py +54 -2
  345. package/skills/gsd-path-research/templates/research-handoff.md +14 -3
  346. package/skills/gsd-path-roadmap/SKILL.md +16 -6
  347. package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
  348. package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
  349. package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
  350. package/skills/gsd-path-roadmap/scripts/_common.py +256 -2
  351. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +14 -13
  352. package/skills/gsd-path-roadmap/scripts/build_recovery.py +155 -58
  353. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +635 -57
  354. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +293 -32
  355. package/skills/gsd-path-roadmap/scripts/detect_project.py +260 -20
  356. package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
  357. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +93 -35
  358. package/skills/gsd-path-roadmap/scripts/integration.py +244 -65
  359. package/skills/gsd-path-roadmap/scripts/isolation.py +539 -70
  360. package/skills/gsd-path-roadmap/scripts/loop_run.py +16 -4
  361. package/skills/gsd-path-roadmap/scripts/members.py +77 -32
  362. package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
  363. package/skills/gsd-path-roadmap/scripts/path_config.py +3 -3
  364. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +131 -42
  365. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +139 -39
  366. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
  367. package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
  368. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +39 -36
  369. package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
  370. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +54 -2
  371. package/skills/gsd-path-ship/SKILL.md +32 -5
  372. package/skills/gsd-path-ship/references/dispatch.md +13 -0
  373. package/skills/gsd-path-ship/references/operating-rules.md +11 -3
  374. package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
  375. package/skills/gsd-path-ship/references/reviewer-wave.md +56 -12
  376. package/skills/gsd-path-ship/references/reviewer.md +3 -1
  377. package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
  378. package/skills/gsd-path-ship/references/spec-probes.md +150 -0
  379. package/skills/gsd-path-ship/scripts/_common.py +256 -2
  380. package/skills/gsd-path-ship/scripts/archive_milestone.py +14 -13
  381. package/skills/gsd-path-ship/scripts/build_recovery.py +155 -58
  382. package/skills/gsd-path-ship/scripts/build_state.py +274 -8
  383. package/skills/gsd-path-ship/scripts/check_handoffs.py +635 -57
  384. package/skills/gsd-path-ship/scripts/check_task_briefs.py +293 -32
  385. package/skills/gsd-path-ship/scripts/detect_project.py +260 -20
  386. package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
  387. package/skills/gsd-path-ship/scripts/discussion_validate.py +93 -35
  388. package/skills/gsd-path-ship/scripts/integration.py +244 -65
  389. package/skills/gsd-path-ship/scripts/isolation.py +539 -70
  390. package/skills/gsd-path-ship/scripts/loop_run.py +16 -4
  391. package/skills/gsd-path-ship/scripts/members.py +77 -32
  392. package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
  393. package/skills/gsd-path-ship/scripts/path_config.py +3 -3
  394. package/skills/gsd-path-ship/scripts/pipeline_git.py +131 -42
  395. package/skills/gsd-path-ship/scripts/pipeline_state.py +139 -39
  396. package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
  397. package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
  398. package/skills/gsd-path-ship/scripts/state_checkpoint.py +39 -36
  399. package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
  400. package/skills/gsd-path-ship/scripts/workflow_run.py +14 -4
  401. package/skills/gsd-path-ship/scripts/worktree_paths.py +54 -2
  402. package/skills/gsd-path-ship/templates/final-review.md +11 -2
  403. package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
  404. package/skills/gsd-path-ship/templates/wave-review.md +28 -9
  405. package/skills/gsd-path-undo/references/operating-rules.md +11 -3
  406. package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
  407. package/skills/gsd-path-undo/scripts/_common.py +256 -2
  408. package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
  409. package/skills/gsd-path-undo/scripts/loop_run.py +16 -4
  410. package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
  411. package/skills/gsd-path-undo/scripts/path_config.py +3 -3
  412. package/skills/gsd-path-undo/scripts/pipeline_undo.py +118 -16
  413. package/skills/gsd-path-undo/scripts/worktree_paths.py +54 -2
  414. package/skills/path/DEFINE.md +34 -5
  415. package/skills/path/PLAN.md +14 -3
  416. package/skills/path/RESEARCH.md +11 -0
  417. package/skills/path/ROADMAP.md +16 -6
  418. package/skills/path/SHIP.md +32 -5
  419. package/skills/path/SKILL.md +52 -24
  420. package/skills/path/references/build-native.md +112 -10
  421. package/skills/path/references/build-recovery.md +13 -0
  422. package/skills/path/references/coder.md +7 -3
  423. package/skills/path/references/dispatch.md +13 -0
  424. package/skills/path/references/docs-auditor.md +5 -0
  425. package/skills/path/references/operating-rules.md +11 -3
  426. package/skills/path/references/planner.md +26 -4
  427. package/skills/path/references/reviewer-final.md +20 -2
  428. package/skills/path/references/reviewer-wave.md +56 -12
  429. package/skills/path/references/reviewer.md +3 -1
  430. package/skills/path/references/runtime-selection.md +9 -0
  431. package/skills/path/references/spec-probes.md +150 -0
  432. package/skills/path/scripts/_common.py +256 -2
  433. package/skills/path/scripts/archive_milestone.py +14 -13
  434. package/skills/path/scripts/build_recovery.py +155 -58
  435. package/skills/path/scripts/build_state.py +274 -8
  436. package/skills/path/scripts/check_docs_audit.py +77 -4
  437. package/skills/path/scripts/check_handoffs.py +635 -57
  438. package/skills/path/scripts/check_task_briefs.py +293 -32
  439. package/skills/path/scripts/detect_project.py +260 -20
  440. package/skills/path/scripts/discussion_records.py +9 -1
  441. package/skills/path/scripts/discussion_validate.py +93 -35
  442. package/skills/path/scripts/dispatch_driver.py +549 -53
  443. package/skills/path/scripts/git_guard.py +27 -8
  444. package/skills/path/scripts/integration.py +244 -65
  445. package/skills/path/scripts/isolation.py +539 -70
  446. package/skills/path/scripts/loop_run.py +16 -4
  447. package/skills/path/scripts/members.py +77 -32
  448. package/skills/path/scripts/model_policy.py +5 -1
  449. package/skills/path/scripts/path_config.py +3 -3
  450. package/skills/path/scripts/pipeline_git.py +131 -42
  451. package/skills/path/scripts/pipeline_state.py +139 -39
  452. package/skills/path/scripts/promote_lookahead.py +3 -3
  453. package/skills/path/scripts/review_findings.py +180 -44
  454. package/skills/path/scripts/roadmap.py +9 -1
  455. package/skills/path/scripts/state_checkpoint.py +39 -36
  456. package/skills/path/scripts/state_promote.py +15 -4
  457. package/skills/path/scripts/status_runtime.py +22 -4
  458. package/skills/path/scripts/task_context.py +14 -3
  459. package/skills/path/scripts/workflow_run.py +14 -4
  460. package/skills/path/scripts/worktree_paths.py +54 -2
  461. package/skills/path/templates/docs-audit.md +5 -1
  462. package/skills/path/templates/final-review.md +11 -2
  463. package/skills/path/templates/intent.md +30 -0
  464. package/skills/path/templates/patch-findings.md +3 -3
  465. package/skills/path/templates/plan.md +17 -2
  466. package/skills/path/templates/research-handoff.md +14 -3
  467. package/skills/path/templates/state.md +2 -1
  468. package/skills/path/templates/task.md +1 -0
  469. package/skills/path/templates/wave-review.md +28 -9
  470. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  471. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  472. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  473. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  474. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  475. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  476. 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
  )
@@ -262,6 +407,18 @@ GIT_REASONS = {
262
407
  "clean": "git clean -f deletes untracked evidence and retained task worktrees",
263
408
  "push": "force pushes rewrite build-branch history the pipeline resumes from",
264
409
  "branch": "git branch -D destroys task branches the recovery protocol inspects",
410
+ "unresolved-target": (
411
+ "{action} needs a literal target the guard can resolve; name the branch, "
412
+ "ref, or registered worktree directly"
413
+ ),
414
+ "git-environment": (
415
+ "env changes Git's repository or alias configuration context; run the Git "
416
+ "command without resetting or unsetting Git-related environment variables"
417
+ ),
418
+ "update-ref-order": (
419
+ "git update-ref deletion after another Git command can follow a symbolic "
420
+ "ref that command changed; run it as its own command or add --no-deref"
421
+ ),
265
422
  "branch-move": (
266
423
  "git branch -m renames a branch the pipeline resumes by name "
267
424
  "(STATE.branch); create a new branch instead"
@@ -306,6 +463,8 @@ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
306
463
  UNVALIDATED_EXECUTION_REASONS = {
307
464
  ".": SCRIPT_FILE_REASON,
308
465
  "source": SCRIPT_FILE_REASON,
466
+ "readonly": "readonly changes shell assignment behavior the guard cannot validate; "
467
+ "avoid readonly declarations",
309
468
  "start": "start launches a detached process the guard cannot inspect; run the program directly",
310
469
  "setenv": ENV_BUILTIN_REASON,
311
470
  "unsetenv": ENV_BUILTIN_REASON,
@@ -370,7 +529,64 @@ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
370
529
  }
371
530
  )
372
531
  IN_PLACE_EDITORS = frozenset({"perl", "sed"})
532
+ SED_SAFE_OPTIONS = frozenset(
533
+ {
534
+ "--debug",
535
+ "--help",
536
+ "--null-data",
537
+ "--posix",
538
+ "--quiet",
539
+ "--regexp-extended",
540
+ "--sandbox",
541
+ "--separate",
542
+ "--silent",
543
+ "--unbuffered",
544
+ "--version",
545
+ "-E",
546
+ "-n",
547
+ "-r",
548
+ "-s",
549
+ "-u",
550
+ }
551
+ )
373
552
  FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
553
+ # Programs that run the command in their arguments, with the options of each
554
+ # that take a separate value. The guard checks the command they run.
555
+ EXEC_PREFIX_VALUE_OPTIONS = {
556
+ "nice": frozenset({"-n", "--adjustment"}),
557
+ "nohup": frozenset(),
558
+ "setsid": frozenset(),
559
+ "stdbuf": frozenset({"-i", "-o", "-e", "--input", "--output", "--error"}),
560
+ "sudo": frozenset({
561
+ "-u", "-g", "-h", "-p", "-C", "-T", "-U", "-r", "-t",
562
+ "--user", "--group", "--host", "--prompt", "--close-from",
563
+ "--command-timeout", "--other-user", "--role", "--type",
564
+ }),
565
+ "time": frozenset({"-f", "--format"}),
566
+ "timeout": frozenset({"-s", "-k", "--signal", "--kill-after"}),
567
+ "watch": frozenset({"-n", "-q", "--interval", "--equexit"}),
568
+ }
569
+ # Runner options that write, edit or enter a file or directory by themselves.
570
+ EXEC_PREFIX_FILE_OPTIONS = {
571
+ "sudo": frozenset({"-e", "-D", "-R", "--edit", "--chdir", "--chroot"}),
572
+ "time": frozenset({"-o", "--output"}),
573
+ "watch": frozenset({"-s", "--shotsdir"}),
574
+ }
575
+ # The short option letters of each runner that take no value.
576
+ EXEC_PREFIX_FLAG_LETTERS = {
577
+ "nice": "",
578
+ "nohup": "",
579
+ "setsid": "cfw",
580
+ "stdbuf": "",
581
+ "sudo": "AbBEHiKklnNPSsVv",
582
+ "time": "ahlpqvV",
583
+ "timeout": "fpv",
584
+ "watch": "bcCdegprtwx",
585
+ }
586
+ EXEC_PREFIX_INTERVAL_OPTIONS = frozenset({"-n", "--interval"})
587
+ RUNNER_DURATION = re.compile(
588
+ r"(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?)[smhd]?", re.IGNORECASE
589
+ )
374
590
  SHELL_CONTROL_WORDS = frozenset(
375
591
  {
376
592
  "!",
@@ -925,30 +1141,29 @@ def closed_target_reason(targets):
925
1141
  return None
926
1142
 
927
1143
 
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)
1144
+ def pipeline_product_phase_reason(classified):
1145
+ """Why product writes are denied outside the routed build phase, or None when allowed."""
933
1146
  if classified is None:
934
- return
1147
+ return None
935
1148
  repo, state, kinds = classified
936
1149
  for path, kind in kinds:
937
1150
  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
1151
+ return f"{CONTROL_FILE_REASON} {path}"
1152
+ kind_values = [kind for _, kind in kinds]
1153
+ if all(kind == "external" for kind in kind_values):
1154
+ return None
1155
+ if not any(kind == "product" for kind in kind_values):
1156
+ return None
942
1157
  try:
943
1158
  status = project_status(repo)
944
1159
  except (OSError, ValueError, json.JSONDecodeError):
945
- deny(REENTRY_FAILURE_REASON)
1160
+ return REENTRY_FAILURE_REASON
946
1161
  route = status.get("route")
947
1162
  state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
948
1163
  completion = status.get("completion")
949
1164
  if (state_data.get("phase") == "shipped"
950
1165
  and isinstance(completion, dict) and completion.get("status") == "verified"):
951
- return
1166
+ return None
952
1167
  routed_build = (
953
1168
  state_data.get("phase") == "build"
954
1169
  and isinstance(route, dict)
@@ -956,20 +1171,31 @@ def enforce_pipeline_reentry(paths, working_directories):
956
1171
  and route.get("phase") == "build"
957
1172
  )
958
1173
  if routed_build:
959
- return
960
- if all(kind in {"external", "artifact"} for kind in kinds):
961
- return
1174
+ return None
1175
+ if all(kind in {"external", "artifact"} for kind in kind_values):
1176
+ return None
962
1177
  phase = state_data.get("phase", "unknown")
963
1178
  if isinstance(route, dict) and route.get("action") != "run-phase":
964
1179
  next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
965
1180
  else:
966
1181
  next_step = status.get("next_skill") or "gsd-path"
967
- deny(
1182
+ return (
968
1183
  f"GSD Path is {phase}; direct product-file changes require the routed "
969
1184
  f"build phase. Review {status.get('path', state)}; next: {next_step}"
970
1185
  )
971
1186
 
972
1187
 
1188
+ def enforce_pipeline_reentry(paths, working_directories):
1189
+ reason = closed_target_reason((path, working_directories) for path in paths)
1190
+ if reason:
1191
+ deny(reason)
1192
+ reason = pipeline_product_phase_reason(
1193
+ pipeline_target_kinds((path, working_directories) for path in paths)
1194
+ )
1195
+ if reason:
1196
+ deny(reason)
1197
+
1198
+
973
1199
  def in_archive(path):
974
1200
  # Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
975
1201
  normalized = normalize_posix(path).casefold()
@@ -1147,10 +1373,10 @@ def directory_change_target(arguments):
1147
1373
  return operands[-1]
1148
1374
 
1149
1375
 
1150
- def segment_directories(tokens, working_directories):
1376
+ def segment_directories(tokens, working_directories, contexts=None):
1151
1377
  """Yield each command segment with the working directories in effect for it."""
1152
1378
  current_directories = list(working_directories)
1153
- for segment in command_segments(tokens):
1379
+ for position, segment in enumerate(command_segments(tokens)):
1154
1380
  yield segment, current_directories
1155
1381
  invocation = command_invocation(segment)
1156
1382
  if invocation is None:
@@ -1163,16 +1389,36 @@ def segment_directories(tokens, working_directories):
1163
1389
  if command not in DIRECTORY_CHANGE_COMMANDS:
1164
1390
  continue
1165
1391
  target = directory_change_target(arguments)
1166
- if SHELL_PARAMETER_SYNTAX.search(target):
1392
+ tracked = {}
1393
+ if contexts is not None and position < len(contexts):
1394
+ tracked = {
1395
+ name: value
1396
+ for name, value in contexts[position].items()
1397
+ if name not in SHELL_DIRECTORY_VARIABLES
1398
+ }
1399
+ expanded = expand_environment_parameters(target, tracked, tracked_only=True)
1400
+ # An unquoted empty expansion leaves no word: the shell changes to HOME.
1401
+ if (
1402
+ (target and not expanded)
1403
+ or SHELL_PARAMETER_SYNTAX.search(expanded)
1404
+ or CMD_PARAMETER_SYNTAX.search(expanded)
1405
+ ):
1167
1406
  raise ValueError(
1168
1407
  f"cd target {target} cannot be resolved by the guard; "
1169
1408
  "run that command first and cd to the literal result"
1170
1409
  )
1171
- if is_absolute_path(target):
1172
- current_directories = [target]
1410
+ if (
1411
+ (SHELL_PARAMETER_SYNTAX.search(target) or CMD_PARAMETER_SYNTAX.search(target))
1412
+ and any(character.isspace() or character in "*?[]" for character in expanded)
1413
+ ):
1414
+ raise ValueError(
1415
+ f"cd target {target} may split or glob; pass a literal path"
1416
+ )
1417
+ if is_absolute_path(expanded):
1418
+ current_directories = [expanded]
1173
1419
  else:
1174
1420
  bases = current_directories or ["."]
1175
- current_directories = [f"{base}/{target}" for base in bases]
1421
+ current_directories = [f"{base}/{expanded}" for base in bases]
1176
1422
 
1177
1423
 
1178
1424
  def command_can_destroy_files(invocation):
@@ -1188,9 +1434,11 @@ def command_can_destroy_files(invocation):
1188
1434
  def literal_path(operand):
1189
1435
  if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
1190
1436
  operand = operand[1:-1]
1191
- pattern = r"[A-Za-z0-9._/-]+"
1437
+ # A space is safe here: the operand is one shlex unit, so it cannot carry a
1438
+ # second word; injection-relevant characters stay excluded.
1439
+ pattern = r"[A-Za-z0-9._/ -]+"
1192
1440
  if os.name == "nt":
1193
- pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\-]+"
1441
+ pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\ -]+"
1194
1442
  return operand if re.fullmatch(pattern, operand) is not None else None
1195
1443
 
1196
1444
 
@@ -1203,19 +1451,27 @@ def literal_destructive_operand(operand):
1203
1451
  return literal
1204
1452
 
1205
1453
 
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
1454
+ def segment_references_archive(segment, directories, assignments=None):
1455
+ invocation = command_invocation(segment)
1456
+ ancestors = command_can_destroy_files(invocation)
1457
+ # A here-document body survives tokenization only inside a wrapped payload
1458
+ # (bash -c '...'), where it is data, not commands; keep its text from
1459
+ # flipping the segment into archive context.
1460
+ def searchable(token):
1461
+ return strip_heredoc_bodies(token) if "\n" in token else token
1462
+
1463
+ operands = [
1464
+ expand_environment_parameters(searchable(token), assignments)
1465
+ for token in segment
1466
+ ]
1467
+ if ancestors:
1468
+ resolved = command_invocation(operands)
1469
+ operands = [literal_destructive_operand(operand) for operand in resolved[1]]
1470
+ if any(path_in_archive(operand, directories, ancestors) for operand in operands):
1471
+ return True
1472
+ return unresolved_archive_expansion(
1473
+ " ".join(searchable(token) for token in segment), assignments
1474
+ )
1219
1475
 
1220
1476
 
1221
1477
  def copy_destinations(arguments, directories, assignments=None):
@@ -1286,43 +1542,258 @@ def copy_destinations(arguments, directories, assignments=None):
1286
1542
  yield str(output / entry.relative_to(source_path))
1287
1543
 
1288
1544
 
1545
+ def sed_delimited_end(script, index, delimiter):
1546
+ while index < len(script):
1547
+ if script[index] == "\\":
1548
+ index += 2
1549
+ elif script[index] == delimiter:
1550
+ return index + 1
1551
+ else:
1552
+ index += 1
1553
+ return None
1554
+
1555
+
1556
+ def sed_address_end(script, index):
1557
+ if index >= len(script):
1558
+ return index
1559
+ if script[index].isdigit():
1560
+ while index < len(script) and script[index].isdigit():
1561
+ index += 1
1562
+ if index < len(script) and script[index] == "~":
1563
+ index += 1
1564
+ while index < len(script) and script[index].isdigit():
1565
+ index += 1
1566
+ elif script[index] == "$":
1567
+ index += 1
1568
+ elif script[index] == "/":
1569
+ end = sed_delimited_end(script, index + 1, "/")
1570
+ return len(script) if end is None else end
1571
+ elif script[index] == "\\" and index + 1 < len(script):
1572
+ end = sed_delimited_end(script, index + 2, script[index + 1])
1573
+ return len(script) if end is None else end
1574
+ return index
1575
+
1576
+
1577
+ def sed_script_is_read_only(script):
1578
+ """Accept only sed scripts whose commands cannot write or execute."""
1579
+ # Sed blocks require recursively validating their nested command grammar;
1580
+ # reject them until that grammar is modeled explicitly.
1581
+ safe_commands = frozenset("aAcCdDgGhHiIlLnNpPqQrstTxv=:")
1582
+ index = 0
1583
+ while index < len(script):
1584
+ while index < len(script) and (script[index].isspace() or script[index] == ";"):
1585
+ index += 1
1586
+ if index >= len(script):
1587
+ break
1588
+ address_start = index
1589
+ index = sed_address_end(script, index)
1590
+ if index < len(script) and script[index] == ",":
1591
+ index = sed_address_end(script, index + 1)
1592
+ if index > address_start and index < len(script) and script[index] == "!":
1593
+ index += 1
1594
+ while index < len(script) and script[index].isspace():
1595
+ index += 1
1596
+ if index >= len(script):
1597
+ break
1598
+ command = script[index]
1599
+ index += 1
1600
+ if command in {"w", "W", "e"}:
1601
+ return False
1602
+ if command == "#":
1603
+ newline = script.find("\n", index)
1604
+ index = len(script) if newline < 0 else newline + 1
1605
+ continue
1606
+ if command == "s":
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
+ end = index
1617
+ while end < len(script) and script[end] not in ";\n":
1618
+ end += 1
1619
+ if any(flag in "wWe" for flag in script[index:end]):
1620
+ return False
1621
+ index = end
1622
+ continue
1623
+ if command == "y":
1624
+ if index >= len(script):
1625
+ return False
1626
+ delimiter = script[index]
1627
+ index = sed_delimited_end(script, index + 1, delimiter)
1628
+ if index is None:
1629
+ return False
1630
+ index = sed_delimited_end(script, index, delimiter)
1631
+ if index is None:
1632
+ return False
1633
+ continue
1634
+ if command not in safe_commands:
1635
+ return False
1636
+ while index < len(script) and script[index] not in ";\n":
1637
+ index += 1
1638
+ return True
1639
+
1640
+
1641
+ def sed_arguments(arguments):
1642
+ """Return (in_place, input_files, script_is_read_only) for sed arguments."""
1643
+ in_place = False
1644
+ scripts = []
1645
+ inputs = []
1646
+ script_seen = False
1647
+ scripts_are_literal = True
1648
+ index = 0
1649
+ while index < len(arguments):
1650
+ argument = arguments[index]
1651
+ index += 1
1652
+ if argument == "--":
1653
+ positional = arguments[index:]
1654
+ if not script_seen and positional:
1655
+ scripts.append(positional[0])
1656
+ positional = positional[1:]
1657
+ script_seen = True
1658
+ inputs.extend(positional)
1659
+ break
1660
+ if argument in {"-e", "--expression"}:
1661
+ if index >= len(arguments):
1662
+ return in_place, inputs, False
1663
+ scripts.append(arguments[index])
1664
+ script_seen = True
1665
+ index += 1
1666
+ continue
1667
+ if argument == "-f" or argument == "--file":
1668
+ if index >= len(arguments):
1669
+ return in_place, inputs, False
1670
+ script_seen = True
1671
+ scripts_are_literal = False
1672
+ index += 1
1673
+ continue
1674
+ if argument.startswith("--expression="):
1675
+ scripts.append(argument.split("=", 1)[1])
1676
+ script_seen = True
1677
+ continue
1678
+ if argument.startswith("--file="):
1679
+ script_seen = True
1680
+ scripts_are_literal = False
1681
+ continue
1682
+ if argument == "--in-place" or argument.startswith("--in-place="):
1683
+ in_place = True
1684
+ continue
1685
+ if argument.startswith("--"):
1686
+ if argument not in SED_SAFE_OPTIONS:
1687
+ scripts_are_literal = False
1688
+ continue
1689
+ if argument.startswith("-") and argument != "-":
1690
+ options = argument[1:]
1691
+ cursor = 0
1692
+ while cursor < len(options):
1693
+ option = options[cursor]
1694
+ cursor += 1
1695
+ if option == "i":
1696
+ in_place = True
1697
+ break
1698
+ if option == "e":
1699
+ if cursor < len(options):
1700
+ scripts.append(options[cursor:])
1701
+ script_seen = True
1702
+ elif index < len(arguments):
1703
+ scripts.append(arguments[index])
1704
+ script_seen = True
1705
+ index += 1
1706
+ else:
1707
+ return in_place, inputs, False
1708
+ break
1709
+ if option not in "nErsu":
1710
+ scripts_are_literal = False
1711
+ continue
1712
+ if not script_seen:
1713
+ scripts.append(argument)
1714
+ script_seen = True
1715
+ else:
1716
+ inputs.append(argument)
1717
+ if not scripts or not scripts_are_literal:
1718
+ return in_place, inputs, False
1719
+ return in_place, inputs, all(sed_script_is_read_only(script) for script in scripts)
1720
+
1721
+
1289
1722
  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):
1723
+ """Yield (target, directories, variables) for every path a shell command may write."""
1724
+ for segment, directories, segment_assignments, _ in shell_segment_contexts(
1725
+ tokens, working_directories, assignments
1726
+ ):
1293
1727
  for index, token in enumerate(segment[:-1]):
1294
1728
  if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
1295
1729
  continue
1296
1730
  target = segment[index + 1]
1297
1731
  if token.endswith("&") and (target.isdigit() or target == "-"):
1298
1732
  continue
1299
- yield target, directories
1733
+ yield target, directories, segment_assignments
1300
1734
  wrapped = wrapped_command_tokens(segment)
1301
1735
  if wrapped is not None:
1302
- yield from shell_write_targets(wrapped, directories, assignments)
1736
+ yield from shell_write_targets(wrapped, directories, segment_assignments)
1303
1737
  continue
1304
1738
  invocation = command_invocation(segment)
1305
1739
  if invocation is None:
1306
1740
  continue
1307
1741
  command, arguments = invocation
1742
+ command = command.removesuffix(".exe")
1743
+ if command == "sed":
1744
+ in_place, inputs, _ = sed_arguments(arguments)
1745
+ if in_place:
1746
+ for target in inputs:
1747
+ yield target, directories, segment_assignments
1748
+ continue
1308
1749
  in_place = command in IN_PLACE_EDITORS and (
1309
1750
  has_short_option(arguments, "i")
1310
1751
  or any(argument.startswith("--in-place") for argument in arguments)
1311
1752
  )
1312
- if command.removesuffix(".exe") == "cp":
1313
- for destination in copy_destinations(arguments, directories, assignments):
1314
- yield destination, directories
1753
+ if command == "cp":
1754
+ for destination in copy_destinations(arguments, directories, segment_assignments):
1755
+ yield destination, directories, segment_assignments
1315
1756
  continue
1316
- if command.removesuffix(".exe") in SHELL_WRITE_COMMANDS or in_place:
1757
+ if command in SHELL_WRITE_COMMANDS or in_place:
1317
1758
  for argument in arguments:
1318
1759
  if argument and not argument.startswith("-"):
1319
- yield argument, directories
1760
+ yield argument, directories, segment_assignments
1761
+
1762
+
1763
+ def shell_write_needs_build_phase_gate(targets):
1764
+ """Phase-gate shell product writes only on bound milestone branches in-tree."""
1765
+ repo = repository_root()
1766
+ branch = subprocess.run(
1767
+ ["git", "-C", str(repo), "branch", "--show-current"],
1768
+ capture_output=True,
1769
+ encoding="utf-8",
1770
+ errors="replace",
1771
+ )
1772
+ if branch.returncode or not STATUS_BRANCH.fullmatch(branch.stdout.strip()):
1773
+ return False
1774
+ for path, working_directories in targets:
1775
+ for target in dict.fromkeys(target_paths(path, working_directories, repo)):
1776
+ directory = (
1777
+ target
1778
+ if target.is_dir() and not is_link_like(target)
1779
+ else target.parent
1780
+ )
1781
+ while not directory.exists() and directory != directory.parent:
1782
+ directory = directory.parent
1783
+ root = subprocess.run(
1784
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
1785
+ capture_output=True,
1786
+ encoding="utf-8",
1787
+ errors="replace",
1788
+ )
1789
+ if root.returncode != 0 or Path(root.stdout.strip()) != repo:
1790
+ return False
1791
+ return True
1320
1792
 
1321
1793
 
1322
1794
  def protected_shell_write_reason(tokens, working_directories):
1323
- assignments = shell_assignment_values(tokens)
1324
1795
  targets = []
1325
- for target, directories in shell_write_targets(tokens, working_directories):
1796
+ for target, directories, assignments in shell_write_targets(tokens, working_directories):
1326
1797
  expanded = expand_environment_parameters(target, assignments)
1327
1798
  if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
1328
1799
  expanded
@@ -1342,44 +1813,171 @@ def protected_shell_write_reason(tokens, working_directories):
1342
1813
  for target, kind in classified[2]:
1343
1814
  if kind == "protected":
1344
1815
  return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
1816
+ if shell_write_needs_build_phase_gate(targets):
1817
+ return pipeline_product_phase_reason(classified)
1345
1818
  return None
1346
1819
 
1347
1820
 
1348
- def environment_parameter_value(match, assignments):
1821
+ def environment_parameter_value(match, assignments, tracked_only=False):
1349
1822
  name = match.group(1) or match.group(2)
1350
1823
  if name.startswith(SUBSTITUTION_PLACEHOLDER):
1351
1824
  return match.group(0) # command substitution output is never resolved
1352
- return assignments.get(name, os.environ.get(name, ""))
1825
+ if name in assignments:
1826
+ return assignments[name]
1827
+ if tracked_only:
1828
+ return match.group(0)
1829
+ return os.environ.get(name, match.group(0))
1353
1830
 
1354
1831
 
1355
- def expand_environment_parameters(command, assignments=None):
1832
+ def expand_environment_parameters(command, assignments=None, tracked_only=False):
1356
1833
  values = assignments or {}
1357
1834
 
1358
1835
  def substitute(match):
1359
- return environment_parameter_value(match, values)
1836
+ return environment_parameter_value(match, values, tracked_only)
1360
1837
 
1361
1838
  expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
1362
1839
  return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
1363
1840
 
1364
1841
 
1365
- def shell_assignment_values(tokens):
1366
- values = {}
1367
- for segment in command_segments(tokens):
1368
- variable_command = segment[0].casefold() in VARIABLE_COMMANDS
1842
+ def shell_segment_flow(tokens):
1843
+ """Return whether each segment is conditional or runs in a subshell."""
1844
+ flows = []
1845
+ segment = []
1846
+ conditional = False
1847
+ subshell_depth = 0
1848
+ control_depth = 0
1849
+ separators = set("|;&()\n\r")
1850
+ for token in tokens:
1851
+ if token and set(token) <= separators:
1852
+ if segment:
1853
+ flows.append(
1854
+ (conditional or control_depth > 0, subshell_depth > 0 or token in {"|", "&"})
1855
+ )
1856
+ segment = []
1857
+ if token == "(":
1858
+ subshell_depth += 1
1859
+ conditional = True
1860
+ elif token == ")":
1861
+ subshell_depth = max(0, subshell_depth - 1)
1862
+ conditional = False
1863
+ elif token in {"&&", "||", "|", "&"}:
1864
+ conditional = True
1865
+ else:
1866
+ conditional = False
1867
+ continue
1868
+ word = token.casefold()
1869
+ if not segment and word in SHELL_CONTROL_WORDS:
1870
+ if word in {"if", "while", "until", "for", "select", "case", "coproc"}:
1871
+ control_depth += 1
1872
+ elif word in {"fi", "done", "esac"}:
1873
+ control_depth = max(0, control_depth - 1)
1874
+ if word in {"then", "else", "elif", "do", "in"}:
1875
+ conditional = True
1876
+ continue
1877
+ segment.append(token)
1878
+ if segment:
1879
+ flows.append((conditional or control_depth > 0, subshell_depth > 0))
1880
+ return flows
1881
+
1882
+
1883
+ def shell_assignment_changes(segment, values):
1884
+ """Return variables changed by a persistent assignment or variable builtin."""
1885
+ index = 0
1886
+ prefix = []
1887
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1888
+ prefix.append(segment[index])
1889
+ index += 1
1890
+ executable = segment[index].casefold() if index < len(segment) else None
1891
+ updates = {}
1892
+ removes = set()
1893
+ if executable is None:
1894
+ assignments = prefix
1895
+ elif executable in {"export", "declare", "typeset"}:
1896
+ assignments = [*prefix, *segment[index + 1 :]]
1897
+ else:
1898
+ assignments = []
1899
+ for assignment in assignments:
1900
+ if SHELL_ASSIGNMENT_PATTERN.match(assignment):
1901
+ name, value = assignment.split("=", 1)
1902
+ updates[name] = expand_environment_parameters(value, {**values, **updates})
1903
+ if executable == "unset":
1904
+ removes = {
1905
+ name for name in segment[index + 1 :] if not name.startswith("-")
1906
+ }
1907
+ return updates, removes
1908
+
1909
+
1910
+ def shell_assignment_contexts(tokens, initial=None, working_directories=None):
1911
+ """Return the persistent shell variables visible before each segment."""
1912
+ values = dict(initial or {})
1913
+ directories = list(working_directories or [os.getcwd()])
1914
+ if "PWD" not in values:
1915
+ values["PWD"] = (
1916
+ os.path.abspath(native_path_text(directories[0]))
1917
+ if len(directories) == 1 else "$PWD"
1918
+ )
1919
+ contexts = []
1920
+ segments = list(command_segments(tokens))
1921
+ flows = shell_segment_flow(tokens)
1922
+ if len(segments) != len(flows):
1923
+ raise ValueError("shell command segments cannot be aligned")
1924
+ for segment, (conditional, subshell) in zip(segments, flows):
1925
+ contexts.append(dict(values))
1926
+ updates, removes = shell_assignment_changes(segment, values)
1927
+ if subshell:
1928
+ continue
1929
+ for name in removes:
1930
+ if conditional:
1931
+ values[name] = f"${name}"
1932
+ else:
1933
+ # An unset variable expands to empty, never its inherited value.
1934
+ values[name] = ""
1935
+ for name, value in updates.items():
1936
+ values[name] = f"${name}" if conditional else value
1937
+ invocation = command_invocation(segment)
1938
+ if invocation is not None and invocation[0] in DIRECTORY_CHANGE_COMMANDS:
1939
+ # The same tracked expansion as segment_directories.
1940
+ target = expand_environment_parameters(
1941
+ directory_change_target(invocation[1]),
1942
+ {
1943
+ name: value
1944
+ for name, value in contexts[-1].items()
1945
+ if name not in SHELL_DIRECTORY_VARIABLES
1946
+ },
1947
+ tracked_only=True,
1948
+ )
1949
+ directories = (
1950
+ [target] if is_absolute_path(target)
1951
+ else [f"{directory}/{target}" for directory in directories]
1952
+ )
1953
+ values["OLDPWD"] = values.get("PWD", "$PWD")
1954
+ values["PWD"] = (
1955
+ os.path.abspath(native_path_text(directories[0]))
1956
+ if not conditional and len(directories) == 1 else "$PWD"
1957
+ )
1958
+ if conditional:
1959
+ values["OLDPWD"] = "$OLDPWD"
1960
+ return contexts, values
1961
+
1962
+
1963
+ def shell_segment_contexts(tokens, working_directories, initial=None):
1964
+ """Yield each segment with its working directory and prior shell variables."""
1965
+ contexts, _ = shell_assignment_contexts(tokens, initial, working_directories)
1966
+ segments = list(segment_directories(tokens, working_directories, contexts))
1967
+ if len(segments) != len(contexts):
1968
+ raise ValueError("shell command segments cannot be aligned")
1969
+ for (segment, directories), assignments in zip(segments, contexts):
1970
+ prefix = {}
1369
1971
  for token in segment:
1370
1972
  if not SHELL_ASSIGNMENT_PATTERN.match(token):
1371
- if variable_command:
1372
- continue
1373
1973
  break
1374
1974
  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
1975
+ prefix[name] = expand_environment_parameters(value, assignments)
1976
+ yield segment, directories, assignments, prefix
1379
1977
 
1380
1978
 
1381
- def unresolved_archive_expansion(command, tokens, working_directories):
1382
- expanded = expand_environment_parameters(command, shell_assignment_values(tokens))
1979
+ def unresolved_archive_expansion(command, assignments=None):
1980
+ expanded = expand_environment_parameters(command, assignments)
1383
1981
  return ARCHIVE_REFERENCE.search(expanded) is not None
1384
1982
 
1385
1983
 
@@ -1408,32 +2006,194 @@ def substitution_end(command, start):
1408
2006
  raise ValueError("command substitution $( is not closed")
1409
2007
 
1410
2008
 
2009
+ def heredoc_requests(line):
2010
+ """Find literal here-document delimiters, excluding here-strings and quotes."""
2011
+ requests = []
2012
+ quote = None
2013
+ index = 0
2014
+ while index < len(line):
2015
+ character = line[index]
2016
+ if quote:
2017
+ if character == "\\" and quote == '"':
2018
+ index += 2
2019
+ continue
2020
+ if character == quote:
2021
+ quote = None
2022
+ index += 1
2023
+ continue
2024
+ if character == "\\":
2025
+ index += 2
2026
+ continue
2027
+ if character == "#" and (
2028
+ index == 0
2029
+ or line[index - 1].isspace()
2030
+ or line[index - 1] in ";|&()<>"
2031
+ ):
2032
+ break
2033
+ if character in "'\"":
2034
+ quote = character
2035
+ index += 1
2036
+ continue
2037
+ if line.startswith("$(", index):
2038
+ index = substitution_end(line, index + 2) + 1
2039
+ continue
2040
+ if character == "`":
2041
+ end = index + 1
2042
+ while end < len(line) and line[end] != "`":
2043
+ end += 2 if line[end] == "\\" else 1
2044
+ index = min(end + 1, len(line))
2045
+ continue
2046
+ if line.startswith("<<<", index):
2047
+ index += 3
2048
+ continue
2049
+ if not line.startswith("<<", index):
2050
+ index += 1
2051
+ continue
2052
+ cursor = index + 2
2053
+ strip_tabs = cursor < len(line) and line[cursor] == "-"
2054
+ if strip_tabs:
2055
+ cursor += 1
2056
+ while cursor < len(line) and line[cursor] in " \t":
2057
+ cursor += 1
2058
+ delimiter = []
2059
+ delimiter_quote = None
2060
+ quoted = False
2061
+ while cursor < len(line):
2062
+ value = line[cursor]
2063
+ if delimiter_quote:
2064
+ if value == delimiter_quote:
2065
+ delimiter_quote = None
2066
+ else:
2067
+ delimiter.append(value)
2068
+ cursor += 1
2069
+ continue
2070
+ if value in "'\"":
2071
+ delimiter_quote = value
2072
+ quoted = True
2073
+ cursor += 1
2074
+ continue
2075
+ if value == "\\" and cursor + 1 < len(line):
2076
+ quoted = True
2077
+ delimiter.append(line[cursor + 1])
2078
+ cursor += 2
2079
+ continue
2080
+ if value.isspace() or value in ";|&()<>":
2081
+ break
2082
+ delimiter.append(value)
2083
+ cursor += 1
2084
+ if not delimiter:
2085
+ raise ValueError("here-document delimiter cannot be validated")
2086
+ requests.append(("".join(delimiter), strip_tabs, quoted))
2087
+ index = cursor
2088
+ return requests
2089
+
2090
+
2091
+ def heredoc_modes(command):
2092
+ """Mark here-document bodies as data or shell-expanded input."""
2093
+ lines = command.splitlines(keepends=True)
2094
+ modes = ["shell"] * len(command)
2095
+ offsets = []
2096
+ offset = 0
2097
+ for line in lines:
2098
+ offsets.append(offset)
2099
+ offset += len(line)
2100
+ line_index = 0
2101
+ while line_index < len(lines):
2102
+ requests = heredoc_requests(lines[line_index].rstrip("\r\n"))
2103
+ line_index += 1
2104
+ for delimiter, strip_tabs, quoted in requests:
2105
+ found = False
2106
+ while line_index < len(lines):
2107
+ line = lines[line_index]
2108
+ content = line.rstrip("\r\n")
2109
+ if strip_tabs:
2110
+ content = content.lstrip("\t")
2111
+ start = offsets[line_index]
2112
+ stop = start + len(line)
2113
+ mode = "literal" if quoted or content == delimiter else "heredoc"
2114
+ modes[start:stop] = [mode] * (stop - start)
2115
+ line_index += 1
2116
+ if content == delimiter:
2117
+ found = True
2118
+ break
2119
+ if not found:
2120
+ raise ValueError(f"here-document {delimiter} is not terminated")
2121
+ return modes
2122
+
2123
+
1411
2124
  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
2125
+ """Replace executable substitutions, leaving quoted text as shell data."""
2126
+ modes = heredoc_modes(command)
2127
+ outer, inner, index, quote = [], [], 0, None
1420
2128
  while index < len(command):
2129
+ mode = modes[index]
2130
+ character = command[index]
2131
+ if mode == "literal":
2132
+ outer.append(character)
2133
+ index += 1
2134
+ continue
2135
+ if mode == "heredoc":
2136
+ if character == "\\" and index + 1 < len(command) and command[index + 1] in "\\$`":
2137
+ outer.append(command[index : index + 2])
2138
+ index += 2
2139
+ continue
2140
+ if command.startswith("$(", index):
2141
+ end = substitution_end(command, index + 2)
2142
+ inner.append(command[index + 2 : end])
2143
+ elif character == "`":
2144
+ end = index + 1
2145
+ while end < len(command) and command[end] != "`":
2146
+ end += 2 if command[end] == "\\" else 1
2147
+ if end >= len(command):
2148
+ raise ValueError("backtick command substitution is not closed")
2149
+ inner.append(command[index + 1 : end])
2150
+ elif command.startswith("@(", index):
2151
+ raise ValueError(
2152
+ "@( expansion cannot be validated; write the list as literal arguments"
2153
+ )
2154
+ else:
2155
+ outer.append(character)
2156
+ index += 1
2157
+ continue
2158
+ outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
2159
+ index = end + 1
2160
+ continue
2161
+ if quote == "'":
2162
+ outer.append(character)
2163
+ if character == "'":
2164
+ quote = None
2165
+ index += 1
2166
+ continue
2167
+ if character == "\\":
2168
+ outer.append(command[index : index + 2])
2169
+ index += 2
2170
+ continue
2171
+ if quote is None and character == "'":
2172
+ quote = character
2173
+ outer.append(character)
2174
+ index += 1
2175
+ continue
2176
+ if character == '"':
2177
+ quote = None if quote == '"' else '"'
2178
+ outer.append(character)
2179
+ index += 1
2180
+ continue
1421
2181
  if command.startswith("$(", index):
1422
2182
  end = substitution_end(command, index + 2)
1423
2183
  inner.append(command[index + 2 : end])
1424
- elif command[index] == "`":
2184
+ elif character == "`":
1425
2185
  end = index + 1
1426
2186
  while end < len(command) and command[end] != "`":
1427
2187
  end += 2 if command[end] == "\\" else 1
1428
2188
  if end >= len(command):
1429
2189
  raise ValueError("backtick command substitution is not closed")
1430
2190
  inner.append(command[index + 1 : end])
1431
- elif command[index] == "\\":
1432
- outer.append(command[index : index + 2])
1433
- index += 2
1434
- continue
2191
+ elif command.startswith("@(", index):
2192
+ raise ValueError(
2193
+ "@( expansion cannot be validated; write the list as literal arguments"
2194
+ )
1435
2195
  else:
1436
- outer.append(command[index])
2196
+ outer.append(character)
1437
2197
  index += 1
1438
2198
  continue
1439
2199
  outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
@@ -1454,11 +2214,8 @@ def strip_heredoc_bodies(command):
1454
2214
  line = lines[index]
1455
2215
  kept.append(line)
1456
2216
  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"
2217
+ for delimiter, strip_tabs, _ in heredoc_requests(line):
2218
+ strip = "\t\r" if strip_tabs else "\r"
1462
2219
  while index < len(lines) and lines[index].strip(strip) != delimiter:
1463
2220
  index += 1
1464
2221
  if index >= len(lines):
@@ -1497,30 +2254,323 @@ def decode_patch_path(raw_path, strip_prefix):
1497
2254
  return path
1498
2255
 
1499
2256
 
1500
- def shell_tokens(command):
1501
- command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
1502
- lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
1503
- lexer.whitespace = " \t"
1504
- lexer.whitespace_split = True
1505
- lexer.commenters = ""
1506
- try:
1507
- tokens = list(lexer)
1508
- except ValueError as error:
1509
- raise ValueError(
1510
- f"shell command cannot be tokenized ({error}); balance the quotes "
1511
- "or write the content with an editor tool"
1512
- ) from None
1513
- if not tokens:
1514
- raise ValueError("shell command is empty")
1515
- return tokens
2257
+ PARAMETER_MARKERS = frozenset("$%!")
2258
+ SHELL_DESCRIPTOR_WORD = re.compile(r"[0-9]{2,}|\{\w+\}", re.ASCII)
2259
+ SHELL_OPERATOR_PARTS = re.compile(
2260
+ r"[<>]\(|&>>?|<<<|<<|>>|>&|<&|<>|>\||[<>]|&&|\|\||\|&|[\s\S]"
2261
+ )
1516
2262
 
1517
2263
 
1518
- def command_segments(tokens):
1519
- """Yield simple commands with separators and leading control words removed."""
1520
- segment = []
1521
- for token in tokens:
1522
- if token and set(token) <= set("|;&()\n\r"):
1523
- if segment:
2264
+ class ShellToken(str):
2265
+ """A token that keeps, per parameter marker character, whether double quotes enclosed it.
2266
+
2267
+ `redirection` is true for an unquoted redirection operator and for one
2268
+ ASCII file descriptor digit written against one (the 2 of 2>file, not of
2269
+ 2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
2270
+ `descriptor` is true for a longer digit word or a {name} word written
2271
+ against a redirection operator: bash reads it as a file descriptor, zsh
2272
+ as an argument.
2273
+ `body` holds the tokens of a process substitution on its operator token,
2274
+ once destructive_git_reason has folded them.
2275
+ `literal` is "bare" for a word written with no quote or escape, "quoted"
2276
+ for a word inside one pair of plain quotes, "mixed" for plain quoted and
2277
+ unquoted parts where no unquoted part can expand, and None when the shell
2278
+ may build the word in another way (escapes, an expanding unquoted part, a
2279
+ line continuation in the word or one that joins it to the next word).
2280
+ """
2281
+
2282
+ double_quoted = ()
2283
+ redirection = False
2284
+ descriptor = False
2285
+ literal = None
2286
+ body = None
2287
+
2288
+
2289
+ def double_quoted_markers(command):
2290
+ """Return, per parameter marker in order, whether plain double quotes enclose it.
2291
+
2292
+ None means the command uses quoting outside the plain constructs this scan
2293
+ models (plain single quotes, plain double quotes, unquoted text), so no
2294
+ marker in it can be proven quoted.
2295
+ """
2296
+ flags = []
2297
+ quote = None
2298
+ comment = False
2299
+ braces = 0
2300
+ previous = " "
2301
+ index = 0
2302
+ while index < len(command):
2303
+ character = command[index]
2304
+ following = command[index + 1 : index + 2]
2305
+ if comment:
2306
+ if character in "\r\n":
2307
+ comment = False
2308
+ elif character in "'\"\\`":
2309
+ return None
2310
+ elif quote == "'":
2311
+ if character == "'":
2312
+ quote = None
2313
+ elif character == "\\":
2314
+ if following and following in PARAMETER_MARKERS:
2315
+ # The shell executes the escaped marker as literal text; the
2316
+ # token keeps the bare marker, so the pair is never provable.
2317
+ flags.append(False)
2318
+ previous = character
2319
+ index += 2
2320
+ continue
2321
+ elif character == "`" or (character == "$" and following == "("):
2322
+ return None
2323
+ elif character == "$" and following == "{":
2324
+ braces += 1
2325
+ elif character == "}" and braces:
2326
+ braces -= 1
2327
+ elif character in "'\"" and braces:
2328
+ return None
2329
+ elif character == '"':
2330
+ quote = None if quote else '"'
2331
+ elif quote is None:
2332
+ if character == "'":
2333
+ quote = "'"
2334
+ elif character == "$" and following and following in "'\"":
2335
+ return None
2336
+ elif character == "<" and following == "<":
2337
+ return None
2338
+ elif character == "#" and previous in " \t\r\n;&|()<>":
2339
+ comment = True
2340
+ if character in PARAMETER_MARKERS:
2341
+ flags.append(quote == '"' and not comment)
2342
+ previous = character
2343
+ index += 1
2344
+ return flags if quote is None and not braces else None
2345
+
2346
+
2347
+ def zsh_expansion_flag(text):
2348
+ """Whether the text holds an unbraced zsh expansion flag ($=x, $~x, $^x, $+x).
2349
+
2350
+ These match no parameter syntax, so without this check they never enter
2351
+ the parameter proof at all — while zsh word-splits them exactly like the
2352
+ forms the proof denies. Any other unmatched $ is inert literal text.
2353
+ """
2354
+ return ZSH_EXPANSION_FLAG_SYNTAX.search(text)
2355
+
2356
+
2357
+ def parameters_double_quoted(token):
2358
+ """Whether every parameter in the token expands inside double quotes to one word.
2359
+
2360
+ Only plain $name and ${name} qualify: flags, operators, subscripts,
2361
+ positional and special parameters can split even inside double quotes.
2362
+ A backslash-escaped marker stays one flag short of proven: the shell
2363
+ executes the literal text while the guard's token still shows the bare
2364
+ marker, so nothing about its expansion is provable.
2365
+ """
2366
+ flags = getattr(token, "double_quoted", ())
2367
+ positions = [
2368
+ index for index, character in enumerate(token) if character in PARAMETER_MARKERS
2369
+ ]
2370
+ if len(flags) != len(positions):
2371
+ return False
2372
+ quoted = dict(zip(positions, flags))
2373
+ plain = {
2374
+ match.start(): match.end() for match in NAMED_SHELL_PARAMETER_SYNTAX.finditer(token)
2375
+ }
2376
+ if any(
2377
+ character == "$" and index not in plain for index, character in enumerate(token)
2378
+ ) or any(token[end : end + 1] == "[" for end in plain.values()):
2379
+ return False
2380
+ return all(
2381
+ quoted[match.start()] and quoted.get(match.end() - 1, True)
2382
+ for syntax in (SHELL_PARAMETER_SYNTAX, CMD_PARAMETER_SYNTAX)
2383
+ for match in syntax.finditer(token)
2384
+ )
2385
+
2386
+
2387
+ def shell_tokens(command, direct=True):
2388
+ """Tokenize a command; `direct` is False for a string that a second shell parses.
2389
+
2390
+ Only a directly parsed command keeps quote flags: an outer shell expands
2391
+ parameters before the inner parse, so inner quotes prove nothing.
2392
+ """
2393
+ breaks = set()
2394
+ if LINE_CONTINUATION.search(command):
2395
+ mark = next(
2396
+ character for character in map(chr, range(0xE000, 0xF900))
2397
+ if character not in command
2398
+ )
2399
+ marked = strip_heredoc_bodies(
2400
+ LINE_CONTINUATION.sub(lambda match: match.group(1) + mark, command)
2401
+ )
2402
+ command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2403
+ breaks = (
2404
+ {index for index, character in enumerate(marked) if character == mark}
2405
+ if marked.replace(mark, " ") == command
2406
+ else None
2407
+ )
2408
+ else:
2409
+ command = strip_heredoc_bodies(command)
2410
+ joins = set()
2411
+ for index in breaks or ():
2412
+ left, right = index - 1, index + 1
2413
+ while left >= 0 and marked[left] == mark:
2414
+ left -= 1
2415
+ while right < len(marked) and marked[right] == mark:
2416
+ right += 1
2417
+ if left >= 0 and marked[left].strip() and marked[right:right + 1].strip():
2418
+ joins.add(index)
2419
+ lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
2420
+ lexer.whitespace = " \t"
2421
+ lexer.whitespace_split = True
2422
+ lexer.commenters = ""
2423
+ tokens = []
2424
+ previous_end = 0
2425
+ try:
2426
+ for token in lexer:
2427
+ end = lexer.instream.tell() - len(lexer._pushback_chars)
2428
+ if command[end - 1:end] in (" ", "\t"):
2429
+ end -= 1
2430
+ word_start = previous_end
2431
+ while word_start < end and command[word_start] in " \t":
2432
+ word_start += 1
2433
+ previous_end = end
2434
+ word = command[word_start:end]
2435
+ literal = None
2436
+ if (
2437
+ breaks is not None
2438
+ and "\\" not in word
2439
+ and not ANSI_QUOTE_START.search(word)
2440
+ and breaks.isdisjoint(range(word_start, end))
2441
+ and end not in joins
2442
+ and word_start - 1 not in joins
2443
+ and PLAIN_QUOTED_SEGMENT.sub(lambda match: match.group(0)[1:-1], word) == token
2444
+ ):
2445
+ unquoted = PLAIN_QUOTED_SEGMENT.sub("", word)
2446
+ if (
2447
+ "'" not in unquoted
2448
+ and '"' not in unquoted
2449
+ and not GIT_CONFIG_EXPANDING_WORD.search(unquoted)
2450
+ ):
2451
+ literal = "mixed"
2452
+ for quote in ("", "'", '"'):
2453
+ start = end - len(token) - 2 * len(quote)
2454
+ if (
2455
+ breaks is not None
2456
+ and start >= 0
2457
+ and command[start:end] == quote + token + quote
2458
+ and (start == 0 or command[start - 1] in " \t|;&()<>\n\r")
2459
+ and breaks.isdisjoint(range(start, end))
2460
+ and end not in joins
2461
+ and start - 1 not in joins
2462
+ ):
2463
+ literal = "quoted" if quote else "bare"
2464
+ break
2465
+ start = end - len(token)
2466
+ before = command[start - 1:start] if start > 0 else ""
2467
+ escaped = (start - len(command[:start].rstrip("\\"))) % 2 == 1
2468
+ redirection = command[start:end] == token and (
2469
+ (
2470
+ set(token) <= SHELL_WRITE_REDIRECTION_CHARS
2471
+ and ("<" in token or ">" in token)
2472
+ and not escaped
2473
+ )
2474
+ or (
2475
+ len(token) == 1
2476
+ and token in "0123456789"
2477
+ and command[end:end + 1] in ("<", ">")
2478
+ and (not before or before in " \t|;&()<>\n\r")
2479
+ )
2480
+ )
2481
+ descriptor = (
2482
+ command[start:end] == token
2483
+ and SHELL_DESCRIPTOR_WORD.fullmatch(token) is not None
2484
+ and command[end:end + 1] in ("<", ">")
2485
+ and (not before or before in " \t|;&()<>\n\r")
2486
+ )
2487
+ # The shell reads a control operator and a redirection operator
2488
+ # written against each other as two operators.
2489
+ parts = [token]
2490
+ if (
2491
+ command[start:end] == token
2492
+ and set(token) <= set("|;&()<>\n\r")
2493
+ and ("<" in token or ">" in token)
2494
+ and not escaped
2495
+ ):
2496
+ parts = []
2497
+ for part in SHELL_OPERATOR_PARTS.findall(token):
2498
+ if "<" in part or ">" in part or not parts or (
2499
+ "<" in parts[-1] or ">" in parts[-1]
2500
+ ):
2501
+ parts.append(part)
2502
+ else:
2503
+ parts[-1] += part
2504
+ for part in parts:
2505
+ part = ShellToken(part)
2506
+ part.redirection = redirection if len(parts) == 1 else (
2507
+ ("<" in part or ">" in part)
2508
+ and set(part) <= SHELL_WRITE_REDIRECTION_CHARS
2509
+ )
2510
+ part.descriptor = descriptor
2511
+ part.literal = literal
2512
+ tokens.append(part)
2513
+ except ValueError as error:
2514
+ raise ValueError(
2515
+ f"shell command cannot be tokenized ({error}); balance the quotes "
2516
+ "or write the content with an editor tool"
2517
+ ) from None
2518
+ if not tokens:
2519
+ raise ValueError("shell command is empty")
2520
+ flags = double_quoted_markers(command) if direct else None
2521
+ if flags is None or len(flags) != sum(
2522
+ character in PARAMETER_MARKERS for token in tokens for character in token
2523
+ ):
2524
+ return tokens
2525
+ marked = []
2526
+ for token in tokens:
2527
+ count = sum(character in PARAMETER_MARKERS for character in token)
2528
+ redirection, descriptor, literal = (
2529
+ token.redirection, token.descriptor, token.literal
2530
+ )
2531
+ token = ShellToken(token)
2532
+ token.redirection, token.descriptor, token.literal = (
2533
+ redirection, descriptor, literal
2534
+ )
2535
+ token.double_quoted, flags = tuple(flags[:count]), flags[count:]
2536
+ marked.append(token)
2537
+ return marked
2538
+
2539
+
2540
+ def shell_parameter_quoting_uncertain(command):
2541
+ """Detect literal parameters whose quoting is lost by shell_tokens."""
2542
+ command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2543
+ # These forms expand in cmd, but are literal directory text in POSIX shells.
2544
+ if CMD_PARAMETER_SYNTAX.search(command):
2545
+ return True
2546
+ quote = None
2547
+ index = 0
2548
+ while index < len(command):
2549
+ character = command[index]
2550
+ if quote == "'":
2551
+ if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index):
2552
+ return True
2553
+ if character == "'":
2554
+ quote = None
2555
+ elif character == "\\":
2556
+ if NAMED_SHELL_PARAMETER_SYNTAX.match(command, index + 1):
2557
+ return True
2558
+ index += 1
2559
+ elif character in "'\"":
2560
+ if quote is None:
2561
+ quote = character
2562
+ elif character == quote:
2563
+ quote = None
2564
+ index += 1
2565
+ return False
2566
+
2567
+
2568
+ def command_segments(tokens):
2569
+ """Yield simple commands with separators and leading control words removed."""
2570
+ segment = []
2571
+ for token in tokens:
2572
+ if token and set(token) <= set("|;&()\n\r"):
2573
+ if segment:
1524
2574
  yield segment
1525
2575
  segment = []
1526
2576
  elif segment or token.casefold() not in SHELL_CONTROL_WORDS:
@@ -1572,56 +2622,163 @@ def xargs_command(arguments):
1572
2622
 
1573
2623
 
1574
2624
  def require_read_command(wrapper, wrapped):
1575
- if wrapped and not archive_command_is_read_only(" ".join(wrapped), wrapped, True):
2625
+ segments = list(command_segments(wrapped))
2626
+ if wrapped and (
2627
+ not segments
2628
+ or not all(
2629
+ archive_command_is_read_only(shlex.join(segment), segment, True)
2630
+ for segment in segments
2631
+ )
2632
+ ):
1576
2633
  raise ValueError(
1577
2634
  f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
1578
2635
  f"only read commands may follow {wrapper}, or pass the paths as literals"
1579
2636
  )
1580
2637
 
1581
2638
 
1582
- def command_invocation(segment):
2639
+ def runner_value_option(prefix, token):
2640
+ """The option in a runner option word whose value is the next word, if any.
2641
+
2642
+ Raises for an option that writes, edits or enters a file or directory. A
2643
+ long option matches by each prefix, as getopt accepts it. A short word is
2644
+ read as getopt reads it: flag letters, then the first value option letter,
2645
+ whose value is the rest of the word or, with no rest, the next word.
2646
+ """
2647
+ value_options = EXEC_PREFIX_VALUE_OPTIONS[prefix]
2648
+ file_options = EXEC_PREFIX_FILE_OPTIONS.get(prefix, frozenset())
2649
+ denial = ValueError(
2650
+ f"{prefix} {token} writes, edits or enters a file or directory that the "
2651
+ "guard cannot check; run the command without this option and redirect "
2652
+ "its output instead"
2653
+ )
2654
+ if token.startswith("--"):
2655
+ name = token.split("=", 1)[0]
2656
+ if any(option.startswith(name) for option in file_options):
2657
+ raise denial
2658
+ option = next((option for option in value_options if option.startswith(name)), None)
2659
+ return option if "=" not in token else None
2660
+ for position, letter in enumerate(token[1:], start=1):
2661
+ option = f"-{letter}"
2662
+ if option in file_options:
2663
+ raise denial
2664
+ if option in value_options:
2665
+ return option if position == len(token) - 1 else None
2666
+ if letter not in EXEC_PREFIX_FLAG_LETTERS[prefix]:
2667
+ return None
2668
+ return None
2669
+
2670
+
2671
+ def nonliteral_runner_option(token):
2672
+ literal = getattr(token, "literal", "bare")
2673
+ return (
2674
+ SHELL_PARAMETER_SYNTAX.search(token)
2675
+ or CMD_PARAMETER_SYNTAX.search(token)
2676
+ or zsh_expansion_flag(token)
2677
+ or literal is None
2678
+ or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(token))
2679
+ )
2680
+
2681
+
2682
+ def unresolved_runner_word(token):
2683
+ return (
2684
+ SHELL_PARAMETER_SYNTAX.search(token)
2685
+ or CMD_PARAMETER_SYNTAX.search(token)
2686
+ or zsh_expansion_flag(token)
2687
+ ) and not parameters_double_quoted(token)
2688
+
2689
+
2690
+ def command_invocation(segment, unwrap_runners=True):
2691
+ """`unwrap_runners` is False where a command runner itself must be judged."""
1583
2692
  index = 0
1584
- while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1585
- validate_shell_assignment(segment[index])
1586
- index += 1
1587
- if index >= len(segment):
1588
- return None
1589
- executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1590
- if executable == "env":
2693
+ # The shell word `time` is already removed; its only option can remain.
2694
+ while unwrap_runners and index < len(segment) and segment[index] == "-p":
1591
2695
  index += 1
1592
- while index < len(segment):
1593
- token = segment[index]
1594
- if token == "--":
1595
- index += 1
2696
+ while True:
2697
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
2698
+ validate_shell_assignment(segment[index])
2699
+ index += 1
2700
+ if index >= len(segment):
2701
+ return None
2702
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
2703
+ prefix = executable.removesuffix(".exe")
2704
+ if executable == "env":
2705
+ index += 1
2706
+ while index < len(segment):
2707
+ token = segment[index]
2708
+ if token == "--":
2709
+ index += 1
2710
+ break
2711
+ if SHELL_ASSIGNMENT_PATTERN.match(token):
2712
+ validate_shell_assignment(token)
2713
+ index += 1
2714
+ continue
2715
+ option = token.split("=", 1)[0]
2716
+ if option in ENV_OPTIONS_WITHOUT_VALUES:
2717
+ index += 1
2718
+ continue
2719
+ if option in ENV_OPTIONS_WITH_VALUES:
2720
+ if option in {"-C", "--chdir"}:
2721
+ raise ValueError(
2722
+ f"env {option} changes the working directory the guard "
2723
+ "tracks; use cd with a literal path instead"
2724
+ )
2725
+ index += 1
2726
+ if "=" not in token:
2727
+ if index >= len(segment):
2728
+ raise ValueError(f"env {option} lacks a value")
2729
+ index += 1
2730
+ continue
2731
+ if token.startswith("-"):
2732
+ raise ValueError(
2733
+ f"env {token} cannot be validated; use env NAME=VALUE <command>"
2734
+ )
2735
+ if SHELL_PARAMETER_SYNTAX.search(token) or zsh_expansion_flag(token):
2736
+ raise ValueError(
2737
+ f"env word {token} cannot be resolved by the guard; env "
2738
+ "assignments must be literal NAME=VALUE words and the "
2739
+ "program a literal name"
2740
+ )
1596
2741
  break
1597
- if SHELL_ASSIGNMENT_PATTERN.match(token):
1598
- validate_shell_assignment(token)
2742
+ elif unwrap_runners and prefix in EXEC_PREFIX_VALUE_OPTIONS:
2743
+ index += 1
2744
+ while index < len(segment):
2745
+ token = segment[index]
2746
+ if token == "--":
2747
+ index += 1
2748
+ break
2749
+ if not token.startswith("-") or token == "-":
2750
+ break
1599
2751
  index += 1
1600
- continue
1601
- option = token.split("=", 1)[0]
1602
- if option in ENV_OPTIONS_WITHOUT_VALUES:
2752
+ if nonliteral_runner_option(token):
2753
+ raise ValueError(
2754
+ f"{prefix} option {token} cannot be resolved by the guard; "
2755
+ f"{prefix} options must be literal words"
2756
+ )
2757
+ option = runner_value_option(prefix, token)
2758
+ if option is None or index >= len(segment):
2759
+ continue
2760
+ value = segment[index]
1603
2761
  index += 1
1604
- continue
1605
- if option in ENV_OPTIONS_WITH_VALUES:
1606
- if option in {"-C", "--chdir"}:
2762
+ if prefix == "watch" and option in EXEC_PREFIX_INTERVAL_OPTIONS:
2763
+ if not RUNNER_DURATION.fullmatch(value):
2764
+ raise ValueError(
2765
+ f"watch interval {value} cannot be resolved by the guard; "
2766
+ "the interval must be a literal number"
2767
+ )
2768
+ elif unresolved_runner_word(value):
2769
+ raise ValueError(
2770
+ f"{prefix} option value {value} cannot be resolved by the "
2771
+ f"guard; {prefix} option values must be literal words"
2772
+ )
2773
+ if prefix == "timeout" and index < len(segment):
2774
+ if not RUNNER_DURATION.fullmatch(segment[index]):
1607
2775
  raise ValueError(
1608
- f"env {option} changes the working directory the guard "
1609
- "tracks; use cd with a literal path instead"
2776
+ f"timeout duration {segment[index]} cannot be resolved by the "
2777
+ "guard; the duration must be a literal number"
1610
2778
  )
1611
2779
  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
- )
2780
+ else:
1621
2781
  break
1622
- if index >= len(segment):
1623
- return None
1624
- executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1625
2782
  arguments = segment[index + 1 :]
1626
2783
  if SHELL_PARAMETER_SYNTAX.search(executable):
1627
2784
  raise ValueError(
@@ -1645,19 +2802,103 @@ def command_invocation(segment):
1645
2802
  return executable, arguments
1646
2803
 
1647
2804
 
1648
- def git_command(segment):
2805
+ def require_resolved_git_word(word, mixed=True):
2806
+ """Deny a git word the shell can turn into other or several arguments."""
2807
+ literal = getattr(word, "literal", "bare")
2808
+ if (
2809
+ literal is None
2810
+ or (literal == "mixed" and not mixed)
2811
+ or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(word))
2812
+ ):
2813
+ raise ValueError(
2814
+ f"git argument {word} cannot be resolved by the guard; "
2815
+ "pass a literal value"
2816
+ )
2817
+
2818
+
2819
+ def git_command(segment, assignments=None):
1649
2820
  invocation = command_invocation(segment)
1650
2821
  if invocation is None:
1651
2822
  return None
1652
2823
  executable, arguments = invocation
1653
2824
  if executable not in {"git", "git.exe"}:
1654
2825
  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>)"
2826
+ resolved_arguments = []
2827
+ parameter_arguments = []
2828
+ unproven_parameters = []
2829
+ argument_index = 0
2830
+ while argument_index < len(arguments):
2831
+ argument = arguments[argument_index]
2832
+ attached_directory = argument.startswith("-C") and len(argument) > 2
2833
+ if argument == "-C" and argument_index + 1 < len(arguments):
2834
+ argument_index += 1
2835
+ directory = arguments[argument_index]
2836
+ require_resolved_git_word(directory)
2837
+ expanded = expand_environment_parameters(directory, assignments)
2838
+ if (
2839
+ SHELL_PARAMETER_SYNTAX.search(expanded)
2840
+ or CMD_PARAMETER_SYNTAX.search(expanded)
2841
+ or zsh_expansion_flag(expanded)
2842
+ ):
2843
+ raise ValueError(
2844
+ f"git argument {directory} cannot be resolved by the guard; "
2845
+ "pass a literal path"
2846
+ )
2847
+ if (
2848
+ (SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
2849
+ and any(character.isspace() or character in "*?[]" for character in expanded)
2850
+ ):
2851
+ raise ValueError(
2852
+ f"git -C path {directory} may split or glob; pass a literal path"
2853
+ )
2854
+ resolved_arguments.extend((argument, expanded))
2855
+ elif attached_directory:
2856
+ require_resolved_git_word(argument)
2857
+ directory = argument[2:]
2858
+ expanded = expand_environment_parameters(directory, assignments)
2859
+ if (
2860
+ SHELL_PARAMETER_SYNTAX.search(expanded)
2861
+ or CMD_PARAMETER_SYNTAX.search(expanded)
2862
+ or zsh_expansion_flag(expanded)
2863
+ ):
2864
+ raise ValueError(
2865
+ f"git argument {directory} cannot be resolved by the guard; "
2866
+ "pass a literal path"
2867
+ )
2868
+ if (
2869
+ (SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
2870
+ and any(character.isspace() or character in "*?[]" for character in expanded)
2871
+ ):
2872
+ raise ValueError(
2873
+ f"git -C path {directory} may split or glob; pass a literal path"
2874
+ )
2875
+ resolved_arguments.append(f"-C{expanded}")
2876
+ else:
2877
+ parameter = (
2878
+ SHELL_PARAMETER_SYNTAX.search(argument)
2879
+ or CMD_PARAMETER_SYNTAX.search(argument)
2880
+ or zsh_expansion_flag(argument)
1660
2881
  )
2882
+ if parameter:
2883
+ parameter_arguments.append(argument)
2884
+ literal = re.split(r"[$%!]", argument, maxsplit=1)[0]
2885
+ expanded = expand_environment_parameters(argument, assignments)
2886
+ if (
2887
+ not literal
2888
+ or (literal.startswith("-") and "=" not in literal)
2889
+ or git_option_in(literal, GIT_PARAMETER_DENIED_OPTIONS)
2890
+ or git_option_in(arguments[argument_index - 1], GIT_PARAMETER_DENIED_OPTIONS)
2891
+ or (literal.startswith("-c") and literal != "-c")
2892
+ or "::" in literal
2893
+ or not parameters_double_quoted(argument)
2894
+ or SUBSTITUTION_PLACEHOLDER in argument
2895
+ or SUBSTITUTION_PLACEHOLDER in expanded
2896
+ ):
2897
+ unproven_parameters.append(argument)
2898
+ expanded = expand_environment_parameters(argument, assignments)
2899
+ resolved_arguments.append(argument if expanded == argument else expanded)
2900
+ argument_index += 1
2901
+ arguments = resolved_arguments
1661
2902
  index = 0
1662
2903
  git_options = []
1663
2904
  while index < len(arguments) and arguments[index].startswith("-"):
@@ -1668,18 +2909,32 @@ def git_command(segment):
1668
2909
  option = token.split("=", 1)[0]
1669
2910
  value = token.split("=", 1)[1] if "=" in token else None
1670
2911
  index += 1
2912
+ if option != "-C":
2913
+ require_resolved_git_word(token)
1671
2914
  if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
1672
2915
  if index >= len(arguments):
1673
2916
  raise ValueError(f"git option {option} lacks a value")
1674
2917
  value = arguments[index]
2918
+ if option != "-C":
2919
+ require_resolved_git_word(value)
1675
2920
  index += 1
1676
2921
  if option in {"-c", "--config-env"}:
1677
- config_key = str(value).split("=", 1)[0].casefold()
2922
+ config_key, _, config_value = str(value).partition("=")
2923
+ config_key = config_key.casefold()
1678
2924
  if config_key.startswith("alias."):
1679
2925
  raise ValueError(
1680
2926
  f"git -c {config_key} defines an alias the guard cannot inspect; "
1681
2927
  "run the underlying git command directly"
1682
2928
  )
2929
+ if executed_config_key(config_key) and not (
2930
+ option == "-c"
2931
+ and not loaded_config_key(config_key)
2932
+ and config_value.casefold() in GIT_CONFIG_DISABLING_VALUES
2933
+ ):
2934
+ raise ValueError(
2935
+ f"git -c {config_key} configures a program git executes; "
2936
+ "the guard cannot allow it"
2937
+ )
1683
2938
  if config_key == "clean.requireforce":
1684
2939
  raise ValueError(
1685
2940
  "git -c clean.requireForce disables the git clean safety check; "
@@ -1688,8 +2943,47 @@ def git_command(segment):
1688
2943
  if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
1689
2944
  git_options.extend((option, str(value)))
1690
2945
  if index >= len(arguments):
2946
+ if parameter_arguments:
2947
+ raise ValueError(
2948
+ f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
2949
+ "pass a literal value (for commit messages use -F <file>)"
2950
+ )
1691
2951
  return None
1692
- return arguments[index].casefold(), arguments[index + 1:], git_options
2952
+ require_resolved_git_word(arguments[index], mixed=False)
2953
+ subcommand = arguments[index].casefold()
2954
+ if subcommand == "config":
2955
+ for argument in arguments[index + 1:]:
2956
+ literal = getattr(argument, "literal", None)
2957
+ if not getattr(argument, "redirection", False) and (
2958
+ literal in (None, "mixed")
2959
+ or (
2960
+ literal == "bare"
2961
+ and (argument.startswith("#") or GIT_CONFIG_EXPANDING_WORD.search(argument))
2962
+ )
2963
+ ):
2964
+ raise ValueError(
2965
+ f"git argument {argument} cannot be resolved by the guard; "
2966
+ "pass a literal value"
2967
+ )
2968
+ denied = denied_git_config_argument(arguments[index + 1:])
2969
+ if denied is not None:
2970
+ raise ValueError(
2971
+ f"git config {denied} persists a program git executes; "
2972
+ "the guard cannot allow it"
2973
+ )
2974
+ read_only_subcommands = ARCHIVE_READ_GIT_COMMANDS | CLOSED_READ_GIT_COMMANDS
2975
+ if subcommand in read_only_subcommands:
2976
+ # Read-only subcommands still write through --output. A parameter
2977
+ # passes only inside plain double quotes, where its expansion stays
2978
+ # one word; an unquoted one can word-split into an option, and
2979
+ # command substitution output is never known.
2980
+ parameter_arguments = unproven_parameters
2981
+ if parameter_arguments:
2982
+ raise ValueError(
2983
+ f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
2984
+ "pass a literal value (for commit messages use -F <file>)"
2985
+ )
2986
+ return subcommand, arguments[index + 1:], git_options
1693
2987
 
1694
2988
 
1695
2989
  def wrapped_command_tokens(segment, expand_parameters=True):
@@ -1698,7 +2992,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1698
2992
  return None
1699
2993
  executable, arguments = invocation
1700
2994
  if executable == "eval":
1701
- return shell_tokens(" ".join(arguments)) if arguments else None
2995
+ return shell_tokens(" ".join(arguments), direct=False) if arguments else None
1702
2996
  if executable in COMMAND_WRAPPERS:
1703
2997
  if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
1704
2998
  return None
@@ -1723,7 +3017,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1723
3017
  executable=executable, option=argument
1724
3018
  )
1725
3019
  )
1726
- return shell_tokens(arguments[index + 1])
3020
+ return shell_tokens(arguments[index + 1], direct=False)
1727
3021
  raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1728
3022
  if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
1729
3023
  switches = {"-c", "-command", "/c", "/k"}
@@ -1742,8 +3036,8 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1742
3036
  for argument in payload
1743
3037
  ]
1744
3038
  if len(payload) == 1:
1745
- return shell_tokens(payload[0])
1746
- return payload
3039
+ return shell_tokens(payload[0], direct=False)
3040
+ return [str(argument) for argument in payload]
1747
3041
  raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1748
3042
  return None
1749
3043
 
@@ -1757,21 +3051,31 @@ def has_short_option(arguments, option):
1757
3051
  )
1758
3052
 
1759
3053
 
1760
- def git_alias(command, git_options):
1761
- result = subprocess.run(
1762
- ["git", *git_options, "config", "--get", f"alias.{command}"],
1763
- encoding="utf-8", errors="replace",
1764
- capture_output=True,
1765
- check=False,
1766
- )
1767
- if result.returncode == 1:
1768
- return None
1769
- if result.returncode != 0:
1770
- raise ValueError("git alias configuration cannot be validated")
1771
- alias = result.stdout.strip()
1772
- if not alias or alias.startswith("!"):
1773
- raise ValueError("git alias configuration cannot be validated")
1774
- return alias
3054
+ def git_alias(command, git_options, working_directories=None):
3055
+ aliases = set()
3056
+ for directory in working_directories or [os.getcwd()]:
3057
+ try:
3058
+ result = subprocess.run(
3059
+ ["git", *git_options, "config", "--get", f"alias.{command}"],
3060
+ cwd=directory,
3061
+ encoding="utf-8", errors="replace",
3062
+ capture_output=True,
3063
+ check=False,
3064
+ )
3065
+ except OSError:
3066
+ raise ValueError("git alias configuration cannot be validated") from None
3067
+ if result.returncode == 1:
3068
+ aliases.add(None)
3069
+ continue
3070
+ if result.returncode != 0:
3071
+ raise ValueError("git alias configuration cannot be validated")
3072
+ alias = result.stdout.strip()
3073
+ if not alias or alias.startswith("!"):
3074
+ raise ValueError("git alias configuration cannot be validated")
3075
+ aliases.add(alias)
3076
+ if len(aliases) > 1:
3077
+ raise ValueError("git alias configuration is ambiguous across working directories")
3078
+ return next(iter(aliases), None)
1775
3079
 
1776
3080
 
1777
3081
  def update_ref_deletes(arguments):
@@ -1791,24 +3095,1012 @@ def update_ref_deletes(arguments):
1791
3095
  operands[1] == "" or not operands[1].strip("0")
1792
3096
  )
1793
3097
  return (
1794
- "-d" in arguments
3098
+ has_short_option(arguments, "d") # also a cluster such as -dm
1795
3099
  or "--delete" in arguments
1796
- or any(argument.startswith("--stdin") for argument in arguments)
3100
+ or any(git_option_in(argument, {"--stdin"}) for argument in arguments)
1797
3101
  or zero_value
1798
3102
  )
1799
3103
 
1800
3104
 
1801
- def destructive_git_reason(tokens, resolved_aliases=frozenset()):
3105
+ PATH_OWNED_REF_PREFIXES = (
3106
+ "gsd-path/",
3107
+ "gsd-path-task/",
3108
+ "gsd-path-verify/",
3109
+ "gsd-path-integrate/",
3110
+ )
3111
+ # Git ignores an alias that has the name of one of its own commands, so these
3112
+ # need no alias inspection. Each has been a Git command since Git 2.31, the
3113
+ # oldest Git that has the rev-parse --path-format option the pipeline uses.
3114
+ # Deprecated commands stay out: newer Git lets an alias replace them
3115
+ # (whatchanged, pack-redundant). A name that is absent only fails closed.
3116
+ GIT_BUILT_IN_COMMANDS = frozenset({
3117
+ "add", "am", "annotate", "apply", "archive", "bisect", "blame", "branch",
3118
+ "bugreport", "bundle", "cat-file", "check-attr", "check-ignore",
3119
+ "check-mailmap", "check-ref-format", "checkout", "checkout-index", "cherry",
3120
+ "cherry-pick", "clean", "clone", "column", "commit", "commit-graph",
3121
+ "commit-tree", "config", "count-objects", "credential", "credential-store",
3122
+ "describe", "diff", "diff-files", "diff-index", "diff-tree", "difftool",
3123
+ "fast-export", "fast-import", "fetch", "fetch-pack", "fmt-merge-msg",
3124
+ "for-each-ref", "for-each-repo", "format-patch", "fsck", "gc",
3125
+ "get-tar-commit-id", "grep", "hash-object", "help", "index-pack", "init",
3126
+ "interpret-trailers", "log", "ls-files", "ls-remote", "ls-tree", "mailinfo",
3127
+ "mailsplit", "maintenance", "merge", "merge-base", "merge-file",
3128
+ "merge-index", "merge-tree", "mergetool", "mktag", "mktree",
3129
+ "multi-pack-index", "mv", "name-rev", "notes", "pack-objects", "pack-refs",
3130
+ "patch-id", "prune", "prune-packed", "pull", "push", "range-diff",
3131
+ "read-tree", "rebase", "receive-pack", "reflog", "remote", "repack",
3132
+ "replace", "rerere", "reset", "restore", "rev-list", "rev-parse", "revert",
3133
+ "rm", "send-pack", "shortlog", "show", "show-branch", "show-index",
3134
+ "show-ref", "sparse-checkout", "stage", "stash", "status", "stripspace",
3135
+ "submodule", "switch", "symbolic-ref", "tag", "unpack-file",
3136
+ "unpack-objects", "update-index", "update-ref", "update-server-info",
3137
+ "upload-archive", "upload-pack", "var", "verify-commit", "verify-pack",
3138
+ "verify-tag", "version", "worktree", "write-tree",
3139
+ })
3140
+
3141
+
3142
+ def _ownership_scalar(raw, path, key):
3143
+ value = raw.strip()
3144
+ if not value:
3145
+ return None
3146
+ if value.startswith('"'):
3147
+ quoted = re.match(r'^("(?:\\.|[^"\\])*\")(?:[ \t]+#.*)?$', value)
3148
+ if quoted is None:
3149
+ raise ValueError(f"{path.name} has an unreadable {key} value")
3150
+ try:
3151
+ value = json.loads(quoted.group(1))
3152
+ except json.JSONDecodeError:
3153
+ raise ValueError(f"{path.name} has an unreadable {key} value") from None
3154
+ if not isinstance(value, str):
3155
+ raise ValueError(f"{path.name} has a non-string {key} value")
3156
+ return value or None
3157
+ if value.startswith("'"):
3158
+ quoted = re.fullmatch(r"'((?:[^']|'')*)'(?:[ \t]+#.*)?", value)
3159
+ if quoted is None:
3160
+ raise ValueError(f"{path.name} has an unreadable {key} value")
3161
+ value = quoted.group(1).replace("''", "'")
3162
+ return value or None
3163
+ value = re.sub(r"[ \t]+#.*$", "", value).strip()
3164
+ if not value or value.casefold() in {"null", "~", "none"}:
3165
+ return None
3166
+ if value.casefold() in {"true", "false", "yes", "no", "on", "off"} or re.fullmatch(
3167
+ r"[-+]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][-+]?[0-9]+)?", value
3168
+ ):
3169
+ raise ValueError(f"{path.name} has a non-string {key} value")
3170
+ if value[0] in "[{|>&*!" or ": " in value:
3171
+ raise ValueError(f"{path.name} has a non-scalar {key} value")
3172
+ return value
3173
+
3174
+
3175
+ def ownership_frontmatter(path, keys, *, missing_ok=False):
3176
+ try:
3177
+ metadata = path.lstat()
3178
+ except FileNotFoundError:
3179
+ if missing_ok and not os.path.lexists(path):
3180
+ return {}
3181
+ raise ValueError(f"{path.name} ownership metadata cannot be read") from None
3182
+ except OSError as error:
3183
+ raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
3184
+ if not stat.S_ISREG(metadata.st_mode):
3185
+ raise ValueError(f"{path.name} ownership metadata is not a regular file")
3186
+ try:
3187
+ lines = path.read_text(encoding="utf-8").splitlines()
3188
+ except (OSError, UnicodeDecodeError) as error:
3189
+ raise ValueError(f"{path.name} ownership metadata cannot be read: {error}") from None
3190
+ if not lines or lines[0] != "---":
3191
+ raise ValueError(f"{path.name} has no readable frontmatter")
3192
+ try:
3193
+ end = lines.index("---", 1)
3194
+ except ValueError:
3195
+ raise ValueError(f"{path.name} frontmatter is not terminated") from None
3196
+
3197
+ values = {}
3198
+ pending_scalar = None
3199
+ for line in lines[1:end]:
3200
+ if not line.strip() or line.lstrip().startswith("#"):
3201
+ continue
3202
+ if line[0].isspace():
3203
+ if pending_scalar is not None:
3204
+ raise ValueError(
3205
+ f"{path.name} has a non-scalar {pending_scalar} value"
3206
+ )
3207
+ continue
3208
+ pending_scalar = None
3209
+ match = re.fullmatch(r"(?P<key>[A-Za-z_][A-Za-z0-9_-]*):(?P<value>.*)", line)
3210
+ if match is None:
3211
+ raise ValueError(f"{path.name} has unreadable frontmatter")
3212
+ key = match.group("key")
3213
+ if key not in keys:
3214
+ continue
3215
+ if key in values:
3216
+ raise ValueError(f"{path.name} repeats ownership field {key}")
3217
+ value = _ownership_scalar(match.group("value"), path, key)
3218
+ values[key] = value
3219
+ if value is None and not match.group("value").strip():
3220
+ pending_scalar = key
3221
+ return values
3222
+
3223
+
3224
+ def git_base_directories(git_options, working_directories):
3225
+ """Resolve target paths from each shell cwd after Git's ordered ``-C`` options."""
3226
+ result = []
3227
+ for directory in working_directories or [os.getcwd()]:
3228
+ current = Path(directory or os.getcwd()).resolve()
3229
+ index = 0
3230
+ while index < len(git_options):
3231
+ option = git_options[index]
3232
+ value = git_options[index + 1]
3233
+ if option == "-C":
3234
+ target = Path(value)
3235
+ current = (target if target.is_absolute() else current / target).resolve()
3236
+ index += 2
3237
+ result.append(current)
3238
+ return result
3239
+
3240
+
3241
+ def _git_output(git_options, directory, *arguments):
3242
+ try:
3243
+ result = subprocess.run(
3244
+ ["git", *git_options, *arguments],
3245
+ cwd=directory,
3246
+ capture_output=True,
3247
+ encoding="utf-8",
3248
+ errors="replace",
3249
+ check=False,
3250
+ )
3251
+ except OSError:
3252
+ raise ValueError("git target repository cannot be resolved") from None
3253
+ if result.returncode != 0:
3254
+ raise ValueError("git target repository cannot be resolved")
3255
+ return result.stdout
3256
+
3257
+
3258
+ def registered_worktree_records(output, separator="\0"):
3259
+ records = {}
3260
+ for record in output.split(separator * 2):
3261
+ fields = [field for field in record.split(separator) if field]
3262
+ if not fields:
3263
+ continue
3264
+ paths = [field[len("worktree "):] for field in fields if field.startswith("worktree ")]
3265
+ branches = [field[len("branch "):] for field in fields if field.startswith("branch ")]
3266
+ if len(paths) != 1 or len(branches) > 1:
3267
+ raise ValueError("registered worktree metadata cannot be resolved")
3268
+ path = Path(paths[0])
3269
+ if not path.is_absolute():
3270
+ raise ValueError("registered worktree path is not absolute")
3271
+ resolved = path.resolve()
3272
+ if resolved in records:
3273
+ raise ValueError("registered worktree metadata is ambiguous")
3274
+ records[resolved] = branches[0] if branches else None
3275
+ if not records:
3276
+ raise ValueError("registered worktrees cannot be resolved")
3277
+ return records
3278
+
3279
+
3280
+ def git_target_contexts(git_options, working_directories):
3281
+ contexts = []
3282
+ for directory, effective_directory in zip(
3283
+ working_directories or [os.getcwd()],
3284
+ git_base_directories(git_options, working_directories),
3285
+ ):
3286
+ root = Path(
3287
+ _git_output(git_options, directory, "rev-parse", "--show-toplevel").strip()
3288
+ ).resolve()
3289
+ listing = ("worktree", "list", "--porcelain")
3290
+ try:
3291
+ output, separator = _git_output(git_options, directory, *listing, "-z"), "\0"
3292
+ except ValueError:
3293
+ # Git before 2.36 has no -z here; pipeline_git.worktree_list_porcelain
3294
+ # retries the same way. A line then ends each field.
3295
+ output, separator = _git_output(git_options, directory, *listing), "\n"
3296
+ records = registered_worktree_records(output, separator)
3297
+ if root not in records:
3298
+ raise ValueError("git target repository worktree cannot be resolved")
3299
+ common = Path(
3300
+ _git_output(git_options, directory, "rev-parse", "--git-common-dir").strip()
3301
+ )
3302
+ if not common.is_absolute():
3303
+ common = effective_directory / common
3304
+ contexts.append(
3305
+ {
3306
+ "root": root,
3307
+ "directory": directory,
3308
+ "effective_directory": effective_directory,
3309
+ "common": common.resolve(),
3310
+ "git_options": list(git_options),
3311
+ "worktrees": records,
3312
+ }
3313
+ )
3314
+ return contexts
3315
+
3316
+
3317
+ def path_owned_records(roots):
3318
+ branches, worktrees = set(), set()
3319
+ for root in roots:
3320
+ project = root / ".project"
3321
+ if not os.path.lexists(project):
3322
+ continue
3323
+ if is_link_like(project) or not project.is_dir():
3324
+ raise ValueError(".project ownership metadata cannot be resolved")
3325
+ state = ownership_frontmatter(project / "STATE.md", {"branch"}, missing_ok=True)
3326
+ if state.get("branch"):
3327
+ branches.add(state["branch"])
3328
+ task_files = []
3329
+
3330
+ def raise_walk_error(error):
3331
+ raise error
3332
+
3333
+ try:
3334
+ for directory, child_directories, filenames in os.walk(
3335
+ project, onerror=raise_walk_error, followlinks=False
3336
+ ):
3337
+ if any(is_link_like(Path(directory) / name) for name in child_directories):
3338
+ raise ValueError("task ownership metadata contains a linked directory")
3339
+ if Path(directory).name == "tasks":
3340
+ task_files.extend(
3341
+ Path(directory) / name
3342
+ for name in filenames
3343
+ if name.endswith(".md")
3344
+ )
3345
+ except OSError as error:
3346
+ raise ValueError(f"task ownership metadata cannot be listed: {error}") from None
3347
+ task_files.sort()
3348
+ for task in task_files:
3349
+ fields = ownership_frontmatter(task, {"task_branch", "worktree"})
3350
+ if fields.get("task_branch"):
3351
+ branches.add(fields["task_branch"])
3352
+ recorded = fields.get("worktree")
3353
+ if recorded:
3354
+ path = Path(recorded).expanduser()
3355
+ if not path.is_absolute():
3356
+ path = root / path
3357
+ try:
3358
+ worktrees.add(path.resolve())
3359
+ except (OSError, RuntimeError):
3360
+ raise ValueError("task worktree ownership path cannot be resolved") from None
3361
+ return branches, worktrees
3362
+
3363
+
3364
+ def ref_is_path_owned(name, owned_branches):
3365
+ lowered = name.casefold()
3366
+ # git stash drop and clear are denied too; refs/stash holds each stash.
3367
+ if lowered.startswith("refs/gsd-path/") or lowered == "refs/stash":
3368
+ return True
3369
+ if lowered.startswith("refs/remotes/"):
3370
+ # The remote-tracking ref of a Path branch can be the last ref to its
3371
+ # commits. A remote name can contain a slash, so try each split.
3372
+ parts = name.split("/")[2:]
3373
+ return any(
3374
+ ref_is_path_owned("refs/heads/" + "/".join(parts[index:]), owned_branches)
3375
+ for index in range(1, len(parts))
3376
+ )
3377
+ short = name
3378
+ if short.startswith("refs/heads/"):
3379
+ short = short[len("refs/heads/"):]
3380
+ elif short.startswith("heads/"):
3381
+ short = short[len("heads/"):]
3382
+ if short.casefold().startswith(PATH_OWNED_REF_PREFIXES):
3383
+ return True
3384
+
3385
+ def canonical(value):
3386
+ if value.startswith("refs/heads/"):
3387
+ return value[len("refs/heads/"):]
3388
+ if value.startswith("heads/"):
3389
+ return value[len("heads/"):]
3390
+ return value
3391
+
3392
+ # Loose refs that differ only in case are one file on macOS and Windows.
3393
+ return any(
3394
+ canonical(name).casefold() == canonical(branch).casefold()
3395
+ for branch in owned_branches
3396
+ )
3397
+
3398
+
3399
+ def _literal_target(value):
3400
+ return bool(value) and not any(character in value for character in "*?[]{}~")
3401
+
3402
+
3403
+ def _positional_targets(arguments):
3404
+ targets = []
3405
+ after_separator = False
3406
+ for argument in arguments:
3407
+ if after_separator:
3408
+ targets.append(argument)
3409
+ elif argument == "--":
3410
+ after_separator = True
3411
+ elif not argument.startswith("-"):
3412
+ targets.append(argument)
3413
+ return targets
3414
+
3415
+
3416
+ PLAIN_REDIRECTION_OPERATORS = frozenset({">", ">>", "<", ">&", "&>", "&>>"})
3417
+
3418
+
3419
+ def _opens_process_substitution(token):
3420
+ return token in ("<(", ">(") and getattr(token, "literal", None) != "quoted"
3421
+
3422
+
3423
+ def _fold_process_substitutions(tokens):
3424
+ """The tokens with each process substitution body moved onto its operator."""
3425
+ folded = []
3426
+ body = None
3427
+ depth = 0
3428
+ for token in tokens:
3429
+ opens = _opens_process_substitution(token) and (
3430
+ getattr(token, "body", None) is None
3431
+ )
3432
+ if depth == 0:
3433
+ if opens:
3434
+ depth, body = 1, []
3435
+ operator = ShellToken(token)
3436
+ operator.literal = getattr(token, "literal", None)
3437
+ operator.body = body
3438
+ token = operator
3439
+ folded.append(token)
3440
+ continue
3441
+ if opens:
3442
+ depth += 1
3443
+ elif token and set(token) <= set("|;&()\n\r") and (
3444
+ getattr(token, "literal", None) != "quoted"
3445
+ ):
3446
+ for index, character in enumerate(token):
3447
+ depth += (character == "(") - (character == ")")
3448
+ if depth == 0:
3449
+ if token[index + 1:]:
3450
+ folded.append(ShellToken(token[index + 1:]))
3451
+ if token[:index]:
3452
+ body.append(token[:index])
3453
+ break
3454
+ if depth == 0:
3455
+ continue
3456
+ body.append(token)
3457
+ return folded
3458
+
3459
+
3460
+ def _runs_git_or_wrapper(segment, assignments):
3461
+ words, _exact = _without_redirections(segment)
3462
+ return (
3463
+ wrapped_command_tokens(words if len(words) > 1 else segment) is not None
3464
+ or git_command(words, assignments) is not None
3465
+ )
3466
+
3467
+
3468
+ def _holds_git_command(tokens, assignments):
3469
+ """Whether the tokens run Git or a wrapper, at each process substitution depth."""
1802
3470
  for segment in command_segments(tokens):
1803
- wrapped = wrapped_command_tokens(segment)
3471
+ words = []
3472
+ for token in [*segment, None]:
3473
+ if token is not None and not _opens_process_substitution(token):
3474
+ if not getattr(token, "descriptor", False):
3475
+ words.append(token)
3476
+ continue
3477
+ if words and _runs_git_or_wrapper(words, assignments):
3478
+ return True
3479
+ words = []
3480
+ if getattr(token, "body", None) and _holds_git_command(
3481
+ token.body, assignments
3482
+ ):
3483
+ return True
3484
+ return False
3485
+
3486
+
3487
+ def _without_descriptor(token):
3488
+ word = ShellToken(token)
3489
+ word.redirection = getattr(token, "redirection", False)
3490
+ word.literal = getattr(token, "literal", None)
3491
+ return word
3492
+
3493
+
3494
+ def _without_redirections(words):
3495
+ """The words of a simple command without the redirections the shell parsed.
3496
+
3497
+ Each redirection operator goes with the word after it and one file
3498
+ descriptor digit written against it. The second result is false when one
3499
+ of them is not plain: an operator outside PLAIN_REDIRECTION_OPERATORS or
3500
+ a target that is not one literal word. A process substitution operator is
3501
+ removed too and is never plain.
3502
+ """
3503
+ kept = []
3504
+ exact = True
3505
+ index = 0
3506
+ while index < len(words):
3507
+ word = words[index]
3508
+ if _opens_process_substitution(word):
3509
+ exact = False
3510
+ index += 1
3511
+ continue
3512
+ if not getattr(word, "redirection", False):
3513
+ kept.append(word)
3514
+ index += 1
3515
+ continue
3516
+ if word.isdigit():
3517
+ index += 1
3518
+ word = words[index] if index < len(words) else ""
3519
+ target = words[index + 1] if index + 1 < len(words) else ""
3520
+ exact = exact and not (
3521
+ word not in PLAIN_REDIRECTION_OPERATORS
3522
+ or getattr(target, "redirection", False)
3523
+ or getattr(target, "literal", None) not in ("bare", "quoted")
3524
+ or not _literal_target(target)
3525
+ or any(character in target for character in "$`<>()&|#")
3526
+ )
3527
+ index += 2
3528
+ return kept, exact
3529
+
3530
+
3531
+ def _frontmatter_roots(contexts):
3532
+ return {
3533
+ path
3534
+ for context in contexts
3535
+ for path in context["worktrees"]
3536
+ }
3537
+
3538
+
3539
+ def _git_refs(context):
3540
+ output = _git_output(
3541
+ context["git_options"],
3542
+ context["directory"],
3543
+ "for-each-ref",
3544
+ "--format=%(refname)",
3545
+ )
3546
+ return {line for line in output.splitlines() if line}
3547
+
3548
+
3549
+ def _symbolic_ref_target(context, target):
3550
+ try:
3551
+ result = subprocess.run(
3552
+ ["git", *context["git_options"], "symbolic-ref", "--quiet", target],
3553
+ cwd=context["directory"],
3554
+ capture_output=True,
3555
+ encoding="utf-8",
3556
+ errors="replace",
3557
+ check=False,
3558
+ )
3559
+ except OSError:
3560
+ raise ValueError("git ref target cannot be resolved") from None
3561
+ if result.returncode == 1:
3562
+ return None
3563
+ if result.returncode != 0:
3564
+ raise ValueError("git ref target cannot be resolved")
3565
+ return result.stdout.strip()
3566
+
3567
+
3568
+ def _update_ref_names(target, refs, context, no_deref):
3569
+ """The ref names a deletion of this operand touches, or None when unresolved.
3570
+
3571
+ Git reads an update-ref operand as a full name below the git directory,
3572
+ never as a branch short name. It follows each symbolic ref to the ref it
3573
+ deletes; with --no-deref it deletes the named symbolic ref itself. A name
3574
+ outside refs/ (HEAD, a symbolic ref at the top of the git directory) is
3575
+ unresolved: an ordinary command such as git switch changes its target.
3576
+ """
3577
+ if not target.startswith("refs/"):
3578
+ return None
3579
+ names = [target]
3580
+ while True:
3581
+ symbolic = _symbolic_ref_target(context, names[-1])
3582
+ if symbolic is None:
3583
+ return names if names[-1] in refs else None
3584
+ if no_deref:
3585
+ return names
3586
+ if symbolic in names:
3587
+ return None
3588
+ names.append(symbolic)
3589
+
3590
+
3591
+ def _workspace_roots(contexts):
3592
+ roots = [(Path.home() / ".gsd-path").resolve()]
3593
+ configured = os.environ.get("GSD_PATH_WORKTREE_ROOT")
3594
+ if configured:
3595
+ configured_root = Path(configured).expanduser()
3596
+ if not configured_root.is_absolute():
3597
+ raise ValueError("GSD_PATH_WORKTREE_ROOT must be absolute")
3598
+ roots.append(configured_root.resolve())
3599
+ for common in {context["common"] for context in contexts}:
3600
+ receipts = common / "gsd-path" / "workspaces"
3601
+ if not os.path.lexists(receipts):
3602
+ continue
3603
+ if is_link_like(receipts) or not receipts.is_dir():
3604
+ raise ValueError("pinned workspace receipts cannot be resolved")
3605
+ try:
3606
+ with os.scandir(receipts) as entries:
3607
+ receipt_files = sorted(
3608
+ (Path(entry.path) for entry in entries if entry.name.endswith(".json")),
3609
+ key=lambda path: path.name,
3610
+ )
3611
+ except OSError as error:
3612
+ raise ValueError(f"pinned workspace receipts cannot be listed: {error}") from None
3613
+ for receipt in receipt_files:
3614
+ try:
3615
+ metadata = receipt.lstat()
3616
+ if not stat.S_ISREG(metadata.st_mode):
3617
+ raise ValueError("receipt is not a regular file")
3618
+ data = json.loads(receipt.read_text(encoding="utf-8"))
3619
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError):
3620
+ raise ValueError(
3621
+ f"pinned workspace receipt cannot be read: {receipt.name}"
3622
+ ) from None
3623
+ if (
3624
+ not isinstance(data, dict)
3625
+ or not isinstance(data.get("primary"), str)
3626
+ or not isinstance(data.get("root"), str)
3627
+ or not Path(data["primary"]).is_absolute()
3628
+ or not Path(data["root"]).is_absolute()
3629
+ ):
3630
+ raise ValueError(
3631
+ f"pinned workspace receipt is malformed: {receipt.name}"
3632
+ )
3633
+ roots.append(Path(data["root"]).resolve())
3634
+ return roots
3635
+
3636
+
3637
+ def owned_target_reason(command, arguments, git_options, working_directories):
3638
+ action = {
3639
+ "branch": "git branch -D",
3640
+ "update-ref": "git update-ref deletion",
3641
+ "worktree": "git worktree remove --force",
3642
+ }[command]
3643
+ unresolved = GIT_REASONS["unresolved-target"].format(action=action)
3644
+ # The shell expands a leading ~ and a glob in an option value such as the
3645
+ # -C directory; the guard has only the literal text.
3646
+ if any(
3647
+ value[:1] == "~" or any(character in value for character in "*?[]{}")
3648
+ for value in git_options[1::2]
3649
+ ):
3650
+ return unresolved
3651
+ contexts = git_target_contexts(git_options, working_directories)
3652
+ branches, task_worktrees = path_owned_records(_frontmatter_roots(contexts))
3653
+
3654
+ if command == "branch":
3655
+ # Each word that is not an option is read as a branch name. Git does
3656
+ # the same after --color; after an option with a separate value
3657
+ # (--sort key) this only adds a name to check.
3658
+ targets = _positional_targets(arguments)
3659
+ if not targets or not all(_literal_target(target) for target in targets):
3660
+ return unresolved
3661
+ remote = has_short_option(arguments, "r") or any(
3662
+ git_option_in(argument, {"--remotes"}) for argument in arguments
3663
+ )
3664
+ for target in targets:
3665
+ # Git reads the name in exactly one namespace, with no other form.
3666
+ ref = f"refs/{'remotes' if remote else 'heads'}/{target}"
3667
+ if ref_is_path_owned(ref, branches) or ref_is_path_owned(
3668
+ f"refs/heads/{target}", branches
3669
+ ):
3670
+ return GIT_REASONS["branch"]
3671
+ if any(ref not in _git_refs(context) for context in contexts):
3672
+ return unresolved
3673
+ return None
3674
+
3675
+ if command == "update-ref":
3676
+ operands, options = [], set()
3677
+ index = 0
3678
+ while index < len(arguments):
3679
+ argument = arguments[index]
3680
+ if argument == "-m": # its value is not an option, also when it is -d
3681
+ index += 2
3682
+ continue
3683
+ if argument.startswith("-"):
3684
+ options.add(argument)
3685
+ index += 1
3686
+ continue
3687
+ operands.append(argument)
3688
+ index += 1
3689
+ deletion_flag = "-d" in options
3690
+ expected_counts = {1, 2} if deletion_flag else {2, 3}
3691
+ if len(operands) not in expected_counts or not _literal_target(operands[0]):
3692
+ return unresolved
3693
+ if not deletion_flag and not (operands[1] == "" or not operands[1].strip("0")):
3694
+ return unresolved
3695
+ if deletion_flag and len(operands) == 2 and not _literal_target(operands[1]):
3696
+ return unresolved
3697
+ if not deletion_flag and len(operands) == 3 and not _literal_target(operands[2]):
3698
+ return unresolved
3699
+ # Dereferencing a symbolic ref must not erase ownership of its name.
3700
+ if ref_is_path_owned(operands[0], branches):
3701
+ return GIT_REASONS["update-ref"]
3702
+ for context in contexts:
3703
+ names = _update_ref_names(
3704
+ operands[0], _git_refs(context), context, "--no-deref" in options
3705
+ )
3706
+ if names is None:
3707
+ return unresolved
3708
+ if any(ref_is_path_owned(name, branches) for name in names):
3709
+ return GIT_REASONS["update-ref"]
3710
+ return None
3711
+
3712
+ targets = _positional_targets(arguments[1:])
3713
+ if len(targets) != 1 or not _literal_target(targets[0]):
3714
+ return unresolved
3715
+ recorded_paths = set(task_worktrees)
3716
+ managed_roots = _workspace_roots(contexts)
3717
+ for context in contexts:
3718
+ target = Path(targets[0])
3719
+ if not target.is_absolute():
3720
+ target = context["effective_directory"] / target
3721
+ resolved_target = None
3722
+ try:
3723
+ resolved_target = target.resolve(strict=True)
3724
+ except (OSError, RuntimeError):
3725
+ pass
3726
+ registered = context["worktrees"]
3727
+ # Git tries a unique path suffix first, then the path, and compares
3728
+ # without case on macOS and Windows. Both readings must agree.
3729
+ matches = {resolved_target} & set(registered)
3730
+ suffix = tuple(
3731
+ part.casefold() for part in Path(targets[0]).parts if part not in {"", "."}
3732
+ )
3733
+ if suffix and not Path(targets[0]).is_absolute() and ".." not in suffix:
3734
+ by_suffix = [
3735
+ path for path in registered
3736
+ if tuple(part.casefold() for part in path.parts[-len(suffix):]) == suffix
3737
+ ]
3738
+ if len(by_suffix) == 1:
3739
+ matches.add(by_suffix[0])
3740
+ if len(matches) != 1:
3741
+ return unresolved
3742
+ resolved_target = matches.pop()
3743
+ if not resolved_target.is_dir():
3744
+ return unresolved
3745
+ branch = registered[resolved_target]
3746
+ primary = next(iter(registered))
3747
+ if (
3748
+ resolved_target in recorded_paths
3749
+ or (branch is not None and ref_is_path_owned(branch, branches))
3750
+ or any(
3751
+ resolved_target == root or resolved_target.is_relative_to(root)
3752
+ for root in (*managed_roots, primary.parent / f"{primary.name}.gsd-path")
3753
+ )
3754
+ or (
3755
+ resolved_target.parent == primary.parent
3756
+ and resolved_target.name.startswith(f".{primary.name}-gsd-path-integrate-")
3757
+ )
3758
+ ):
3759
+ return GIT_REASONS["worktree"]
3760
+ return None
3761
+
3762
+
3763
+ def _path_has_symlink_component(value):
3764
+ path = Path(value)
3765
+ if not path.is_absolute():
3766
+ path = Path.cwd() / path
3767
+ current = Path(path.anchor)
3768
+ for component in path.parts[1:]:
3769
+ if component in {"", "."}:
3770
+ continue
3771
+ if component == "..":
3772
+ current = current.parent
3773
+ continue
3774
+ current = current / component
3775
+ try:
3776
+ if os.path.lexists(current) and is_link_like(current):
3777
+ return True
3778
+ except OSError:
3779
+ return True
3780
+ return False
3781
+
3782
+
3783
+ def env_wrapper_changes_git_context(segment):
3784
+ """Whether an env wrapper makes ownership or alias lookup context uncertain."""
3785
+ index = 0
3786
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
3787
+ index += 1
3788
+ if index >= len(segment):
3789
+ return False
3790
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
3791
+ if executable != "env":
3792
+ return False
3793
+
3794
+ def affects_git_context(name):
3795
+ folded = name.upper()
3796
+ return folded.startswith("GIT_") or folded in {"HOME", "XDG_CONFIG_HOME"}
3797
+
3798
+ index += 1
3799
+ while index < len(segment):
3800
+ token = segment[index]
3801
+ if token == "--":
3802
+ return False
3803
+ if token in {"-i", "--ignore-environment"}:
3804
+ return True
3805
+ if token.startswith("--unset="):
3806
+ if affects_git_context(token.split("=", 1)[1]):
3807
+ return True
3808
+ index += 1
3809
+ continue
3810
+ if token in {"-u", "--unset"}:
3811
+ if index + 1 >= len(segment):
3812
+ return False
3813
+ if affects_git_context(segment[index + 1]):
3814
+ return True
3815
+ index += 2
3816
+ continue
3817
+ if SHELL_ASSIGNMENT_PATTERN.match(token):
3818
+ if affects_git_context(token.partition("=")[0]):
3819
+ return True
3820
+ index += 1
3821
+ continue
3822
+ option = token.split("=", 1)[0]
3823
+ if option in ENV_OPTIONS_WITH_VALUES:
3824
+ index += 1 if "=" in token else 2
3825
+ continue
3826
+ if option in ENV_OPTIONS_WITHOUT_VALUES:
3827
+ index += 1
3828
+ continue
3829
+ return False
3830
+ return False
3831
+
3832
+
3833
+ def ownership_cwd_is_uncertain(tokens, working_directories=None):
3834
+ """Whether a scoped delete follows a cd whose shell control flow is unclear."""
3835
+ cwd_wrappers = COMMAND_WRAPPERS | {
3836
+ "eval", "source", ".",
3837
+ # These change what a later cd does, or run a cd later.
3838
+ "alias", "trap", "enable", "disable",
3839
+ }
3840
+ segments = []
3841
+ current = []
3842
+ separator = None
3843
+ depth = 0
3844
+ comment = False
3845
+ for token in tokens:
3846
+ # Most shells run nothing from a # word to the end of the line, but
3847
+ # the guard reads that text as commands. A cd there can run or not.
3848
+ if comment and "\n" in token:
3849
+ if not set(token) <= set("|;&()\n\r"):
3850
+ return True
3851
+ comment = False
3852
+ elif comment and token.casefold() in DIRECTORY_CHANGE_COMMANDS:
3853
+ return True
3854
+ if token and set(token) <= set("|;&()\n\r"):
3855
+ if "()" in token:
3856
+ return True # a function definition: its body runs only on a call
3857
+ if current:
3858
+ segments.append((current, separator, depth))
3859
+ current = []
3860
+ if "(" in token:
3861
+ depth += token.count("(")
3862
+ if ")" in token:
3863
+ depth = max(0, depth - token.count(")"))
3864
+ separator = token.strip()
3865
+ else:
3866
+ comment = comment or (
3867
+ token.startswith("#") and getattr(token, "literal", None) != "quoted"
3868
+ )
3869
+ current.append(token)
3870
+ if current:
3871
+ segments.append((current, separator, depth))
3872
+
3873
+ uncertain = False
3874
+ conditional = False
3875
+ current_directories = list(working_directories or [os.getcwd()])
3876
+ initial_symlink = any(
3877
+ _path_has_symlink_component(directory) for directory in current_directories
3878
+ )
3879
+ for index, (segment, previous_separator, group_depth) in enumerate(segments):
3880
+ plain, _exact = _without_redirections(segment)
3881
+ redirected = len(plain) != len(segment)
3882
+ segment = plain
3883
+ if not segment:
3884
+ continue
3885
+ if env_wrapper_changes_git_context(segment):
3886
+ return True
3887
+ if segment[0] == "function":
3888
+ return True
3889
+ if any(token.casefold() in SHELL_CONTROL_WORDS for token in segment):
3890
+ conditional = True
3891
+ while segment and segment[0].casefold() in SHELL_CONTROL_WORDS:
3892
+ segment = segment[1:]
3893
+ invocation = command_invocation(segment)
3894
+ if invocation is None:
3895
+ continue
3896
+ command, arguments = invocation
3897
+ next_separator = (
3898
+ segments[index + 1][1] if index + 1 < len(segments) else None
3899
+ )
3900
+ if command in DIRECTORY_CHANGE_COMMANDS:
3901
+ target = directory_change_target(arguments)
3902
+ absolute = is_absolute_path(target)
3903
+ dotted = target.replace("\\", "/")
3904
+ next_directories = (
3905
+ [target]
3906
+ if absolute
3907
+ else [f"{directory}/{target}" for directory in current_directories]
3908
+ )
3909
+ if (
3910
+ # Only a plain `cd <directory>` has the same effect in each shell.
3911
+ segment[0] != "cd"
3912
+ or redirected
3913
+ or arguments not in ([target], ["--", target])
3914
+ # A shell with CDPATH looks for a plain relative name in other
3915
+ # directories first; ./name and ../name are not looked up there.
3916
+ or not (
3917
+ absolute
3918
+ or dotted in {".", ".."}
3919
+ or dotted.startswith(("./", "../"))
3920
+ )
3921
+ or any(character in target for character in "*?[]{}$")
3922
+ or group_depth > 0
3923
+ or previous_separator in {"&&", "||", "&"}
3924
+ or next_separator in {"||", "&"}
3925
+ or (previous_separator is not None and "|" in previous_separator)
3926
+ or (next_separator is not None and "|" in next_separator)
3927
+ or conditional
3928
+ or initial_symlink
3929
+ or any(_path_has_symlink_component(path) for path in next_directories)
3930
+ ):
3931
+ uncertain = True
3932
+ current_directories = next_directories
3933
+ initial_symlink = False
3934
+ if command in cwd_wrappers and index + 1 < len(segments):
3935
+ uncertain = True
3936
+ if uncertain and index + 1 < len(segments):
3937
+ return True
3938
+ if any(token.casefold() in {"fi", "done", "esac"} for token in segment):
3939
+ conditional = False
3940
+ return False
3941
+
3942
+
3943
+ def assignments_change_git_context(assignments):
3944
+ return any(
3945
+ name.upper().startswith("GIT_")
3946
+ or name.upper() in {"HOME", "XDG_CONFIG_HOME"}
3947
+ for name in assignments
3948
+ )
3949
+
3950
+ GIT_CONTEXT_NAME = re.compile(r"GIT_\w+|HOME|XDG_CONFIG_HOME")
3951
+ # export, declare, typeset, local, readonly and unset of these names are
3952
+ # denied where the guard reads assignments.
3953
+ VARIABLE_SETTING_COMMANDS = frozenset(
3954
+ {"read", "getopts", "mapfile", "readarray", "integer", "float", "vared"}
3955
+ )
3956
+ LOOP_VARIABLE_WORDS = frozenset({"for", "select"})
3957
+
3958
+
3959
+ def command_sets_git_context_name(tokens):
3960
+ """Whether a command that the guard does not track sets a Git context variable."""
3961
+ words = []
3962
+ for token in [*tokens, ";"]:
3963
+ if not (token and set(token) <= set("|;&()\n\r")):
3964
+ words.append(token)
3965
+ continue
3966
+ words, _exact = _without_redirections(words)
3967
+ while words and (
3968
+ words[0].casefold() in SHELL_CONTROL_WORDS - LOOP_VARIABLE_WORDS
3969
+ or words[0] in COMMAND_WRAPPERS
3970
+ or SHELL_ASSIGNMENT_PATTERN.match(words[0])
3971
+ ):
3972
+ del words[0]
3973
+ command, operands, words = "".join(words[:1]), words[1:], []
3974
+ if command in LOOP_VARIABLE_WORDS:
3975
+ operands = operands[:1]
3976
+ elif command in {"printf", "print"}:
3977
+ operands = [
3978
+ name for option, name in zip(operands, operands[1:]) if option == "-v"
3979
+ ] + [option[2:] for option in operands if option.startswith("-v")]
3980
+ elif command not in VARIABLE_SETTING_COMMANDS:
3981
+ continue
3982
+ if any(GIT_CONTEXT_NAME.fullmatch(operand) for operand in operands):
3983
+ return True
3984
+ return False
3985
+
3986
+
3987
+ def destructive_git_reason(
3988
+ tokens,
3989
+ resolved_aliases=frozenset(),
3990
+ initial_assignments=None,
3991
+ working_directories=None,
3992
+ cwd_reliable=True,
3993
+ git_context_reliable=True,
3994
+ earlier_git=False,
3995
+ fold=True,
3996
+ ):
3997
+ # A process substitution has two readings: the tokens as they stand, and
3998
+ # the tokens with each substitution body folded onto its operator.
3999
+ folded = _fold_process_substitutions(tokens) if fold else tokens
4000
+ if len(folded) != len(tokens):
4001
+ reason = destructive_git_reason(
4002
+ tokens,
4003
+ resolved_aliases,
4004
+ initial_assignments=initial_assignments,
4005
+ working_directories=working_directories,
4006
+ cwd_reliable=cwd_reliable,
4007
+ git_context_reliable=git_context_reliable,
4008
+ earlier_git=earlier_git,
4009
+ fold=False,
4010
+ )
4011
+ if reason is not None:
4012
+ return reason
4013
+ tokens = folded
4014
+ cwd_reliable = cwd_reliable and not ownership_cwd_is_uncertain(
4015
+ tokens, working_directories
4016
+ )
4017
+ git_context_reliable = (
4018
+ git_context_reliable and not command_sets_git_context_name(tokens)
4019
+ )
4020
+ this_git = False
4021
+ for segment, directories, assignments, _prefix in shell_segment_contexts(
4022
+ tokens, working_directories or [os.getcwd()], initial_assignments
4023
+ ):
4024
+ # Whether a Git command or a wrapped command ran before this segment.
4025
+ earlier_git = earlier_git or this_git
4026
+ # A dollar expansion can introduce literal percent/bang text; the second
4027
+ # expansion pass must not silently reinterpret it as a different path.
4028
+ cwd_reliable = cwd_reliable and not any(
4029
+ CMD_PARAMETER_SYNTAX.search(NAMED_SHELL_PARAMETER_SYNTAX.sub(
4030
+ lambda match: environment_parameter_value(match, assignments), token
4031
+ ))
4032
+ for token in segment
4033
+ )
4034
+ # A descriptor word is a file descriptor in bash and an argument in
4035
+ # zsh; a process substitution runs a second command. Each reading is
4036
+ # checked, and a scoped deletion in one of them is denied.
4037
+ descriptor = any(getattr(token, "descriptor", False) for token in segment)
4038
+ if descriptor:
4039
+ readings = [
4040
+ [token for token in segment if not getattr(token, "descriptor", False)],
4041
+ [
4042
+ _without_descriptor(token)
4043
+ if getattr(token, "descriptor", False) else token
4044
+ for token in segment
4045
+ ],
4046
+ ]
4047
+ else:
4048
+ readings = [token.body for token in segment if getattr(token, "body", None)]
4049
+ for reading in readings:
4050
+ reason = destructive_git_reason(
4051
+ reading,
4052
+ resolved_aliases,
4053
+ initial_assignments=assignments,
4054
+ working_directories=directories,
4055
+ cwd_reliable=False,
4056
+ git_context_reliable=git_context_reliable,
4057
+ earlier_git=earlier_git,
4058
+ )
4059
+ if reason is not None:
4060
+ return reason
4061
+ nested_git = (
4062
+ descriptor or any(_opens_process_substitution(token) for token in segment)
4063
+ ) and _holds_git_command(segment, assignments)
4064
+ if descriptor:
4065
+ this_git = nested_git
4066
+ continue
4067
+ # A redirection can stand at each position of a simple command.
4068
+ words, exact = _without_redirections(segment)
4069
+ segment_git_context_reliable = (
4070
+ git_context_reliable
4071
+ and not env_wrapper_changes_git_context(words)
4072
+ and not assignments_change_git_context(assignments)
4073
+ )
4074
+ wrapped = wrapped_command_tokens(words if len(words) > 1 else segment)
1804
4075
  if wrapped is not None:
1805
- reason = destructive_git_reason(wrapped, resolved_aliases)
4076
+ reason = destructive_git_reason(
4077
+ wrapped,
4078
+ resolved_aliases,
4079
+ initial_assignments=assignments,
4080
+ working_directories=directories,
4081
+ cwd_reliable=cwd_reliable and exact,
4082
+ git_context_reliable=segment_git_context_reliable,
4083
+ earlier_git=earlier_git,
4084
+ )
1806
4085
  if reason is not None:
1807
4086
  return reason
1808
- invocation = git_command(segment)
4087
+ invocation = git_command(words, assignments)
4088
+ this_git = nested_git or wrapped is not None or invocation is not None
1809
4089
  if invocation is None:
1810
4090
  continue
1811
4091
  command, arguments, git_options = invocation
4092
+ # Git matches its own commands by exact case, but alias names in any case.
4093
+ built_in = (
4094
+ command_invocation(words)[1][-len(arguments) - 1] in GIT_BUILT_IN_COMMANDS
4095
+ )
4096
+ if not segment_git_context_reliable and not built_in:
4097
+ return GIT_REASONS["git-environment"]
4098
+ # A scoped deletion needs literal words: the shell can change a variable
4099
+ # in ways the guard does not track (read, +=, for, printf -v).
4100
+ resolvable = cwd_reliable and exact and not any(
4101
+ SHELL_PARAMETER_SYNTAX.search(token) for token in segment
4102
+ )
4103
+ directories = [native_path_text(directory) for directory in directories]
1812
4104
  if command == "config":
1813
4105
  alias_indexes = [
1814
4106
  index
@@ -1820,16 +4112,32 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1820
4112
  "git config alias.* defines an alias the guard cannot inspect; "
1821
4113
  "run the underlying git command directly"
1822
4114
  )
1823
- alias = git_alias(command, git_options)
4115
+ # A directory that an earlier segment creates does not exist yet.
4116
+ alias = None
4117
+ if not built_in or all(os.path.isdir(directory) for directory in directories):
4118
+ alias = git_alias(command, git_options, directories)
1824
4119
  if alias is not None:
1825
4120
  if command in resolved_aliases:
1826
4121
  raise ValueError(
1827
4122
  f"git alias {command} refers to itself; run the underlying "
1828
4123
  "git command directly"
1829
4124
  )
4125
+ # Git passes each word of an alias on as an argument; it runs no shell.
4126
+ alias_words = shell_tokens(alias, direct=False)
4127
+ for word in alias_words:
4128
+ word.redirection = False
1830
4129
  reason = destructive_git_reason(
1831
- ["git", *git_options, *shell_tokens(alias), *arguments],
4130
+ ["git", *git_options, *alias_words, *arguments],
1832
4131
  resolved_aliases | {command},
4132
+ initial_assignments=assignments,
4133
+ working_directories=directories,
4134
+ # Non-shell Git aliases do not expand environment parameters.
4135
+ cwd_reliable=resolvable and not (
4136
+ NAMED_SHELL_PARAMETER_SYNTAX.search(alias)
4137
+ or CMD_PARAMETER_SYNTAX.search(alias)
4138
+ ),
4139
+ git_context_reliable=segment_git_context_reliable,
4140
+ earlier_git=earlier_git,
1833
4141
  )
1834
4142
  if reason is not None:
1835
4143
  return reason
@@ -1857,18 +4165,29 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1857
4165
  if force_option or force_refspec:
1858
4166
  return GIT_REASONS[command]
1859
4167
  if command == "branch":
4168
+ # Git accepts each unique prefix of a long option (--del, --forc).
1860
4169
  deletes = (
1861
- "--delete" in arguments
4170
+ any(git_option_in(argument, {"--delete"}) for argument in arguments)
1862
4171
  or has_short_option(arguments, "d")
1863
4172
  or has_short_option(arguments, "D")
1864
4173
  )
1865
4174
  forces = (
1866
- "--force" in arguments
4175
+ any(git_option_in(argument, {"--force"}) for argument in arguments)
1867
4176
  or has_short_option(arguments, "f")
1868
4177
  or has_short_option(arguments, "D")
1869
4178
  )
1870
4179
  if deletes and forces:
1871
- return GIT_REASONS[command]
4180
+ if not segment_git_context_reliable:
4181
+ return GIT_REASONS["git-environment"]
4182
+ if not resolvable:
4183
+ return GIT_REASONS["unresolved-target"].format(
4184
+ action="git branch -D"
4185
+ )
4186
+ reason = owned_target_reason(
4187
+ command, arguments, git_options, directories
4188
+ )
4189
+ if reason is not None:
4190
+ return reason
1872
4191
  if (
1873
4192
  "--move" in arguments
1874
4193
  or has_short_option(arguments, "m")
@@ -1877,10 +4196,39 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1877
4196
  return GIT_REASONS["branch-move"]
1878
4197
  if command == "update-ref":
1879
4198
  if update_ref_deletes(arguments):
1880
- return GIT_REASONS[command]
4199
+ if not segment_git_context_reliable:
4200
+ return GIT_REASONS["git-environment"]
4201
+ if not resolvable:
4202
+ return GIT_REASONS["unresolved-target"].format(
4203
+ action="git update-ref deletion"
4204
+ )
4205
+ # The guard reads each symbolic ref now; an earlier Git command
4206
+ # of this command line can point it at a Path-owned ref later.
4207
+ # (-m can take the text --no-deref as its value.)
4208
+ if earlier_git and (
4209
+ "--no-deref" not in arguments or "-m" in arguments
4210
+ ):
4211
+ return GIT_REASONS["update-ref-order"]
4212
+ reason = owned_target_reason(
4213
+ command, arguments, git_options, directories
4214
+ )
4215
+ if reason is not None:
4216
+ return reason
1881
4217
  if command == "worktree" and arguments[:1] == ["remove"]:
1882
- if "--force" in arguments or has_short_option(arguments[1:], "f"):
1883
- return GIT_REASONS[command]
4218
+ if any(
4219
+ git_option_in(argument, {"--force"}) for argument in arguments
4220
+ ) or has_short_option(arguments[1:], "f"):
4221
+ if not segment_git_context_reliable:
4222
+ return GIT_REASONS["git-environment"]
4223
+ if not resolvable:
4224
+ return GIT_REASONS["unresolved-target"].format(
4225
+ action="git worktree remove --force"
4226
+ )
4227
+ reason = owned_target_reason(
4228
+ "worktree", arguments, git_options, directories
4229
+ )
4230
+ if reason is not None:
4231
+ return reason
1884
4232
  if command == "stash" and arguments[:1] in (["drop"], ["clear"]):
1885
4233
  return GIT_REASONS[command]
1886
4234
  if command in {"checkout", "restore"} and any(
@@ -1983,28 +4331,76 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
1983
4331
  return False
1984
4332
 
1985
4333
 
1986
- def archive_command_is_read_only(command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS):
4334
+ def archive_command_is_read_only(
4335
+ command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS,
4336
+ source=None,
4337
+ ):
4338
+ """`source` is the unexpanded (segment, assignments) whose tokens keep their quote flags."""
1987
4339
  if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
1988
4340
  return False
1989
4341
  if not archive_context:
1990
4342
  return True
1991
- executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold()
4343
+ invocation = command_invocation(tokens, unwrap_runners=False)
4344
+ if invocation is None:
4345
+ return True
4346
+ executable, arguments = invocation
4347
+ executable = executable.removesuffix(".exe")
4348
+ if executable in DIRECTORY_CHANGE_COMMANDS:
4349
+ return True
4350
+ if executable in {"export", "declare", "typeset", "unset"}:
4351
+ return True
1992
4352
  denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
1993
4353
  if any(
1994
4354
  token == option or token.startswith(f"{option}=")
1995
- for token in tokens[1:]
4355
+ for token in arguments
1996
4356
  for option in denied_options
1997
4357
  ):
1998
4358
  return False
4359
+ if executable == "sed":
4360
+ in_place, _inputs, script_is_read_only = sed_arguments(arguments)
4361
+ return not in_place and script_is_read_only
1999
4362
  if executable in ARCHIVE_READ_COMMANDS:
2000
4363
  return True
2001
- if executable != "git" or len(tokens) < 2:
4364
+ if executable != "git":
4365
+ return False
4366
+ git = git_command(*(source or (tokens,)))
4367
+ if git is None:
2002
4368
  return False
2003
- if tokens[1].casefold() not in git_commands:
4369
+ subcommand, git_arguments, _options = git
4370
+ if subcommand not in git_commands:
2004
4371
  return False
2005
4372
  return not any(
2006
- token in GIT_READ_WRITE_OPTIONS or token.startswith("--output=")
2007
- for token in tokens[2:]
4373
+ git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
4374
+ )
4375
+
4376
+
4377
+ def archive_write_attempt(segment, source=None):
4378
+ """Whether a segment denied in archive context attempts a write there."""
4379
+ if any(
4380
+ token
4381
+ and (set(token) <= SHELL_WRITE_REDIRECTION_CHARS
4382
+ or re.fullmatch(r"\d*[<>]+&?", token))
4383
+ for token in segment
4384
+ ):
4385
+ return True
4386
+ invocation = command_invocation(segment)
4387
+ if invocation is None:
4388
+ return False
4389
+ executable, arguments = invocation
4390
+ executable = executable.removesuffix(".exe")
4391
+ if executable in SHELL_WRITE_COMMANDS:
4392
+ return True
4393
+ if executable in IN_PLACE_EDITORS and (
4394
+ has_short_option(arguments, "i")
4395
+ or any(argument.startswith("--in-place") for argument in arguments)
4396
+ ):
4397
+ return True
4398
+ git = git_command(*(source or (segment,)))
4399
+ if git is None:
4400
+ return False
4401
+ subcommand, git_arguments, _options = git
4402
+ return subcommand not in ARCHIVE_READ_GIT_COMMANDS or any(
4403
+ git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
2008
4404
  )
2009
4405
 
2010
4406
 
@@ -2126,22 +4522,28 @@ def closed_git_listing(tokens):
2126
4522
  return not positionals
2127
4523
 
2128
4524
 
2129
- def closed_shell_execution_reason(command, tokens, working_directories):
4525
+ def closed_shell_execution_reason(
4526
+ command, tokens, working_directories, initial_assignments=None
4527
+ ):
2130
4528
  helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
2131
4529
  rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
2132
4530
  if rest[:1] == ["pending"]:
2133
4531
  helpers = helpers | {"discussion_records.py"}
2134
4532
  if bundled_helper_invocation(command, tokens, working_directories, helpers):
2135
4533
  return None
2136
- for segment, directories in segment_directories(tokens, working_directories):
4534
+ for segment, directories, assignments, _prefix in shell_segment_contexts(
4535
+ tokens, working_directories, initial_assignments
4536
+ ):
2137
4537
  wrapped = wrapped_command_tokens(segment)
2138
4538
  if wrapped is not None:
2139
- reason = closed_shell_execution_reason(shlex.join(wrapped), wrapped, directories)
4539
+ reason = closed_shell_execution_reason(
4540
+ shlex.join(wrapped), wrapped, directories, assignments
4541
+ )
2140
4542
  if reason:
2141
4543
  return reason
2142
4544
  continue
2143
4545
  metadata_closed = None
2144
- git = git_command(segment)
4546
+ git = git_command(segment, assignments)
2145
4547
  checked_segment = segment
2146
4548
  if git is not None:
2147
4549
  subcommand, arguments, options = git
@@ -2180,6 +4582,7 @@ def closed_shell_execution_reason(command, tokens, working_directories):
2180
4582
  def command_denial(command, working_directories, allow_destructive=True):
2181
4583
  """Return why a shell command is denied, or None when it may run."""
2182
4584
  working_directories = working_directories or [os.getcwd()]
4585
+ command = re.sub(r"(?i:(\.project)\\(archive))", r"\1/\2", command)
2183
4586
  outer, substitutions = split_command_substitutions(command)
2184
4587
  try:
2185
4588
  tokens = shell_tokens(outer)
@@ -2188,6 +4591,7 @@ def command_denial(command, working_directories, allow_destructive=True):
2188
4591
  for interpreter in ("python3", "python", "py -3")
2189
4592
  ):
2190
4593
  return None
4594
+ bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
2191
4595
  destructive = list(destructive_shell_invocations(tokens))
2192
4596
  if destructive and (
2193
4597
  not allow_destructive
@@ -2208,19 +4612,35 @@ def command_denial(command, working_directories, allow_destructive=True):
2208
4612
  reason = command_denial(inner, working_directories, allow_destructive=False)
2209
4613
  if reason is not None:
2210
4614
  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)
2219
- ):
2220
- return ARCHIVE_REASON
2221
- reason = destructive_git_reason(tokens) or protected_shell_write_reason(
4615
+ for segment, directories, assignments, prefix in shell_segment_contexts(
2222
4616
  tokens, working_directories
2223
- )
4617
+ ):
4618
+ resolved_segment = [
4619
+ expand_environment_parameters(token, assignments) for token in segment
4620
+ ]
4621
+ archive_context = (
4622
+ any(working_directory_in_archive(path) for path in directories)
4623
+ or segment_references_archive(segment, directories, assignments)
4624
+ or any(
4625
+ ARCHIVE_REFERENCE.search(value)
4626
+ or path_in_archive(value, directories, ancestors=False)
4627
+ for value in prefix.values()
4628
+ )
4629
+ )
4630
+ if not archive_command_is_read_only(
4631
+ shlex.join(resolved_segment), resolved_segment, archive_context,
4632
+ source=(segment, assignments),
4633
+ ) and not (
4634
+ archive_context
4635
+ and bundled_helper
4636
+ ):
4637
+ if archive_write_attempt(resolved_segment, (segment, assignments)):
4638
+ return ARCHIVE_REASON
4639
+ return ARCHIVE_UNPROVEN_READ_REASON
4640
+ reason = destructive_git_reason(
4641
+ tokens, working_directories=working_directories,
4642
+ cwd_reliable=not shell_parameter_quoting_uncertain(outer),
4643
+ ) or protected_shell_write_reason(tokens, working_directories)
2224
4644
  except ValueError as error:
2225
4645
  raise ValueError(describe_substitutions(str(error), substitutions)) from None
2226
4646
  return reason and describe_substitutions(reason, substitutions)