@opengsd/gsd-path 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (481) hide show
  1. package/AGENTS.md +2 -207
  2. package/CHANGELOG.md +331 -69
  3. package/DOCS.md +25 -9
  4. package/FULL.md +12 -5
  5. package/GUIDE.md +1 -1
  6. package/HOOKS.md +104 -14
  7. package/QUICK.md +5 -2
  8. package/README.md +131 -111
  9. package/RUNTIME.md +2 -2
  10. package/UPDATE.md +61 -11
  11. package/WORKFLOW.md +44 -7
  12. package/package.json +13 -9
  13. package/platforms/shared-agents/dispatch.md +13 -0
  14. package/scripts/_common.py +619 -18
  15. package/scripts/archive_milestone.py +160 -86
  16. package/scripts/bootstrap_repository.py +8 -5
  17. package/scripts/build_recovery.py +100 -32
  18. package/scripts/build_state.py +143 -24
  19. package/scripts/check_docs_audit.py +91 -7
  20. package/scripts/check_handoffs.py +662 -50
  21. package/scripts/check_task_briefs.py +338 -30
  22. package/scripts/check_update.py +2 -2
  23. package/scripts/core_hook_gate.py +10 -1
  24. package/scripts/core_hook_settings.py +11 -4
  25. package/scripts/detect_project.py +587 -32
  26. package/scripts/discussion_records.py +19 -13
  27. package/scripts/discussion_validate.py +152 -50
  28. package/scripts/dispatch_driver.py +501 -46
  29. package/scripts/git_guard.py +118 -31
  30. package/scripts/guard_hook.py +1567 -194
  31. package/scripts/install.mjs +77 -46
  32. package/scripts/install.py +611 -50
  33. package/scripts/integration.py +580 -25
  34. package/scripts/isolation.py +1438 -57
  35. package/scripts/lean_verification.py +99 -19
  36. package/scripts/loop_run.py +31 -29
  37. package/scripts/members.py +706 -0
  38. package/scripts/migrate_core.py +2 -2
  39. package/scripts/path_config.py +5 -5
  40. package/scripts/pipeline_diagnose.py +82 -31
  41. package/scripts/pipeline_git.py +124 -34
  42. package/scripts/pipeline_state.py +287 -87
  43. package/scripts/pipeline_undo.py +218 -30
  44. package/scripts/promote_lookahead.py +1 -1
  45. package/scripts/review_panel.py +1 -1
  46. package/scripts/runtime_store.py +55 -11
  47. package/scripts/skill-resources.json +37 -5
  48. package/scripts/state_checkpoint.py +135 -44
  49. package/scripts/state_promote.py +2 -0
  50. package/scripts/status_runtime.py +58 -10
  51. package/scripts/sync_skill_resources.py +8 -7
  52. package/scripts/task_context.py +12 -3
  53. package/scripts/token_budget.py +12 -8
  54. package/scripts/wizard.mjs +1 -0
  55. package/scripts/workflow_run.py +107 -30
  56. package/scripts/worktree_paths.py +72 -10
  57. package/skills/gsd-path/BUILD.md +7 -1
  58. package/skills/gsd-path/DECIDE.md +11 -4
  59. package/skills/gsd-path/DEFINE.md +36 -4
  60. package/skills/gsd-path/DOCS-AUDIT.md +6 -4
  61. package/skills/gsd-path/INSPECT.md +5 -4
  62. package/skills/gsd-path/PLAN.md +18 -4
  63. package/skills/gsd-path/RESEARCH.md +13 -1
  64. package/skills/gsd-path/ROADMAP.md +2 -1
  65. package/skills/gsd-path/SHIP.md +33 -11
  66. package/skills/gsd-path/SKILL.md +45 -1
  67. package/skills/gsd-path/references/build-native.md +63 -15
  68. package/skills/gsd-path/references/codebase-mapper.md +2 -1
  69. package/skills/gsd-path/references/dispatch.md +13 -0
  70. package/skills/gsd-path/references/docs-auditor.md +7 -1
  71. package/skills/gsd-path/references/operating-rules.md +214 -0
  72. package/skills/gsd-path/references/planner.md +22 -4
  73. package/skills/gsd-path/references/reviewer-final.md +20 -2
  74. package/skills/gsd-path/references/reviewer-wave.md +18 -5
  75. package/skills/gsd-path/references/reviewer.md +3 -1
  76. package/skills/gsd-path/references/spec-probes.md +150 -0
  77. package/skills/gsd-path/scripts/_common.py +619 -18
  78. package/skills/gsd-path/scripts/archive_milestone.py +160 -86
  79. package/skills/gsd-path/scripts/bootstrap_repository.py +8 -5
  80. package/skills/gsd-path/scripts/build_recovery.py +100 -32
  81. package/skills/gsd-path/scripts/build_state.py +143 -24
  82. package/skills/gsd-path/scripts/check_docs_audit.py +91 -7
  83. package/skills/gsd-path/scripts/check_handoffs.py +662 -50
  84. package/skills/gsd-path/scripts/check_task_briefs.py +338 -30
  85. package/skills/gsd-path/scripts/check_update.py +2 -2
  86. package/skills/gsd-path/scripts/detect_project.py +587 -32
  87. package/skills/gsd-path/scripts/discussion_records.py +19 -13
  88. package/skills/gsd-path/scripts/discussion_validate.py +152 -50
  89. package/skills/gsd-path/scripts/dispatch_driver.py +501 -46
  90. package/skills/gsd-path/scripts/git_guard.py +118 -31
  91. package/skills/gsd-path/scripts/integration.py +580 -25
  92. package/skills/gsd-path/scripts/isolation.py +1438 -57
  93. package/skills/gsd-path/scripts/lean_verification.py +99 -19
  94. package/skills/gsd-path/scripts/loop_run.py +31 -29
  95. package/skills/gsd-path/scripts/members.py +706 -0
  96. package/skills/gsd-path/scripts/path_config.py +5 -5
  97. package/skills/gsd-path/scripts/pipeline_git.py +124 -34
  98. package/skills/gsd-path/scripts/pipeline_state.py +287 -87
  99. package/skills/gsd-path/scripts/promote_lookahead.py +1 -1
  100. package/skills/gsd-path/scripts/review_panel.py +1 -1
  101. package/skills/gsd-path/scripts/state_checkpoint.py +135 -44
  102. package/skills/gsd-path/scripts/state_promote.py +2 -0
  103. package/skills/gsd-path/scripts/status_runtime.py +58 -10
  104. package/skills/gsd-path/scripts/task_context.py +12 -3
  105. package/skills/gsd-path/scripts/token_budget.py +12 -8
  106. package/skills/gsd-path/scripts/workflow_run.py +107 -30
  107. package/skills/gsd-path/scripts/worktree_paths.py +72 -10
  108. package/skills/gsd-path/templates/codebase.md +1 -1
  109. package/skills/gsd-path/templates/docs-audit.md +6 -2
  110. package/skills/gsd-path/templates/final-review.md +16 -4
  111. package/skills/gsd-path/templates/gap-review.md +2 -1
  112. package/skills/gsd-path/templates/intent.md +30 -0
  113. package/skills/gsd-path/templates/patch-findings.md +3 -3
  114. package/skills/gsd-path/templates/plan.md +17 -2
  115. package/skills/gsd-path/templates/research-handoff.md +14 -3
  116. package/skills/gsd-path/templates/state.md +1 -1
  117. package/skills/gsd-path/templates/task.md +1 -0
  118. package/skills/gsd-path/templates/wave-review.md +16 -4
  119. package/skills/gsd-path-build/SKILL.md +7 -1
  120. package/skills/gsd-path-build/references/build-native.md +63 -15
  121. package/skills/gsd-path-build/references/dispatch.md +13 -0
  122. package/skills/gsd-path-build/references/operating-rules.md +214 -0
  123. package/skills/gsd-path-build/references/reviewer-final.md +20 -2
  124. package/skills/gsd-path-build/references/reviewer-wave.md +18 -5
  125. package/skills/gsd-path-build/references/reviewer.md +3 -1
  126. package/skills/gsd-path-build/references/spec-probes.md +150 -0
  127. package/skills/gsd-path-build/scripts/_common.py +619 -18
  128. package/skills/gsd-path-build/scripts/archive_milestone.py +160 -86
  129. package/skills/gsd-path-build/scripts/build_recovery.py +100 -32
  130. package/skills/gsd-path-build/scripts/build_state.py +143 -24
  131. package/skills/gsd-path-build/scripts/check_handoffs.py +662 -50
  132. package/skills/gsd-path-build/scripts/check_task_briefs.py +338 -30
  133. package/skills/gsd-path-build/scripts/detect_project.py +587 -32
  134. package/skills/gsd-path-build/scripts/discussion_records.py +19 -13
  135. package/skills/gsd-path-build/scripts/discussion_validate.py +152 -50
  136. package/skills/gsd-path-build/scripts/dispatch_driver.py +501 -46
  137. package/skills/gsd-path-build/scripts/integration.py +580 -25
  138. package/skills/gsd-path-build/scripts/isolation.py +1438 -57
  139. package/skills/gsd-path-build/scripts/loop_run.py +31 -29
  140. package/skills/gsd-path-build/scripts/members.py +706 -0
  141. package/skills/gsd-path-build/scripts/path_config.py +5 -5
  142. package/skills/gsd-path-build/scripts/pipeline_git.py +124 -34
  143. package/skills/gsd-path-build/scripts/pipeline_state.py +287 -87
  144. package/skills/gsd-path-build/scripts/promote_lookahead.py +1 -1
  145. package/skills/gsd-path-build/scripts/review_panel.py +1 -1
  146. package/skills/gsd-path-build/scripts/state_checkpoint.py +135 -44
  147. package/skills/gsd-path-build/scripts/state_promote.py +2 -0
  148. package/skills/gsd-path-build/scripts/task_context.py +12 -3
  149. package/skills/gsd-path-build/scripts/token_budget.py +12 -8
  150. package/skills/gsd-path-build/scripts/workflow_run.py +107 -30
  151. package/skills/gsd-path-build/scripts/worktree_paths.py +72 -10
  152. package/skills/gsd-path-build/templates/task.md +1 -0
  153. package/skills/gsd-path-build/templates/wave-review.md +16 -4
  154. package/skills/gsd-path-decide/SKILL.md +11 -4
  155. package/skills/gsd-path-decide/references/dispatch.md +13 -0
  156. package/skills/gsd-path-decide/references/operating-rules.md +214 -0
  157. package/skills/gsd-path-decide/scripts/_common.py +619 -18
  158. package/skills/gsd-path-decide/scripts/archive_milestone.py +160 -86
  159. package/skills/gsd-path-decide/scripts/build_recovery.py +100 -32
  160. package/skills/gsd-path-decide/scripts/check_handoffs.py +662 -50
  161. package/skills/gsd-path-decide/scripts/check_task_briefs.py +338 -30
  162. package/skills/gsd-path-decide/scripts/detect_project.py +587 -32
  163. package/skills/gsd-path-decide/scripts/discussion_records.py +19 -13
  164. package/skills/gsd-path-decide/scripts/discussion_validate.py +152 -50
  165. package/skills/gsd-path-decide/scripts/integration.py +580 -25
  166. package/skills/gsd-path-decide/scripts/isolation.py +1438 -57
  167. package/skills/gsd-path-decide/scripts/loop_run.py +31 -29
  168. package/skills/gsd-path-decide/scripts/members.py +706 -0
  169. package/skills/gsd-path-decide/scripts/path_config.py +5 -5
  170. package/skills/gsd-path-decide/scripts/pipeline_git.py +124 -34
  171. package/skills/gsd-path-decide/scripts/pipeline_state.py +287 -87
  172. package/skills/gsd-path-decide/scripts/promote_lookahead.py +1 -1
  173. package/skills/gsd-path-decide/scripts/review_panel.py +1 -1
  174. package/skills/gsd-path-decide/scripts/state_checkpoint.py +135 -44
  175. package/skills/gsd-path-decide/scripts/state_promote.py +2 -0
  176. package/skills/gsd-path-decide/scripts/worktree_paths.py +72 -10
  177. package/skills/gsd-path-define/SKILL.md +36 -4
  178. package/skills/gsd-path-define/references/operating-rules.md +214 -0
  179. package/skills/gsd-path-define/references/spec-probes.md +150 -0
  180. package/skills/gsd-path-define/scripts/_common.py +619 -18
  181. package/skills/gsd-path-define/scripts/archive_milestone.py +160 -86
  182. package/skills/gsd-path-define/scripts/build_recovery.py +100 -32
  183. package/skills/gsd-path-define/scripts/check_handoffs.py +662 -50
  184. package/skills/gsd-path-define/scripts/check_task_briefs.py +338 -30
  185. package/skills/gsd-path-define/scripts/detect_project.py +587 -32
  186. package/skills/gsd-path-define/scripts/discussion_records.py +19 -13
  187. package/skills/gsd-path-define/scripts/discussion_validate.py +152 -50
  188. package/skills/gsd-path-define/scripts/integration.py +580 -25
  189. package/skills/gsd-path-define/scripts/isolation.py +1438 -57
  190. package/skills/gsd-path-define/scripts/loop_run.py +31 -29
  191. package/skills/gsd-path-define/scripts/members.py +706 -0
  192. package/skills/gsd-path-define/scripts/path_config.py +5 -5
  193. package/skills/gsd-path-define/scripts/pipeline_git.py +124 -34
  194. package/skills/gsd-path-define/scripts/pipeline_state.py +287 -87
  195. package/skills/gsd-path-define/scripts/promote_lookahead.py +1 -1
  196. package/skills/gsd-path-define/scripts/review_panel.py +1 -1
  197. package/skills/gsd-path-define/scripts/state_checkpoint.py +135 -44
  198. package/skills/gsd-path-define/scripts/state_promote.py +2 -0
  199. package/skills/gsd-path-define/scripts/worktree_paths.py +72 -10
  200. package/skills/gsd-path-define/templates/intent.md +30 -0
  201. package/skills/gsd-path-define/templates/state.md +1 -1
  202. package/skills/gsd-path-discuss/SKILL.md +2 -1
  203. package/skills/gsd-path-discuss/references/operating-rules.md +214 -0
  204. package/skills/gsd-path-discuss/scripts/_common.py +619 -18
  205. package/skills/gsd-path-discuss/scripts/discussion_records.py +19 -13
  206. package/skills/gsd-path-discuss/scripts/loop_run.py +31 -29
  207. package/skills/gsd-path-discuss/scripts/path_config.py +5 -5
  208. package/skills/gsd-path-discuss/scripts/review_panel.py +1 -1
  209. package/skills/gsd-path-discuss/scripts/worktree_paths.py +72 -10
  210. package/skills/gsd-path-docs-audit/SKILL.md +6 -4
  211. package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
  212. package/skills/gsd-path-docs-audit/references/docs-auditor.md +7 -1
  213. package/skills/gsd-path-docs-audit/references/operating-rules.md +214 -0
  214. package/skills/gsd-path-docs-audit/scripts/_common.py +619 -18
  215. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +160 -86
  216. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +100 -32
  217. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +91 -7
  218. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +662 -50
  219. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +338 -30
  220. package/skills/gsd-path-docs-audit/scripts/detect_project.py +587 -32
  221. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +19 -13
  222. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +152 -50
  223. package/skills/gsd-path-docs-audit/scripts/integration.py +580 -25
  224. package/skills/gsd-path-docs-audit/scripts/isolation.py +1438 -57
  225. package/skills/gsd-path-docs-audit/scripts/loop_run.py +31 -29
  226. package/skills/gsd-path-docs-audit/scripts/members.py +706 -0
  227. package/skills/gsd-path-docs-audit/scripts/path_config.py +5 -5
  228. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +124 -34
  229. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +287 -87
  230. package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +1 -1
  231. package/skills/gsd-path-docs-audit/scripts/review_panel.py +1 -1
  232. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +135 -44
  233. package/skills/gsd-path-docs-audit/scripts/state_promote.py +2 -0
  234. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +72 -10
  235. package/skills/gsd-path-docs-audit/templates/docs-audit.md +6 -2
  236. package/skills/gsd-path-forensics/SKILL.md +2 -1
  237. package/skills/gsd-path-forensics/references/operating-rules.md +214 -0
  238. package/skills/gsd-path-forensics/scripts/_common.py +619 -18
  239. package/skills/gsd-path-forensics/scripts/discussion_records.py +19 -13
  240. package/skills/gsd-path-forensics/scripts/loop_run.py +31 -29
  241. package/skills/gsd-path-forensics/scripts/path_config.py +5 -5
  242. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +82 -31
  243. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +218 -30
  244. package/skills/gsd-path-forensics/scripts/review_panel.py +1 -1
  245. package/skills/gsd-path-forensics/scripts/worktree_paths.py +72 -10
  246. package/skills/gsd-path-inspect/SKILL.md +5 -4
  247. package/skills/gsd-path-inspect/references/codebase-mapper.md +2 -1
  248. package/skills/gsd-path-inspect/references/dispatch.md +13 -0
  249. package/skills/gsd-path-inspect/references/docs-auditor.md +7 -1
  250. package/skills/gsd-path-inspect/references/operating-rules.md +214 -0
  251. package/skills/gsd-path-inspect/scripts/_common.py +619 -18
  252. package/skills/gsd-path-inspect/scripts/archive_milestone.py +160 -86
  253. package/skills/gsd-path-inspect/scripts/build_recovery.py +100 -32
  254. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +91 -7
  255. package/skills/gsd-path-inspect/scripts/check_handoffs.py +662 -50
  256. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +338 -30
  257. package/skills/gsd-path-inspect/scripts/detect_project.py +587 -32
  258. package/skills/gsd-path-inspect/scripts/discussion_records.py +19 -13
  259. package/skills/gsd-path-inspect/scripts/discussion_validate.py +152 -50
  260. package/skills/gsd-path-inspect/scripts/integration.py +580 -25
  261. package/skills/gsd-path-inspect/scripts/isolation.py +1438 -57
  262. package/skills/gsd-path-inspect/scripts/loop_run.py +31 -29
  263. package/skills/gsd-path-inspect/scripts/members.py +706 -0
  264. package/skills/gsd-path-inspect/scripts/path_config.py +5 -5
  265. package/skills/gsd-path-inspect/scripts/pipeline_git.py +124 -34
  266. package/skills/gsd-path-inspect/scripts/pipeline_state.py +287 -87
  267. package/skills/gsd-path-inspect/scripts/promote_lookahead.py +1 -1
  268. package/skills/gsd-path-inspect/scripts/review_panel.py +1 -1
  269. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +135 -44
  270. package/skills/gsd-path-inspect/scripts/state_promote.py +2 -0
  271. package/skills/gsd-path-inspect/scripts/workflow_run.py +107 -30
  272. package/skills/gsd-path-inspect/scripts/worktree_paths.py +72 -10
  273. package/skills/gsd-path-inspect/templates/codebase.md +1 -1
  274. package/skills/gsd-path-inspect/templates/docs-audit.md +6 -2
  275. package/skills/gsd-path-inspect/templates/state.md +1 -1
  276. package/skills/gsd-path-loop/SKILL.md +2 -1
  277. package/skills/gsd-path-loop/references/operating-rules.md +214 -0
  278. package/skills/gsd-path-loop/scripts/_common.py +619 -18
  279. package/skills/gsd-path-loop/scripts/discussion_records.py +19 -13
  280. package/skills/gsd-path-loop/scripts/loop_run.py +31 -29
  281. package/skills/gsd-path-loop/scripts/path_config.py +5 -5
  282. package/skills/gsd-path-loop/scripts/review_panel.py +1 -1
  283. package/skills/gsd-path-loop/scripts/worktree_paths.py +72 -10
  284. package/skills/gsd-path-migrate/SKILL.md +2 -1
  285. package/skills/gsd-path-migrate/references/operating-rules.md +214 -0
  286. package/skills/gsd-path-migrate/scripts/_common.py +619 -18
  287. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +10 -1
  288. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +11 -4
  289. package/skills/gsd-path-migrate/scripts/discussion_records.py +19 -13
  290. package/skills/gsd-path-migrate/scripts/loop_run.py +31 -29
  291. package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -2
  292. package/skills/gsd-path-migrate/scripts/path_config.py +5 -5
  293. package/skills/gsd-path-migrate/scripts/review_panel.py +1 -1
  294. package/skills/gsd-path-plan/SKILL.md +18 -4
  295. package/skills/gsd-path-plan/references/dispatch.md +13 -0
  296. package/skills/gsd-path-plan/references/operating-rules.md +214 -0
  297. package/skills/gsd-path-plan/references/planner.md +22 -4
  298. package/skills/gsd-path-plan/references/spec-probes.md +150 -0
  299. package/skills/gsd-path-plan/scripts/_common.py +619 -18
  300. package/skills/gsd-path-plan/scripts/archive_milestone.py +160 -86
  301. package/skills/gsd-path-plan/scripts/build_recovery.py +100 -32
  302. package/skills/gsd-path-plan/scripts/check_handoffs.py +662 -50
  303. package/skills/gsd-path-plan/scripts/check_task_briefs.py +338 -30
  304. package/skills/gsd-path-plan/scripts/detect_project.py +587 -32
  305. package/skills/gsd-path-plan/scripts/discussion_records.py +19 -13
  306. package/skills/gsd-path-plan/scripts/discussion_validate.py +152 -50
  307. package/skills/gsd-path-plan/scripts/integration.py +580 -25
  308. package/skills/gsd-path-plan/scripts/isolation.py +1438 -57
  309. package/skills/gsd-path-plan/scripts/loop_run.py +31 -29
  310. package/skills/gsd-path-plan/scripts/members.py +706 -0
  311. package/skills/gsd-path-plan/scripts/path_config.py +5 -5
  312. package/skills/gsd-path-plan/scripts/pipeline_git.py +124 -34
  313. package/skills/gsd-path-plan/scripts/pipeline_state.py +287 -87
  314. package/skills/gsd-path-plan/scripts/promote_lookahead.py +1 -1
  315. package/skills/gsd-path-plan/scripts/review_panel.py +1 -1
  316. package/skills/gsd-path-plan/scripts/state_checkpoint.py +135 -44
  317. package/skills/gsd-path-plan/scripts/state_promote.py +2 -0
  318. package/skills/gsd-path-plan/scripts/workflow_run.py +107 -30
  319. package/skills/gsd-path-plan/scripts/worktree_paths.py +72 -10
  320. package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
  321. package/skills/gsd-path-plan/templates/plan.md +17 -2
  322. package/skills/gsd-path-plan/templates/task.md +1 -0
  323. package/skills/gsd-path-research/SKILL.md +13 -1
  324. package/skills/gsd-path-research/references/dispatch.md +13 -0
  325. package/skills/gsd-path-research/references/operating-rules.md +214 -0
  326. package/skills/gsd-path-research/scripts/_common.py +619 -18
  327. package/skills/gsd-path-research/scripts/archive_milestone.py +160 -86
  328. package/skills/gsd-path-research/scripts/build_recovery.py +100 -32
  329. package/skills/gsd-path-research/scripts/check_handoffs.py +662 -50
  330. package/skills/gsd-path-research/scripts/check_task_briefs.py +338 -30
  331. package/skills/gsd-path-research/scripts/detect_project.py +587 -32
  332. package/skills/gsd-path-research/scripts/discussion_records.py +19 -13
  333. package/skills/gsd-path-research/scripts/discussion_validate.py +152 -50
  334. package/skills/gsd-path-research/scripts/integration.py +580 -25
  335. package/skills/gsd-path-research/scripts/isolation.py +1438 -57
  336. package/skills/gsd-path-research/scripts/loop_run.py +31 -29
  337. package/skills/gsd-path-research/scripts/members.py +706 -0
  338. package/skills/gsd-path-research/scripts/path_config.py +5 -5
  339. package/skills/gsd-path-research/scripts/pipeline_git.py +124 -34
  340. package/skills/gsd-path-research/scripts/pipeline_state.py +287 -87
  341. package/skills/gsd-path-research/scripts/promote_lookahead.py +1 -1
  342. package/skills/gsd-path-research/scripts/review_panel.py +1 -1
  343. package/skills/gsd-path-research/scripts/state_checkpoint.py +135 -44
  344. package/skills/gsd-path-research/scripts/state_promote.py +2 -0
  345. package/skills/gsd-path-research/scripts/worktree_paths.py +72 -10
  346. package/skills/gsd-path-research/templates/research-handoff.md +14 -3
  347. package/skills/gsd-path-roadmap/SKILL.md +2 -1
  348. package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
  349. package/skills/gsd-path-roadmap/references/operating-rules.md +214 -0
  350. package/skills/gsd-path-roadmap/scripts/_common.py +619 -18
  351. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +160 -86
  352. package/skills/gsd-path-roadmap/scripts/build_recovery.py +100 -32
  353. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +662 -50
  354. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +338 -30
  355. package/skills/gsd-path-roadmap/scripts/detect_project.py +587 -32
  356. package/skills/gsd-path-roadmap/scripts/discussion_records.py +19 -13
  357. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +152 -50
  358. package/skills/gsd-path-roadmap/scripts/integration.py +580 -25
  359. package/skills/gsd-path-roadmap/scripts/isolation.py +1438 -57
  360. package/skills/gsd-path-roadmap/scripts/loop_run.py +31 -29
  361. package/skills/gsd-path-roadmap/scripts/members.py +706 -0
  362. package/skills/gsd-path-roadmap/scripts/path_config.py +5 -5
  363. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +124 -34
  364. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +287 -87
  365. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +1 -1
  366. package/skills/gsd-path-roadmap/scripts/review_panel.py +1 -1
  367. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +135 -44
  368. package/skills/gsd-path-roadmap/scripts/state_promote.py +2 -0
  369. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +72 -10
  370. package/skills/gsd-path-ship/SKILL.md +33 -11
  371. package/skills/gsd-path-ship/references/dispatch.md +13 -0
  372. package/skills/gsd-path-ship/references/operating-rules.md +214 -0
  373. package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
  374. package/skills/gsd-path-ship/references/reviewer-wave.md +18 -5
  375. package/skills/gsd-path-ship/references/reviewer.md +3 -1
  376. package/skills/gsd-path-ship/references/spec-probes.md +150 -0
  377. package/skills/gsd-path-ship/scripts/_common.py +619 -18
  378. package/skills/gsd-path-ship/scripts/archive_milestone.py +160 -86
  379. package/skills/gsd-path-ship/scripts/build_recovery.py +100 -32
  380. package/skills/gsd-path-ship/scripts/build_state.py +143 -24
  381. package/skills/gsd-path-ship/scripts/check_handoffs.py +662 -50
  382. package/skills/gsd-path-ship/scripts/check_task_briefs.py +338 -30
  383. package/skills/gsd-path-ship/scripts/detect_project.py +587 -32
  384. package/skills/gsd-path-ship/scripts/discussion_records.py +19 -13
  385. package/skills/gsd-path-ship/scripts/discussion_validate.py +152 -50
  386. package/skills/gsd-path-ship/scripts/integration.py +580 -25
  387. package/skills/gsd-path-ship/scripts/isolation.py +1438 -57
  388. package/skills/gsd-path-ship/scripts/lean_verification.py +99 -19
  389. package/skills/gsd-path-ship/scripts/loop_run.py +31 -29
  390. package/skills/gsd-path-ship/scripts/members.py +706 -0
  391. package/skills/gsd-path-ship/scripts/path_config.py +5 -5
  392. package/skills/gsd-path-ship/scripts/pipeline_git.py +124 -34
  393. package/skills/gsd-path-ship/scripts/pipeline_state.py +287 -87
  394. package/skills/gsd-path-ship/scripts/promote_lookahead.py +1 -1
  395. package/skills/gsd-path-ship/scripts/review_panel.py +1 -1
  396. package/skills/gsd-path-ship/scripts/state_checkpoint.py +135 -44
  397. package/skills/gsd-path-ship/scripts/state_promote.py +2 -0
  398. package/skills/gsd-path-ship/scripts/workflow_run.py +107 -30
  399. package/skills/gsd-path-ship/scripts/worktree_paths.py +72 -10
  400. package/skills/gsd-path-ship/templates/final-review.md +16 -4
  401. package/skills/gsd-path-ship/templates/gap-review.md +2 -1
  402. package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
  403. package/skills/gsd-path-ship/templates/wave-review.md +16 -4
  404. package/skills/gsd-path-undo/SKILL.md +2 -1
  405. package/skills/gsd-path-undo/references/operating-rules.md +214 -0
  406. package/skills/gsd-path-undo/scripts/_common.py +619 -18
  407. package/skills/gsd-path-undo/scripts/discussion_records.py +19 -13
  408. package/skills/gsd-path-undo/scripts/loop_run.py +31 -29
  409. package/skills/gsd-path-undo/scripts/path_config.py +5 -5
  410. package/skills/gsd-path-undo/scripts/pipeline_undo.py +218 -30
  411. package/skills/gsd-path-undo/scripts/review_panel.py +1 -1
  412. package/skills/gsd-path-undo/scripts/worktree_paths.py +72 -10
  413. package/skills/path/BUILD.md +7 -1
  414. package/skills/path/DECIDE.md +11 -4
  415. package/skills/path/DEFINE.md +36 -4
  416. package/skills/path/DOCS-AUDIT.md +6 -4
  417. package/skills/path/INSPECT.md +5 -4
  418. package/skills/path/PLAN.md +18 -4
  419. package/skills/path/RESEARCH.md +13 -1
  420. package/skills/path/ROADMAP.md +2 -1
  421. package/skills/path/SHIP.md +33 -11
  422. package/skills/path/SKILL.md +45 -1
  423. package/skills/path/references/build-native.md +63 -15
  424. package/skills/path/references/codebase-mapper.md +2 -1
  425. package/skills/path/references/dispatch.md +13 -0
  426. package/skills/path/references/docs-auditor.md +7 -1
  427. package/skills/path/references/operating-rules.md +214 -0
  428. package/skills/path/references/planner.md +22 -4
  429. package/skills/path/references/reviewer-final.md +20 -2
  430. package/skills/path/references/reviewer-wave.md +18 -5
  431. package/skills/path/references/reviewer.md +3 -1
  432. package/skills/path/references/spec-probes.md +150 -0
  433. package/skills/path/scripts/_common.py +619 -18
  434. package/skills/path/scripts/archive_milestone.py +160 -86
  435. package/skills/path/scripts/bootstrap_repository.py +8 -5
  436. package/skills/path/scripts/build_recovery.py +100 -32
  437. package/skills/path/scripts/build_state.py +143 -24
  438. package/skills/path/scripts/check_docs_audit.py +91 -7
  439. package/skills/path/scripts/check_handoffs.py +662 -50
  440. package/skills/path/scripts/check_task_briefs.py +338 -30
  441. package/skills/path/scripts/check_update.py +2 -2
  442. package/skills/path/scripts/detect_project.py +587 -32
  443. package/skills/path/scripts/discussion_records.py +19 -13
  444. package/skills/path/scripts/discussion_validate.py +152 -50
  445. package/skills/path/scripts/dispatch_driver.py +501 -46
  446. package/skills/path/scripts/git_guard.py +118 -31
  447. package/skills/path/scripts/integration.py +580 -25
  448. package/skills/path/scripts/isolation.py +1438 -57
  449. package/skills/path/scripts/lean_verification.py +99 -19
  450. package/skills/path/scripts/loop_run.py +31 -29
  451. package/skills/path/scripts/members.py +706 -0
  452. package/skills/path/scripts/path_config.py +5 -5
  453. package/skills/path/scripts/pipeline_git.py +124 -34
  454. package/skills/path/scripts/pipeline_state.py +287 -87
  455. package/skills/path/scripts/promote_lookahead.py +1 -1
  456. package/skills/path/scripts/review_panel.py +1 -1
  457. package/skills/path/scripts/state_checkpoint.py +135 -44
  458. package/skills/path/scripts/state_promote.py +2 -0
  459. package/skills/path/scripts/status_runtime.py +58 -10
  460. package/skills/path/scripts/task_context.py +12 -3
  461. package/skills/path/scripts/token_budget.py +12 -8
  462. package/skills/path/scripts/workflow_run.py +107 -30
  463. package/skills/path/scripts/worktree_paths.py +72 -10
  464. package/skills/path/templates/codebase.md +1 -1
  465. package/skills/path/templates/docs-audit.md +6 -2
  466. package/skills/path/templates/final-review.md +16 -4
  467. package/skills/path/templates/gap-review.md +2 -1
  468. package/skills/path/templates/intent.md +30 -0
  469. package/skills/path/templates/patch-findings.md +3 -3
  470. package/skills/path/templates/plan.md +17 -2
  471. package/skills/path/templates/research-handoff.md +14 -3
  472. package/skills/path/templates/state.md +1 -1
  473. package/skills/path/templates/task.md +1 -0
  474. package/skills/path/templates/wave-review.md +16 -4
  475. package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  476. package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  477. package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  478. package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
  479. package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  480. package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
  481. package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
