@opengsd/gsd-path 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (398) hide show
  1. package/AGENTS.md +397 -0
  2. package/DOCS.md +341 -0
  3. package/FULL.md +475 -0
  4. package/GUIDE.md +23 -0
  5. package/HOOKS.md +273 -0
  6. package/LICENSE +21 -0
  7. package/MIGRATE.md +100 -0
  8. package/QUICK.md +146 -0
  9. package/README.md +280 -0
  10. package/RUNTIME.md +229 -0
  11. package/UPDATE.md +196 -0
  12. package/WORKFLOW.md +683 -0
  13. package/package.json +80 -0
  14. package/platforms/claude/dispatch.md +63 -0
  15. package/platforms/copilot/dispatch.md +61 -0
  16. package/platforms/cursor/agent.md +17 -0
  17. package/platforms/cursor/dispatch.md +63 -0
  18. package/platforms/grok/dispatch.md +61 -0
  19. package/platforms/kimi/dispatch.md +64 -0
  20. package/platforms/kiro/dispatch.md +58 -0
  21. package/platforms/opencode/dispatch.md +62 -0
  22. package/platforms/qwen/dispatch.md +60 -0
  23. package/platforms/shared-agents/dispatch.md +131 -0
  24. package/plugin.json +14 -0
  25. package/scripts/_common.py +180 -0
  26. package/scripts/archive_milestone.py +2265 -0
  27. package/scripts/bootstrap_repository.py +710 -0
  28. package/scripts/build_state.py +854 -0
  29. package/scripts/check_docs_audit.py +463 -0
  30. package/scripts/check_handoffs.py +1855 -0
  31. package/scripts/check_task_briefs.py +376 -0
  32. package/scripts/check_update.py +93 -0
  33. package/scripts/core_hook_gate.py +34 -0
  34. package/scripts/core_hook_settings.py +119 -0
  35. package/scripts/detect_project.py +1517 -0
  36. package/scripts/discussion_records.py +619 -0
  37. package/scripts/discussion_validate.py +1321 -0
  38. package/scripts/dispatch_driver.py +1812 -0
  39. package/scripts/git_guard.py +759 -0
  40. package/scripts/guard_hook.py +2124 -0
  41. package/scripts/install.mjs +3239 -0
  42. package/scripts/install.py +3008 -0
  43. package/scripts/integration.py +1433 -0
  44. package/scripts/isolation.py +3538 -0
  45. package/scripts/lean_verification.py +256 -0
  46. package/scripts/loop_run.py +837 -0
  47. package/scripts/migrate_core.py +169 -0
  48. package/scripts/pipeline_diagnose.py +715 -0
  49. package/scripts/pipeline_git.py +940 -0
  50. package/scripts/pipeline_state.py +2291 -0
  51. package/scripts/pipeline_undo.py +1039 -0
  52. package/scripts/promote_lookahead.py +415 -0
  53. package/scripts/review_findings.py +714 -0
  54. package/scripts/review_panel.py +553 -0
  55. package/scripts/skill-resources.json +340 -0
  56. package/scripts/state_checkpoint.py +1039 -0
  57. package/scripts/state_promote.py +743 -0
  58. package/scripts/status_runtime.py +122 -0
  59. package/scripts/sync_skill_resources.py +259 -0
  60. package/scripts/token_budget.py +146 -0
  61. package/scripts/wizard.mjs +179 -0
  62. package/scripts/workflow_run.py +117 -0
  63. package/skills/gsd-path/BUILD.md +700 -0
  64. package/skills/gsd-path/DECIDE.md +115 -0
  65. package/skills/gsd-path/DEFINE.md +297 -0
  66. package/skills/gsd-path/DOCS-AUDIT.md +190 -0
  67. package/skills/gsd-path/INSPECT.md +167 -0
  68. package/skills/gsd-path/PLAN.md +411 -0
  69. package/skills/gsd-path/RESEARCH.md +159 -0
  70. package/skills/gsd-path/ROADMAP.md +215 -0
  71. package/skills/gsd-path/SHIP.md +412 -0
  72. package/skills/gsd-path/SKILL.md +471 -0
  73. package/skills/gsd-path/agents/openai.yaml +6 -0
  74. package/skills/gsd-path/references/codebase-mapper.md +52 -0
  75. package/skills/gsd-path/references/coder.md +90 -0
  76. package/skills/gsd-path/references/decider.md +36 -0
  77. package/skills/gsd-path/references/dispatch.md +131 -0
  78. package/skills/gsd-path/references/docs-auditor.md +60 -0
  79. package/skills/gsd-path/references/planner.md +104 -0
  80. package/skills/gsd-path/references/researcher.md +42 -0
  81. package/skills/gsd-path/references/reviewer.md +188 -0
  82. package/skills/gsd-path/references/roadmapper.md +48 -0
  83. package/skills/gsd-path/scripts/_common.py +180 -0
  84. package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
  85. package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
  86. package/skills/gsd-path/scripts/build_state.py +854 -0
  87. package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
  88. package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
  89. package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
  90. package/skills/gsd-path/scripts/check_update.py +93 -0
  91. package/skills/gsd-path/scripts/detect_project.py +1517 -0
  92. package/skills/gsd-path/scripts/discussion_records.py +619 -0
  93. package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
  94. package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
  95. package/skills/gsd-path/scripts/integration.py +1433 -0
  96. package/skills/gsd-path/scripts/isolation.py +3538 -0
  97. package/skills/gsd-path/scripts/lean_verification.py +256 -0
  98. package/skills/gsd-path/scripts/loop_run.py +837 -0
  99. package/skills/gsd-path/scripts/pipeline_git.py +940 -0
  100. package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
  101. package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
  102. package/skills/gsd-path/scripts/review_findings.py +714 -0
  103. package/skills/gsd-path/scripts/review_panel.py +553 -0
  104. package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
  105. package/skills/gsd-path/scripts/state_promote.py +743 -0
  106. package/skills/gsd-path/scripts/token_budget.py +146 -0
  107. package/skills/gsd-path/scripts/workflow_run.py +117 -0
  108. package/skills/gsd-path/templates/answers.md +29 -0
  109. package/skills/gsd-path/templates/archive-manifest.md +37 -0
  110. package/skills/gsd-path/templates/charter.md +50 -0
  111. package/skills/gsd-path/templates/codebase.md +46 -0
  112. package/skills/gsd-path/templates/dialogue.md +21 -0
  113. package/skills/gsd-path/templates/docs-audit.md +69 -0
  114. package/skills/gsd-path/templates/evidence.md +24 -0
  115. package/skills/gsd-path/templates/final-review.md +27 -0
  116. package/skills/gsd-path/templates/gap-review.md +20 -0
  117. package/skills/gsd-path/templates/intent.md +98 -0
  118. package/skills/gsd-path/templates/loop.md +59 -0
  119. package/skills/gsd-path/templates/patch-findings.md +16 -0
  120. package/skills/gsd-path/templates/plan-panel.md +25 -0
  121. package/skills/gsd-path/templates/plan.md +89 -0
  122. package/skills/gsd-path/templates/repository.md +16 -0
  123. package/skills/gsd-path/templates/research-handoff.md +25 -0
  124. package/skills/gsd-path/templates/roadmap.md +38 -0
  125. package/skills/gsd-path/templates/skeptic.md +36 -0
  126. package/skills/gsd-path/templates/state.md +27 -0
  127. package/skills/gsd-path/templates/synthesis.md +36 -0
  128. package/skills/gsd-path/templates/task.md +74 -0
  129. package/skills/gsd-path/templates/wave-panel.md +25 -0
  130. package/skills/gsd-path/templates/wave-review.md +64 -0
  131. package/skills/gsd-path-build/SKILL.md +700 -0
  132. package/skills/gsd-path-build/agents/openai.yaml +6 -0
  133. package/skills/gsd-path-build/references/coder.md +90 -0
  134. package/skills/gsd-path-build/references/dispatch.md +131 -0
  135. package/skills/gsd-path-build/references/reviewer.md +188 -0
  136. package/skills/gsd-path-build/scripts/_common.py +180 -0
  137. package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
  138. package/skills/gsd-path-build/scripts/build_state.py +854 -0
  139. package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
  140. package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
  141. package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
  142. package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
  143. package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
  144. package/skills/gsd-path-build/scripts/integration.py +1433 -0
  145. package/skills/gsd-path-build/scripts/isolation.py +3538 -0
  146. package/skills/gsd-path-build/scripts/loop_run.py +837 -0
  147. package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
  148. package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
  149. package/skills/gsd-path-build/scripts/review_findings.py +714 -0
  150. package/skills/gsd-path-build/scripts/review_panel.py +553 -0
  151. package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
  152. package/skills/gsd-path-build/scripts/state_promote.py +743 -0
  153. package/skills/gsd-path-build/scripts/token_budget.py +146 -0
  154. package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
  155. package/skills/gsd-path-build/templates/skeptic.md +36 -0
  156. package/skills/gsd-path-build/templates/task.md +74 -0
  157. package/skills/gsd-path-build/templates/wave-panel.md +25 -0
  158. package/skills/gsd-path-build/templates/wave-review.md +64 -0
  159. package/skills/gsd-path-decide/SKILL.md +115 -0
  160. package/skills/gsd-path-decide/agents/openai.yaml +6 -0
  161. package/skills/gsd-path-decide/references/decider.md +36 -0
  162. package/skills/gsd-path-decide/references/dispatch.md +131 -0
  163. package/skills/gsd-path-decide/scripts/_common.py +180 -0
  164. package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
  165. package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
  166. package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
  167. package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
  168. package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
  169. package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
  170. package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
  171. package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
  172. package/skills/gsd-path-decide/templates/synthesis.md +36 -0
  173. package/skills/gsd-path-define/SKILL.md +297 -0
  174. package/skills/gsd-path-define/agents/openai.yaml +6 -0
  175. package/skills/gsd-path-define/scripts/_common.py +180 -0
  176. package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
  177. package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
  178. package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
  179. package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
  180. package/skills/gsd-path-define/scripts/isolation.py +3538 -0
  181. package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
  182. package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
  183. package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
  184. package/skills/gsd-path-define/scripts/state_promote.py +743 -0
  185. package/skills/gsd-path-define/templates/charter.md +50 -0
  186. package/skills/gsd-path-define/templates/intent.md +98 -0
  187. package/skills/gsd-path-define/templates/state.md +27 -0
  188. package/skills/gsd-path-discuss/SKILL.md +162 -0
  189. package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
  190. package/skills/gsd-path-discuss/scripts/_common.py +180 -0
  191. package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
  192. package/skills/gsd-path-discuss/templates/answers.md +29 -0
  193. package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
  194. package/skills/gsd-path-docs-audit/SKILL.md +190 -0
  195. package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
  196. package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
  197. package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
  198. package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
  199. package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
  200. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
  201. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
  202. package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
  203. package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
  204. package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
  205. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
  206. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
  207. package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
  208. package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
  209. package/skills/gsd-path-forensics/SKILL.md +39 -0
  210. package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
  211. package/skills/gsd-path-forensics/scripts/_common.py +180 -0
  212. package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
  213. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
  214. package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
  215. package/skills/gsd-path-inspect/SKILL.md +167 -0
  216. package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
  217. package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
  218. package/skills/gsd-path-inspect/references/dispatch.md +131 -0
  219. package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
  220. package/skills/gsd-path-inspect/scripts/_common.py +180 -0
  221. package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
  222. package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
  223. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
  224. package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
  225. package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
  226. package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
  227. package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
  228. package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
  229. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
  230. package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
  231. package/skills/gsd-path-inspect/templates/codebase.md +46 -0
  232. package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
  233. package/skills/gsd-path-inspect/templates/state.md +27 -0
  234. package/skills/gsd-path-loop/SKILL.md +98 -0
  235. package/skills/gsd-path-loop/agents/openai.yaml +6 -0
  236. package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
  237. package/skills/gsd-path-loop/scripts/_common.py +180 -0
  238. package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
  239. package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
  240. package/skills/gsd-path-loop/templates/loop.md +59 -0
  241. package/skills/gsd-path-migrate/SKILL.md +134 -0
  242. package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
  243. package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
  244. package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
  245. package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
  246. package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
  247. package/skills/gsd-path-plan/SKILL.md +411 -0
  248. package/skills/gsd-path-plan/agents/openai.yaml +6 -0
  249. package/skills/gsd-path-plan/references/dispatch.md +131 -0
  250. package/skills/gsd-path-plan/references/planner.md +104 -0
  251. package/skills/gsd-path-plan/scripts/_common.py +180 -0
  252. package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
  253. package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
  254. package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
  255. package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
  256. package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
  257. package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
  258. package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
  259. package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
  260. package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
  261. package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
  262. package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
  263. package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
  264. package/skills/gsd-path-plan/templates/plan.md +89 -0
  265. package/skills/gsd-path-plan/templates/task.md +74 -0
  266. package/skills/gsd-path-research/SKILL.md +159 -0
  267. package/skills/gsd-path-research/agents/openai.yaml +6 -0
  268. package/skills/gsd-path-research/references/dispatch.md +131 -0
  269. package/skills/gsd-path-research/references/researcher.md +42 -0
  270. package/skills/gsd-path-research/scripts/_common.py +180 -0
  271. package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
  272. package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
  273. package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
  274. package/skills/gsd-path-research/scripts/isolation.py +3538 -0
  275. package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
  276. package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
  277. package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
  278. package/skills/gsd-path-research/scripts/state_promote.py +743 -0
  279. package/skills/gsd-path-research/templates/evidence.md +24 -0
  280. package/skills/gsd-path-research/templates/research-handoff.md +25 -0
  281. package/skills/gsd-path-roadmap/SKILL.md +215 -0
  282. package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
  283. package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
  284. package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
  285. package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
  286. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
  287. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
  288. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
  289. package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
  290. package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
  291. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
  292. package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
  293. package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
  294. package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
  295. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
  296. package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
  297. package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
  298. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
  299. package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
  300. package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
  301. package/skills/gsd-path-ship/SKILL.md +412 -0
  302. package/skills/gsd-path-ship/agents/openai.yaml +6 -0
  303. package/skills/gsd-path-ship/references/dispatch.md +131 -0
  304. package/skills/gsd-path-ship/references/reviewer.md +188 -0
  305. package/skills/gsd-path-ship/scripts/_common.py +180 -0
  306. package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
  307. package/skills/gsd-path-ship/scripts/build_state.py +854 -0
  308. package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
  309. package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
  310. package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
  311. package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
  312. package/skills/gsd-path-ship/scripts/integration.py +1433 -0
  313. package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
  314. package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
  315. package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
  316. package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
  317. package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
  318. package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
  319. package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
  320. package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
  321. package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
  322. package/skills/gsd-path-ship/templates/final-review.md +27 -0
  323. package/skills/gsd-path-ship/templates/gap-review.md +20 -0
  324. package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
  325. package/skills/gsd-path-ship/templates/wave-review.md +64 -0
  326. package/skills/gsd-path-undo/SKILL.md +45 -0
  327. package/skills/gsd-path-undo/agents/openai.yaml +6 -0
  328. package/skills/gsd-path-undo/scripts/_common.py +180 -0
  329. package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
  330. package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
  331. package/skills/path/BUILD.md +700 -0
  332. package/skills/path/DECIDE.md +115 -0
  333. package/skills/path/DEFINE.md +297 -0
  334. package/skills/path/DOCS-AUDIT.md +190 -0
  335. package/skills/path/INSPECT.md +167 -0
  336. package/skills/path/PLAN.md +411 -0
  337. package/skills/path/RESEARCH.md +159 -0
  338. package/skills/path/ROADMAP.md +215 -0
  339. package/skills/path/SHIP.md +412 -0
  340. package/skills/path/SKILL.md +471 -0
  341. package/skills/path/agents/openai.yaml +6 -0
  342. package/skills/path/references/codebase-mapper.md +52 -0
  343. package/skills/path/references/coder.md +90 -0
  344. package/skills/path/references/decider.md +36 -0
  345. package/skills/path/references/dispatch.md +131 -0
  346. package/skills/path/references/docs-auditor.md +60 -0
  347. package/skills/path/references/planner.md +104 -0
  348. package/skills/path/references/researcher.md +42 -0
  349. package/skills/path/references/reviewer.md +188 -0
  350. package/skills/path/references/roadmapper.md +48 -0
  351. package/skills/path/scripts/_common.py +180 -0
  352. package/skills/path/scripts/archive_milestone.py +2265 -0
  353. package/skills/path/scripts/bootstrap_repository.py +710 -0
  354. package/skills/path/scripts/build_state.py +854 -0
  355. package/skills/path/scripts/check_docs_audit.py +463 -0
  356. package/skills/path/scripts/check_handoffs.py +1855 -0
  357. package/skills/path/scripts/check_task_briefs.py +376 -0
  358. package/skills/path/scripts/check_update.py +93 -0
  359. package/skills/path/scripts/detect_project.py +1517 -0
  360. package/skills/path/scripts/discussion_records.py +619 -0
  361. package/skills/path/scripts/discussion_validate.py +1321 -0
  362. package/skills/path/scripts/dispatch_driver.py +1812 -0
  363. package/skills/path/scripts/integration.py +1433 -0
  364. package/skills/path/scripts/isolation.py +3538 -0
  365. package/skills/path/scripts/lean_verification.py +256 -0
  366. package/skills/path/scripts/loop_run.py +837 -0
  367. package/skills/path/scripts/pipeline_git.py +940 -0
  368. package/skills/path/scripts/pipeline_state.py +2291 -0
  369. package/skills/path/scripts/promote_lookahead.py +415 -0
  370. package/skills/path/scripts/review_findings.py +714 -0
  371. package/skills/path/scripts/review_panel.py +553 -0
  372. package/skills/path/scripts/state_checkpoint.py +1039 -0
  373. package/skills/path/scripts/state_promote.py +743 -0
  374. package/skills/path/scripts/token_budget.py +146 -0
  375. package/skills/path/scripts/workflow_run.py +117 -0
  376. package/skills/path/templates/answers.md +29 -0
  377. package/skills/path/templates/archive-manifest.md +37 -0
  378. package/skills/path/templates/charter.md +50 -0
  379. package/skills/path/templates/codebase.md +46 -0
  380. package/skills/path/templates/dialogue.md +21 -0
  381. package/skills/path/templates/docs-audit.md +69 -0
  382. package/skills/path/templates/evidence.md +24 -0
  383. package/skills/path/templates/final-review.md +27 -0
  384. package/skills/path/templates/gap-review.md +20 -0
  385. package/skills/path/templates/intent.md +98 -0
  386. package/skills/path/templates/loop.md +59 -0
  387. package/skills/path/templates/patch-findings.md +16 -0
  388. package/skills/path/templates/plan-panel.md +25 -0
  389. package/skills/path/templates/plan.md +89 -0
  390. package/skills/path/templates/repository.md +16 -0
  391. package/skills/path/templates/research-handoff.md +25 -0
  392. package/skills/path/templates/roadmap.md +38 -0
  393. package/skills/path/templates/skeptic.md +36 -0
  394. package/skills/path/templates/state.md +27 -0
  395. package/skills/path/templates/synthesis.md +36 -0
  396. package/skills/path/templates/task.md +74 -0
  397. package/skills/path/templates/wave-panel.md +25 -0
  398. package/skills/path/templates/wave-review.md +64 -0