@@ -25,6 +25,7 @@ import json
25
25
  import os
26
26
  import re
27
27
  import shlex
28
+ import stat
28
29
  import subprocess
29
30
  import sys
30
31
  from pathlib import Path, PurePosixPath
@@ -120,6 +121,10 @@ ARCHIVE_REASON = (
120
121
  "committed GSD Path archives under .project/archive/ are read-only; "
121
122
  "only the bundled pipeline helpers may write there during a ship transaction"
122
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
+ )
123
128
  DESTRUCTIVE_SHAPE_REASON = (
124
129
  "destructive commands must run alone with literal paths; split compound, "
125
130
  "substituted, or expanded destructive commands into single commands"
@@ -181,7 +186,7 @@ STATUS_STATE_FIELDS = frozenset(
181
186
  "integration_source",
182
187
  }
183
188
  )
184
- STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
189
+ STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request", "external-landing"})
185
190
  STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
186
191
  STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
187
192
  STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
@@ -205,6 +210,7 @@ ARCHIVE_READ_COMMANDS = frozenset(
205
210
  "tail",
206
211
  "grep",
207
212
  "rg",
213
+ "sed",
208
214
  "ls",
209
215
  "stat",
210
216
  "wc",
@@ -232,6 +238,147 @@ CLOSED_LISTING_GIT_OPTIONS = {
232
238
  "symbolic-ref": frozenset({"-q", "--quiet", "--short"}),
233
239
  }
234
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
+
235
382
  ARCHIVE_READ_EXECUTION_OPTIONS = {"rg": frozenset({"--pre"})}
236
383
  AMBIGUOUS_SHELL_SYNTAX = re.compile(r"[\r\n|;&<>`]|\$\(|@\(")
237
384
  SUBSTITUTION_PLACEHOLDER = "COMMAND_SUBSTITUTION_"
@@ -239,10 +386,6 @@ SUBSTITUTION_PLACEHOLDER_SYNTAX = re.compile(
239
386
  r"\$\{" + SUBSTITUTION_PLACEHOLDER + r"(\d+)\}"
240
387
  )
241
388
  LINE_CONTINUATION = re.compile(r"(?<!\\)((?:\\\\)*)\\\r?\n")
242
- HEREDOC_PATTERN = re.compile(
243
- r"<<(?P<dash>-?)\s*(?:'(?P<single>[^']+)'|\"(?P<double>[^\"]+)\""
244
- r"|\\?(?P<bare>[A-Za-z_][A-Za-z0-9_]*))"
245
- )
246
389
  SHELL_WRITE_REDIRECTION_CHARS = frozenset("<>&|")
247
390
  SHELL_PARAMETER_SYNTAX = re.compile(
248
391
  r"\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[-*@#?$!]|\{[^}\r\n]+\})"
@@ -250,9 +393,12 @@ SHELL_PARAMETER_SYNTAX = re.compile(
250
393
  NAMED_SHELL_PARAMETER_SYNTAX = re.compile(
251
394
  r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})"
252
395
  )
396
+ ZSH_EXPANSION_FLAG_SYNTAX = re.compile(r"\$[=~^+]")
253
397
  CMD_PARAMETER_SYNTAX = re.compile(
254
398
  r"%([A-Za-z_][A-Za-z0-9_]*)%|!([A-Za-z_][A-Za-z0-9_]*)!"
255
399
  )
400
+ # The shell reassigns these on every cd, so a tracked value is stale.
401
+ SHELL_DIRECTORY_VARIABLES = frozenset({"PWD", "OLDPWD"})
256
402
  DIRECTORY_CHANGE_COMMANDS = frozenset(
257
403
  {"cd", "chdir", "pushd", "set-location", "sl"}
258
404
  )
@@ -305,6 +451,8 @@ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
305
451
  UNVALIDATED_EXECUTION_REASONS = {
306
452
  ".": SCRIPT_FILE_REASON,
307
453
  "source": SCRIPT_FILE_REASON,
454
+ "readonly": "readonly changes shell assignment behavior the guard cannot validate; "
455
+ "avoid readonly declarations",
308
456
  "start": "start launches a detached process the guard cannot inspect; run the program directly",
309
457
  "setenv": ENV_BUILTIN_REASON,
310
458
  "unsetenv": ENV_BUILTIN_REASON,
@@ -369,7 +517,64 @@ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
369
517
  }
370
518
  )
371
519
  IN_PLACE_EDITORS = frozenset({"perl", "sed"})
520
+ SED_SAFE_OPTIONS = frozenset(
521
+ {
522
+ "--debug",
523
+ "--help",
524
+ "--null-data",
525
+ "--posix",
526
+ "--quiet",
527
+ "--regexp-extended",
528
+ "--sandbox",
529
+ "--separate",
530
+ "--silent",
531
+ "--unbuffered",
532
+ "--version",
533
+ "-E",
534
+ "-n",
535
+ "-r",
536
+ "-s",
537
+ "-u",
538
+ }
539
+ )
372
540
  FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
541
+ # Programs that run the command in their arguments, with the options of each
542
+ # that take a separate value. The guard checks the command they run.
543
+ EXEC_PREFIX_VALUE_OPTIONS = {
544
+ "nice": frozenset({"-n", "--adjustment"}),
545
+ "nohup": frozenset(),
546
+ "setsid": frozenset(),
547
+ "stdbuf": frozenset({"-i", "-o", "-e", "--input", "--output", "--error"}),
548
+ "sudo": frozenset({
549
+ "-u", "-g", "-h", "-p", "-C", "-T", "-U", "-r", "-t",
550
+ "--user", "--group", "--host", "--prompt", "--close-from",
551
+ "--command-timeout", "--other-user", "--role", "--type",
552
+ }),
553
+ "time": frozenset({"-f", "--format"}),
554
+ "timeout": frozenset({"-s", "-k", "--signal", "--kill-after"}),
555
+ "watch": frozenset({"-n", "-q", "--interval", "--equexit"}),
556
+ }
557
+ # Runner options that write, edit or enter a file or directory by themselves.
558
+ EXEC_PREFIX_FILE_OPTIONS = {
559
+ "sudo": frozenset({"-e", "-D", "-R", "--edit", "--chdir", "--chroot"}),
560
+ "time": frozenset({"-o", "--output"}),
561
+ "watch": frozenset({"-s", "--shotsdir"}),
562
+ }
563
+ # The short option letters of each runner that take no value.
564
+ EXEC_PREFIX_FLAG_LETTERS = {
565
+ "nice": "",
566
+ "nohup": "",
567
+ "setsid": "cfw",
568
+ "stdbuf": "",
569
+ "sudo": "AbBEHiKklnNPSsVv",
570
+ "time": "ahlpqvV",
571
+ "timeout": "fpv",
572
+ "watch": "bcCdegprtwx",
573
+ }
574
+ EXEC_PREFIX_INTERVAL_OPTIONS = frozenset({"-n", "--interval"})
575
+ RUNNER_DURATION = re.compile(
576
+ r"(?:(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?|inf(?:inity)?)[smhd]?", re.IGNORECASE
577
+ )
373
578
  SHELL_CONTROL_WORDS = frozenset(
374
579
  {
375
580
  "!",
@@ -474,6 +679,20 @@ def collect(
474
679
  )
475
680
 
476
681
 
682
+ def is_link_like(path):
683
+ """A symlink, or on Windows any name-surrogate reparse point such as a junction.
684
+
685
+ Path.is_symlink() is false for a junction, which still redirects the path.
686
+ """
687
+ try:
688
+ status = os.lstat(path)
689
+ except OSError:
690
+ return False
691
+ return stat.S_ISLNK(status.st_mode) or bool(
692
+ getattr(status, "st_reparse_tag", 0) & 0x20000000 # IO_REPARSE_TAG name surrogate
693
+ )
694
+
695
+
477
696
  def normalize_posix(path):
478
697
  text = path.replace("\\", "/").strip()
479
698
  if not text:
@@ -536,7 +755,7 @@ def repository_root():
536
755
  result = subprocess.run(
537
756
  ["git", "rev-parse", "--show-toplevel"],
538
757
  cwd=candidate,
539
- text=True,
758
+ encoding="utf-8", errors="replace",
540
759
  capture_output=True,
541
760
  check=False,
542
761
  )
@@ -544,7 +763,7 @@ def repository_root():
544
763
  state = candidate / ".project" / "STATE.md"
545
764
  if (
546
765
  candidate.is_dir()
547
- and not candidate.is_symlink()
766
+ and not is_link_like(candidate)
548
767
  and os.path.lexists(state)
549
768
  ):
550
769
  return candidate
@@ -560,7 +779,7 @@ def project_status(repo):
560
779
  result = subprocess.run(
561
780
  [sys.executable, "-B", str(launcher), "--repo", str(repo)],
562
781
  cwd=repo,
563
- text=True,
782
+ encoding="utf-8", errors="replace",
564
783
  capture_output=True,
565
784
  check=False,
566
785
  )
@@ -697,13 +916,29 @@ def valid_status_branch(value):
697
916
 
698
917
  def target_paths(path, working_directories, repo):
699
918
  repo = repo.resolve()
700
- candidate = Path(path)
919
+ candidate = Path(native_path_text(path))
701
920
  if not candidate.is_absolute():
702
921
  base = Path(working_directories[0]) if working_directories else repo
703
922
  if not base.is_absolute():
704
923
  base = repo / base
705
924
  candidate = base / candidate
706
- return Path(os.path.abspath(candidate)), candidate.resolve(strict=False)
925
+ return Path(os.path.abspath(candidate)), resolve_lenient(candidate)
926
+
927
+
928
+ def resolve_lenient(path):
929
+ """Path.resolve(strict=False) that still follows links on Windows.
930
+
931
+ Python 3.9 raises OSError on Windows for names it cannot open (rep*) and
932
+ for some links to missing files; the archive checks must not skip those
933
+ (a symlinked archive path would be allowed), so fall back to
934
+ os.path.realpath, which resolves what it can and keeps the remainder.
935
+ """
936
+ try:
937
+ return path.resolve(strict=False)
938
+ except OSError:
939
+ if os.name != "nt":
940
+ raise
941
+ return Path(os.path.realpath(path))
707
942
 
708
943
 
709
944
  @lru_cache(maxsize=None)
@@ -712,7 +947,7 @@ def repository_control_roots(repo):
712
947
  result = subprocess.run(
713
948
  ["git", "rev-parse", "--git-common-dir"],
714
949
  cwd=repo,
715
- text=True,
950
+ encoding="utf-8", errors="replace",
716
951
  capture_output=True,
717
952
  env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
718
953
  check=False,
@@ -789,6 +1024,51 @@ def path_kind(candidate, repo, control_roots=()):
789
1024
  return "product"
790
1025
 
791
1026
 
1027
+ def member_target_kind(candidate, repo):
1028
+ """'product' when the path lies in a verified member of this coordinator.
1029
+
1030
+ A member follows its coordinator's phase. A stale member of this
1031
+ coordinator raises MembersError, so the caller denies the write.
1032
+ """
1033
+ directory = candidate if candidate.is_dir() else candidate.parent
1034
+ while not directory.is_dir() and directory != directory.parent:
1035
+ directory = directory.parent
1036
+ found = subprocess.run(
1037
+ ["git", "-C", str(directory), "rev-parse", "--path-format=absolute",
1038
+ "--show-toplevel", "--git-common-dir"],
1039
+ encoding="utf-8", errors="replace", capture_output=True, check=False,
1040
+ env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
1041
+ )
1042
+ lines = found.stdout.splitlines()
1043
+ if found.returncode != 0:
1044
+ if found.stderr.strip().startswith("fatal: not a git repository"):
1045
+ return None
1046
+ raise RuntimeError(found.stderr.strip() or "Git inspection failed")
1047
+ if len(lines) != 2:
1048
+ raise RuntimeError("Git inspection returned an unexpected result")
1049
+ toplevel, common = Path(lines[0]), Path(lines[1])
1050
+ if not os.path.lexists(common / "gsd-path" / "member.json"):
1051
+ return None
1052
+ import members # the guard's runtime directory; only paths in a member need it
1053
+ coordinator = members.marker_coordinator(toplevel)
1054
+ if coordinator is None:
1055
+ return None
1056
+ if coordinator.resolve() != repo.resolve():
1057
+ for member in members.read_members(repo):
1058
+ try:
1059
+ listed_common = members._common_dir(Path(member["checkout"]))
1060
+ except (members.MembersError, OSError):
1061
+ continue
1062
+ if listed_common == common.resolve():
1063
+ raise members.MembersError(
1064
+ "member marker names an old coordinator; "
1065
+ "run members.py repair --repo <coordinator>"
1066
+ )
1067
+ return None
1068
+ members.member_role(toplevel)
1069
+ return "product"
1070
+
1071
+
792
1072
  def target_kind(path, working_directories, repo, control_roots=None):
793
1073
  lexical, resolved = target_paths(path, working_directories, repo)
794
1074
  repo = repo.resolve()
@@ -798,10 +1078,21 @@ def target_kind(path, working_directories, repo, control_roots=None):
798
1078
  _canonical_control_alias(lexical, repo, control_roots),
799
1079
  _canonical_control_alias(resolved, repo, control_roots),
800
1080
  )
801
- kinds = {
1081
+ resolved_kind = path_kind(resolved, repo, control_roots)
1082
+ kinds = {resolved_kind} | {
802
1083
  path_kind(candidate, repo, control_roots)
803
- for candidate in (lexical, resolved, *aliases)
1084
+ for candidate in (lexical, *aliases)
804
1085
  }
1086
+ if resolved_kind == "external" and "protected" not in kinds:
1087
+ try:
1088
+ member_kind = member_target_kind(resolved, repo)
1089
+ except Exception as error:
1090
+ deny(
1091
+ f"member write refused: {error}; "
1092
+ "run members.py repair --repo <coordinator>"
1093
+ )
1094
+ if member_kind:
1095
+ kinds.add(member_kind)
805
1096
  for kind in ("protected", "product", "artifact", "external"):
806
1097
  if kind in kinds:
807
1098
  return kind
@@ -824,12 +1115,12 @@ def pipeline_target_kinds(targets):
824
1115
  def closed_target_reason(targets):
825
1116
  for path, working_directories in targets:
826
1117
  for target in dict.fromkeys(target_paths(path, working_directories, repository_root())):
827
- directory = target if target.is_dir() and not target.is_symlink() else target.parent
1118
+ directory = target if target.is_dir() and not is_link_like(target) else target.parent
828
1119
  while not directory.exists() and directory != directory.parent:
829
1120
  directory = directory.parent
830
1121
  root = subprocess.run(
831
1122
  ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
832
- capture_output=True, text=True,
1123
+ capture_output=True, encoding="utf-8", errors="replace",
833
1124
  )
834
1125
  if root.returncode == 0:
835
1126
  reason = closed_milestone_reason(Path(root.stdout.strip()))
@@ -838,30 +1129,29 @@ def closed_target_reason(targets):
838
1129
  return None
839
1130
 
840
1131
 
841
- def enforce_pipeline_reentry(paths, working_directories):
842
- reason = closed_target_reason((path, working_directories) for path in paths)
843
- if reason:
844
- deny(reason)
845
- classified = pipeline_target_kinds((path, working_directories) for path in paths)
1132
+ def pipeline_product_phase_reason(classified):
1133
+ """Why product writes are denied outside the routed build phase, or None when allowed."""
846
1134
  if classified is None:
847
- return
1135
+ return None
848
1136
  repo, state, kinds = classified
849
1137
  for path, kind in kinds:
850
1138
  if kind == "protected":
851
- deny(f"{CONTROL_FILE_REASON} {path}")
852
- kinds = [kind for _, kind in kinds]
853
- if all(kind == "external" for kind in kinds):
854
- return
1139
+ return f"{CONTROL_FILE_REASON} {path}"
1140
+ kind_values = [kind for _, kind in kinds]
1141
+ if all(kind == "external" for kind in kind_values):
1142
+ return None
1143
+ if not any(kind == "product" for kind in kind_values):
1144
+ return None
855
1145
  try:
856
1146
  status = project_status(repo)
857
1147
  except (OSError, ValueError, json.JSONDecodeError):
858
- deny(REENTRY_FAILURE_REASON)
1148
+ return REENTRY_FAILURE_REASON
859
1149
  route = status.get("route")
860
1150
  state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
861
1151
  completion = status.get("completion")
862
1152
  if (state_data.get("phase") == "shipped"
863
1153
  and isinstance(completion, dict) and completion.get("status") == "verified"):
864
- return
1154
+ return None
865
1155
  routed_build = (
866
1156
  state_data.get("phase") == "build"
867
1157
  and isinstance(route, dict)
@@ -869,20 +1159,31 @@ def enforce_pipeline_reentry(paths, working_directories):
869
1159
  and route.get("phase") == "build"
870
1160
  )
871
1161
  if routed_build:
872
- return
873
- if all(kind in {"external", "artifact"} for kind in kinds):
874
- return
1162
+ return None
1163
+ if all(kind in {"external", "artifact"} for kind in kind_values):
1164
+ return None
875
1165
  phase = state_data.get("phase", "unknown")
876
1166
  if isinstance(route, dict) and route.get("action") != "run-phase":
877
1167
  next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
878
1168
  else:
879
1169
  next_step = status.get("next_skill") or "gsd-path"
880
- deny(
1170
+ return (
881
1171
  f"GSD Path is {phase}; direct product-file changes require the routed "
882
1172
  f"build phase. Review {status.get('path', state)}; next: {next_step}"
883
1173
  )
884
1174
 
885
1175
 
1176
+ def enforce_pipeline_reentry(paths, working_directories):
1177
+ reason = closed_target_reason((path, working_directories) for path in paths)
1178
+ if reason:
1179
+ deny(reason)
1180
+ reason = pipeline_product_phase_reason(
1181
+ pipeline_target_kinds((path, working_directories) for path in paths)
1182
+ )
1183
+ if reason:
1184
+ deny(reason)
1185
+
1186
+
886
1187
  def in_archive(path):
887
1188
  # Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
888
1189
  normalized = normalize_posix(path).casefold()
@@ -919,10 +1220,35 @@ def expansion_can_match_archive(path):
919
1220
  )
920
1221
 
921
1222
 
1223
+ # Git Bash (Claude Code's shell on Windows) names drive C: as /c or /cygdrive/c.
1224
+ MSYS_DRIVE_PATH = re.compile(r"^/(?:cygdrive/)?([A-Za-z])(?=/|$)")
1225
+
1226
+
1227
+ def native_path_text(value):
1228
+ """On Windows, the drive path that a Git Bash path such as /c/repo names.
1229
+
1230
+ Python reads /c/repo as a folder named c at the root of the current drive,
1231
+ which would hide an archive from the containment checks.
1232
+ """
1233
+ if os.name != "nt":
1234
+ return value
1235
+ text = value.replace("\\", "/")
1236
+ match = MSYS_DRIVE_PATH.match(text)
1237
+ if match is None:
1238
+ return value
1239
+ return f"{match.group(1).upper()}:{text[match.end():] or '/'}"
1240
+
1241
+
922
1242
  def path_values(value):
923
- yield value
1243
+ values = [value]
924
1244
  if value.startswith("-") and "=" in value:
925
- yield value.split("=", 1)[1]
1245
+ values.append(value.split("=", 1)[1])
1246
+ for candidate in values:
1247
+ yield candidate
1248
+ # Deny when either reading of an ambiguous Windows path reaches an archive.
1249
+ native = native_path_text(candidate)
1250
+ if native != candidate:
1251
+ yield native
926
1252
 
927
1253
 
928
1254
  def is_absolute_path(path):
@@ -932,7 +1258,7 @@ def is_absolute_path(path):
932
1258
 
933
1259
  def contains_existing_archive(path, working_directories):
934
1260
  try:
935
- target = Path(path).resolve(strict=False)
1261
+ target = resolve_lenient(Path(path))
936
1262
  except (OSError, RuntimeError):
937
1263
  return False
938
1264
  search_paths = [target, Path.cwd(), *(Path(value) for value in working_directories)]
@@ -940,7 +1266,7 @@ def contains_existing_archive(path, working_directories):
940
1266
  if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", str(search_path)):
941
1267
  continue
942
1268
  try:
943
- resolved = search_path.resolve(strict=False)
1269
+ resolved = resolve_lenient(search_path)
944
1270
  except (OSError, RuntimeError):
945
1271
  continue
946
1272
  for parent in (resolved, *resolved.parents):
@@ -973,7 +1299,7 @@ def path_in_archive(path, working_directories=(), ancestors=True):
973
1299
  if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", combined):
974
1300
  continue
975
1301
  try:
976
- resolved = Path(combined).resolve(strict=False)
1302
+ resolved = resolve_lenient(Path(combined))
977
1303
  except (OSError, RuntimeError):
978
1304
  continue
979
1305
  if in_archive(resolved.as_posix()) or (
@@ -997,7 +1323,7 @@ def working_directory_in_archive(path):
997
1323
  if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", value):
998
1324
  continue
999
1325
  try:
1000
- resolved = Path(value).resolve(strict=False)
1326
+ resolved = resolve_lenient(Path(value))
1001
1327
  except (OSError, RuntimeError):
1002
1328
  continue
1003
1329
  if in_archive(resolved.as_posix()):
@@ -1035,10 +1361,10 @@ def directory_change_target(arguments):
1035
1361
  return operands[-1]
1036
1362
 
1037
1363
 
1038
- def segment_directories(tokens, working_directories):
1364
+ def segment_directories(tokens, working_directories, contexts=None):
1039
1365
  """Yield each command segment with the working directories in effect for it."""
1040
1366
  current_directories = list(working_directories)
1041
- for segment in command_segments(tokens):
1367
+ for position, segment in enumerate(command_segments(tokens)):
1042
1368
  yield segment, current_directories
1043
1369
  invocation = command_invocation(segment)
1044
1370
  if invocation is None:
@@ -1051,16 +1377,31 @@ def segment_directories(tokens, working_directories):
1051
1377
  if command not in DIRECTORY_CHANGE_COMMANDS:
1052
1378
  continue
1053
1379
  target = directory_change_target(arguments)
1054
- if SHELL_PARAMETER_SYNTAX.search(target):
1380
+ tracked = {}
1381
+ if contexts is not None and position < len(contexts):
1382
+ tracked = {
1383
+ name: value
1384
+ for name, value in contexts[position].items()
1385
+ if name not in SHELL_DIRECTORY_VARIABLES
1386
+ }
1387
+ expanded = expand_environment_parameters(target, tracked, tracked_only=True)
1388
+ if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(expanded):
1055
1389
  raise ValueError(
1056
1390
  f"cd target {target} cannot be resolved by the guard; "
1057
1391
  "run that command first and cd to the literal result"
1058
1392
  )
1059
- if is_absolute_path(target):
1060
- current_directories = [target]
1393
+ if (
1394
+ (SHELL_PARAMETER_SYNTAX.search(target) or CMD_PARAMETER_SYNTAX.search(target))
1395
+ and any(character.isspace() or character in "*?[]" for character in expanded)
1396
+ ):
1397
+ raise ValueError(
1398
+ f"cd target {target} may split or glob; pass a literal path"
1399
+ )
1400
+ if is_absolute_path(expanded):
1401
+ current_directories = [expanded]
1061
1402
  else:
1062
1403
  bases = current_directories or ["."]
1063
- current_directories = [f"{base}/{target}" for base in bases]
1404
+ current_directories = [f"{base}/{expanded}" for base in bases]
1064
1405
 
1065
1406
 
1066
1407
  def command_can_destroy_files(invocation):
@@ -1076,9 +1417,11 @@ def command_can_destroy_files(invocation):
1076
1417
  def literal_path(operand):
1077
1418
  if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
1078
1419
  operand = operand[1:-1]
1079
- pattern = r"[A-Za-z0-9._/-]+"
1420
+ # A space is safe here: the operand is one shlex unit, so it cannot carry a
1421
+ # second word; injection-relevant characters stay excluded.
1422
+ pattern = r"[A-Za-z0-9._/ -]+"
1080
1423
  if os.name == "nt":
1081
- pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\-]+"
1424
+ pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\ -]+"
1082
1425
  return operand if re.fullmatch(pattern, operand) is not None else None
1083
1426
 
1084
1427
 
@@ -1091,19 +1434,27 @@ def literal_destructive_operand(operand):
1091
1434
  return literal
1092
1435
 
1093
1436
 
1094
- def command_references_archive(tokens, working_directories):
1095
- for segment, directories in segment_directories(tokens, working_directories):
1096
- invocation = command_invocation(segment)
1097
- ancestors = command_can_destroy_files(invocation)
1098
- operands = segment
1099
- if ancestors:
1100
- operands = [literal_destructive_operand(operand) for operand in invocation[1]]
1101
- if any(path_in_archive(operand, directories, ancestors) for operand in operands):
1102
- return True
1103
- wrapped = wrapped_command_tokens(segment, expand_parameters=False)
1104
- if wrapped is not None and command_references_archive(wrapped, directories):
1105
- return True
1106
- return False
1437
+ def segment_references_archive(segment, directories, assignments=None):
1438
+ invocation = command_invocation(segment)
1439
+ ancestors = command_can_destroy_files(invocation)
1440
+ # A here-document body survives tokenization only inside a wrapped payload
1441
+ # (bash -c '...'), where it is data, not commands; keep its text from
1442
+ # flipping the segment into archive context.
1443
+ def searchable(token):
1444
+ return strip_heredoc_bodies(token) if "\n" in token else token
1445
+
1446
+ operands = [
1447
+ expand_environment_parameters(searchable(token), assignments)
1448
+ for token in segment
1449
+ ]
1450
+ if ancestors:
1451
+ resolved = command_invocation(operands)
1452
+ operands = [literal_destructive_operand(operand) for operand in resolved[1]]
1453
+ if any(path_in_archive(operand, directories, ancestors) for operand in operands):
1454
+ return True
1455
+ return unresolved_archive_expansion(
1456
+ " ".join(searchable(token) for token in segment), assignments
1457
+ )
1107
1458
 
1108
1459
 
1109
1460
  def copy_destinations(arguments, directories, assignments=None):
@@ -1159,7 +1510,7 @@ def copy_destinations(arguments, directories, assignments=None):
1159
1510
  lexical, source_path = target_paths(source, directories, repository_root())
1160
1511
  if source_path.is_dir():
1161
1512
  # ponytail: reject ambiguous directory links instead of emulating cp flags.
1162
- if lexical.is_symlink():
1513
+ if is_link_like(lexical):
1163
1514
  raise ValueError("cp directory source is a symlink; pass a literal directory")
1164
1515
 
1165
1516
  def unreadable(error):
@@ -1168,49 +1519,264 @@ def copy_destinations(arguments, directories, assignments=None):
1168
1519
  for root, folders, files in os.walk(source_path, onerror=unreadable):
1169
1520
  for name in folders + files:
1170
1521
  entry = Path(root) / name
1171
- if entry.is_symlink() and entry.is_dir():
1522
+ if is_link_like(entry) and entry.is_dir():
1172
1523
  raise ValueError("cp source contains a directory symlink; copy it separately")
1173
1524
  for output in outputs:
1174
1525
  yield str(output / entry.relative_to(source_path))
1175
1526
 
1176
1527
 
1528
+ def sed_delimited_end(script, index, delimiter):
1529
+ while index < len(script):
1530
+ if script[index] == "\\":
1531
+ index += 2
1532
+ elif script[index] == delimiter:
1533
+ return index + 1
1534
+ else:
1535
+ index += 1
1536
+ return None
1537
+
1538
+
1539
+ def sed_address_end(script, index):
1540
+ if index >= len(script):
1541
+ return index
1542
+ if script[index].isdigit():
1543
+ while index < len(script) and script[index].isdigit():
1544
+ index += 1
1545
+ if index < len(script) and script[index] == "~":
1546
+ index += 1
1547
+ while index < len(script) and script[index].isdigit():
1548
+ index += 1
1549
+ elif script[index] == "$":
1550
+ index += 1
1551
+ elif script[index] == "/":
1552
+ end = sed_delimited_end(script, index + 1, "/")
1553
+ return len(script) if end is None else end
1554
+ elif script[index] == "\\" and index + 1 < len(script):
1555
+ end = sed_delimited_end(script, index + 2, script[index + 1])
1556
+ return len(script) if end is None else end
1557
+ return index
1558
+
1559
+
1560
+ def sed_script_is_read_only(script):
1561
+ """Accept only sed scripts whose commands cannot write or execute."""
1562
+ # Sed blocks require recursively validating their nested command grammar;
1563
+ # reject them until that grammar is modeled explicitly.
1564
+ safe_commands = frozenset("aAcCdDgGhHiIlLnNpPqQrstTxv=:")
1565
+ index = 0
1566
+ while index < len(script):
1567
+ while index < len(script) and (script[index].isspace() or script[index] == ";"):
1568
+ index += 1
1569
+ if index >= len(script):
1570
+ break
1571
+ address_start = index
1572
+ index = sed_address_end(script, index)
1573
+ if index < len(script) and script[index] == ",":
1574
+ index = sed_address_end(script, index + 1)
1575
+ if index > address_start and index < len(script) and script[index] == "!":
1576
+ index += 1
1577
+ while index < len(script) and script[index].isspace():
1578
+ index += 1
1579
+ if index >= len(script):
1580
+ break
1581
+ command = script[index]
1582
+ index += 1
1583
+ if command in {"w", "W", "e"}:
1584
+ return False
1585
+ if command == "#":
1586
+ newline = script.find("\n", index)
1587
+ index = len(script) if newline < 0 else newline + 1
1588
+ continue
1589
+ if command == "s":
1590
+ if index >= len(script):
1591
+ return False
1592
+ delimiter = script[index]
1593
+ index = sed_delimited_end(script, index + 1, delimiter)
1594
+ if index is None:
1595
+ return False
1596
+ index = sed_delimited_end(script, index, delimiter)
1597
+ if index is None:
1598
+ return False
1599
+ end = index
1600
+ while end < len(script) and script[end] not in ";\n":
1601
+ end += 1
1602
+ if any(flag in "wWe" for flag in script[index:end]):
1603
+ return False
1604
+ index = end
1605
+ continue
1606
+ if command == "y":
1607
+ if index >= len(script):
1608
+ return False
1609
+ delimiter = script[index]
1610
+ index = sed_delimited_end(script, index + 1, delimiter)
1611
+ if index is None:
1612
+ return False
1613
+ index = sed_delimited_end(script, index, delimiter)
1614
+ if index is None:
1615
+ return False
1616
+ continue
1617
+ if command not in safe_commands:
1618
+ return False
1619
+ while index < len(script) and script[index] not in ";\n":
1620
+ index += 1
1621
+ return True
1622
+
1623
+
1624
+ def sed_arguments(arguments):
1625
+ """Return (in_place, input_files, script_is_read_only) for sed arguments."""
1626
+ in_place = False
1627
+ scripts = []
1628
+ inputs = []
1629
+ script_seen = False
1630
+ scripts_are_literal = True
1631
+ index = 0
1632
+ while index < len(arguments):
1633
+ argument = arguments[index]
1634
+ index += 1
1635
+ if argument == "--":
1636
+ positional = arguments[index:]
1637
+ if not script_seen and positional:
1638
+ scripts.append(positional[0])
1639
+ positional = positional[1:]
1640
+ script_seen = True
1641
+ inputs.extend(positional)
1642
+ break
1643
+ if argument in {"-e", "--expression"}:
1644
+ if index >= len(arguments):
1645
+ return in_place, inputs, False
1646
+ scripts.append(arguments[index])
1647
+ script_seen = True
1648
+ index += 1
1649
+ continue
1650
+ if argument == "-f" or argument == "--file":
1651
+ if index >= len(arguments):
1652
+ return in_place, inputs, False
1653
+ script_seen = True
1654
+ scripts_are_literal = False
1655
+ index += 1
1656
+ continue
1657
+ if argument.startswith("--expression="):
1658
+ scripts.append(argument.split("=", 1)[1])
1659
+ script_seen = True
1660
+ continue
1661
+ if argument.startswith("--file="):
1662
+ script_seen = True
1663
+ scripts_are_literal = False
1664
+ continue
1665
+ if argument == "--in-place" or argument.startswith("--in-place="):
1666
+ in_place = True
1667
+ continue
1668
+ if argument.startswith("--"):
1669
+ if argument not in SED_SAFE_OPTIONS:
1670
+ scripts_are_literal = False
1671
+ continue
1672
+ if argument.startswith("-") and argument != "-":
1673
+ options = argument[1:]
1674
+ cursor = 0
1675
+ while cursor < len(options):
1676
+ option = options[cursor]
1677
+ cursor += 1
1678
+ if option == "i":
1679
+ in_place = True
1680
+ break
1681
+ if option == "e":
1682
+ if cursor < len(options):
1683
+ scripts.append(options[cursor:])
1684
+ script_seen = True
1685
+ elif index < len(arguments):
1686
+ scripts.append(arguments[index])
1687
+ script_seen = True
1688
+ index += 1
1689
+ else:
1690
+ return in_place, inputs, False
1691
+ break
1692
+ if option not in "nErsu":
1693
+ scripts_are_literal = False
1694
+ continue
1695
+ if not script_seen:
1696
+ scripts.append(argument)
1697
+ script_seen = True
1698
+ else:
1699
+ inputs.append(argument)
1700
+ if not scripts or not scripts_are_literal:
1701
+ return in_place, inputs, False
1702
+ return in_place, inputs, all(sed_script_is_read_only(script) for script in scripts)
1703
+
1704
+
1177
1705
  def shell_write_targets(tokens, working_directories, assignments=None):
1178
- """Yield (target, directories) for every path a shell command may write."""
1179
- assignments = {**(assignments or {}), **shell_assignment_values(tokens)}
1180
- for segment, directories in segment_directories(tokens, working_directories):
1706
+ """Yield (target, directories, variables) for every path a shell command may write."""
1707
+ for segment, directories, segment_assignments, _ in shell_segment_contexts(
1708
+ tokens, working_directories, assignments
1709
+ ):
1181
1710
  for index, token in enumerate(segment[:-1]):
1182
1711
  if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
1183
1712
  continue
1184
1713
  target = segment[index + 1]
1185
1714
  if token.endswith("&") and (target.isdigit() or target == "-"):
1186
1715
  continue
1187
- yield target, directories
1716
+ yield target, directories, segment_assignments
1188
1717
  wrapped = wrapped_command_tokens(segment)
1189
1718
  if wrapped is not None:
1190
- yield from shell_write_targets(wrapped, directories, assignments)
1719
+ yield from shell_write_targets(wrapped, directories, segment_assignments)
1191
1720
  continue
1192
1721
  invocation = command_invocation(segment)
1193
1722
  if invocation is None:
1194
1723
  continue
1195
1724
  command, arguments = invocation
1725
+ command = command.removesuffix(".exe")
1726
+ if command == "sed":
1727
+ in_place, inputs, _ = sed_arguments(arguments)
1728
+ if in_place:
1729
+ for target in inputs:
1730
+ yield target, directories, segment_assignments
1731
+ continue
1196
1732
  in_place = command in IN_PLACE_EDITORS and (
1197
1733
  has_short_option(arguments, "i")
1198
1734
  or any(argument.startswith("--in-place") for argument in arguments)
1199
1735
  )
1200
- if command.removesuffix(".exe") == "cp":
1201
- for destination in copy_destinations(arguments, directories, assignments):
1202
- yield destination, directories
1736
+ if command == "cp":
1737
+ for destination in copy_destinations(arguments, directories, segment_assignments):
1738
+ yield destination, directories, segment_assignments
1203
1739
  continue
1204
- if command.removesuffix(".exe") in SHELL_WRITE_COMMANDS or in_place:
1740
+ if command in SHELL_WRITE_COMMANDS or in_place:
1205
1741
  for argument in arguments:
1206
1742
  if argument and not argument.startswith("-"):
1207
- yield argument, directories
1743
+ yield argument, directories, segment_assignments
1744
+
1745
+
1746
+ def shell_write_needs_build_phase_gate(targets):
1747
+ """Phase-gate shell product writes only on bound milestone branches in-tree."""
1748
+ repo = repository_root()
1749
+ branch = subprocess.run(
1750
+ ["git", "-C", str(repo), "branch", "--show-current"],
1751
+ capture_output=True,
1752
+ encoding="utf-8",
1753
+ errors="replace",
1754
+ )
1755
+ if branch.returncode or not STATUS_BRANCH.fullmatch(branch.stdout.strip()):
1756
+ return False
1757
+ for path, working_directories in targets:
1758
+ for target in dict.fromkeys(target_paths(path, working_directories, repo)):
1759
+ directory = (
1760
+ target
1761
+ if target.is_dir() and not is_link_like(target)
1762
+ else target.parent
1763
+ )
1764
+ while not directory.exists() and directory != directory.parent:
1765
+ directory = directory.parent
1766
+ root = subprocess.run(
1767
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
1768
+ capture_output=True,
1769
+ encoding="utf-8",
1770
+ errors="replace",
1771
+ )
1772
+ if root.returncode != 0 or Path(root.stdout.strip()) != repo:
1773
+ return False
1774
+ return True
1208
1775
 
1209
1776
 
1210
1777
  def protected_shell_write_reason(tokens, working_directories):
1211
- assignments = shell_assignment_values(tokens)
1212
1778
  targets = []
1213
- for target, directories in shell_write_targets(tokens, working_directories):
1779
+ for target, directories, assignments in shell_write_targets(tokens, working_directories):
1214
1780
  expanded = expand_environment_parameters(target, assignments)
1215
1781
  if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
1216
1782
  expanded
@@ -1230,44 +1796,141 @@ def protected_shell_write_reason(tokens, working_directories):
1230
1796
  for target, kind in classified[2]:
1231
1797
  if kind == "protected":
1232
1798
  return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
1799
+ if shell_write_needs_build_phase_gate(targets):
1800
+ return pipeline_product_phase_reason(classified)
1233
1801
  return None
1234
1802
 
1235
1803
 
1236
- def environment_parameter_value(match, assignments):
1804
+ def environment_parameter_value(match, assignments, tracked_only=False):
1237
1805
  name = match.group(1) or match.group(2)
1238
1806
  if name.startswith(SUBSTITUTION_PLACEHOLDER):
1239
1807
  return match.group(0) # command substitution output is never resolved
1240
- return assignments.get(name, os.environ.get(name, ""))
1808
+ if name in assignments:
1809
+ return assignments[name]
1810
+ if tracked_only:
1811
+ return match.group(0)
1812
+ return os.environ.get(name, match.group(0))
1241
1813
 
1242
1814
 
1243
- def expand_environment_parameters(command, assignments=None):
1815
+ def expand_environment_parameters(command, assignments=None, tracked_only=False):
1244
1816
  values = assignments or {}
1245
1817
 
1246
1818
  def substitute(match):
1247
- return environment_parameter_value(match, values)
1819
+ return environment_parameter_value(match, values, tracked_only)
1248
1820
 
1249
1821
  expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
1250
1822
  return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
1251
1823
 
1252
1824
 
1253
- def shell_assignment_values(tokens):
1254
- values = {}
1255
- for segment in command_segments(tokens):
1256
- variable_command = segment[0].casefold() in VARIABLE_COMMANDS
1825
+ def shell_segment_flow(tokens):
1826
+ """Return whether each segment is conditional or runs in a subshell."""
1827
+ flows = []
1828
+ segment = []
1829
+ conditional = False
1830
+ subshell_depth = 0
1831
+ control_depth = 0
1832
+ separators = set("|;&()\n\r")
1833
+ for token in tokens:
1834
+ if token and set(token) <= separators:
1835
+ if segment:
1836
+ flows.append(
1837
+ (conditional or control_depth > 0, subshell_depth > 0 or token in {"|", "&"})
1838
+ )
1839
+ segment = []
1840
+ if token == "(":
1841
+ subshell_depth += 1
1842
+ conditional = True
1843
+ elif token == ")":
1844
+ subshell_depth = max(0, subshell_depth - 1)
1845
+ conditional = False
1846
+ elif token in {"&&", "||", "|", "&"}:
1847
+ conditional = True
1848
+ else:
1849
+ conditional = False
1850
+ continue
1851
+ word = token.casefold()
1852
+ if not segment and word in SHELL_CONTROL_WORDS:
1853
+ if word in {"if", "while", "until", "for", "select", "case", "coproc"}:
1854
+ control_depth += 1
1855
+ elif word in {"fi", "done", "esac"}:
1856
+ control_depth = max(0, control_depth - 1)
1857
+ if word in {"then", "else", "elif", "do", "in"}:
1858
+ conditional = True
1859
+ continue
1860
+ segment.append(token)
1861
+ if segment:
1862
+ flows.append((conditional or control_depth > 0, subshell_depth > 0))
1863
+ return flows
1864
+
1865
+
1866
+ def shell_assignment_changes(segment, values):
1867
+ """Return variables changed by a persistent assignment or variable builtin."""
1868
+ index = 0
1869
+ prefix = []
1870
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1871
+ prefix.append(segment[index])
1872
+ index += 1
1873
+ executable = segment[index].casefold() if index < len(segment) else None
1874
+ updates = {}
1875
+ removes = set()
1876
+ if executable is None:
1877
+ assignments = prefix
1878
+ elif executable in {"export", "declare", "typeset"}:
1879
+ assignments = [*prefix, *segment[index + 1 :]]
1880
+ else:
1881
+ assignments = []
1882
+ for assignment in assignments:
1883
+ if SHELL_ASSIGNMENT_PATTERN.match(assignment):
1884
+ name, value = assignment.split("=", 1)
1885
+ updates[name] = expand_environment_parameters(value, {**values, **updates})
1886
+ if executable == "unset":
1887
+ removes = {
1888
+ name for name in segment[index + 1 :] if not name.startswith("-")
1889
+ }
1890
+ return updates, removes
1891
+
1892
+
1893
+ def shell_assignment_contexts(tokens, initial=None):
1894
+ """Return the persistent shell variables visible before each segment."""
1895
+ values = dict(initial or {})
1896
+ contexts = []
1897
+ segments = list(command_segments(tokens))
1898
+ flows = shell_segment_flow(tokens)
1899
+ if len(segments) != len(flows):
1900
+ raise ValueError("shell command segments cannot be aligned")
1901
+ for segment, (conditional, subshell) in zip(segments, flows):
1902
+ contexts.append(dict(values))
1903
+ updates, removes = shell_assignment_changes(segment, values)
1904
+ if subshell:
1905
+ continue
1906
+ for name in removes:
1907
+ if conditional:
1908
+ values[name] = f"${name}"
1909
+ else:
1910
+ values.pop(name, None)
1911
+ for name, value in updates.items():
1912
+ values[name] = f"${name}" if conditional else value
1913
+ return contexts, values
1914
+
1915
+
1916
+ def shell_segment_contexts(tokens, working_directories, initial=None):
1917
+ """Yield each segment with its working directory and prior shell variables."""
1918
+ contexts, _ = shell_assignment_contexts(tokens, initial)
1919
+ segments = list(segment_directories(tokens, working_directories, contexts))
1920
+ if len(segments) != len(contexts):
1921
+ raise ValueError("shell command segments cannot be aligned")
1922
+ for (segment, directories), assignments in zip(segments, contexts):
1923
+ prefix = {}
1257
1924
  for token in segment:
1258
1925
  if not SHELL_ASSIGNMENT_PATTERN.match(token):
1259
- if variable_command:
1260
- continue
1261
1926
  break
1262
1927
  name, value = token.split("=", 1)
1263
- if name in values:
1264
- raise ValueError(f"shell variable {name} is reassigned; split the commands")
1265
- values[name] = expand_environment_parameters(value, values)
1266
- return values
1928
+ prefix[name] = expand_environment_parameters(value, assignments)
1929
+ yield segment, directories, assignments, prefix
1267
1930
 
1268
1931
 
1269
- def unresolved_archive_expansion(command, tokens, working_directories):
1270
- expanded = expand_environment_parameters(command, shell_assignment_values(tokens))
1932
+ def unresolved_archive_expansion(command, assignments=None):
1933
+ expanded = expand_environment_parameters(command, assignments)
1271
1934
  return ARCHIVE_REFERENCE.search(expanded) is not None
1272
1935
 
1273
1936
 
@@ -1296,32 +1959,194 @@ def substitution_end(command, start):
1296
1959
  raise ValueError("command substitution $( is not closed")
1297
1960
 
1298
1961
 
1962
+ def heredoc_requests(line):
1963
+ """Find literal here-document delimiters, excluding here-strings and quotes."""
1964
+ requests = []
1965
+ quote = None
1966
+ index = 0
1967
+ while index < len(line):
1968
+ character = line[index]
1969
+ if quote:
1970
+ if character == "\\" and quote == '"':
1971
+ index += 2
1972
+ continue
1973
+ if character == quote:
1974
+ quote = None
1975
+ index += 1
1976
+ continue
1977
+ if character == "\\":
1978
+ index += 2
1979
+ continue
1980
+ if character == "#" and (
1981
+ index == 0
1982
+ or line[index - 1].isspace()
1983
+ or line[index - 1] in ";|&()<>"
1984
+ ):
1985
+ break
1986
+ if character in "'\"":
1987
+ quote = character
1988
+ index += 1
1989
+ continue
1990
+ if line.startswith("$(", index):
1991
+ index = substitution_end(line, index + 2) + 1
1992
+ continue
1993
+ if character == "`":
1994
+ end = index + 1
1995
+ while end < len(line) and line[end] != "`":
1996
+ end += 2 if line[end] == "\\" else 1
1997
+ index = min(end + 1, len(line))
1998
+ continue
1999
+ if line.startswith("<<<", index):
2000
+ index += 3
2001
+ continue
2002
+ if not line.startswith("<<", index):
2003
+ index += 1
2004
+ continue
2005
+ cursor = index + 2
2006
+ strip_tabs = cursor < len(line) and line[cursor] == "-"
2007
+ if strip_tabs:
2008
+ cursor += 1
2009
+ while cursor < len(line) and line[cursor] in " \t":
2010
+ cursor += 1
2011
+ delimiter = []
2012
+ delimiter_quote = None
2013
+ quoted = False
2014
+ while cursor < len(line):
2015
+ value = line[cursor]
2016
+ if delimiter_quote:
2017
+ if value == delimiter_quote:
2018
+ delimiter_quote = None
2019
+ else:
2020
+ delimiter.append(value)
2021
+ cursor += 1
2022
+ continue
2023
+ if value in "'\"":
2024
+ delimiter_quote = value
2025
+ quoted = True
2026
+ cursor += 1
2027
+ continue
2028
+ if value == "\\" and cursor + 1 < len(line):
2029
+ quoted = True
2030
+ delimiter.append(line[cursor + 1])
2031
+ cursor += 2
2032
+ continue
2033
+ if value.isspace() or value in ";|&()<>":
2034
+ break
2035
+ delimiter.append(value)
2036
+ cursor += 1
2037
+ if not delimiter:
2038
+ raise ValueError("here-document delimiter cannot be validated")
2039
+ requests.append(("".join(delimiter), strip_tabs, quoted))
2040
+ index = cursor
2041
+ return requests
2042
+
2043
+
2044
+ def heredoc_modes(command):
2045
+ """Mark here-document bodies as data or shell-expanded input."""
2046
+ lines = command.splitlines(keepends=True)
2047
+ modes = ["shell"] * len(command)
2048
+ offsets = []
2049
+ offset = 0
2050
+ for line in lines:
2051
+ offsets.append(offset)
2052
+ offset += len(line)
2053
+ line_index = 0
2054
+ while line_index < len(lines):
2055
+ requests = heredoc_requests(lines[line_index].rstrip("\r\n"))
2056
+ line_index += 1
2057
+ for delimiter, strip_tabs, quoted in requests:
2058
+ found = False
2059
+ while line_index < len(lines):
2060
+ line = lines[line_index]
2061
+ content = line.rstrip("\r\n")
2062
+ if strip_tabs:
2063
+ content = content.lstrip("\t")
2064
+ start = offsets[line_index]
2065
+ stop = start + len(line)
2066
+ mode = "literal" if quoted or content == delimiter else "heredoc"
2067
+ modes[start:stop] = [mode] * (stop - start)
2068
+ line_index += 1
2069
+ if content == delimiter:
2070
+ found = True
2071
+ break
2072
+ if not found:
2073
+ raise ValueError(f"here-document {delimiter} is not terminated")
2074
+ return modes
2075
+
2076
+
1299
2077
  def split_command_substitutions(command):
1300
- """Replace every $(...) and `...` with a placeholder; return (outer, inner)."""
1301
- if "@(" in command:
1302
- raise ValueError(
1303
- "@( expansion cannot be validated; write the list as literal arguments"
1304
- )
1305
- if "$(" not in command and "`" not in command:
1306
- return command, []
1307
- outer, inner, index = [], [], 0
2078
+ """Replace executable substitutions, leaving quoted text as shell data."""
2079
+ modes = heredoc_modes(command)
2080
+ outer, inner, index, quote = [], [], 0, None
1308
2081
  while index < len(command):
2082
+ mode = modes[index]
2083
+ character = command[index]
2084
+ if mode == "literal":
2085
+ outer.append(character)
2086
+ index += 1
2087
+ continue
2088
+ if mode == "heredoc":
2089
+ if character == "\\" and index + 1 < len(command) and command[index + 1] in "\\$`":
2090
+ outer.append(command[index : index + 2])
2091
+ index += 2
2092
+ continue
2093
+ if command.startswith("$(", index):
2094
+ end = substitution_end(command, index + 2)
2095
+ inner.append(command[index + 2 : end])
2096
+ elif character == "`":
2097
+ end = index + 1
2098
+ while end < len(command) and command[end] != "`":
2099
+ end += 2 if command[end] == "\\" else 1
2100
+ if end >= len(command):
2101
+ raise ValueError("backtick command substitution is not closed")
2102
+ inner.append(command[index + 1 : end])
2103
+ elif command.startswith("@(", index):
2104
+ raise ValueError(
2105
+ "@( expansion cannot be validated; write the list as literal arguments"
2106
+ )
2107
+ else:
2108
+ outer.append(character)
2109
+ index += 1
2110
+ continue
2111
+ outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
2112
+ index = end + 1
2113
+ continue
2114
+ if quote == "'":
2115
+ outer.append(character)
2116
+ if character == "'":
2117
+ quote = None
2118
+ index += 1
2119
+ continue
2120
+ if character == "\\":
2121
+ outer.append(command[index : index + 2])
2122
+ index += 2
2123
+ continue
2124
+ if quote is None and character == "'":
2125
+ quote = character
2126
+ outer.append(character)
2127
+ index += 1
2128
+ continue
2129
+ if character == '"':
2130
+ quote = None if quote == '"' else '"'
2131
+ outer.append(character)
2132
+ index += 1
2133
+ continue
1309
2134
  if command.startswith("$(", index):
1310
2135
  end = substitution_end(command, index + 2)
1311
2136
  inner.append(command[index + 2 : end])
1312
- elif command[index] == "`":
2137
+ elif character == "`":
1313
2138
  end = index + 1
1314
2139
  while end < len(command) and command[end] != "`":
1315
2140
  end += 2 if command[end] == "\\" else 1
1316
2141
  if end >= len(command):
1317
2142
  raise ValueError("backtick command substitution is not closed")
1318
2143
  inner.append(command[index + 1 : end])
1319
- elif command[index] == "\\":
1320
- outer.append(command[index : index + 2])
1321
- index += 2
1322
- continue
2144
+ elif command.startswith("@(", index):
2145
+ raise ValueError(
2146
+ "@( expansion cannot be validated; write the list as literal arguments"
2147
+ )
1323
2148
  else:
1324
- outer.append(command[index])
2149
+ outer.append(character)
1325
2150
  index += 1
1326
2151
  continue
1327
2152
  outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
@@ -1342,11 +2167,8 @@ def strip_heredoc_bodies(command):
1342
2167
  line = lines[index]
1343
2168
  kept.append(line)
1344
2169
  index += 1
1345
- for match in HEREDOC_PATTERN.finditer(line):
1346
- delimiter = (
1347
- match.group("single") or match.group("double") or match.group("bare")
1348
- )
1349
- strip = "\t\r" if match.group("dash") else "\r"
2170
+ for delimiter, strip_tabs, _ in heredoc_requests(line):
2171
+ strip = "\t\r" if strip_tabs else "\r"
1350
2172
  while index < len(lines) and lines[index].strip(strip) != delimiter:
1351
2173
  index += 1
1352
2174
  if index >= len(lines):
@@ -1385,14 +2207,222 @@ def decode_patch_path(raw_path, strip_prefix):
1385
2207
  return path
1386
2208
 
1387
2209
 
1388
- def shell_tokens(command):
1389
- command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2210
+ PARAMETER_MARKERS = frozenset("$%!")
2211
+
2212
+
2213
+ class ShellToken(str):
2214
+ """A token that keeps, per parameter marker character, whether double quotes enclosed it.
2215
+
2216
+ `redirection` is true for an unquoted redirection operator and for one
2217
+ ASCII file descriptor digit written against one (the 2 of 2>file, not of
2218
+ 2 >file). zsh passes a longer or non-ASCII digit word on as an argument.
2219
+ `literal` is "bare" for a word written with no quote or escape, "quoted"
2220
+ for a word inside one pair of plain quotes, "mixed" for plain quoted and
2221
+ unquoted parts where no unquoted part can expand, and None when the shell
2222
+ may build the word in another way (escapes, an expanding unquoted part, a
2223
+ line continuation in the word or one that joins it to the next word).
2224
+ """
2225
+
2226
+ double_quoted = ()
2227
+ redirection = False
2228
+ literal = None
2229
+
2230
+
2231
+ def double_quoted_markers(command):
2232
+ """Return, per parameter marker in order, whether plain double quotes enclose it.
2233
+
2234
+ None means the command uses quoting outside the plain constructs this scan
2235
+ models (plain single quotes, plain double quotes, unquoted text), so no
2236
+ marker in it can be proven quoted.
2237
+ """
2238
+ flags = []
2239
+ quote = None
2240
+ comment = False
2241
+ braces = 0
2242
+ previous = " "
2243
+ index = 0
2244
+ while index < len(command):
2245
+ character = command[index]
2246
+ following = command[index + 1 : index + 2]
2247
+ if comment:
2248
+ if character in "\r\n":
2249
+ comment = False
2250
+ elif character in "'\"\\`":
2251
+ return None
2252
+ elif quote == "'":
2253
+ if character == "'":
2254
+ quote = None
2255
+ elif character == "\\":
2256
+ if following and following in PARAMETER_MARKERS:
2257
+ # The shell executes the escaped marker as literal text; the
2258
+ # token keeps the bare marker, so the pair is never provable.
2259
+ flags.append(False)
2260
+ previous = character
2261
+ index += 2
2262
+ continue
2263
+ elif character == "`" or (character == "$" and following == "("):
2264
+ return None
2265
+ elif character == "$" and following == "{":
2266
+ braces += 1
2267
+ elif character == "}" and braces:
2268
+ braces -= 1
2269
+ elif character in "'\"" and braces:
2270
+ return None
2271
+ elif character == '"':
2272
+ quote = None if quote else '"'
2273
+ elif quote is None:
2274
+ if character == "'":
2275
+ quote = "'"
2276
+ elif character == "$" and following and following in "'\"":
2277
+ return None
2278
+ elif character == "<" and following == "<":
2279
+ return None
2280
+ elif character == "#" and previous in " \t\r\n;&|()<>":
2281
+ comment = True
2282
+ if character in PARAMETER_MARKERS:
2283
+ flags.append(quote == '"' and not comment)
2284
+ previous = character
2285
+ index += 1
2286
+ return flags if quote is None and not braces else None
2287
+
2288
+
2289
+ def zsh_expansion_flag(text):
2290
+ """Whether the text holds an unbraced zsh expansion flag ($=x, $~x, $^x, $+x).
2291
+
2292
+ These match no parameter syntax, so without this check they never enter
2293
+ the parameter proof at all — while zsh word-splits them exactly like the
2294
+ forms the proof denies. Any other unmatched $ is inert literal text.
2295
+ """
2296
+ return ZSH_EXPANSION_FLAG_SYNTAX.search(text)
2297
+
2298
+
2299
+ def parameters_double_quoted(token):
2300
+ """Whether every parameter in the token expands inside double quotes to one word.
2301
+
2302
+ Only plain $name and ${name} qualify: flags, operators, subscripts,
2303
+ positional and special parameters can split even inside double quotes.
2304
+ A backslash-escaped marker stays one flag short of proven: the shell
2305
+ executes the literal text while the guard's token still shows the bare
2306
+ marker, so nothing about its expansion is provable.
2307
+ """
2308
+ flags = getattr(token, "double_quoted", ())
2309
+ positions = [
2310
+ index for index, character in enumerate(token) if character in PARAMETER_MARKERS
2311
+ ]
2312
+ if len(flags) != len(positions):
2313
+ return False
2314
+ quoted = dict(zip(positions, flags))
2315
+ plain = {
2316
+ match.start(): match.end() for match in NAMED_SHELL_PARAMETER_SYNTAX.finditer(token)
2317
+ }
2318
+ if any(
2319
+ character == "$" and index not in plain for index, character in enumerate(token)
2320
+ ) or any(token[end : end + 1] == "[" for end in plain.values()):
2321
+ return False
2322
+ return all(
2323
+ quoted[match.start()] and quoted.get(match.end() - 1, True)
2324
+ for syntax in (SHELL_PARAMETER_SYNTAX, CMD_PARAMETER_SYNTAX)
2325
+ for match in syntax.finditer(token)
2326
+ )
2327
+
2328
+
2329
+ def shell_tokens(command, direct=True):
2330
+ """Tokenize a command; `direct` is False for a string that a second shell parses.
2331
+
2332
+ Only a directly parsed command keeps quote flags: an outer shell expands
2333
+ parameters before the inner parse, so inner quotes prove nothing.
2334
+ """
2335
+ breaks = set()
2336
+ if LINE_CONTINUATION.search(command):
2337
+ mark = next(
2338
+ character for character in map(chr, range(0xE000, 0xF900))
2339
+ if character not in command
2340
+ )
2341
+ marked = strip_heredoc_bodies(
2342
+ LINE_CONTINUATION.sub(lambda match: match.group(1) + mark, command)
2343
+ )
2344
+ command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
2345
+ breaks = (
2346
+ {index for index, character in enumerate(marked) if character == mark}
2347
+ if marked.replace(mark, " ") == command
2348
+ else None
2349
+ )
2350
+ else:
2351
+ command = strip_heredoc_bodies(command)
2352
+ joins = set()
2353
+ for index in breaks or ():
2354
+ left, right = index - 1, index + 1
2355
+ while left >= 0 and marked[left] == mark:
2356
+ left -= 1
2357
+ while right < len(marked) and marked[right] == mark:
2358
+ right += 1
2359
+ if left >= 0 and marked[left].strip() and marked[right:right + 1].strip():
2360
+ joins.add(index)
1390
2361
  lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
1391
2362
  lexer.whitespace = " \t"
1392
2363
  lexer.whitespace_split = True
1393
2364
  lexer.commenters = ""
2365
+ tokens = []
2366
+ previous_end = 0
1394
2367
  try:
1395
- tokens = list(lexer)
2368
+ for token in lexer:
2369
+ end = lexer.instream.tell() - len(lexer._pushback_chars)
2370
+ if command[end - 1:end] in (" ", "\t"):
2371
+ end -= 1
2372
+ word_start = previous_end
2373
+ while word_start < end and command[word_start] in " \t":
2374
+ word_start += 1
2375
+ previous_end = end
2376
+ word = command[word_start:end]
2377
+ literal = None
2378
+ if (
2379
+ breaks is not None
2380
+ and "\\" not in word
2381
+ and not ANSI_QUOTE_START.search(word)
2382
+ and breaks.isdisjoint(range(word_start, end))
2383
+ and end not in joins
2384
+ and word_start - 1 not in joins
2385
+ and PLAIN_QUOTED_SEGMENT.sub(lambda match: match.group(0)[1:-1], word) == token
2386
+ ):
2387
+ unquoted = PLAIN_QUOTED_SEGMENT.sub("", word)
2388
+ if (
2389
+ "'" not in unquoted
2390
+ and '"' not in unquoted
2391
+ and not GIT_CONFIG_EXPANDING_WORD.search(unquoted)
2392
+ ):
2393
+ literal = "mixed"
2394
+ for quote in ("", "'", '"'):
2395
+ start = end - len(token) - 2 * len(quote)
2396
+ if (
2397
+ breaks is not None
2398
+ and start >= 0
2399
+ and command[start:end] == quote + token + quote
2400
+ and (start == 0 or command[start - 1] in " \t|;&()<>\n\r")
2401
+ and breaks.isdisjoint(range(start, end))
2402
+ and end not in joins
2403
+ and start - 1 not in joins
2404
+ ):
2405
+ literal = "quoted" if quote else "bare"
2406
+ break
2407
+ start = end - len(token)
2408
+ before = command[start - 1:start] if start > 0 else ""
2409
+ redirection = command[start:end] == token and (
2410
+ (
2411
+ set(token) <= SHELL_WRITE_REDIRECTION_CHARS
2412
+ and ("<" in token or ">" in token)
2413
+ and before != "\\"
2414
+ )
2415
+ or (
2416
+ len(token) == 1
2417
+ and token in "0123456789"
2418
+ and command[end:end + 1] in ("<", ">")
2419
+ and (not before or before in " \t|;&()<>\n\r")
2420
+ )
2421
+ )
2422
+ token = ShellToken(token)
2423
+ token.redirection = redirection
2424
+ token.literal = literal
2425
+ tokens.append(token)
1396
2426
  except ValueError as error:
1397
2427
  raise ValueError(
1398
2428
  f"shell command cannot be tokenized ({error}); balance the quotes "
@@ -1400,7 +2430,20 @@ def shell_tokens(command):
1400
2430
  ) from None
1401
2431
  if not tokens:
1402
2432
  raise ValueError("shell command is empty")
1403
- return tokens
2433
+ flags = double_quoted_markers(command) if direct else None
2434
+ if flags is None or len(flags) != sum(
2435
+ character in PARAMETER_MARKERS for token in tokens for character in token
2436
+ ):
2437
+ return tokens
2438
+ marked = []
2439
+ for token in tokens:
2440
+ count = sum(character in PARAMETER_MARKERS for character in token)
2441
+ redirection, literal = token.redirection, token.literal
2442
+ token = ShellToken(token)
2443
+ token.redirection, token.literal = redirection, literal
2444
+ token.double_quoted, flags = tuple(flags[:count]), flags[count:]
2445
+ marked.append(token)
2446
+ return marked
1404
2447
 
1405
2448
 
1406
2449
  def command_segments(tokens):
@@ -1460,56 +2503,163 @@ def xargs_command(arguments):
1460
2503
 
1461
2504
 
1462
2505
  def require_read_command(wrapper, wrapped):
1463
- if wrapped and not archive_command_is_read_only(" ".join(wrapped), wrapped, True):
2506
+ segments = list(command_segments(wrapped))
2507
+ if wrapped and (
2508
+ not segments
2509
+ or not all(
2510
+ archive_command_is_read_only(shlex.join(segment), segment, True)
2511
+ for segment in segments
2512
+ )
2513
+ ):
1464
2514
  raise ValueError(
1465
2515
  f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
1466
2516
  f"only read commands may follow {wrapper}, or pass the paths as literals"
1467
2517
  )
1468
2518
 
1469
2519
 
1470
- def command_invocation(segment):
2520
+ def runner_value_option(prefix, token):
2521
+ """The option in a runner option word whose value is the next word, if any.
2522
+
2523
+ Raises for an option that writes, edits or enters a file or directory. A
2524
+ long option matches by each prefix, as getopt accepts it. A short word is
2525
+ read as getopt reads it: flag letters, then the first value option letter,
2526
+ whose value is the rest of the word or, with no rest, the next word.
2527
+ """
2528
+ value_options = EXEC_PREFIX_VALUE_OPTIONS[prefix]
2529
+ file_options = EXEC_PREFIX_FILE_OPTIONS.get(prefix, frozenset())
2530
+ denial = ValueError(
2531
+ f"{prefix} {token} writes, edits or enters a file or directory that the "
2532
+ "guard cannot check; run the command without this option and redirect "
2533
+ "its output instead"
2534
+ )
2535
+ if token.startswith("--"):
2536
+ name = token.split("=", 1)[0]
2537
+ if any(option.startswith(name) for option in file_options):
2538
+ raise denial
2539
+ option = next((option for option in value_options if option.startswith(name)), None)
2540
+ return option if "=" not in token else None
2541
+ for position, letter in enumerate(token[1:], start=1):
2542
+ option = f"-{letter}"
2543
+ if option in file_options:
2544
+ raise denial
2545
+ if option in value_options:
2546
+ return option if position == len(token) - 1 else None
2547
+ if letter not in EXEC_PREFIX_FLAG_LETTERS[prefix]:
2548
+ return None
2549
+ return None
2550
+
2551
+
2552
+ def nonliteral_runner_option(token):
2553
+ literal = getattr(token, "literal", "bare")
2554
+ return (
2555
+ SHELL_PARAMETER_SYNTAX.search(token)
2556
+ or CMD_PARAMETER_SYNTAX.search(token)
2557
+ or zsh_expansion_flag(token)
2558
+ or literal is None
2559
+ or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(token))
2560
+ )
2561
+
2562
+
2563
+ def unresolved_runner_word(token):
2564
+ return (
2565
+ SHELL_PARAMETER_SYNTAX.search(token)
2566
+ or CMD_PARAMETER_SYNTAX.search(token)
2567
+ or zsh_expansion_flag(token)
2568
+ ) and not parameters_double_quoted(token)
2569
+
2570
+
2571
+ def command_invocation(segment, unwrap_runners=True):
2572
+ """`unwrap_runners` is False where a command runner itself must be judged."""
1471
2573
  index = 0
1472
- while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1473
- validate_shell_assignment(segment[index])
1474
- index += 1
1475
- if index >= len(segment):
1476
- return None
1477
- executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1478
- if executable == "env":
2574
+ # The shell word `time` is already removed; its only option can remain.
2575
+ while unwrap_runners and index < len(segment) and segment[index] == "-p":
1479
2576
  index += 1
1480
- while index < len(segment):
1481
- token = segment[index]
1482
- if token == "--":
1483
- index += 1
2577
+ while True:
2578
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
2579
+ validate_shell_assignment(segment[index])
2580
+ index += 1
2581
+ if index >= len(segment):
2582
+ return None
2583
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
2584
+ prefix = executable.removesuffix(".exe")
2585
+ if executable == "env":
2586
+ index += 1
2587
+ while index < len(segment):
2588
+ token = segment[index]
2589
+ if token == "--":
2590
+ index += 1
2591
+ break
2592
+ if SHELL_ASSIGNMENT_PATTERN.match(token):
2593
+ validate_shell_assignment(token)
2594
+ index += 1
2595
+ continue
2596
+ option = token.split("=", 1)[0]
2597
+ if option in ENV_OPTIONS_WITHOUT_VALUES:
2598
+ index += 1
2599
+ continue
2600
+ if option in ENV_OPTIONS_WITH_VALUES:
2601
+ if option in {"-C", "--chdir"}:
2602
+ raise ValueError(
2603
+ f"env {option} changes the working directory the guard "
2604
+ "tracks; use cd with a literal path instead"
2605
+ )
2606
+ index += 1
2607
+ if "=" not in token:
2608
+ if index >= len(segment):
2609
+ raise ValueError(f"env {option} lacks a value")
2610
+ index += 1
2611
+ continue
2612
+ if token.startswith("-"):
2613
+ raise ValueError(
2614
+ f"env {token} cannot be validated; use env NAME=VALUE <command>"
2615
+ )
2616
+ if SHELL_PARAMETER_SYNTAX.search(token) or zsh_expansion_flag(token):
2617
+ raise ValueError(
2618
+ f"env word {token} cannot be resolved by the guard; env "
2619
+ "assignments must be literal NAME=VALUE words and the "
2620
+ "program a literal name"
2621
+ )
1484
2622
  break
1485
- if SHELL_ASSIGNMENT_PATTERN.match(token):
1486
- validate_shell_assignment(token)
2623
+ elif unwrap_runners and prefix in EXEC_PREFIX_VALUE_OPTIONS:
2624
+ index += 1
2625
+ while index < len(segment):
2626
+ token = segment[index]
2627
+ if token == "--":
2628
+ index += 1
2629
+ break
2630
+ if not token.startswith("-") or token == "-":
2631
+ break
1487
2632
  index += 1
1488
- continue
1489
- option = token.split("=", 1)[0]
1490
- if option in ENV_OPTIONS_WITHOUT_VALUES:
2633
+ if nonliteral_runner_option(token):
2634
+ raise ValueError(
2635
+ f"{prefix} option {token} cannot be resolved by the guard; "
2636
+ f"{prefix} options must be literal words"
2637
+ )
2638
+ option = runner_value_option(prefix, token)
2639
+ if option is None or index >= len(segment):
2640
+ continue
2641
+ value = segment[index]
1491
2642
  index += 1
1492
- continue
1493
- if option in ENV_OPTIONS_WITH_VALUES:
1494
- if option in {"-C", "--chdir"}:
2643
+ if prefix == "watch" and option in EXEC_PREFIX_INTERVAL_OPTIONS:
2644
+ if not RUNNER_DURATION.fullmatch(value):
2645
+ raise ValueError(
2646
+ f"watch interval {value} cannot be resolved by the guard; "
2647
+ "the interval must be a literal number"
2648
+ )
2649
+ elif unresolved_runner_word(value):
2650
+ raise ValueError(
2651
+ f"{prefix} option value {value} cannot be resolved by the "
2652
+ f"guard; {prefix} option values must be literal words"
2653
+ )
2654
+ if prefix == "timeout" and index < len(segment):
2655
+ if not RUNNER_DURATION.fullmatch(segment[index]):
1495
2656
  raise ValueError(
1496
- f"env {option} changes the working directory the guard "
1497
- "tracks; use cd with a literal path instead"
2657
+ f"timeout duration {segment[index]} cannot be resolved by the "
2658
+ "guard; the duration must be a literal number"
1498
2659
  )
1499
2660
  index += 1
1500
- if "=" not in token:
1501
- if index >= len(segment):
1502
- raise ValueError(f"env {option} lacks a value")
1503
- index += 1
1504
- continue
1505
- if token.startswith("-"):
1506
- raise ValueError(
1507
- f"env {token} cannot be validated; use env NAME=VALUE <command>"
1508
- )
2661
+ else:
1509
2662
  break
1510
- if index >= len(segment):
1511
- return None
1512
- executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1513
2663
  arguments = segment[index + 1 :]
1514
2664
  if SHELL_PARAMETER_SYNTAX.search(executable):
1515
2665
  raise ValueError(
@@ -1533,19 +2683,103 @@ def command_invocation(segment):
1533
2683
  return executable, arguments
1534
2684
 
1535
2685
 
1536
- def git_command(segment):
2686
+ def require_resolved_git_word(word, mixed=True):
2687
+ """Deny a git word the shell can turn into other or several arguments."""
2688
+ literal = getattr(word, "literal", "bare")
2689
+ if (
2690
+ literal is None
2691
+ or (literal == "mixed" and not mixed)
2692
+ or (literal == "bare" and GIT_CONFIG_EXPANDING_WORD.search(word))
2693
+ ):
2694
+ raise ValueError(
2695
+ f"git argument {word} cannot be resolved by the guard; "
2696
+ "pass a literal value"
2697
+ )
2698
+
2699
+
2700
+ def git_command(segment, assignments=None):
1537
2701
  invocation = command_invocation(segment)
1538
2702
  if invocation is None:
1539
2703
  return None
1540
2704
  executable, arguments = invocation
1541
2705
  if executable not in {"git", "git.exe"}:
1542
2706
  return None
1543
- for argument in arguments:
1544
- if SHELL_PARAMETER_SYNTAX.search(argument):
1545
- raise ValueError(
1546
- f"git argument {argument} cannot be resolved by the guard; pass a "
1547
- "literal value (for commit messages use -F <file>)"
2707
+ resolved_arguments = []
2708
+ parameter_arguments = []
2709
+ unproven_parameters = []
2710
+ argument_index = 0
2711
+ while argument_index < len(arguments):
2712
+ argument = arguments[argument_index]
2713
+ attached_directory = argument.startswith("-C") and len(argument) > 2
2714
+ if argument == "-C" and argument_index + 1 < len(arguments):
2715
+ argument_index += 1
2716
+ directory = arguments[argument_index]
2717
+ require_resolved_git_word(directory)
2718
+ expanded = expand_environment_parameters(directory, assignments)
2719
+ if (
2720
+ SHELL_PARAMETER_SYNTAX.search(expanded)
2721
+ or CMD_PARAMETER_SYNTAX.search(expanded)
2722
+ or zsh_expansion_flag(expanded)
2723
+ ):
2724
+ raise ValueError(
2725
+ f"git argument {directory} cannot be resolved by the guard; "
2726
+ "pass a literal path"
2727
+ )
2728
+ if (
2729
+ (SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
2730
+ and any(character.isspace() or character in "*?[]" for character in expanded)
2731
+ ):
2732
+ raise ValueError(
2733
+ f"git -C path {directory} may split or glob; pass a literal path"
2734
+ )
2735
+ resolved_arguments.extend((argument, expanded))
2736
+ elif attached_directory:
2737
+ require_resolved_git_word(argument)
2738
+ directory = argument[2:]
2739
+ expanded = expand_environment_parameters(directory, assignments)
2740
+ if (
2741
+ SHELL_PARAMETER_SYNTAX.search(expanded)
2742
+ or CMD_PARAMETER_SYNTAX.search(expanded)
2743
+ or zsh_expansion_flag(expanded)
2744
+ ):
2745
+ raise ValueError(
2746
+ f"git argument {directory} cannot be resolved by the guard; "
2747
+ "pass a literal path"
2748
+ )
2749
+ if (
2750
+ (SHELL_PARAMETER_SYNTAX.search(directory) or CMD_PARAMETER_SYNTAX.search(directory))
2751
+ and any(character.isspace() or character in "*?[]" for character in expanded)
2752
+ ):
2753
+ raise ValueError(
2754
+ f"git -C path {directory} may split or glob; pass a literal path"
2755
+ )
2756
+ resolved_arguments.append(f"-C{expanded}")
2757
+ else:
2758
+ parameter = (
2759
+ SHELL_PARAMETER_SYNTAX.search(argument)
2760
+ or CMD_PARAMETER_SYNTAX.search(argument)
2761
+ or zsh_expansion_flag(argument)
1548
2762
  )
2763
+ if parameter:
2764
+ parameter_arguments.append(argument)
2765
+ literal = re.split(r"[$%!]", argument, maxsplit=1)[0]
2766
+ expanded = expand_environment_parameters(argument, assignments)
2767
+ if (
2768
+ not literal
2769
+ or (literal.startswith("-") and "=" not in literal)
2770
+ or git_option_in(literal, GIT_PARAMETER_DENIED_OPTIONS)
2771
+ or git_option_in(arguments[argument_index - 1], GIT_PARAMETER_DENIED_OPTIONS)
2772
+ or (literal.startswith("-c") and literal != "-c")
2773
+ or "::" in literal
2774
+ or not parameters_double_quoted(argument)
2775
+ or SUBSTITUTION_PLACEHOLDER in argument
2776
+ or SUBSTITUTION_PLACEHOLDER in expanded
2777
+ ):
2778
+ unproven_parameters.append(argument)
2779
+ expanded = expand_environment_parameters(argument, assignments)
2780
+ resolved_arguments.append(argument if expanded == argument else expanded)
2781
+ argument_index += 1
2782
+ arguments = resolved_arguments
1549
2783
  index = 0
1550
2784
  git_options = []
1551
2785
  while index < len(arguments) and arguments[index].startswith("-"):
@@ -1556,18 +2790,32 @@ def git_command(segment):
1556
2790
  option = token.split("=", 1)[0]
1557
2791
  value = token.split("=", 1)[1] if "=" in token else None
1558
2792
  index += 1
2793
+ if option != "-C":
2794
+ require_resolved_git_word(token)
1559
2795
  if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
1560
2796
  if index >= len(arguments):
1561
2797
  raise ValueError(f"git option {option} lacks a value")
1562
2798
  value = arguments[index]
2799
+ if option != "-C":
2800
+ require_resolved_git_word(value)
1563
2801
  index += 1
1564
2802
  if option in {"-c", "--config-env"}:
1565
- config_key = str(value).split("=", 1)[0].casefold()
2803
+ config_key, _, config_value = str(value).partition("=")
2804
+ config_key = config_key.casefold()
1566
2805
  if config_key.startswith("alias."):
1567
2806
  raise ValueError(
1568
2807
  f"git -c {config_key} defines an alias the guard cannot inspect; "
1569
2808
  "run the underlying git command directly"
1570
2809
  )
2810
+ if executed_config_key(config_key) and not (
2811
+ option == "-c"
2812
+ and not loaded_config_key(config_key)
2813
+ and config_value.casefold() in GIT_CONFIG_DISABLING_VALUES
2814
+ ):
2815
+ raise ValueError(
2816
+ f"git -c {config_key} configures a program git executes; "
2817
+ "the guard cannot allow it"
2818
+ )
1571
2819
  if config_key == "clean.requireforce":
1572
2820
  raise ValueError(
1573
2821
  "git -c clean.requireForce disables the git clean safety check; "
@@ -1576,8 +2824,47 @@ def git_command(segment):
1576
2824
  if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
1577
2825
  git_options.extend((option, str(value)))
1578
2826
  if index >= len(arguments):
2827
+ if parameter_arguments:
2828
+ raise ValueError(
2829
+ f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
2830
+ "pass a literal value (for commit messages use -F <file>)"
2831
+ )
1579
2832
  return None
1580
- return arguments[index].casefold(), arguments[index + 1:], git_options
2833
+ require_resolved_git_word(arguments[index], mixed=False)
2834
+ subcommand = arguments[index].casefold()
2835
+ if subcommand == "config":
2836
+ for argument in arguments[index + 1:]:
2837
+ literal = getattr(argument, "literal", None)
2838
+ if not getattr(argument, "redirection", False) and (
2839
+ literal in (None, "mixed")
2840
+ or (
2841
+ literal == "bare"
2842
+ and (argument.startswith("#") or GIT_CONFIG_EXPANDING_WORD.search(argument))
2843
+ )
2844
+ ):
2845
+ raise ValueError(
2846
+ f"git argument {argument} cannot be resolved by the guard; "
2847
+ "pass a literal value"
2848
+ )
2849
+ denied = denied_git_config_argument(arguments[index + 1:])
2850
+ if denied is not None:
2851
+ raise ValueError(
2852
+ f"git config {denied} persists a program git executes; "
2853
+ "the guard cannot allow it"
2854
+ )
2855
+ read_only_subcommands = ARCHIVE_READ_GIT_COMMANDS | CLOSED_READ_GIT_COMMANDS
2856
+ if subcommand in read_only_subcommands:
2857
+ # Read-only subcommands still write through --output. A parameter
2858
+ # passes only inside plain double quotes, where its expansion stays
2859
+ # one word; an unquoted one can word-split into an option, and
2860
+ # command substitution output is never known.
2861
+ parameter_arguments = unproven_parameters
2862
+ if parameter_arguments:
2863
+ raise ValueError(
2864
+ f"git argument {parameter_arguments[0]} cannot be resolved by the guard; "
2865
+ "pass a literal value (for commit messages use -F <file>)"
2866
+ )
2867
+ return subcommand, arguments[index + 1:], git_options
1581
2868
 
1582
2869
 
1583
2870
  def wrapped_command_tokens(segment, expand_parameters=True):
@@ -1586,7 +2873,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1586
2873
  return None
1587
2874
  executable, arguments = invocation
1588
2875
  if executable == "eval":
1589
- return shell_tokens(" ".join(arguments)) if arguments else None
2876
+ return shell_tokens(" ".join(arguments), direct=False) if arguments else None
1590
2877
  if executable in COMMAND_WRAPPERS:
1591
2878
  if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
1592
2879
  return None
@@ -1611,7 +2898,7 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1611
2898
  executable=executable, option=argument
1612
2899
  )
1613
2900
  )
1614
- return shell_tokens(arguments[index + 1])
2901
+ return shell_tokens(arguments[index + 1], direct=False)
1615
2902
  raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1616
2903
  if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
1617
2904
  switches = {"-c", "-command", "/c", "/k"}
@@ -1630,8 +2917,8 @@ def wrapped_command_tokens(segment, expand_parameters=True):
1630
2917
  for argument in payload
1631
2918
  ]
1632
2919
  if len(payload) == 1:
1633
- return shell_tokens(payload[0])
1634
- return payload
2920
+ return shell_tokens(payload[0], direct=False)
2921
+ return [str(argument) for argument in payload]
1635
2922
  raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1636
2923
  return None
1637
2924
 
@@ -1648,7 +2935,7 @@ def has_short_option(arguments, option):
1648
2935
  def git_alias(command, git_options):
1649
2936
  result = subprocess.run(
1650
2937
  ["git", *git_options, "config", "--get", f"alias.{command}"],
1651
- text=True,
2938
+ encoding="utf-8", errors="replace",
1652
2939
  capture_output=True,
1653
2940
  check=False,
1654
2941
  )
@@ -1686,14 +2973,18 @@ def update_ref_deletes(arguments):
1686
2973
  )
1687
2974
 
1688
2975
 
1689
- def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1690
- for segment in command_segments(tokens):
2976
+ def destructive_git_reason(
2977
+ tokens, resolved_aliases=frozenset(), initial_assignments=None
2978
+ ):
2979
+ segments = list(command_segments(tokens))
2980
+ contexts, _ = shell_assignment_contexts(tokens, initial_assignments)
2981
+ for segment, assignments in zip(segments, contexts):
1691
2982
  wrapped = wrapped_command_tokens(segment)
1692
2983
  if wrapped is not None:
1693
- reason = destructive_git_reason(wrapped, resolved_aliases)
2984
+ reason = destructive_git_reason(wrapped, resolved_aliases, assignments)
1694
2985
  if reason is not None:
1695
2986
  return reason
1696
- invocation = git_command(segment)
2987
+ invocation = git_command(segment, assignments)
1697
2988
  if invocation is None:
1698
2989
  continue
1699
2990
  command, arguments, git_options = invocation
@@ -1716,8 +3007,9 @@ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1716
3007
  "git command directly"
1717
3008
  )