@@ -0,0 +1,2124 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-host pre-tool-use guard for GSD Path projects.
3
+
4
+ Reads one hook event as JSON on stdin and enforces the two pipeline
5
+ invariants a prompt contract cannot guarantee:
6
+
7
+ - committed archives under .project/archive/ are read-only (only the bundled
8
+ helpers pipeline_state.py and archive_milestone.py in the guard-owned runtime
9
+ directory may name that path in a single plain python or python3 command), and
10
+ - destructive git commands that break build recovery are refused.
11
+
12
+ Allow: exit 0 with no output, except Cursor (whose fail-closed hooks treat
13
+ empty stdout as a failure) also gets {"permission": "allow"} on stdout.
14
+ Deny: exit 2, a one-line reason on stderr,
15
+ and a denial JSON on stdout carrying every supported host's decision keys
16
+ (exit code 2 satisfies Claude Code, Codex, Qwen, Kimi, Grok, Cursor, and
17
+ Kiro; the JSON covers hosts that read a decision object instead).
18
+ Malformed input or an internal failure denies the tool call.
19
+ """
20
+
21
+ import ast
22
+ from fnmatch import fnmatchcase
23
+ from functools import lru_cache
24
+ import json
25
+ import os
26
+ import re
27
+ import shlex
28
+ import subprocess
29
+ import sys
30
+ from pathlib import Path, PurePosixPath
31
+
32
+ PATH_KEYS = frozenset(
33
+ {
34
+ "file_path",
35
+ "filepath",
36
+ "path",
37
+ "file",
38
+ "notebook_path",
39
+ "target_file",
40
+ "relative_path",
41
+ "target_notebook",
42
+ "notebook",
43
+ "directory",
44
+ }
45
+ )
46
+ MUTATION_OPERAND_KEYS = frozenset(
47
+ {
48
+ "destination",
49
+ "destination_directory",
50
+ "destination_file",
51
+ "destination_path",
52
+ "from",
53
+ "from_file",
54
+ "from_path",
55
+ "old_file",
56
+ "old_path",
57
+ "source",
58
+ "source_directory",
59
+ "source_file",
60
+ "source_notebook",
61
+ "source_path",
62
+ "target_path",
63
+ "to",
64
+ "to_file",
65
+ "to_path",
66
+ }
67
+ )
68
+ WORKING_DIRECTORY_KEYS = frozenset({"working_directory", "workdir", "cwd"})
69
+ COMMAND_KEYS = frozenset({"command", "cmd", "script"})
70
+ PATCH_KEYS = frozenset({"patch", "patch_body", "patch_text", "diff"})
71
+ PATCH_PATH_PATTERN = re.compile(
72
+ r"^\*\*\* (?:Add|Update|Delete) File: (.+)$|^\*\*\* Move to: (.+)$",
73
+ re.MULTILINE,
74
+ )
75
+ UNIFIED_DIFF_PATH_PATTERN = re.compile(
76
+ r"^(?:---|\+\+\+) ([^\t\r\n]+)$", re.MULTILINE
77
+ )
78
+ GIT_DIFF_PATH_PATTERN = re.compile(
79
+ r'^diff --git ("(?:\\.|[^"])*"|\S+) ("(?:\\.|[^"])*"|\S+)$',
80
+ re.MULTILINE,
81
+ )
82
+ GIT_RENAME_PATH_PATTERN = re.compile(
83
+ r"^(?:rename from|rename to) (.+)$", re.MULTILINE
84
+ )
85
+
86
+ # A tool skips path checks only when its name carries a read-only verb and
87
+ # no write-capable verb: `get_and_write` must still be path-checked.
88
+ READ_VERBS = frozenset(
89
+ {"read", "grep", "glob", "search", "view", "list", "get", "cat", "open"}
90
+ )
91
+ WRITE_VERBS = frozenset(
92
+ {
93
+ "write",
94
+ "save",
95
+ "copy",
96
+ "touch",
97
+ "edit",
98
+ "create",
99
+ "delete",
100
+ "remove",
101
+ "update",
102
+ "set",
103
+ "put",
104
+ "post",
105
+ "patch",
106
+ "move",
107
+ "rename",
108
+ "append",
109
+ "insert",
110
+ "replace",
111
+ }
112
+ )
113
+ AMBIGUOUS_WRITE_VERBS = frozenset({"create", "update", "set", "put", "post"})
114
+ DIRECT_FILE_WRITE_VERBS = WRITE_VERBS - AMBIGUOUS_WRITE_VERBS
115
+ FILE_TARGET_TOKENS = frozenset({"file", "path", "notebook"})
116
+ TOOL_TOKEN_PATTERN = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+")
117
+ SHELL_ASSIGNMENT_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
118
+
119
+ ARCHIVE_REASON = (
120
+ "committed GSD Path archives under .project/archive/ are read-only; "
121
+ "only the bundled pipeline helpers may write there during a ship transaction"
122
+ )
123
+ DESTRUCTIVE_SHAPE_REASON = (
124
+ "destructive commands must run alone with literal paths; split compound, "
125
+ "substituted, or expanded destructive commands into single commands"
126
+ )
127
+ ARCHIVE_MARKER = ".project/archive"
128
+ INVALID_INPUT_REASON = "GSD Path guard could not validate the tool request"
129
+ CONTROL_PATHS = ".git, .project/STATE.md, .project/next, .gsd-path"
130
+ PROTECTED_SHELL_WRITE_REASON = (
131
+ f"GSD Path routing controls ({CONTROL_PATHS}) change only through the "
132
+ "pipeline helpers; the shell command writes"
133
+ )
134
+ REENTRY_FAILURE_REASON = (
135
+ "GSD Path status could not be verified; review .project/STATE.md and invoke "
136
+ "gsd-path-forensics before changing product files"
137
+ )
138
+ CONTROL_FILE_REASON = (
139
+ "GSD Path routing controls cannot be changed by direct write, edit, or patch "
140
+ "tools; use the pipeline helpers (pipeline_state.py) for"
141
+ )
142
+ STATUS_ACTIONS = frozenset(
143
+ {
144
+ "bind-initial",
145
+ "block",
146
+ "resume-checkpoint",
147
+ "resume-next-handoff",
148
+ "resume-promotion",
149
+ "resume-shipment",
150
+ "resume-undo",
151
+ "run-phase",
152
+ "validate-integrated",
153
+ "wait",
154
+ }
155
+ )
156
+ STATUS_PHASES = frozenset(
157
+ {
158
+ "inspect",
159
+ "define",
160
+ "research",
161
+ "decide",
162
+ "roadmap",
163
+ "plan",
164
+ "build",
165
+ "ship",
166
+ "shipped",
167
+ }
168
+ )
169
+ STATUS_VALUES = frozenset({"active", "done", "blocked"})
170
+ STATUS_STATE_FIELDS = frozenset(
171
+ {
172
+ "pipeline",
173
+ "project",
174
+ "milestone",
175
+ "phase",
176
+ "status",
177
+ "branch",
178
+ "archive",
179
+ "integration_default",
180
+ "integration",
181
+ "integration_source",
182
+ }
183
+ )
184
+ STATUS_INTEGRATION_MODES = frozenset({"direct", "pull-request"})
185
+ STATUS_INTEGRATION_SOURCES = frozenset({"default", "milestone"})
186
+ STATUS_SLUG = re.compile(r"^[a-z0-9][a-z0-9-]*$")
187
+ STATUS_BRANCH = re.compile(r"^gsd-path/M(\d{3,})$")
188
+ STATUS_ARCHIVE = re.compile(r"^\.project/archive/(\d{3,})-([a-z0-9][a-z0-9-]*)/?$")
189
+ STATUS_TRANSITIONS = {
190
+ "inspect": frozenset({"inspect", "define"}),
191
+ "define": frozenset({"define", "research", "plan"}),
192
+ "research": frozenset({"research", "decide"}),
193
+ "decide": frozenset({"decide", "roadmap", "plan"}),
194
+ "roadmap": frozenset({"roadmap", "define"}),
195
+ "plan": frozenset({"plan", "build"}),
196
+ "build": frozenset({"build"}),
197
+ "ship": frozenset({"ship", "plan"}),
198
+ "shipped": frozenset({"ship"}),
199
+ }
200
+ ARCHIVE_REFERENCE = re.compile(r"(?i:\.project[\\/]archive)")
201
+ ARCHIVE_READ_COMMANDS = frozenset(
202
+ {
203
+ "cat",
204
+ "head",
205
+ "tail",
206
+ "grep",
207
+ "rg",
208
+ "ls",
209
+ "stat",
210
+ "wc",
211
+ "file",
212
+ "readlink",
213
+ "realpath",
214
+ "test",
215
+ "get-content",
216
+ "get-childitem",
217
+ "get-item",
218
+ "get-acl",
219
+ "select-string",
220
+ "test-path",
221
+ }
222
+ )
223
+ ARCHIVE_READ_GIT_COMMANDS = frozenset({"status", "diff", "log", "show", "ls-files"})
224
+ CLOSED_READ_GIT_COMMANDS = ARCHIVE_READ_GIT_COMMANDS | {
225
+ "rev-parse", "ls-remote", "ls-tree", "show-ref", "merge-base",
226
+ }
227
+ # Subcommands that also have write forms: only these listing options keep them read-only.
228
+ CLOSED_LISTING_GIT_OPTIONS = {
229
+ "cat-file": frozenset({"-p", "-t", "-s", "-e"}),
230
+ "branch": frozenset({"-a", "--all", "-r", "--remotes", "-v", "-vv", "--verbose", "-l", "--list", "--show-current", "--no-color"}),
231
+ "worktree list": frozenset({"--porcelain", "-v", "--verbose", "-z"}),
232
+ "symbolic-ref": frozenset({"-q", "--quiet", "--short"}),
233
+ }
234
+ GIT_READ_WRITE_OPTIONS = frozenset({"--output", "--ext-diff", "--textconv"})
235
+ ARCHIVE_READ_EXECUTION_OPTIONS = {"rg": frozenset({"--pre"})}
236
+ AMBIGUOUS_SHELL_SYNTAX = re.compile(r"[\r\n|;&<>`]|\$\(|@\(")
237
+ SUBSTITUTION_PLACEHOLDER = "COMMAND_SUBSTITUTION_"
238
+ SUBSTITUTION_PLACEHOLDER_SYNTAX = re.compile(
239
+ r"\$\{" + SUBSTITUTION_PLACEHOLDER + r"(\d+)\}"
240
+ )
241
+ 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
+ SHELL_WRITE_REDIRECTION_CHARS = frozenset("<>&|")
247
+ SHELL_PARAMETER_SYNTAX = re.compile(
248
+ r"\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+|[-*@#?$!]|\{[^}\r\n]+\})"
249
+ )
250
+ NAMED_SHELL_PARAMETER_SYNTAX = re.compile(
251
+ r"\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)\})"
252
+ )
253
+ CMD_PARAMETER_SYNTAX = re.compile(
254
+ r"%([A-Za-z_][A-Za-z0-9_]*)%|!([A-Za-z_][A-Za-z0-9_]*)!"
255
+ )
256
+ DIRECTORY_CHANGE_COMMANDS = frozenset(
257
+ {"cd", "chdir", "pushd", "set-location", "sl"}
258
+ )
259
+ GIT_REASONS = {
260
+ "reset": "git reset --hard discards work the build recovery protocol needs",
261
+ "clean": "git clean -f deletes untracked evidence and retained task worktrees",
262
+ "push": "force pushes rewrite build-branch history the pipeline resumes from",
263
+ "branch": "git branch -D destroys task branches the recovery protocol inspects",
264
+ "branch-move": (
265
+ "git branch -m renames a branch the pipeline resumes by name "
266
+ "(STATE.branch); create a new branch instead"
267
+ ),
268
+ "update-ref": "git update-ref deletion destroys refs the recovery protocol inspects",
269
+ "worktree": (
270
+ "git worktree remove --force discards uncommitted task work the recovery "
271
+ "protocol needs; commit or stash it, then remove without --force"
272
+ ),
273
+ "stash": (
274
+ "git stash drop and git stash clear destroy stashed work the recovery "
275
+ "protocol may need; apply or keep the stash"
276
+ ),
277
+ "checkout": (
278
+ "git checkout -- . and git restore . discard every uncommitted change; "
279
+ "name the files to restore instead"
280
+ ),
281
+ }
282
+ WHOLE_TREE_PATHSPECS = frozenset({".", "./", ":/"})
283
+ GIT_GLOBAL_OPTIONS_WITH_VALUES = frozenset(
284
+ {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"}
285
+ )
286
+ ENV_OPTIONS_WITH_VALUES = frozenset({"-C", "-u", "--chdir", "--unset"})
287
+ ENV_OPTIONS_WITHOUT_VALUES = frozenset(
288
+ {"-0", "-i", "-v", "--debug", "--ignore-environment", "--null"}
289
+ )
290
+ POSIX_SHELL_WRAPPERS = frozenset({"bash", "dash", "fish", "ksh", "sh", "zsh"})
291
+ POWERSHELL_WRAPPERS = frozenset(
292
+ {"powershell", "powershell.exe", "pwsh", "pwsh.exe"}
293
+ )
294
+ COMMAND_WRAPPERS = frozenset({"builtin", "call", "command", "exec"})
295
+ SCRIPT_FILE_REASON = (
296
+ "{executable} runs the script file {argument}, which the guard cannot "
297
+ "inspect; run its commands directly"
298
+ )
299
+ ENV_BUILTIN_REASON = "{executable} cannot be validated; assign with NAME=VALUE instead"
300
+ SHELL_FILE_REASON = (
301
+ "{executable} reads commands from a file or stdin the guard cannot inspect; "
302
+ "run the commands directly or use {executable} -c '<commands>'"
303
+ )
304
+ MISSING_COMMAND_STRING_REASON = "{executable} {option} lacks a command string"
305
+ UNVALIDATED_EXECUTION_REASONS = {
306
+ ".": SCRIPT_FILE_REASON,
307
+ "source": SCRIPT_FILE_REASON,
308
+ "start": "start launches a detached process the guard cannot inspect; run the program directly",
309
+ "setenv": ENV_BUILTIN_REASON,
310
+ "unsetenv": ENV_BUILTIN_REASON,
311
+ }
312
+ VARIABLE_COMMANDS = frozenset(
313
+ {"declare", "export", "local", "readonly", "set", "typeset", "unset"}
314
+ )
315
+ XARGS_COMMANDS = frozenset({"xargs", "xargs.exe"})
316
+ XARGS_OPTIONS_WITH_VALUES = frozenset(
317
+ {
318
+ "-I",
319
+ "-J",
320
+ "-L",
321
+ "-n",
322
+ "-P",
323
+ "-R",
324
+ "-S",
325
+ "-s",
326
+ "-E",
327
+ "-d",
328
+ "-a",
329
+ "-l",
330
+ "--replace",
331
+ "--max-args",
332
+ "--max-lines",
333
+ "--max-procs",
334
+ "--max-chars",
335
+ "--delimiter",
336
+ "--eof",
337
+ "--arg-file",
338
+ "--process-slot-var",
339
+ }
340
+ )
341
+ DESTRUCTIVE_SHELL_COMMANDS = frozenset(
342
+ {
343
+ "del",
344
+ "erase",
345
+ "mi",
346
+ "move",
347
+ "move-item",
348
+ "mv",
349
+ "rd",
350
+ "remove-item",
351
+ "ri",
352
+ "rm",
353
+ "rmdir",
354
+ "rsync",
355
+ "shred",
356
+ "trash",
357
+ "unlink",
358
+ }
359
+ )
360
+ SHELL_WRITE_COMMANDS = DESTRUCTIVE_SHELL_COMMANDS | frozenset(
361
+ {
362
+ "cp",
363
+ "install",
364
+ "ln",
365
+ "mkdir",
366
+ "tee",
367
+ "touch",
368
+ "truncate",
369
+ }
370
+ )
371
+ IN_PLACE_EDITORS = frozenset({"perl", "sed"})
372
+ FIND_EXECUTION_ACTIONS = frozenset({"-exec", "-execdir", "-ok", "-okdir"})
373
+ SHELL_CONTROL_WORDS = frozenset(
374
+ {
375
+ "!",
376
+ "case",
377
+ "coproc",
378
+ "do",
379
+ "done",
380
+ "elif",
381
+ "else",
382
+ "esac",
383
+ "fi",
384
+ "for",
385
+ "function",
386
+ "if",
387
+ "in",
388
+ "select",
389
+ "then",
390
+ "time",
391
+ "until",
392
+ "while",
393
+ "{",
394
+ "}",
395
+ }
396
+ )
397
+
398
+
399
+ def collect(
400
+ node,
401
+ paths,
402
+ working_directories,
403
+ commands,
404
+ patch_payloads,
405
+ path_keys=PATH_KEYS,
406
+ inherited_working_directories=(),
407
+ ):
408
+ if isinstance(node, dict):
409
+ local_working_directories = []
410
+ for key, value in node.items():
411
+ if key.lower() not in WORKING_DIRECTORY_KEYS:
412
+ continue
413
+ # Cursor sends "cwd": "" when it has no directory; treat that as absent.
414
+ if isinstance(value, str):
415
+ if value:
416
+ local_working_directories.append(value)
417
+ elif isinstance(value, list):
418
+ strings = [item for item in value if isinstance(item, str)]
419
+ if len(strings) != len(value):
420
+ raise ValueError("working directories cannot be validated")
421
+ local_working_directories.extend(item for item in strings if item)
422
+ if len(local_working_directories) > 1:
423
+ raise ValueError("working directory is ambiguous")
424
+ working_directories.extend(local_working_directories)
425
+ context = tuple(local_working_directories) or inherited_working_directories
426
+ for key, value in node.items():
427
+ lowered = key.lower()
428
+ if isinstance(value, str):
429
+ if lowered in path_keys:
430
+ paths.append((value, context))
431
+ elif lowered in COMMAND_KEYS:
432
+ commands.append((value, context))
433
+ elif lowered in PATCH_KEYS:
434
+ patch_payloads.append((value, context))
435
+ elif isinstance(value, list):
436
+ strings = [item for item in value if isinstance(item, str)]
437
+ if strings and lowered in path_keys:
438
+ paths.extend((item, context) for item in strings)
439
+ elif lowered in COMMAND_KEYS:
440
+ if not strings or len(strings) != len(value):
441
+ raise ValueError("command argv cannot be validated")
442
+ commands.append((shlex.join(strings), context))
443
+ elif strings and lowered in PATCH_KEYS:
444
+ patch_payloads.extend((item, context) for item in strings)
445
+ collect(
446
+ value,
447
+ paths,
448
+ working_directories,
449
+ commands,
450
+ patch_payloads,
451
+ path_keys,
452
+ context,
453
+ )
454
+ else:
455
+ collect(
456
+ value,
457
+ paths,
458
+ working_directories,
459
+ commands,
460
+ patch_payloads,
461
+ path_keys,
462
+ context,
463
+ )
464
+ elif isinstance(node, list):
465
+ for value in node:
466
+ collect(
467
+ value,
468
+ paths,
469
+ working_directories,
470
+ commands,
471
+ patch_payloads,
472
+ path_keys,
473
+ inherited_working_directories,
474
+ )
475
+
476
+
477
+ def normalize_posix(path):
478
+ text = path.replace("\\", "/").strip()
479
+ if not text:
480
+ return "/"
481
+ parts = []
482
+ for part in PurePosixPath(text).parts:
483
+ if part == "..":
484
+ if parts:
485
+ parts.pop()
486
+ elif part != ".":
487
+ parts.append(part)
488
+ if not parts:
489
+ return "/"
490
+ return "/" + "/".join(parts)
491
+
492
+
493
+ def tool_tokens(tool):
494
+ return {token.casefold() for token in TOOL_TOKEN_PATTERN.findall(tool)}
495
+
496
+
497
+ def is_read_tool(tool):
498
+ tokens = tool_tokens(tool)
499
+ return bool(tokens & READ_VERBS) and not tokens & WRITE_VERBS
500
+
501
+
502
+ def is_patch_tool(tool):
503
+ return "patch" in tool_tokens(tool)
504
+
505
+
506
+ def has_patch_headers(payload):
507
+ return bool(
508
+ PATCH_PATH_PATTERN.search(payload)
509
+ or UNIFIED_DIFF_PATH_PATTERN.search(payload)
510
+ or GIT_DIFF_PATH_PATTERN.search(payload)
511
+ or GIT_RENAME_PATH_PATTERN.search(payload)
512
+ )
513
+
514
+
515
+ def is_direct_write_tool(tool, has_file_targets=False):
516
+ if has_file_targets:
517
+ return not is_read_tool(tool)
518
+ tokens = tool_tokens(tool)
519
+ if tokens & DIRECT_FILE_WRITE_VERBS:
520
+ return (
521
+ len(tokens) == 1
522
+ or bool(tokens & FILE_TARGET_TOKENS)
523
+ or has_file_targets
524
+ )
525
+ if tokens == {"create"}:
526
+ return True
527
+ return bool(
528
+ (tokens & AMBIGUOUS_WRITE_VERBS)
529
+ and ((tokens & FILE_TARGET_TOKENS) or has_file_targets)
530
+ )
531
+
532
+
533
+ @lru_cache(maxsize=None)
534
+ def repository_root():
535
+ candidate = Path(__file__).resolve().parent.parent
536
+ result = subprocess.run(
537
+ ["git", "rev-parse", "--show-toplevel"],
538
+ cwd=candidate,
539
+ text=True,
540
+ capture_output=True,
541
+ check=False,
542
+ )
543
+ if result.returncode != 0 or not result.stdout.strip():
544
+ state = candidate / ".project" / "STATE.md"
545
+ if (
546
+ candidate.is_dir()
547
+ and not candidate.is_symlink()
548
+ and os.path.lexists(state)
549
+ ):
550
+ return candidate
551
+ raise ValueError("repository root cannot be resolved")
552
+ return Path(result.stdout.strip()).resolve()
553
+
554
+
555
+ def project_status(repo):
556
+ script_root = Path(__file__).resolve().parent
557
+ launcher = script_root / "status_runtime.py"
558
+ if not launcher.is_file():
559
+ raise ValueError("project status runtime is unavailable")
560
+ result = subprocess.run(
561
+ [sys.executable, "-B", str(launcher), "--repo", str(repo)],
562
+ cwd=repo,
563
+ text=True,
564
+ capture_output=True,
565
+ check=False,
566
+ )
567
+ if result.returncode != 0:
568
+ raise ValueError("project status failed")
569
+ payload = json.loads(result.stdout)
570
+ if not valid_status_payload(payload, repo):
571
+ raise ValueError("project status returned an invalid payload")
572
+ return payload
573
+
574
+
575
+ def valid_status_payload(payload, repo):
576
+ if not isinstance(payload, dict) or payload.get("schema") != "gsd-path/status/v1":
577
+ return False
578
+ state = payload.get("state")
579
+ route = payload.get("route")
580
+ status_path = payload.get("path")
581
+ next_skill = payload.get("next_skill")
582
+ if (
583
+ payload.get("advance") is not False
584
+ or not isinstance(state, dict)
585
+ or not valid_status_state(state)
586
+ or not isinstance(route, dict)
587
+ or route.get("action") not in STATUS_ACTIONS
588
+ or not isinstance(route.get("reason"), str)
589
+ or not route["reason"]
590
+ or not isinstance(status_path, str)
591
+ or not Path(status_path).is_absolute()
592
+ or Path(status_path).resolve(strict=False)
593
+ != (repo / ".project" / "STATE.md").resolve(strict=False)
594
+ ):
595
+ return False
596
+ action = route["action"]
597
+ if action == "run-phase":
598
+ phase = route.get("phase")
599
+ return (
600
+ isinstance(phase, str)
601
+ and phase in STATUS_TRANSITIONS[state["phase"]]
602
+ and state["branch"] is not None
603
+ and next_skill == f"gsd-path-{phase}"
604
+ )
605
+ if "phase" in route:
606
+ return False
607
+ if action == "bind-initial":
608
+ return (
609
+ state["branch"] is None
610
+ and isinstance(route.get("branch"), str)
611
+ and valid_status_branch(route["branch"]) is not None
612
+ and next_skill == "gsd-path"
613
+ )
614
+ if state["branch"] is None:
615
+ return False
616
+ if action == "validate-integrated" and not (
617
+ state["phase"] == "shipped" and state["status"] == "done"
618
+ ):
619
+ return False
620
+ if action == "wait" and not (
621
+ state["phase"] == "plan" and state["status"] == "done"
622
+ ):
623
+ return False
624
+ if action == "resume-undo":
625
+ expected = "gsd-path-undo"
626
+ elif action == "wait":
627
+ expected = None
628
+ else:
629
+ expected = "gsd-path"
630
+ return next_skill == expected
631
+
632
+
633
+ def valid_status_state(state):
634
+ if set(state) != STATUS_STATE_FIELDS:
635
+ return False
636
+ milestone = state["milestone"]
637
+ branch = state["branch"]
638
+ archive = state["archive"]
639
+ integration_default = state["integration_default"]
640
+ integration = state["integration"]
641
+ integration_source = state["integration_source"]
642
+ archive_match = (
643
+ STATUS_ARCHIVE.fullmatch(archive or "")
644
+ if isinstance(archive, (str, type(None)))
645
+ else None
646
+ )
647
+ if (
648
+ state["pipeline"] != "gsd-path/v2"
649
+ or not isinstance(state["project"], str)
650
+ or STATUS_SLUG.fullmatch(state["project"]) is None
651
+ or not (
652
+ milestone is None
653
+ or isinstance(milestone, str)
654
+ and STATUS_SLUG.fullmatch(milestone) is not None
655
+ )
656
+ or not isinstance(state["phase"], str)
657
+ or state["phase"] not in STATUS_PHASES
658
+ or not isinstance(state["status"], str)
659
+ or state["status"] not in STATUS_VALUES
660
+ or integration_default not in STATUS_INTEGRATION_MODES
661
+ or integration not in STATUS_INTEGRATION_MODES
662
+ or integration_source not in STATUS_INTEGRATION_SOURCES
663
+ or (
664
+ integration_source == "default"
665
+ and integration != integration_default
666
+ )
667
+ or not (
668
+ branch is None
669
+ or isinstance(branch, str)
670
+ and valid_status_branch(branch) is not None
671
+ )
672
+ or not (archive is None or archive_match is not None)
673
+ ):
674
+ return False
675
+ if state["phase"] == "shipped" and (
676
+ state["status"] != "done" or archive is None
677
+ ):
678
+ return False
679
+ if archive is not None and state["phase"] not in {"build", "ship", "shipped"}:
680
+ return False
681
+ if state["phase"] in {"ship", "shipped"} and branch is None:
682
+ return False
683
+ if archive_match is not None:
684
+ branch_match = valid_status_branch(branch)
685
+ return (
686
+ milestone == archive_match.group(2)
687
+ and branch_match is not None
688
+ and int(branch_match.group(1)) == int(archive_match.group(1))
689
+ )
690
+ return True
691
+
692
+
693
+ def valid_status_branch(value):
694
+ match = STATUS_BRANCH.fullmatch(value) if isinstance(value, str) else None
695
+ return match if match is not None and int(match.group(1)) >= 1 else None
696
+
697
+
698
+ def target_paths(path, working_directories, repo):
699
+ repo = repo.resolve()
700
+ candidate = Path(path)
701
+ if not candidate.is_absolute():
702
+ base = Path(working_directories[0]) if working_directories else repo
703
+ if not base.is_absolute():
704
+ base = repo / base
705
+ candidate = base / candidate
706
+ return Path(os.path.abspath(candidate)), candidate.resolve(strict=False)
707
+
708
+
709
+ @lru_cache(maxsize=None)
710
+ def repository_control_roots(repo):
711
+ roots = [(repo / ".git").resolve(strict=False)]
712
+ result = subprocess.run(
713
+ ["git", "rev-parse", "--git-common-dir"],
714
+ cwd=repo,
715
+ text=True,
716
+ capture_output=True,
717
+ env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
718
+ check=False,
719
+ )
720
+ if result.returncode == 0 and result.stdout.strip():
721
+ common = Path(result.stdout.strip())
722
+ if not common.is_absolute():
723
+ common = repo / common
724
+ roots.append(common.resolve(strict=False))
725
+ return tuple(roots)
726
+
727
+
728
+ def _same_existing_path(left, right):
729
+ try:
730
+ return os.path.samefile(left, right)
731
+ except OSError:
732
+ return False
733
+
734
+
735
+ def _within_existing_root(candidate, root):
736
+ return any(
737
+ _same_existing_path(ancestor, root)
738
+ for ancestor in (candidate, *candidate.parents)
739
+ )
740
+
741
+
742
+ def _canonical_control_alias(candidate, repo, control_roots):
743
+ roots = (*control_roots, repo / ".project", repo / ".gsd-path")
744
+ suffix = []
745
+ current = candidate
746
+ while current != current.parent:
747
+ for root in roots:
748
+ if _same_existing_path(current, root):
749
+ return root.joinpath(*reversed(suffix))
750
+ suffix.append(current.name)
751
+ current = current.parent
752
+ return candidate
753
+
754
+
755
+ def path_kind(candidate, repo, control_roots=()):
756
+ if any(
757
+ candidate == root
758
+ or root in candidate.parents
759
+ or _within_existing_root(candidate, root)
760
+ for root in control_roots
761
+ ):
762
+ return "protected"
763
+ if candidate != repo and repo not in candidate.parents:
764
+ return "external"
765
+ relative = candidate.relative_to(repo)
766
+ parts = relative.parts
767
+ if parts in {
768
+ (".project",),
769
+ (".project", "STATE.md"),
770
+ (".project", "next"),
771
+ (".project", "next", "STATE.md"),
772
+ }:
773
+ return "protected"
774
+ protected_paths = (
775
+ repo / ".project",
776
+ repo / ".project" / "STATE.md",
777
+ repo / ".project" / "next",
778
+ repo / ".project" / "next" / "STATE.md",
779
+ )
780
+ if any(_same_existing_path(candidate, path) for path in protected_paths):
781
+ return "protected"
782
+ managed_root = repo / ".gsd-path"
783
+ if (parts and parts[0] == ".gsd-path") or _within_existing_root(
784
+ candidate, managed_root
785
+ ):
786
+ return "protected"
787
+ if parts and parts[0] == ".project":
788
+ return "artifact"
789
+ return "product"
790
+
791
+
792
+ def target_kind(path, working_directories, repo, control_roots=None):
793
+ lexical, resolved = target_paths(path, working_directories, repo)
794
+ repo = repo.resolve()
795
+ if control_roots is None:
796
+ control_roots = repository_control_roots(repo)
797
+ aliases = (
798
+ _canonical_control_alias(lexical, repo, control_roots),
799
+ _canonical_control_alias(resolved, repo, control_roots),
800
+ )
801
+ kinds = {
802
+ path_kind(candidate, repo, control_roots)
803
+ for candidate in (lexical, resolved, *aliases)
804
+ }
805
+ for kind in ("protected", "product", "artifact", "external"):
806
+ if kind in kinds:
807
+ return kind
808
+ return "external"
809
+
810
+
811
+ def pipeline_target_kinds(targets):
812
+ """Classify (path, working_directories) pairs; None when no pipeline is owned."""
813
+ repo = repository_root()
814
+ state = repo / ".project" / "STATE.md"
815
+ if not os.path.lexists(state):
816
+ return None
817
+ control_roots = repository_control_roots(repo)
818
+ return repo, state, [
819
+ (path, target_kind(path, directories, repo, control_roots))
820
+ for path, directories in targets
821
+ ]
822
+
823
+
824
+ def closed_target_reason(targets):
825
+ for path, working_directories in targets:
826
+ 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
828
+ while not directory.exists() and directory != directory.parent:
829
+ directory = directory.parent
830
+ root = subprocess.run(
831
+ ["git", "-C", str(directory), "rev-parse", "--show-toplevel"],
832
+ capture_output=True, text=True,
833
+ )
834
+ if root.returncode == 0:
835
+ reason = closed_milestone_reason(Path(root.stdout.strip()))
836
+ if reason:
837
+ return reason
838
+ return None
839
+
840
+
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)
846
+ if classified is None:
847
+ return
848
+ repo, state, kinds = classified
849
+ for path, kind in kinds:
850
+ 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
855
+ try:
856
+ status = project_status(repo)
857
+ except (OSError, ValueError, json.JSONDecodeError):
858
+ deny(REENTRY_FAILURE_REASON)
859
+ route = status.get("route")
860
+ state_data = status.get("state") if isinstance(status.get("state"), dict) else {}
861
+ routed_build = (
862
+ state_data.get("phase") == "build"
863
+ and isinstance(route, dict)
864
+ and route.get("action") == "run-phase"
865
+ and route.get("phase") == "build"
866
+ )
867
+ if routed_build:
868
+ return
869
+ if all(kind in {"external", "artifact"} for kind in kinds):
870
+ return
871
+ phase = state_data.get("phase", "unknown")
872
+ if isinstance(route, dict) and route.get("action") != "run-phase":
873
+ next_step = f"{route.get('action', 'route')}: {route.get('reason', 'no reason')}"
874
+ else:
875
+ next_step = status.get("next_skill") or "gsd-path"
876
+ deny(
877
+ f"GSD Path is {phase}; direct product-file changes require the routed "
878
+ f"build phase. Review {status.get('path', state)}; next: {next_step}"
879
+ )
880
+
881
+
882
+ def in_archive(path):
883
+ # Casefold: APFS and Windows resolve `.Project/Archive` to the archive.
884
+ normalized = normalize_posix(path).casefold()
885
+ marker = "/" + ARCHIVE_MARKER
886
+ if marker not in normalized:
887
+ return False
888
+ idx = normalized.index(marker)
889
+ suffix = normalized[idx + len(marker):]
890
+ return suffix == "" or suffix.startswith("/")
891
+
892
+
893
+ def component_can_match(pattern, target):
894
+ match = re.search(r"\{([^{}]+)\}", pattern)
895
+ if match is not None:
896
+ return any(
897
+ component_can_match(
898
+ pattern[: match.start()] + option + pattern[match.end() :], target
899
+ )
900
+ for option in match.group(1).split(",")
901
+ )
902
+ return fnmatchcase(target, pattern)
903
+
904
+
905
+ def expansion_can_match_archive(path):
906
+ components = [
907
+ component.casefold()
908
+ for component in PurePosixPath(normalize_posix(path)).parts
909
+ if component != "/"
910
+ ]
911
+ return any(
912
+ component_can_match(components[index], ".project")
913
+ and component_can_match(components[index + 1], "archive")
914
+ for index in range(len(components) - 1)
915
+ )
916
+
917
+
918
+ def path_values(value):
919
+ yield value
920
+ if value.startswith("-") and "=" in value:
921
+ yield value.split("=", 1)[1]
922
+
923
+
924
+ def is_absolute_path(path):
925
+ normalized = path.replace("\\", "/")
926
+ return normalized.startswith("/") or bool(re.match(r"^[A-Za-z]:/", normalized))
927
+
928
+
929
+ def contains_existing_archive(path, working_directories):
930
+ try:
931
+ target = Path(path).resolve(strict=False)
932
+ except (OSError, RuntimeError):
933
+ return False
934
+ search_paths = [target, Path.cwd(), *(Path(value) for value in working_directories)]
935
+ for search_path in search_paths:
936
+ if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", str(search_path)):
937
+ continue
938
+ try:
939
+ resolved = search_path.resolve(strict=False)
940
+ except (OSError, RuntimeError):
941
+ continue
942
+ for parent in (resolved, *resolved.parents):
943
+ archive = parent / ".project" / "archive"
944
+ if not archive.is_dir():
945
+ continue
946
+ try:
947
+ archive = archive.resolve(strict=True)
948
+ except (OSError, RuntimeError):
949
+ continue
950
+ if target == archive or target in archive.parents:
951
+ return True
952
+ return False
953
+
954
+
955
+ def path_in_archive(path, working_directories=(), ancestors=True):
956
+ """True when path is inside an existing archive, or (ancestors=True) contains one."""
957
+ for value in path_values(path):
958
+ if in_archive(value) or expansion_can_match_archive(value):
959
+ return True
960
+ bases = [""] if is_absolute_path(value) else list(working_directories) or [""]
961
+ for working_directory in bases:
962
+ combined = (
963
+ value
964
+ if is_absolute_path(value) or not working_directory
965
+ else f"{working_directory}/{value}"
966
+ )
967
+ if in_archive(combined) or expansion_can_match_archive(combined):
968
+ return True
969
+ if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", combined):
970
+ continue
971
+ try:
972
+ resolved = Path(combined).resolve(strict=False)
973
+ except (OSError, RuntimeError):
974
+ continue
975
+ if in_archive(resolved.as_posix()) or (
976
+ ancestors and contains_existing_archive(resolved, working_directories)
977
+ ):
978
+ return True
979
+ return False
980
+
981
+
982
+ def working_directory_in_archive(path):
983
+ """A working directory is archive context only when it is inside an archive.
984
+
985
+ The repository root merely contains .project/archive/ once a milestone has
986
+ shipped; treating it as archive context would deny every shell command in
987
+ the project. Ancestor containment stays with path_in_archive for operands,
988
+ so deleting the archive's parent is still refused.
989
+ """
990
+ for value in path_values(path):
991
+ if in_archive(value) or expansion_can_match_archive(value):
992
+ return True
993
+ if os.name != "nt" and re.match(r"^[A-Za-z]:[/\\]", value):
994
+ continue
995
+ try:
996
+ resolved = Path(value).resolve(strict=False)
997
+ except (OSError, RuntimeError):
998
+ continue
999
+ if in_archive(resolved.as_posix()):
1000
+ return True
1001
+ return False
1002
+
1003
+
1004
+ def is_redirection(token):
1005
+ return bool(token) and set(token) <= {"<", ">"}
1006
+
1007
+
1008
+ def directory_change_target(arguments):
1009
+ operands = []
1010
+ for index, argument in enumerate(arguments):
1011
+ if is_redirection(argument):
1012
+ break
1013
+ if (
1014
+ argument.isdigit()
1015
+ and index + 1 < len(arguments)
1016
+ and is_redirection(arguments[index + 1])
1017
+ ):
1018
+ break
1019
+ if (
1020
+ argument != "--"
1021
+ and not argument.startswith("-")
1022
+ and argument.casefold() != "/d"
1023
+ ):
1024
+ operands.append(argument)
1025
+ if not operands:
1026
+ raise ValueError("cd without a literal directory cannot be tracked by the guard")
1027
+ if operands[-1] == "-":
1028
+ raise ValueError(
1029
+ "cd - returns to a directory the guard cannot track; cd to a literal path"
1030
+ )
1031
+ return operands[-1]
1032
+
1033
+
1034
+ def segment_directories(tokens, working_directories):
1035
+ """Yield each command segment with the working directories in effect for it."""
1036
+ current_directories = list(working_directories)
1037
+ for segment in command_segments(tokens):
1038
+ yield segment, current_directories
1039
+ invocation = command_invocation(segment)
1040
+ if invocation is None:
1041
+ continue
1042
+ command, arguments = invocation
1043
+ if command == "popd":
1044
+ raise ValueError(
1045
+ "popd returns to a directory the guard cannot track; cd to a literal path"
1046
+ )
1047
+ if command not in DIRECTORY_CHANGE_COMMANDS:
1048
+ continue
1049
+ target = directory_change_target(arguments)
1050
+ if SHELL_PARAMETER_SYNTAX.search(target):
1051
+ raise ValueError(
1052
+ f"cd target {target} cannot be resolved by the guard; "
1053
+ "run that command first and cd to the literal result"
1054
+ )
1055
+ if is_absolute_path(target):
1056
+ current_directories = [target]
1057
+ else:
1058
+ bases = current_directories or ["."]
1059
+ current_directories = [f"{base}/{target}" for base in bases]
1060
+
1061
+
1062
+ def command_can_destroy_files(invocation):
1063
+ if invocation is None:
1064
+ return False
1065
+ executable, arguments = invocation
1066
+ executable = executable.removesuffix(".exe")
1067
+ return executable in DESTRUCTIVE_SHELL_COMMANDS or (
1068
+ executable == "find" and "-delete" in arguments
1069
+ )
1070
+
1071
+
1072
+ def literal_path(operand):
1073
+ if len(operand) >= 2 and operand[0] in "\"'" and operand[-1] == operand[0]:
1074
+ operand = operand[1:-1]
1075
+ pattern = r"[A-Za-z0-9._/-]+"
1076
+ if os.name == "nt":
1077
+ pattern = r"(?:[A-Za-z]:(?=[/\\]))?[A-Za-z0-9._/\\-]+"
1078
+ return operand if re.fullmatch(pattern, operand) is not None else None
1079
+
1080
+
1081
+ def literal_destructive_operand(operand):
1082
+ literal = literal_path(operand)
1083
+ if literal is None:
1084
+ raise ValueError(
1085
+ f"destructive operand {operand} cannot be validated; pass a literal path"
1086
+ )
1087
+ return literal
1088
+
1089
+
1090
+ def command_references_archive(tokens, working_directories):
1091
+ for segment, directories in segment_directories(tokens, working_directories):
1092
+ invocation = command_invocation(segment)
1093
+ ancestors = command_can_destroy_files(invocation)
1094
+ operands = segment
1095
+ if ancestors:
1096
+ operands = [literal_destructive_operand(operand) for operand in invocation[1]]
1097
+ if any(path_in_archive(operand, directories, ancestors) for operand in operands):
1098
+ return True
1099
+ wrapped = wrapped_command_tokens(segment, expand_parameters=False)
1100
+ if wrapped is not None and command_references_archive(wrapped, directories):
1101
+ return True
1102
+ return False
1103
+
1104
+
1105
+ def copy_destinations(arguments, directories, assignments=None):
1106
+ operands, destination, no_target_directory = [], None, False
1107
+ arguments = iter(arguments)
1108
+ command_arguments = []
1109
+ for argument in arguments:
1110
+ if is_redirection(argument) or (
1111
+ ">" in argument and set(argument) <= SHELL_WRITE_REDIRECTION_CHARS
1112
+ ):
1113
+ if command_arguments and command_arguments[-1].isdigit():
1114
+ raise ValueError("cp numeric operand before redirection is ambiguous")
1115
+ if next(arguments, None) is None:
1116
+ raise ValueError("cp redirection lacks a target")
1117
+ else:
1118
+ command_arguments.append(argument)
1119
+ command_arguments = [expand_environment_parameters(argument, assignments) for argument in command_arguments]
1120
+ if any(SHELL_PARAMETER_SYNTAX.search(argument) or CMD_PARAMETER_SYNTAX.search(argument) for argument in command_arguments):
1121
+ raise ValueError("cp operand cannot be resolved by the guard; pass a literal path")
1122
+ arguments = iter(command_arguments)
1123
+ for argument in arguments:
1124
+ if argument == "--":
1125
+ operands.extend(arguments)
1126
+ break
1127
+ if argument in {"-t", "--target-directory", "-S", "--suffix"}:
1128
+ value = next(arguments, None)
1129
+ if value is None:
1130
+ raise ValueError(f"cp option {argument} lacks a value")
1131
+ if argument in {"-t", "--target-directory"}:
1132
+ destination = value
1133
+ elif argument.startswith("--target-directory="):
1134
+ destination = argument.split("=", 1)[1]
1135
+ elif argument.startswith("-t") and not argument.startswith("--"):
1136
+ destination = argument[2:]
1137
+ elif argument in {"-T", "--no-target-directory"}:
1138
+ no_target_directory = True
1139
+ elif not argument.startswith("-"):
1140
+ operands.append(argument)
1141
+ if destination is None:
1142
+ if len(operands) < 2:
1143
+ raise ValueError("cp source and destination cannot be resolved")
1144
+ destination = operands.pop()
1145
+ yield destination
1146
+ _, target = target_paths(destination, directories, repository_root())
1147
+ for source in operands:
1148
+ output = Path(destination)
1149
+ if not no_target_directory and target.is_dir():
1150
+ output /= os.path.basename(source.rstrip("/"))
1151
+ outputs = [output]
1152
+ if source.endswith("/") and output != Path(destination):
1153
+ outputs.append(Path(destination))
1154
+ yield from map(str, outputs)
1155
+ lexical, source_path = target_paths(source, directories, repository_root())
1156
+ if source_path.is_dir():
1157
+ # ponytail: reject ambiguous directory links instead of emulating cp flags.
1158
+ if lexical.is_symlink():
1159
+ raise ValueError("cp directory source is a symlink; pass a literal directory")
1160
+
1161
+ def unreadable(error):
1162
+ raise ValueError(f"cp source tree cannot be inspected: {error}")
1163
+
1164
+ for root, folders, files in os.walk(source_path, onerror=unreadable):
1165
+ for name in folders + files:
1166
+ entry = Path(root) / name
1167
+ if entry.is_symlink() and entry.is_dir():
1168
+ raise ValueError("cp source contains a directory symlink; copy it separately")
1169
+ for output in outputs:
1170
+ yield str(output / entry.relative_to(source_path))
1171
+
1172
+
1173
+ def shell_write_targets(tokens, working_directories, assignments=None):
1174
+ """Yield (target, directories) for every path a shell command may write."""
1175
+ assignments = {**(assignments or {}), **shell_assignment_values(tokens)}
1176
+ for segment, directories in segment_directories(tokens, working_directories):
1177
+ for index, token in enumerate(segment[:-1]):
1178
+ if ">" not in token or not set(token) <= SHELL_WRITE_REDIRECTION_CHARS:
1179
+ continue
1180
+ target = segment[index + 1]
1181
+ if token.endswith("&") and (target.isdigit() or target == "-"):
1182
+ continue
1183
+ yield target, directories
1184
+ wrapped = wrapped_command_tokens(segment)
1185
+ if wrapped is not None:
1186
+ yield from shell_write_targets(wrapped, directories, assignments)
1187
+ continue
1188
+ invocation = command_invocation(segment)
1189
+ if invocation is None:
1190
+ continue
1191
+ command, arguments = invocation
1192
+ in_place = command in IN_PLACE_EDITORS and (
1193
+ has_short_option(arguments, "i")
1194
+ or any(argument.startswith("--in-place") for argument in arguments)
1195
+ )
1196
+ if command.removesuffix(".exe") == "cp":
1197
+ for destination in copy_destinations(arguments, directories, assignments):
1198
+ yield destination, directories
1199
+ continue
1200
+ if command.removesuffix(".exe") in SHELL_WRITE_COMMANDS or in_place:
1201
+ for argument in arguments:
1202
+ if argument and not argument.startswith("-"):
1203
+ yield argument, directories
1204
+
1205
+
1206
+ def protected_shell_write_reason(tokens, working_directories):
1207
+ assignments = shell_assignment_values(tokens)
1208
+ targets = []
1209
+ for target, directories in shell_write_targets(tokens, working_directories):
1210
+ expanded = expand_environment_parameters(target, assignments)
1211
+ if SHELL_PARAMETER_SYNTAX.search(expanded) or CMD_PARAMETER_SYNTAX.search(
1212
+ expanded
1213
+ ):
1214
+ raise ValueError(
1215
+ f"write target {target} cannot be resolved by the guard; "
1216
+ "pass a literal path"
1217
+ )
1218
+ if expanded and expanded not in {"/dev/null", "NUL"}:
1219
+ targets.append((expanded, directories))
1220
+ reason = closed_target_reason(targets)
1221
+ if reason:
1222
+ return reason
1223
+ classified = pipeline_target_kinds(targets) if targets else None
1224
+ if classified is None:
1225
+ return None
1226
+ for target, kind in classified[2]:
1227
+ if kind == "protected":
1228
+ return f"{PROTECTED_SHELL_WRITE_REASON} {target}"
1229
+ return None
1230
+
1231
+
1232
+ def environment_parameter_value(match, assignments):
1233
+ name = match.group(1) or match.group(2)
1234
+ if name.startswith(SUBSTITUTION_PLACEHOLDER):
1235
+ return match.group(0) # command substitution output is never resolved
1236
+ return assignments.get(name, os.environ.get(name, ""))
1237
+
1238
+
1239
+ def expand_environment_parameters(command, assignments=None):
1240
+ values = assignments or {}
1241
+
1242
+ def substitute(match):
1243
+ return environment_parameter_value(match, values)
1244
+
1245
+ expanded = NAMED_SHELL_PARAMETER_SYNTAX.sub(substitute, command)
1246
+ return CMD_PARAMETER_SYNTAX.sub(substitute, expanded)
1247
+
1248
+
1249
+ def shell_assignment_values(tokens):
1250
+ values = {}
1251
+ for segment in command_segments(tokens):
1252
+ variable_command = segment[0].casefold() in VARIABLE_COMMANDS
1253
+ for token in segment:
1254
+ if not SHELL_ASSIGNMENT_PATTERN.match(token):
1255
+ if variable_command:
1256
+ continue
1257
+ break
1258
+ name, value = token.split("=", 1)
1259
+ if name in values:
1260
+ raise ValueError(f"shell variable {name} is reassigned; split the commands")
1261
+ values[name] = expand_environment_parameters(value, values)
1262
+ return values
1263
+
1264
+
1265
+ def unresolved_archive_expansion(command, tokens, working_directories):
1266
+ expanded = expand_environment_parameters(command, shell_assignment_values(tokens))
1267
+ return ARCHIVE_REFERENCE.search(expanded) is not None
1268
+
1269
+
1270
+ def substitution_end(command, start):
1271
+ depth, quote, index = 1, None, start
1272
+ while index < len(command):
1273
+ character = command[index]
1274
+ if quote:
1275
+ if character == "\\" and quote == '"':
1276
+ index += 2
1277
+ continue
1278
+ if character == quote:
1279
+ quote = None
1280
+ elif character == "\\":
1281
+ index += 2
1282
+ continue
1283
+ elif character in "'\"":
1284
+ quote = character
1285
+ elif character == "(":
1286
+ depth += 1
1287
+ elif character == ")":
1288
+ depth -= 1
1289
+ if depth == 0:
1290
+ return index
1291
+ index += 1
1292
+ raise ValueError("command substitution $( is not closed")
1293
+
1294
+
1295
+ def split_command_substitutions(command):
1296
+ """Replace every $(...) and `...` with a placeholder; return (outer, inner)."""
1297
+ if "@(" in command:
1298
+ raise ValueError(
1299
+ "@( expansion cannot be validated; write the list as literal arguments"
1300
+ )
1301
+ if "$(" not in command and "`" not in command:
1302
+ return command, []
1303
+ outer, inner, index = [], [], 0
1304
+ while index < len(command):
1305
+ if command.startswith("$(", index):
1306
+ end = substitution_end(command, index + 2)
1307
+ inner.append(command[index + 2 : end])
1308
+ elif command[index] == "`":
1309
+ end = index + 1
1310
+ while end < len(command) and command[end] != "`":
1311
+ end += 2 if command[end] == "\\" else 1
1312
+ if end >= len(command):
1313
+ raise ValueError("backtick command substitution is not closed")
1314
+ inner.append(command[index + 1 : end])
1315
+ elif command[index] == "\\":
1316
+ outer.append(command[index : index + 2])
1317
+ index += 2
1318
+ continue
1319
+ else:
1320
+ outer.append(command[index])
1321
+ index += 1
1322
+ continue
1323
+ outer.append(f"${{{SUBSTITUTION_PLACEHOLDER}{len(inner) - 1}}}")
1324
+ index = end + 1
1325
+ return "".join(outer), inner
1326
+
1327
+
1328
+ def describe_substitutions(text, substitutions):
1329
+ return SUBSTITUTION_PLACEHOLDER_SYNTAX.sub(
1330
+ lambda match: f"$({substitutions[int(match.group(1))]})", text
1331
+ )
1332
+
1333
+
1334
+ def strip_heredoc_bodies(command):
1335
+ lines = command.split("\n")
1336
+ kept, index = [], 0
1337
+ while index < len(lines):
1338
+ line = lines[index]
1339
+ kept.append(line)
1340
+ index += 1
1341
+ for match in HEREDOC_PATTERN.finditer(line):
1342
+ delimiter = (
1343
+ match.group("single") or match.group("double") or match.group("bare")
1344
+ )
1345
+ strip = "\t\r" if match.group("dash") else "\r"
1346
+ while index < len(lines) and lines[index].strip(strip) != delimiter:
1347
+ index += 1
1348
+ if index >= len(lines):
1349
+ raise ValueError(f"here-document {delimiter} is not terminated")
1350
+ index += 1
1351
+ return "\n".join(kept)
1352
+
1353
+
1354
+ def patch_paths(payload):
1355
+ for match in PATCH_PATH_PATTERN.finditer(payload):
1356
+ yield (match.group(1) or match.group(2)).strip()
1357
+ for match in UNIFIED_DIFF_PATH_PATTERN.finditer(payload):
1358
+ path = decode_patch_path(match.group(1), strip_prefix=True)
1359
+ if path != "/dev/null":
1360
+ yield path
1361
+ for match in GIT_DIFF_PATH_PATTERN.finditer(payload):
1362
+ for raw_path in match.groups():
1363
+ yield decode_patch_path(raw_path, strip_prefix=True)
1364
+ for match in GIT_RENAME_PATH_PATTERN.finditer(payload):
1365
+ yield decode_patch_path(match.group(1), strip_prefix=False)
1366
+
1367
+
1368
+ def decode_patch_path(raw_path, strip_prefix):
1369
+ path = raw_path.strip()
1370
+ if path.startswith('"'):
1371
+ try:
1372
+ path = ast.literal_eval(path)
1373
+ except (SyntaxError, ValueError) as error:
1374
+ raise ValueError("quoted patch path cannot be validated") from error
1375
+ if not isinstance(path, str):
1376
+ raise ValueError("quoted patch path cannot be validated")
1377
+ elif '"' in path:
1378
+ raise ValueError("quoted patch path cannot be validated")
1379
+ if strip_prefix and path.startswith(("a/", "b/")):
1380
+ return path[2:]
1381
+ return path
1382
+
1383
+
1384
+ def shell_tokens(command):
1385
+ command = strip_heredoc_bodies(LINE_CONTINUATION.sub(r"\1 ", command))
1386
+ lexer = shlex.shlex(command, posix=True, punctuation_chars="|;&()<>\n\r")
1387
+ lexer.whitespace = " \t"
1388
+ lexer.whitespace_split = True
1389
+ lexer.commenters = ""
1390
+ try:
1391
+ tokens = list(lexer)
1392
+ except ValueError as error:
1393
+ raise ValueError(
1394
+ f"shell command cannot be tokenized ({error}); balance the quotes "
1395
+ "or write the content with an editor tool"
1396
+ ) from None
1397
+ if not tokens:
1398
+ raise ValueError("shell command is empty")
1399
+ return tokens
1400
+
1401
+
1402
+ def command_segments(tokens):
1403
+ """Yield simple commands with separators and leading control words removed."""
1404
+ segment = []
1405
+ for token in tokens:
1406
+ if token and set(token) <= set("|;&()\n\r"):
1407
+ if segment:
1408
+ yield segment
1409
+ segment = []
1410
+ elif segment or token.casefold() not in SHELL_CONTROL_WORDS:
1411
+ segment.append(token)
1412
+ if segment:
1413
+ yield segment
1414
+
1415
+
1416
+ def validate_shell_assignment(assignment):
1417
+ name = assignment.partition("=")[0]
1418
+ folded = name.casefold()
1419
+ if folded in {"home", "xdg_config_home"} or folded.startswith("git_"):
1420
+ raise ValueError(
1421
+ f"{name} changes where Git reads its configuration, which the guard "
1422
+ "cannot validate; run git without it"
1423
+ )
1424
+
1425
+
1426
+ def validate_variable_command(executable, arguments):
1427
+ for argument in arguments:
1428
+ if SHELL_ASSIGNMENT_PATTERN.match(argument):
1429
+ validate_shell_assignment(argument)
1430
+ elif executable == "set" or (executable == "unset" and argument.startswith("-")):
1431
+ continue
1432
+ elif argument.startswith(("-", "+")):
1433
+ raise ValueError(
1434
+ f"{executable} {argument}: variable options cannot be validated; "
1435
+ "assign with NAME=VALUE instead"
1436
+ )
1437
+ else:
1438
+ validate_shell_assignment(argument)
1439
+
1440
+
1441
+ def first_argument(arguments):
1442
+ return arguments[0] if arguments else ""
1443
+
1444
+
1445
+ def xargs_command(arguments):
1446
+ index = 0
1447
+ while index < len(arguments):
1448
+ argument = arguments[index]
1449
+ if argument in XARGS_OPTIONS_WITH_VALUES:
1450
+ index += 2
1451
+ elif argument.startswith("-") and argument != "-":
1452
+ index += 1
1453
+ else:
1454
+ break
1455
+ return arguments[index:]
1456
+
1457
+
1458
+ def require_read_command(wrapper, wrapped):
1459
+ if wrapped and not archive_command_is_read_only(" ".join(wrapped), wrapped, True):
1460
+ raise ValueError(
1461
+ f"{wrapper} runs {wrapped[0]} on operands the guard cannot check; "
1462
+ f"only read commands may follow {wrapper}, or pass the paths as literals"
1463
+ )
1464
+
1465
+
1466
+ def command_invocation(segment):
1467
+ index = 0
1468
+ while index < len(segment) and SHELL_ASSIGNMENT_PATTERN.match(segment[index]):
1469
+ validate_shell_assignment(segment[index])
1470
+ index += 1
1471
+ if index >= len(segment):
1472
+ return None
1473
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1474
+ if executable == "env":
1475
+ index += 1
1476
+ while index < len(segment):
1477
+ token = segment[index]
1478
+ if token == "--":
1479
+ index += 1
1480
+ break
1481
+ if SHELL_ASSIGNMENT_PATTERN.match(token):
1482
+ validate_shell_assignment(token)
1483
+ index += 1
1484
+ continue
1485
+ option = token.split("=", 1)[0]
1486
+ if option in ENV_OPTIONS_WITHOUT_VALUES:
1487
+ index += 1
1488
+ continue
1489
+ if option in ENV_OPTIONS_WITH_VALUES:
1490
+ if option in {"-C", "--chdir"}:
1491
+ raise ValueError(
1492
+ f"env {option} changes the working directory the guard "
1493
+ "tracks; use cd with a literal path instead"
1494
+ )
1495
+ index += 1
1496
+ if "=" not in token:
1497
+ if index >= len(segment):
1498
+ raise ValueError(f"env {option} lacks a value")
1499
+ index += 1
1500
+ continue
1501
+ if token.startswith("-"):
1502
+ raise ValueError(
1503
+ f"env {token} cannot be validated; use env NAME=VALUE <command>"
1504
+ )
1505
+ break
1506
+ if index >= len(segment):
1507
+ return None
1508
+ executable = segment[index].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1509
+ arguments = segment[index + 1 :]
1510
+ if SHELL_PARAMETER_SYNTAX.search(executable):
1511
+ raise ValueError(
1512
+ f"executable {segment[index]} cannot be resolved by the guard; "
1513
+ "name the program literally"
1514
+ )
1515
+ if executable in {"find", "find.exe"}:
1516
+ for position, argument in enumerate(arguments):
1517
+ if argument.casefold() in FIND_EXECUTION_ACTIONS:
1518
+ require_read_command(f"find {argument}", arguments[position + 1 :])
1519
+ if executable in XARGS_COMMANDS:
1520
+ require_read_command("xargs", xargs_command(arguments))
1521
+ if executable in VARIABLE_COMMANDS:
1522
+ validate_variable_command(executable, arguments)
1523
+ if executable in UNVALIDATED_EXECUTION_REASONS:
1524
+ raise ValueError(
1525
+ UNVALIDATED_EXECUTION_REASONS[executable].format(
1526
+ executable=segment[index], argument=first_argument(arguments)
1527
+ )
1528
+ )
1529
+ return executable, arguments
1530
+
1531
+
1532
+ def git_command(segment):
1533
+ invocation = command_invocation(segment)
1534
+ if invocation is None:
1535
+ return None
1536
+ executable, arguments = invocation
1537
+ if executable not in {"git", "git.exe"}:
1538
+ return None
1539
+ for argument in arguments:
1540
+ if SHELL_PARAMETER_SYNTAX.search(argument):
1541
+ raise ValueError(
1542
+ f"git argument {argument} cannot be resolved by the guard; pass a "
1543
+ "literal value (for commit messages use -F <file>)"
1544
+ )
1545
+ index = 0
1546
+ git_options = []
1547
+ while index < len(arguments) and arguments[index].startswith("-"):
1548
+ token = arguments[index]
1549
+ if token[:2] in {"-c", "-C"} and len(token) > 2 and not token.startswith("--"):
1550
+ option, value = token[:2], token[2:]
1551
+ else:
1552
+ option = token.split("=", 1)[0]
1553
+ value = token.split("=", 1)[1] if "=" in token else None
1554
+ index += 1
1555
+ if option in GIT_GLOBAL_OPTIONS_WITH_VALUES and value is None:
1556
+ if index >= len(arguments):
1557
+ raise ValueError(f"git option {option} lacks a value")
1558
+ value = arguments[index]
1559
+ index += 1
1560
+ if option in {"-c", "--config-env"}:
1561
+ config_key = str(value).split("=", 1)[0].casefold()
1562
+ if config_key.startswith("alias."):
1563
+ raise ValueError(
1564
+ f"git -c {config_key} defines an alias the guard cannot inspect; "
1565
+ "run the underlying git command directly"
1566
+ )
1567
+ if config_key == "clean.requireforce":
1568
+ raise ValueError(
1569
+ "git -c clean.requireForce disables the git clean safety check; "
1570
+ "run git clean with explicit paths instead"
1571
+ )
1572
+ if option in GIT_GLOBAL_OPTIONS_WITH_VALUES:
1573
+ git_options.extend((option, str(value)))
1574
+ if index >= len(arguments):
1575
+ return None
1576
+ return arguments[index].casefold(), arguments[index + 1:], git_options
1577
+
1578
+
1579
+ def wrapped_command_tokens(segment, expand_parameters=True):
1580
+ invocation = command_invocation(segment)
1581
+ if invocation is None:
1582
+ return None
1583
+ executable, arguments = invocation
1584
+ if executable == "eval":
1585
+ return shell_tokens(" ".join(arguments)) if arguments else None
1586
+ if executable in COMMAND_WRAPPERS:
1587
+ if executable == "command" and arguments[:1] in (["-v"], ["-V"]):
1588
+ return None
1589
+ if executable == "command" and arguments[:1] == ["-p"]:
1590
+ arguments = arguments[1:]
1591
+ if not arguments or arguments[0].startswith("-"):
1592
+ raise ValueError(
1593
+ f"{executable} {first_argument(arguments)}: only "
1594
+ f"`{executable} <program> [arguments]` can be validated"
1595
+ )
1596
+ return arguments
1597
+ if executable in POSIX_SHELL_WRAPPERS:
1598
+ for index, argument in enumerate(arguments):
1599
+ if (
1600
+ argument.startswith("-")
1601
+ and not argument.startswith("--")
1602
+ and "c" in argument[1:]
1603
+ ):
1604
+ if index + 1 >= len(arguments):
1605
+ raise ValueError(
1606
+ MISSING_COMMAND_STRING_REASON.format(
1607
+ executable=executable, option=argument
1608
+ )
1609
+ )
1610
+ return shell_tokens(arguments[index + 1])
1611
+ raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1612
+ if executable in POWERSHELL_WRAPPERS or executable in {"cmd", "cmd.exe"}:
1613
+ switches = {"-c", "-command", "/c", "/k"}
1614
+ for index, argument in enumerate(arguments):
1615
+ if argument.casefold() in switches:
1616
+ if index + 1 >= len(arguments):
1617
+ raise ValueError(
1618
+ MISSING_COMMAND_STRING_REASON.format(
1619
+ executable=executable, option=argument
1620
+ )
1621
+ )
1622
+ payload = arguments[index + 1:]
1623
+ if executable in {"cmd", "cmd.exe"} and expand_parameters:
1624
+ payload = [
1625
+ expand_environment_parameters(argument)
1626
+ for argument in payload
1627
+ ]
1628
+ if len(payload) == 1:
1629
+ return shell_tokens(payload[0])
1630
+ return payload
1631
+ raise ValueError(SHELL_FILE_REASON.format(executable=executable))
1632
+ return None
1633
+
1634
+
1635
+ def has_short_option(arguments, option):
1636
+ return any(
1637
+ argument.startswith("-")
1638
+ and not argument.startswith("--")
1639
+ and option in argument[1:]
1640
+ for argument in arguments
1641
+ )
1642
+
1643
+
1644
+ def git_alias(command, git_options):
1645
+ result = subprocess.run(
1646
+ ["git", *git_options, "config", "--get", f"alias.{command}"],
1647
+ text=True,
1648
+ capture_output=True,
1649
+ check=False,
1650
+ )
1651
+ if result.returncode == 1:
1652
+ return None
1653
+ if result.returncode != 0:
1654
+ raise ValueError("git alias configuration cannot be validated")
1655
+ alias = result.stdout.strip()
1656
+ if not alias or alias.startswith("!"):
1657
+ raise ValueError("git alias configuration cannot be validated")
1658
+ return alias
1659
+
1660
+
1661
+ def update_ref_deletes(arguments):
1662
+ operands = []
1663
+ index = 0
1664
+ while index < len(arguments):
1665
+ argument = arguments[index]
1666
+ if argument in {"-m", "--message"}:
1667
+ index += 2
1668
+ continue
1669
+ if argument.startswith("--message=") or argument.startswith("-"):
1670
+ index += 1
1671
+ continue
1672
+ operands.append(argument)
1673
+ index += 1
1674
+ zero_value = len(operands) >= 2 and (
1675
+ operands[1] == "" or not operands[1].strip("0")
1676
+ )
1677
+ return (
1678
+ "-d" in arguments
1679
+ or "--delete" in arguments
1680
+ or any(argument.startswith("--stdin") for argument in arguments)
1681
+ or zero_value
1682
+ )
1683
+
1684
+
1685
+ def destructive_git_reason(tokens, resolved_aliases=frozenset()):
1686
+ for segment in command_segments(tokens):
1687
+ wrapped = wrapped_command_tokens(segment)
1688
+ if wrapped is not None:
1689
+ reason = destructive_git_reason(wrapped, resolved_aliases)
1690
+ if reason is not None:
1691
+ return reason
1692
+ invocation = git_command(segment)
1693
+ if invocation is None:
1694
+ continue
1695
+ command, arguments, git_options = invocation
1696
+ if command == "config":
1697
+ alias_indexes = [
1698
+ index
1699
+ for index, argument in enumerate(arguments)
1700
+ if argument.casefold().startswith("alias.")
1701
+ ]
1702
+ if any(index + 1 < len(arguments) for index in alias_indexes):
1703
+ raise ValueError(
1704
+ "git config alias.* defines an alias the guard cannot inspect; "
1705
+ "run the underlying git command directly"
1706
+ )
1707
+ alias = git_alias(command, git_options)
1708
+ if alias is not None:
1709
+ if command in resolved_aliases:
1710
+ raise ValueError(
1711
+ f"git alias {command} refers to itself; run the underlying "
1712
+ "git command directly"
1713
+ )
1714
+ reason = destructive_git_reason(
1715
+ ["git", *git_options, *shell_tokens(alias), *arguments],
1716
+ resolved_aliases | {command},
1717
+ )
1718
+ if reason is not None:
1719
+ return reason
1720
+ continue
1721
+ if command == "reset" and "--hard" in arguments:
1722
+ return GIT_REASONS[command]
1723
+ if command == "clean":
1724
+ force = "--force" in arguments or has_short_option(arguments, "f")
1725
+ interactive = "--interactive" in arguments or has_short_option(
1726
+ arguments, "i"
1727
+ )
1728
+ if force or interactive:
1729
+ return GIT_REASONS[command]
1730
+ if command == "push":
1731
+ force_option = any(
1732
+ argument == "--force"
1733
+ or argument.startswith("--force-with-lease")
1734
+ or argument == "--mirror"
1735
+ for argument in arguments
1736
+ ) or has_short_option(arguments, "f")
1737
+ force_refspec = any(
1738
+ argument.startswith("+") and len(argument) > 1
1739
+ for argument in arguments
1740
+ )
1741
+ if force_option or force_refspec:
1742
+ return GIT_REASONS[command]
1743
+ if command == "branch":
1744
+ deletes = (
1745
+ "--delete" in arguments
1746
+ or has_short_option(arguments, "d")
1747
+ or has_short_option(arguments, "D")
1748
+ )
1749
+ forces = (
1750
+ "--force" in arguments
1751
+ or has_short_option(arguments, "f")
1752
+ or has_short_option(arguments, "D")
1753
+ )
1754
+ if deletes and forces:
1755
+ return GIT_REASONS[command]
1756
+ if (
1757
+ "--move" in arguments
1758
+ or has_short_option(arguments, "m")
1759
+ or has_short_option(arguments, "M")
1760
+ ):
1761
+ return GIT_REASONS["branch-move"]
1762
+ if command == "update-ref":
1763
+ if update_ref_deletes(arguments):
1764
+ return GIT_REASONS[command]
1765
+ if command == "worktree" and arguments[:1] == ["remove"]:
1766
+ if "--force" in arguments or has_short_option(arguments[1:], "f"):
1767
+ return GIT_REASONS[command]
1768
+ if command == "stash" and arguments[:1] in (["drop"], ["clear"]):
1769
+ return GIT_REASONS[command]
1770
+ if command in {"checkout", "restore"} and any(
1771
+ argument in WHOLE_TREE_PATHSPECS for argument in arguments
1772
+ ):
1773
+ touches_worktree = command == "checkout" or (
1774
+ "--worktree" in arguments
1775
+ or has_short_option(arguments, "W")
1776
+ or not ("--staged" in arguments or has_short_option(arguments, "S"))
1777
+ )
1778
+ if touches_worktree:
1779
+ return GIT_REASONS["checkout"]
1780
+ return None
1781
+
1782
+
1783
+ PIPELINE_HELPERS = frozenset({"pipeline_state.py", "archive_milestone.py"})
1784
+
1785
+
1786
+ def closed_milestone_reason(repo=None):
1787
+ repo = repo or repository_root()
1788
+ branch = subprocess.run(
1789
+ ["git", "-C", str(repo), "branch", "--show-current"],
1790
+ capture_output=True, text=True,
1791
+ )
1792
+ if not re.fullmatch(r"gsd-path/M\d{3,}", branch.stdout.strip()):
1793
+ return None
1794
+ result = subprocess.run(
1795
+ [sys.executable, "-B", str(Path(__file__).with_name("git_guard.py")),
1796
+ "closed-milestone"], cwd=repo, capture_output=True, text=True,
1797
+ )
1798
+ if result.returncode:
1799
+ return result.stderr.strip() or "closed milestone inspection failed"
1800
+ return None
1801
+
1802
+
1803
+ def bundled_helper_invocation(command, tokens, working_directories, helpers=PIPELINE_HELPERS):
1804
+ """Allow one plain ``python[3] [-B] <script>`` from the guard-owned runtime.
1805
+
1806
+ The interpreter token must be exactly python or python3, and the script
1807
+ operand is the exact parsed token, with expansions and parent traversal refused.
1808
+ The resolved helper must be a regular file inside runtime/ beside this guard,
1809
+ or beside the guard itself in the repository layout. Resolve only in supplied
1810
+ tool working directories, falling back to cwd when none are supplied.
1811
+ """
1812
+ if any(char in command for char in "\n\r\\"):
1813
+ return False
1814
+ segments = list(command_segments(tokens))
1815
+ if len(segments) != 1 or segments[0] != tokens:
1816
+ return False
1817
+ if any(
1818
+ re.fullmatch(r"[<>&|]*[<>][<>&|]*|[0-9]+(?:[<>]|&>).*", token)
1819
+ for token in tokens
1820
+ ):
1821
+ return False
1822
+ _, substitutions = split_command_substitutions(command)
1823
+ if substitutions or wrapped_command_tokens(segments[0]) is not None:
1824
+ return False
1825
+ if tokens[0] not in ("python", "python3"):
1826
+ return False
1827
+ rest = tokens[1:]
1828
+ if rest[:1] == ["-B"]:
1829
+ rest = rest[1:]
1830
+ if not rest:
1831
+ return False
1832
+ operand = rest[0]
1833
+ if (
1834
+ operand.startswith("~")
1835
+ or any(char in operand for char in "$`*?[]{}")
1836
+ or ".." in operand.split("/")
1837
+ ):
1838
+ return False
1839
+ here = Path(__file__).resolve().parent
1840
+ runtime = here / "runtime"
1841
+ if not runtime.exists():
1842
+ runtime = here
1843
+ runtime = runtime.resolve()
1844
+ for base in working_directories or [os.getcwd()]:
1845
+ try:
1846
+ script = (Path(base) / operand).resolve()
1847
+ if (
1848
+ script.name in helpers
1849
+ and (script.is_relative_to(runtime) or (
1850
+ script.name == "status_runtime.py" and script == here / "status_runtime.py"
1851
+ ))
1852
+ and script.is_file()
1853
+ ):
1854
+ return True
1855
+ except (OSError, RuntimeError):
1856
+ continue
1857
+ return False
1858
+
1859
+
1860
+ def archive_command_is_read_only(command, tokens, archive_context=False, git_commands=ARCHIVE_READ_GIT_COMMANDS):
1861
+ if archive_context and AMBIGUOUS_SHELL_SYNTAX.search(command):
1862
+ return False
1863
+ if not archive_context:
1864
+ return True
1865
+ executable = tokens[0].replace("\\", "/").rsplit("/", 1)[-1].casefold()
1866
+ denied_options = ARCHIVE_READ_EXECUTION_OPTIONS.get(executable, ())
1867
+ if any(
1868
+ token == option or token.startswith(f"{option}=")
1869
+ for token in tokens[1:]
1870
+ for option in denied_options
1871
+ ):
1872
+ return False
1873
+ if executable in ARCHIVE_READ_COMMANDS:
1874
+ return True
1875
+ if executable != "git" or len(tokens) < 2:
1876
+ return False
1877
+ if tokens[1].casefold() not in git_commands:
1878
+ return False
1879
+ return not any(
1880
+ token in GIT_READ_WRITE_OPTIONS or token.startswith("--output=")
1881
+ for token in tokens[2:]
1882
+ )
1883
+
1884
+
1885
+ def deny(reason):
1886
+ # One denial object per documented host schema: decision/reason (Grok,
1887
+ # Antigravity), permission + user/agentMessage (Cursor), permissionDecision
1888
+ # (Copilot), hookSpecificOutput (Claude Code, Codex, Qwen).
1889
+ print(
1890
+ json.dumps(
1891
+ {
1892
+ "decision": "deny",
1893
+ "reason": reason,
1894
+ "permission": "deny",
1895
+ "userMessage": reason,
1896
+ "agentMessage": reason,
1897
+ "permissionDecision": "deny",
1898
+ "permissionDecisionReason": reason,
1899
+ "hookSpecificOutput": {
1900
+ "hookEventName": "PreToolUse",
1901
+ "permissionDecision": "deny",
1902
+ "permissionDecisionReason": reason,
1903
+ },
1904
+ }
1905
+ )
1906
+ )
1907
+ print(f"gsd-path guard: {reason}", file=sys.stderr)
1908
+ sys.exit(2)
1909
+
1910
+
1911
+ def evaluate(event):
1912
+ tool = event.get("tool_name") or event.get("toolName") or event.get("tool")
1913
+ if not isinstance(tool, str) or not tool.strip():
1914
+ raise ValueError("hook event is missing its tool name")
1915
+ paths, working_directories, commands, patch_payloads = [], [], [], []
1916
+ path_keys = PATH_KEYS
1917
+ if is_direct_write_tool(tool):
1918
+ path_keys |= MUTATION_OPERAND_KEYS
1919
+ collect(
1920
+ event,
1921
+ paths,
1922
+ working_directories,
1923
+ commands,
1924
+ patch_payloads,
1925
+ path_keys,
1926
+ )
1927
+ raw_input = event.get("tool_input", event.get("toolInput"))
1928
+ raw_patch = isinstance(raw_input, str) and has_patch_headers(raw_input)
1929
+ if is_patch_tool(tool) or raw_patch:
1930
+ patch_payloads.extend(commands)
1931
+ if isinstance(raw_input, str):
1932
+ patch_payloads.append((raw_input, tuple(working_directories)))
1933
+ commands = []
1934
+ read_tool = is_read_tool(tool)
1935
+ extracted_patch_paths = []
1936
+ if not read_tool:
1937
+ for path, path_working_directories in paths:
1938
+ if path_in_archive(path, path_working_directories):
1939
+ deny(ARCHIVE_REASON)
1940
+ extracted_patch_paths = [
1941
+ (path, patch_working_directories)
1942
+ for payload, patch_working_directories in patch_payloads
1943
+ for path in patch_paths(payload)
1944
+ ]
1945
+ if patch_payloads and not paths and not extracted_patch_paths:
1946
+ raise ValueError("patch targets cannot be validated")
1947
+ for path, path_working_directories in extracted_patch_paths:
1948
+ if path_in_archive(path, path_working_directories):
1949
+ deny(ARCHIVE_REASON)
1950
+ if is_direct_write_tool(tool, bool(paths or extracted_patch_paths)):
1951
+ write_paths = [*paths, *extracted_patch_paths]
1952
+ if not write_paths:
1953
+ raise ValueError("write targets cannot be validated")
1954
+ grouped_paths = {}
1955
+ for path, path_working_directories in write_paths:
1956
+ grouped_paths.setdefault(path_working_directories, []).append(path)
1957
+ for path_working_directories, targets in grouped_paths.items():
1958
+ enforce_pipeline_reentry(targets, path_working_directories)
1959
+ archive_working_directory = any(
1960
+ working_directory_in_archive(path) for path in working_directories
1961
+ )
1962
+ if archive_working_directory and not commands and not read_tool:
1963
+ deny(ARCHIVE_REASON)
1964
+ for command, command_working_directories in commands:
1965
+ reason = command_denial(command, command_working_directories)
1966
+ if reason is not None:
1967
+ deny(reason)
1968
+
1969
+
1970
+ def destructive_shell_invocations(tokens):
1971
+ for segment in command_segments(tokens):
1972
+ invocation = command_invocation(segment)
1973
+ if command_can_destroy_files(invocation):
1974
+ yield invocation
1975
+ wrapped = wrapped_command_tokens(segment, expand_parameters=False)
1976
+ if wrapped is not None:
1977
+ yield from destructive_shell_invocations(wrapped)
1978
+
1979
+
1980
+ def closed_git_listing(tokens):
1981
+ """Whether a Git call uses only the allowed object-read or listing forms."""
1982
+ if len(tokens) < 2 or tokens[0] != "git":
1983
+ return False
1984
+ subcommand, arguments = tokens[1], tokens[2:]
1985
+ if subcommand == "worktree":
1986
+ if arguments[:1] != ["list"]:
1987
+ return False
1988
+ subcommand, arguments = "worktree list", arguments[1:]
1989
+ allowed = CLOSED_LISTING_GIT_OPTIONS.get(subcommand)
1990
+ if allowed is None:
1991
+ return False
1992
+ options = [argument for argument in arguments if argument.startswith("-")]
1993
+ positionals = len(arguments) - len(options)
1994
+ if any(option not in allowed for option in options):
1995
+ return False
1996
+ if subcommand == "branch":
1997
+ return not positionals or bool({"-l", "--list"} & set(options))
1998
+ if subcommand in {"cat-file", "symbolic-ref"}:
1999
+ return positionals == 1
2000
+ return not positionals
2001
+
2002
+
2003
+ def closed_shell_execution_reason(command, tokens, working_directories):
2004
+ helpers = PIPELINE_HELPERS | {"pipeline_git.py", "status_runtime.py", "promote_lookahead.py", "pipeline_diagnose.py"}
2005
+ rest = tokens[3:] if tokens[1:2] == ["-B"] else tokens[2:]
2006
+ if rest[:1] == ["pending"]:
2007
+ helpers = helpers | {"discussion_records.py"}
2008
+ if bundled_helper_invocation(command, tokens, working_directories, helpers):
2009
+ return None
2010
+ for segment, directories in segment_directories(tokens, working_directories):
2011
+ wrapped = wrapped_command_tokens(segment)
2012
+ if wrapped is not None:
2013
+ reason = closed_shell_execution_reason(shlex.join(wrapped), wrapped, directories)
2014
+ if reason:
2015
+ return reason
2016
+ continue
2017
+ metadata_closed = None
2018
+ git = git_command(segment)
2019
+ checked_segment = segment
2020
+ if git is not None:
2021
+ subcommand, arguments, options = git
2022
+ checked_segment = ["git", subcommand, *arguments]
2023
+ if options:
2024
+ roots = []
2025
+ for directory in directories:
2026
+ result = subprocess.run(
2027
+ ["git", *options, "rev-parse", "--show-toplevel", "--absolute-git-dir"],
2028
+ cwd=directory, capture_output=True, text=True,
2029
+ )
2030
+ if result.returncode:
2031
+ raise ValueError("git target repository cannot be resolved")
2032
+ root, metadata = result.stdout.splitlines()
2033
+ roots.append(root)
2034
+ metadata_closed = metadata_closed or closed_milestone_reason(Path(metadata))
2035
+ directories = roots
2036
+ closed = metadata_closed or closed_target_reason([(".", directories)])
2037
+ if not closed:
2038
+ continue
2039
+ invocation = command_invocation(segment)
2040
+ if invocation is None:
2041
+ continue
2042
+ executable, _ = invocation
2043
+ if executable in DIRECTORY_CHANGE_COMMANDS or checked_segment == ["git", "fetch", "origin"]:
2044
+ continue
2045
+ plain = checked_segment[:next((i for i, token in enumerate(checked_segment) if is_redirection(token)), len(checked_segment))]
2046
+ if executable in {"echo", "printf"} or closed_git_listing(plain) or archive_command_is_read_only(
2047
+ shlex.join(plain), plain, True, CLOSED_READ_GIT_COMMANDS
2048
+ ):
2049
+ continue
2050
+ return closed
2051
+ return None
2052
+
2053
+
2054
+ def command_denial(command, working_directories, allow_destructive=True):
2055
+ """Return why a shell command is denied, or None when it may run."""
2056
+ working_directories = working_directories or [os.getcwd()]
2057
+ outer, substitutions = split_command_substitutions(command)
2058
+ try:
2059
+ tokens = shell_tokens(outer)
2060
+ if tokens in (
2061
+ [interpreter, "-B", "-c", "import sys; raise SystemExit(sys.version_info < (3, 9))"]
2062
+ for interpreter in ("python3", "python")
2063
+ ):
2064
+ return None
2065
+ destructive = list(destructive_shell_invocations(tokens))
2066
+ if destructive and (
2067
+ not allow_destructive
2068
+ or substitutions
2069
+ or any(char in command for char in ";&|()\n\r`")
2070
+ or len(list(command_segments(tokens))) != 1
2071
+ or any(
2072
+ literal_path(operand) is None
2073
+ for _, operands in destructive
2074
+ for operand in operands
2075
+ )
2076
+ ):
2077
+ return DESTRUCTIVE_SHAPE_REASON
2078
+ reason = closed_shell_execution_reason(command, tokens, working_directories)
2079
+ if reason:
2080
+ return reason
2081
+ for inner in substitutions:
2082
+ reason = command_denial(inner, working_directories, allow_destructive=False)
2083
+ if reason is not None:
2084
+ return reason
2085
+ archive_context = (
2086
+ any(working_directory_in_archive(path) for path in working_directories)
2087
+ or bool(ARCHIVE_REFERENCE.search(outer))
2088
+ or command_references_archive(tokens, working_directories)
2089
+ or unresolved_archive_expansion(outer, tokens, working_directories)
2090
+ )
2091
+ if not archive_command_is_read_only(command, tokens, archive_context) and not (
2092
+ archive_context and bundled_helper_invocation(command, tokens, working_directories)
2093
+ ):
2094
+ return ARCHIVE_REASON
2095
+ reason = destructive_git_reason(tokens) or protected_shell_write_reason(
2096
+ tokens, working_directories
2097
+ )
2098
+ except ValueError as error:
2099
+ raise ValueError(describe_substitutions(str(error), substitutions)) from None
2100
+ return reason and describe_substitutions(reason, substitutions)
2101
+
2102
+
2103
+ def allow(event):
2104
+ if "cursor_version" in event: # Cursor fails closed on empty stdout; see the module docstring
2105
+ print(json.dumps({"permission": "allow"}))
2106
+
2107
+
2108
+ def main():
2109
+ try:
2110
+ event = json.load(sys.stdin)
2111
+ if not isinstance(event, dict):
2112
+ raise ValueError("hook event must be an object")
2113
+ evaluate(event)
2114
+ allow(event)
2115
+ except SystemExit:
2116
+ raise
2117
+ except ValueError as error:
2118
+ deny(f"{INVALID_INPUT_REASON}: {error}")
2119
+ except Exception:
2120
+ deny(INVALID_INPUT_REASON)
2121
+
2122
+
2123
+ if __name__ == "__main__":
2124
+ main()