1718
3009
  reason = destructive_git_reason(
1719
- ["git", *git_options, *shell_tokens(alias), *arguments],
3010
+ ["git", *git_options, *shell_tokens(alias, direct=False), *arguments],
1720
3011
  resolved_aliases | {command},
3012
+ assignments,
1721
3013
  )
1722
3014
  if reason is not None:
1723
3015
  return reason
@@ -1791,13 +3083,13 @@ def closed_milestone_reason(repo=None):
1791
3083
  repo = repo or repository_root()
1792
3084
  branch = subprocess.run(
1793
3085
  ["git", "-C", str(repo), "branch", "--show-current"],
1794
- capture_output=True, text=True,
3086
+ capture_output=True, encoding="utf-8", errors="replace",
1795
3087
  )
1796
3088
  if not re.fullmatch(r"gsd-path/M\d{3,}", branch.stdout.strip()):
1797
3089
  return None
1798
3090
  result = subprocess.run(
1799
3091
  [sys.executable, "-B", str(Path(__file__).with_name("git_guard.py")),
1800
- "closed-milestone"], cwd=repo, capture_output=True, text=True,
3092
+ "closed-milestone"], cwd=repo, capture_output=True, encoding="utf-8", errors="replace",
1801
3093
  )
1802
3094
  if result.returncode:
1803
3095
  return result.stderr.strip() or "closed milestone inspection failed"
@@ -1807,7 +3099,7 @@ def closed_milestone_reason(repo=None):
1807
3099
  def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPELINE_HELPERS):
1808
3100
  """Allow one plain ``python[3] [-B] <script>`` from the guard-owned runtime.
1809
3101
 
1810
- The interpreter token must be exactly python or python3, and the script
3102
+ The interpreter must be exactly python, python3, or py -3, and the script
1811
3103
  operand is the exact parsed token, with expansions and parent traversal refused.
1812
3104
  The resolved helper must be a regular file inside runtime/ beside this guard,
1813
3105
  or beside the guard itself in the repository layout. Resolve only in supplied
@@ -1826,9 +3118,12 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
1826
3118
  _, substitutions = split_command_substitutions(command)
1827
3119
  if substitutions or wrapped_command_tokens(segments[0]) is not None:
1828
3120
  return False
1829
- if tokens[0] not in ("python", "python3"):
3121
+ if tokens[:2] == ["py", "-3"]:
3122
+ rest = tokens[2:]
3123
+ elif tokens[0] in ("python", "python3"):
3124
+ rest = tokens[1:]
3125
+ else:
1830
3126
  return False
1831
- rest = tokens[1:]
1832
3127
  if rest[:1] == ["-B"]:
1833
3128
  rest = rest[1:]
1834
3129
  if not rest:
@@ -1853,7 +3148,8 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
1853
3148
  runtime = runtime.resolve()
1854
3149
  for base in working_directories or [os.getcwd()]:
1855
3150
  try:
1856
- script = (Path(base) / operand).resolve()
3151
+ # A Git Bash /c/... operand names the C: drive, not a folder named c.
3152
+ script = (Path(base) / native_path_text(operand)).resolve()
1857
3153
  if (
1858
3154
  script.name in helpers
1859
3155
  and (script.is_relative_to(runtime) or (
@@ -1867,28 +3163,76 @@ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPE
1867
3163
  return False
1868
3164
 
1869
3165
 
1870
- def archive_command_is_read_only(command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS):
3166
+ def archive_command_is_read_only(
3167
+ command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS,
3168
+ source=None,
3169
+ ):
3170
+ """`source` is the unexpanded (segment, assignments) whose tokens keep their quote flags."""
1871
3171
  if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
1872
3172
  return False
1873
3173
  if not archive_context:
1874
3174
  return True
1875
- executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold()
3175
+ invocation = command_invocation(tokens, unwrap_runners=False)
3176
+ if invocation is None:
3177
+ return True
3178
+ executable, arguments = invocation
3179
+ executable = executable.removesuffix(".exe")
3180
+ if executable in DIRECTORY_CHANGE_COMMANDS:
3181
+ return True
3182
+ if executable in {"export", "declare", "typeset", "unset"}:
3183
+ return True
1876
3184
  denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
1877
3185
  if any(
1878
3186
  token == option or token.startswith(f"{option}=")
1879
- for token in tokens[1:]
3187
+ for token in arguments
1880
3188
  for option in denied_options
1881
3189
  ):
1882
3190
  return False
3191
+ if executable == "sed":
3192
+ in_place, _inputs, script_is_read_only = sed_arguments(arguments)
3193
+ return not in_place and script_is_read_only
1883
3194
  if executable in ARCHIVE_READ_COMMANDS:
1884
3195
  return True
1885
- if executable != "git" or len(tokens) < 2:
3196
+ if executable != "git":
1886
3197
  return False
1887
- if tokens[1].casefold() not in git_commands:
3198
+ git = git_command(*(source or (tokens,)))
3199
+ if git is None:
3200
+ return False
3201
+ subcommand, git_arguments, _options = git
3202
+ if subcommand not in git_commands:
1888
3203
  return False
1889
3204
  return not any(
1890
- token in GIT_READ_WRITE_OPTIONS or token.startswith("--output=")
1891
- for token in tokens[2:]
3205
+ git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
3206
+ )
3207
+
3208
+
3209
+ def archive_write_attempt(segment, source=None):
3210
+ """Whether a segment denied in archive context attempts a write there."""
3211
+ if any(
3212
+ token
3213
+ and (set(token) <= SHELL_WRITE_REDIRECTION_CHARS
3214
+ or re.fullmatch(r"\d*[<>]+&?", token))
3215
+ for token in segment
3216
+ ):
3217
+ return True
3218
+ invocation = command_invocation(segment)
3219
+ if invocation is None:
3220
+ return False
3221
+ executable, arguments = invocation
3222
+ executable = executable.removesuffix(".exe")
3223
+ if executable in SHELL_WRITE_COMMANDS:
3224
+ return True
3225
+ if executable in IN_PLACE_EDITORS and (
3226
+ has_short_option(arguments, "i")
3227
+ or any(argument.startswith("--in-place") for argument in arguments)
3228
+ ):
3229
+ return True
3230
+ git = git_command(*(source or (segment,)))
3231
+ if git is None:
3232
+ return False
3233
+ subcommand, git_arguments, _options = git
3234
+ return subcommand not in ARCHIVE_READ_GIT_COMMANDS or any(
3235
+ git_option_in(token, GIT_READ_WRITE_OPTIONS) for token in git_arguments
1892
3236
  )
1893
3237
 
1894
3238
 
@@ -2010,22 +3354,28 @@ def closed_git_listing(tokens):
2010
3354
  return not positionals
2011
3355
 
2012
3356
 
2013
- def closed_shell_execution_reason(command, tokens, working_directories):
3357
+ def closed_shell_execution_reason(
3358
+ command, tokens, working_directories, initial_assignments=None
3359
+ ):
2014
3360
  helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
2015
3361
  rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
2016
3362
  if rest[:1] == ["pending"]:
2017
3363
  helpers = helpers | {"discussion_records.py"}
2018
3364
  if bundled_helper_invocation(command, tokens, working_directories, helpers):
2019
3365
  return None
2020
- for segment, directories in segment_directories(tokens, working_directories):
3366
+ for segment, directories, assignments, _prefix in shell_segment_contexts(
3367
+ tokens, working_directories, initial_assignments
3368
+ ):
2021
3369
  wrapped = wrapped_command_tokens(segment)
2022
3370
  if wrapped is not None:
2023
- reason = closed_shell_execution_reason(shlex.join(wrapped), wrapped, directories)
3371
+ reason = closed_shell_execution_reason(
3372
+ shlex.join(wrapped), wrapped, directories, assignments
3373
+ )
2024
3374
  if reason:
2025
3375
  return reason
2026
3376
  continue
2027
3377
  metadata_closed = None
2028
- git = git_command(segment)
3378
+ git = git_command(segment, assignments)
2029
3379
  checked_segment = segment
2030
3380
  if git is not None:
2031
3381
  subcommand, arguments, options = git
@@ -2035,7 +3385,7 @@ def closed_shell_execution_reason(command, tokens, working_directories):
2035
3385
  for directory in directories:
2036
3386
  result = subprocess.run(
2037
3387
  ["git", *options, "rev-parse", "--show-toplevel", "--absolute-git-dir"],
2038
- cwd=directory, capture_output=True, text=True,
3388
+ cwd=directory, capture_output=True, encoding="utf-8", errors="replace",
2039
3389
  )
2040
3390
  if result.returncode:
2041
3391
  raise ValueError("git target repository cannot be resolved")
@@ -2064,14 +3414,16 @@ def closed_shell_execution_reason(command, tokens, working_directories):
2064
3414
  def command_denial(command, working_directories, allow_destructive=True):
2065
3415
  """Return why a shell command is denied, or None when it may run."""
2066
3416
  working_directories = working_directories or [os.getcwd()]
3417
+ command = re.sub(r"(?i:(\.project)\\(archive))", r"\1/\2", command)
2067
3418
  outer, substitutions = split_command_substitutions(command)
2068
3419
  try:
2069
3420
  tokens = shell_tokens(outer)
2070
3421
  if tokens in (
2071
- [interpreter, "-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"]
2072
- for interpreter in ("python3", "python")
3422
+ [*interpreter.split(), "-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"]
3423
+ for interpreter in ("python3", "python", "py -3")
2073
3424
  ):
2074
3425
  return None
3426
+ bundled_helper = bundled_helper_invocation(command, tokens, working_directories)
2075
3427
  destructive = list(destructive_shell_invocations(tokens))
2076
3428
  if destructive and (
2077
3429
  not allow_destructive
@@ -2092,16 +3444,31 @@ def command_denial(command, working_directories, allow_destructive=True):
2092
3444
  reason = command_denial(inner, working_directories, allow_destructive=False)
2093
3445
  if reason is not None:
2094
3446
  return reason
2095
- archive_context = (
2096
- any(working_directory_in_archive(path) for path in working_directories)
2097
- or bool(ARCHIVE_REFERENCE.search(outer))
2098
- or command_references_archive(tokens, working_directories)
2099
- or unresolved_archive_expansion(outer, tokens, working_directories)
2100
- )
2101
- if not archive_command_is_read_only(command, tokens, archive_context) and not (
2102
- archive_context and bundled_helper_invocation(command, tokens, working_directories)
3447
+ for segment, directories, assignments, prefix in shell_segment_contexts(
3448
+ tokens, working_directories
2103
3449
  ):
2104
- return ARCHIVE_REASON
3450
+ resolved_segment = [
3451
+ expand_environment_parameters(token, assignments) for token in segment
3452
+ ]
3453
+ archive_context = (
3454
+ any(working_directory_in_archive(path) for path in directories)
3455
+ or segment_references_archive(segment, directories, assignments)
3456
+ or any(
3457
+ ARCHIVE_REFERENCE.search(value)
3458
+ or path_in_archive(value, directories, ancestors=False)
3459
+ for value in prefix.values()
3460
+ )
3461
+ )
3462
+ if not archive_command_is_read_only(
3463
+ shlex.join(resolved_segment), resolved_segment, archive_context,
3464
+ source=(segment, assignments),
3465
+ ) and not (
3466
+ archive_context
3467
+ and bundled_helper
3468
+ ):
3469
+ if archive_write_attempt(resolved_segment, (segment, assignments)):
3470
+ return ARCHIVE_REASON
3471
+ return ARCHIVE_UNPROVEN_READ_REASON
2105
3472
  reason = destructive_git_reason(tokens) or protected_shell_write_reason(
2106
3473
  tokens, working_directories
2107
3474
  )
@@ -2116,8 +3483,14 @@ def allow(event):
2116
3483
 
2117
3484
 
2118
3485
  def main():
3486
+ # Hosts exchange hook input and output as UTF-8; a Windows pipe would
3487
+ # default to cp1252. In-process callers may substitute text streams.
3488
+ for stream in (sys.stdout, sys.stderr):
3489
+ if hasattr(stream, "reconfigure"):
3490
+ stream.reconfigure(encoding="utf-8", errors="replace")
2119
3491
  try:
2120
- event = json.load(sys.stdin)
3492
+ stdin = getattr(sys.stdin, "buffer", None)
3493
+ event = json.loads(stdin.read().decode("utf-8") if stdin is not None else sys.stdin.read())
2121
3494
  if not isinstance(event, dict):
2122
3495
  raise ValueError("hook event must be an object")
2123
3496
  evaluate(event)