@opengsd/gsd-core 1.18.0 → 1.20.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 (308) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +2 -1
  3. package/.opencode/plugins/gsd-core.js +2 -3
  4. package/agents/gsd-code-fixer.compact.md +7 -7
  5. package/agents/gsd-code-fixer.md +13 -7
  6. package/agents/gsd-code-reviewer.compact.md +49 -8
  7. package/agents/gsd-code-reviewer.md +53 -14
  8. package/agents/gsd-executor.md +8 -4
  9. package/agents/gsd-verifier.md +1 -1
  10. package/bin/install.js +422 -103
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +179 -30
  13. package/gsd-core/bin/lib/agent-install-check.cjs +29 -3
  14. package/gsd-core/bin/lib/args-terminator.cjs +114 -0
  15. package/gsd-core/bin/lib/broken-windows.cjs +59 -1
  16. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  17. package/gsd-core/bin/lib/capability-lock.cjs +13 -2
  18. package/gsd-core/bin/lib/capability-registry.cjs +376 -103
  19. package/gsd-core/bin/lib/capability-state.cjs +14 -8
  20. package/gsd-core/bin/lib/capability-validator.cjs +32 -0
  21. package/gsd-core/bin/lib/commands.cjs +27 -17
  22. package/gsd-core/bin/lib/config-loader.cjs +13 -3
  23. package/gsd-core/bin/lib/config.cjs +256 -58
  24. package/gsd-core/bin/lib/core-utils.cjs +45 -0
  25. package/gsd-core/bin/lib/decision-coverage-support.cjs +74 -0
  26. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  27. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  28. package/gsd-core/bin/lib/gate-context-drift.cjs +9 -4
  29. package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +6 -1
  30. package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +6 -1
  31. package/gsd-core/bin/lib/gate-evaluation-scope.cjs +134 -13
  32. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  33. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  34. package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +63 -22
  35. package/gsd-core/bin/lib/gate-ui-safety.cjs +23 -5
  36. package/gsd-core/bin/lib/git-info-exclude.cjs +52 -0
  37. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +31 -12
  38. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  39. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  40. package/gsd-core/bin/lib/health-diagnostic-types.cjs +4 -0
  41. package/gsd-core/bin/lib/health-diagnostic.cjs +52 -1
  42. package/gsd-core/bin/lib/host-integration.cjs +13 -6
  43. package/gsd-core/bin/lib/init.cjs +245 -68
  44. package/gsd-core/bin/lib/install-engine.cjs +246 -32
  45. package/gsd-core/bin/lib/install-profiles.cjs +9 -4
  46. package/gsd-core/bin/lib/installer-migration-report.cjs +2 -0
  47. package/gsd-core/bin/lib/io.cjs +6 -0
  48. package/gsd-core/bin/lib/markdown-line-diff.cjs +147 -0
  49. package/gsd-core/bin/lib/markdown-sectionizer.cjs +147 -1
  50. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  51. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  52. package/gsd-core/bin/lib/milestone-lock.cjs +8 -1
  53. package/gsd-core/bin/lib/milestone-release-map.cjs +244 -0
  54. package/gsd-core/bin/lib/milestone-release-tags.cjs +440 -0
  55. package/gsd-core/bin/lib/milestone.cjs +61 -51
  56. package/gsd-core/bin/lib/model-catalog.cjs +8 -0
  57. package/gsd-core/bin/lib/normalize-test-command.cjs +67 -0
  58. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  59. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  60. package/gsd-core/bin/lib/phase-id.cjs +629 -38
  61. package/gsd-core/bin/lib/phase-lifecycle.cjs +12 -44
  62. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  63. package/gsd-core/bin/lib/phase.cjs +3066 -653
  64. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  65. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  66. package/gsd-core/bin/lib/planning-snapshot.cjs +15 -8
  67. package/gsd-core/bin/lib/planning-workspace.cjs +21 -3
  68. package/gsd-core/bin/lib/pristine-baseline.cjs +11 -3
  69. package/gsd-core/bin/lib/read-only-git-env.cjs +79 -0
  70. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  71. package/gsd-core/bin/lib/review-lane-descriptor.cjs +57 -2
  72. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  73. package/gsd-core/bin/lib/review-lane-runner.cjs +33 -4
  74. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  75. package/gsd-core/bin/lib/roadmap-parser.cjs +735 -137
  76. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  77. package/gsd-core/bin/lib/roadmap-upgrade.cjs +47 -7
  78. package/gsd-core/bin/lib/roadmap.cjs +383 -123
  79. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +381 -14
  80. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +8 -4
  81. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +60 -16
  82. package/gsd-core/bin/lib/shell-command-projection.cjs +151 -19
  83. package/gsd-core/bin/lib/smart-entry.cjs +35 -11
  84. package/gsd-core/bin/lib/state-contract.cjs +39 -15
  85. package/gsd-core/bin/lib/state-document.cjs +344 -27
  86. package/gsd-core/bin/lib/state-transition.cjs +156 -32
  87. package/gsd-core/bin/lib/state.cjs +527 -186
  88. package/gsd-core/bin/lib/surface.cjs +9 -0
  89. package/gsd-core/bin/lib/task-command-router.cjs +5 -25
  90. package/gsd-core/bin/lib/task-content-resolution.cjs +46 -1
  91. package/gsd-core/bin/lib/template.cjs +2 -2
  92. package/gsd-core/bin/lib/validate.cjs +3 -2
  93. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  94. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  95. package/gsd-core/bin/lib/verification.cjs +96 -9
  96. package/gsd-core/bin/lib/verify.cjs +4 -2
  97. package/gsd-core/bin/lib/workstream-inventory.cjs +26 -20
  98. package/gsd-core/bin/lib/worktree-safety.cjs +22 -1
  99. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  100. package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
  101. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  102. package/gsd-core/references/cicd-review-patterns.md +45 -0
  103. package/gsd-core/references/git-integration.md +11 -0
  104. package/gsd-core/references/loop-hook-dispatch.md +13 -6
  105. package/gsd-core/references/necessity-review.md +48 -0
  106. package/gsd-core/references/planning-config.md +2 -0
  107. package/gsd-core/references/tdd.md +2 -2
  108. package/gsd-core/templates/DEBUG.md +16 -0
  109. package/gsd-core/templates/config.json +2 -1
  110. package/gsd-core/templates/verification-report.md +1 -1
  111. package/gsd-core/workflows/audit-fix.md +2 -1
  112. package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +10 -1
  113. package/gsd-core/workflows/code-review-fix.md +46 -5
  114. package/gsd-core/workflows/code-review.md +64 -7
  115. package/gsd-core/workflows/complete-milestone/steps/project-md-evolution-review.md +145 -0
  116. package/gsd-core/workflows/complete-milestone/steps/release-map.md +80 -0
  117. package/gsd-core/workflows/complete-milestone.md +37 -150
  118. package/gsd-core/workflows/diagnose-issues.md +29 -5
  119. package/gsd-core/workflows/discuss-phase/modes/power.md +1 -1
  120. package/gsd-core/workflows/discuss-phase-assumptions.md +33 -2
  121. package/gsd-core/workflows/discuss-phase-power.md +19 -2
  122. package/gsd-core/workflows/discuss-phase.md +6 -10
  123. package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +13 -7
  124. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +7 -2
  125. package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -1
  126. package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +2 -2
  127. package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +11 -2
  128. package/gsd-core/workflows/execute-phase.md +10 -6
  129. package/gsd-core/workflows/execute-plan.md +1 -1
  130. package/gsd-core/workflows/health.md +2 -1
  131. package/gsd-core/workflows/import.md +1 -1
  132. package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +2 -2
  133. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  134. package/gsd-core/workflows/plan-phase.md +14 -14
  135. package/gsd-core/workflows/pr-branch.md +2 -0
  136. package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -1
  137. package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +8 -1
  138. package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +2 -1
  139. package/gsd-core/workflows/quick.md +1 -1
  140. package/gsd-core/workflows/reapply-patches.md +104 -0
  141. package/gsd-core/workflows/review.md +70 -154
  142. package/gsd-core/workflows/section-manifest.json +5 -0
  143. package/gsd-core/workflows/secure-phase.md +1 -1
  144. package/gsd-core/workflows/settings-advanced.md +57 -9
  145. package/gsd-core/workflows/settings.md +1 -0
  146. package/gsd-core/workflows/ship.md +15 -4
  147. package/gsd-core/workflows/spec-phase.md +3 -6
  148. package/gsd-core/workflows/verify-work.md +4 -3
  149. package/hooks/dist/gsd-agent-isolation-guard.js +11 -1
  150. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  151. package/hooks/dist/gsd-graphify-update.sh +6 -0
  152. package/hooks/dist/gsd-path-export.js +95 -0
  153. package/hooks/dist/gsd-secret-read-guard.js +172 -39
  154. package/hooks/dist/gsd-statusline.js +37 -3
  155. package/hooks/dist/gsd-windsurf-pre-write.js +29 -2
  156. package/hooks/dist/gsd-workflow-guard.js +3 -2
  157. package/hooks/dist/gsd-worktree-path-guard.js +29 -2
  158. package/hooks/dist/lib/git-probe.js +16 -0
  159. package/hooks/dist/lib/isolation-sentinel.js +44 -0
  160. package/hooks/dist/managed-hooks-registry.cjs +1 -0
  161. package/hooks/gsd-agent-isolation-guard.js +11 -1
  162. package/hooks/gsd-cursor-subagent-start.js +6 -2
  163. package/hooks/gsd-graphify-update.sh +6 -0
  164. package/hooks/gsd-path-export.js +95 -0
  165. package/hooks/gsd-secret-read-guard.js +172 -39
  166. package/hooks/gsd-statusline.js +37 -3
  167. package/hooks/gsd-windsurf-pre-write.js +29 -2
  168. package/hooks/gsd-workflow-guard.js +3 -2
  169. package/hooks/gsd-worktree-path-guard.js +29 -2
  170. package/hooks/hooks.json +1 -0
  171. package/hooks/lib/git-probe.js +16 -0
  172. package/hooks/lib/isolation-sentinel.js +44 -0
  173. package/hooks/managed-hooks-registry.cjs +1 -0
  174. package/package.json +15 -8
  175. package/pi/gsd.cjs +1047 -10
  176. package/pi/package-agents/gsd-advisor-researcher.md +113 -0
  177. package/pi/package-agents/gsd-ai-researcher.md +111 -0
  178. package/pi/package-agents/gsd-assumptions-analyzer.md +110 -0
  179. package/pi/package-agents/gsd-code-fixer.md +774 -0
  180. package/pi/package-agents/gsd-code-reviewer.md +441 -0
  181. package/pi/package-agents/gsd-codebase-mapper.md +850 -0
  182. package/pi/package-agents/gsd-debug-session-manager.md +416 -0
  183. package/pi/package-agents/gsd-debugger.md +1274 -0
  184. package/pi/package-agents/gsd-doc-classifier.md +269 -0
  185. package/pi/package-agents/gsd-doc-synthesizer.md +259 -0
  186. package/pi/package-agents/gsd-doc-verifier.md +212 -0
  187. package/pi/package-agents/gsd-doc-writer.md +626 -0
  188. package/pi/package-agents/gsd-dom-verifier.md +164 -0
  189. package/pi/package-agents/gsd-domain-researcher.md +144 -0
  190. package/pi/package-agents/gsd-eval-auditor.md +185 -0
  191. package/pi/package-agents/gsd-eval-planner.md +149 -0
  192. package/pi/package-agents/gsd-executor.md +898 -0
  193. package/pi/package-agents/gsd-framework-selector.md +159 -0
  194. package/pi/package-agents/gsd-integration-checker.md +476 -0
  195. package/pi/package-agents/gsd-intel-updater.md +338 -0
  196. package/pi/package-agents/gsd-mempalace-curator.md +49 -0
  197. package/pi/package-agents/gsd-nyquist-auditor.md +200 -0
  198. package/pi/package-agents/gsd-pattern-mapper.md +340 -0
  199. package/pi/package-agents/gsd-phase-researcher.md +897 -0
  200. package/pi/package-agents/gsd-plan-checker.md +1077 -0
  201. package/pi/package-agents/gsd-planner.md +1003 -0
  202. package/pi/package-agents/gsd-project-researcher.md +611 -0
  203. package/pi/package-agents/gsd-research-synthesizer.md +259 -0
  204. package/pi/package-agents/gsd-roadmapper.md +779 -0
  205. package/pi/package-agents/gsd-security-auditor.md +171 -0
  206. package/pi/package-agents/gsd-ui-auditor.md +581 -0
  207. package/pi/package-agents/gsd-ui-checker.md +417 -0
  208. package/pi/package-agents/gsd-ui-researcher.md +469 -0
  209. package/pi/package-agents/gsd-user-profiler.md +174 -0
  210. package/pi/package-agents/gsd-verifier.md +963 -0
  211. package/pi/package-agents/manifest.json +39 -0
  212. package/pi/package-skills/gsd-add-tests/SKILL.md +39 -0
  213. package/pi/package-skills/gsd-ai-integration-phase/SKILL.md +36 -0
  214. package/pi/package-skills/gsd-audit-fix/SKILL.md +34 -0
  215. package/pi/package-skills/gsd-audit-milestone/SKILL.md +40 -0
  216. package/pi/package-skills/gsd-audit-uat/SKILL.md +26 -0
  217. package/pi/package-skills/gsd-autonomous/SKILL.md +52 -0
  218. package/pi/package-skills/gsd-capture/SKILL.md +68 -0
  219. package/pi/package-skills/gsd-cleanup/SKILL.md +25 -0
  220. package/pi/package-skills/gsd-code-review/SKILL.md +71 -0
  221. package/pi/package-skills/gsd-complete-milestone/SKILL.md +147 -0
  222. package/pi/package-skills/gsd-config/SKILL.md +60 -0
  223. package/pi/package-skills/gsd-debug/SKILL.md +64 -0
  224. package/pi/package-skills/gsd-discuss-phase/SKILL.md +74 -0
  225. package/pi/package-skills/gsd-docs-update/SKILL.md +50 -0
  226. package/pi/package-skills/gsd-eval-review/SKILL.md +35 -0
  227. package/pi/package-skills/gsd-execute-phase/SKILL.md +62 -0
  228. package/pi/package-skills/gsd-explore/SKILL.md +26 -0
  229. package/pi/package-skills/gsd-extract-learnings/SKILL.md +25 -0
  230. package/pi/package-skills/gsd-fast/SKILL.md +33 -0
  231. package/pi/package-skills/gsd-forensics/SKILL.md +59 -0
  232. package/pi/package-skills/gsd-graphify/SKILL.md +210 -0
  233. package/pi/package-skills/gsd-health/SKILL.md +36 -0
  234. package/pi/package-skills/gsd-help/SKILL.md +37 -0
  235. package/pi/package-skills/gsd-import/SKILL.md +46 -0
  236. package/pi/package-skills/gsd-inbox/SKILL.md +42 -0
  237. package/pi/package-skills/gsd-ingest-docs/SKILL.md +43 -0
  238. package/pi/package-skills/gsd-manager/SKILL.md +46 -0
  239. package/pi/package-skills/gsd-map-codebase/SKILL.md +85 -0
  240. package/pi/package-skills/gsd-mempalace-capture/SKILL.md +111 -0
  241. package/pi/package-skills/gsd-mempalace-recall/SKILL.md +111 -0
  242. package/pi/package-skills/gsd-milestone-summary/SKILL.md +54 -0
  243. package/pi/package-skills/gsd-mvp-phase/SKILL.md +45 -0
  244. package/pi/package-skills/gsd-new-milestone/SKILL.md +49 -0
  245. package/pi/package-skills/gsd-new-project/SKILL.md +49 -0
  246. package/pi/package-skills/gsd-next/SKILL.md +34 -0
  247. package/pi/package-skills/gsd-ns-context/SKILL.md +31 -0
  248. package/pi/package-skills/gsd-ns-ideate/SKILL.md +30 -0
  249. package/pi/package-skills/gsd-ns-manage/SKILL.md +42 -0
  250. package/pi/package-skills/gsd-ns-project/SKILL.md +34 -0
  251. package/pi/package-skills/gsd-ns-review/SKILL.md +35 -0
  252. package/pi/package-skills/gsd-ns-workflow/SKILL.md +44 -0
  253. package/pi/package-skills/gsd-onboard/SKILL.md +46 -0
  254. package/pi/package-skills/gsd-pause-work/SKILL.md +48 -0
  255. package/pi/package-skills/gsd-phase/SKILL.md +61 -0
  256. package/pi/package-skills/gsd-plan-phase/SKILL.md +62 -0
  257. package/pi/package-skills/gsd-plan-review-convergence/SKILL.md +63 -0
  258. package/pi/package-skills/gsd-pr-branch/SKILL.md +31 -0
  259. package/pi/package-skills/gsd-profile-user/SKILL.md +48 -0
  260. package/pi/package-skills/gsd-progress/SKILL.md +51 -0
  261. package/pi/package-skills/gsd-quick/SKILL.md +179 -0
  262. package/pi/package-skills/gsd-quick-batch/SKILL.md +114 -0
  263. package/pi/package-skills/gsd-resume-work/SKILL.md +31 -0
  264. package/pi/package-skills/gsd-review/SKILL.md +50 -0
  265. package/pi/package-skills/gsd-review-backlog/SKILL.md +65 -0
  266. package/pi/package-skills/gsd-secure-phase/SKILL.md +37 -0
  267. package/pi/package-skills/gsd-settings/SKILL.md +30 -0
  268. package/pi/package-skills/gsd-ship/SKILL.md +27 -0
  269. package/pi/package-skills/gsd-sketch/SKILL.md +56 -0
  270. package/pi/package-skills/gsd-spec-phase/SKILL.md +63 -0
  271. package/pi/package-skills/gsd-spike/SKILL.md +53 -0
  272. package/pi/package-skills/gsd-stats/SKILL.md +22 -0
  273. package/pi/package-skills/gsd-surface/SKILL.md +177 -0
  274. package/pi/package-skills/gsd-thread/SKILL.md +29 -0
  275. package/pi/package-skills/gsd-ui-phase/SKILL.md +35 -0
  276. package/pi/package-skills/gsd-ui-review/SKILL.md +35 -0
  277. package/pi/package-skills/gsd-ultraplan-phase/SKILL.md +38 -0
  278. package/pi/package-skills/gsd-undo/SKILL.md +38 -0
  279. package/pi/package-skills/gsd-update/SKILL.md +54 -0
  280. package/pi/package-skills/gsd-validate-phase/SKILL.md +37 -0
  281. package/pi/package-skills/gsd-verify-work/SKILL.md +41 -0
  282. package/pi/package-skills/gsd-workspace/SKILL.md +57 -0
  283. package/pi/package-skills/gsd-workstreams/SKILL.md +73 -0
  284. package/scripts/build-hooks.js +4 -0
  285. package/scripts/ci-next-health.cjs +246 -23
  286. package/scripts/command-contract-helpers.cjs +1 -1
  287. package/scripts/docs-guard-registry.cjs +22 -7
  288. package/scripts/gen-features.cjs +14 -2
  289. package/scripts/gen-health-docs.cjs +13 -1
  290. package/scripts/gen-pi-package-skills.cjs +217 -0
  291. package/scripts/lib/drift-scan.cjs +27 -0
  292. package/scripts/lib/macos-conformance-tier.generated.cjs +17 -1
  293. package/scripts/lib/platform-conformance-tier.generated.cjs +37 -1
  294. package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +3 -3
  295. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  296. package/scripts/lint-retired-runtime-name.cjs +5 -0
  297. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  298. package/scripts/mutation-matrix.cjs +23 -1
  299. package/scripts/npm-audit-baseline.cjs +41 -7
  300. package/scripts/reviewed-or-maintainer-policy.cjs +181 -0
  301. package/scripts/select-docs-guards.cjs +1 -1
  302. package/scripts/self-test-report-policy.cjs +350 -0
  303. package/scripts/sync-next-version.cjs +38 -6
  304. package/skills/gsd-update/SKILL.md +5 -2
  305. package/vscode/package.json +1 -1
  306. package/scripts/diagnose-validate-commit-in-tier.cjs +0 -270
  307. package/scripts/diagnose-validate-commit-phases.cjs +0 -348
  308. package/scripts/xtrace-phase-timing.cjs +0 -205
@@ -56,7 +56,9 @@
56
56
  // EOF)"` — is never operand-checked, so prose mentioning `.env` is safe.
57
57
  // pass 2 groups tokens by segment and evaluates each on its own:
58
58
  // input redirect targets (`<`, `N<`) are always checked; the command word is
59
- // located past `sudo`/`env VAR=x`/`nohup`-style prefixes; a closed set of
59
+ // located past wrapper prefixes AND their own options (`sudo -u U`,
60
+ // `env -i VAR=x`, `timeout -s KILL 5`, `nice -n 5`, `stdbuf -oL`, `exec`;
61
+ // see WRAPPERS, #5627); a closed set of
60
62
  // NON-READING commands (test/[/ls/stat/rm/touch/echo/…) exempts that
61
63
  // segment's operands — `[ -f .env ]` and `ls .env*` are existence checks
62
64
  // GSD's own agents run — while `cp`/`mv`/`ln`/`git` are deliberately NOT
@@ -90,7 +92,9 @@
90
92
  // (`cat .e*`), interpreter one-liners (`python -c "open('.env')"`), a piped
91
93
  // script from a non-echo source (`cat gen.sh | bash`, `curl … | sh`), reads
92
94
  // inside scripts the agent executes, and `glob: '*'` reaching a
93
- // NON-gitignored `.env`. The promise is "no looser than the retired rules
95
+ // NON-gitignored `.env`, and a wrapper (or wrapper option taking a
96
+ // separate value) the WRAPPERS table does not list, or `env -S 'script'` (the split string is skipped as
97
+ // the option's value). The promise is "no looser than the retired rules
94
98
  // on plain commands, without arming the compound-`cd` prompt". Writes to
95
99
  // secret files are out of scope (Write/Edit were never gated). Commands
96
100
  // over 1 MiB are denied outright (`command-too-large`) rather than
@@ -148,8 +152,29 @@ const MAX_GLOB_ALTERNATIVES = 64;
148
152
  const NON_SECRET_ENV_SUFFIXES = new Set(['example', 'sample', 'template', 'dist']);
149
153
 
150
154
  // Command-prefix wrappers to look through when locating the command word at
151
- // the head of a segment (same set as hooks/gsd-windsurf-pre-command.js).
152
- const CMD_PREFIXES = new Set(['sudo', 'env', 'command', 'nice', 'nohup', 'time', 'doas']);
155
+ // the head of a segment (#5627). Each wrapper runs the words that follow its
156
+ // own options, so the table records what resolveCommand must step over:
157
+ // short letters of short options that take a value (`-n 5`, `-n5`)
158
+ // long long options that take a SEPARATE value word (`--adjustment 5`);
159
+ // `--opt=value` is one word and needs no entry
160
+ // assigns leading `VAR=value` words belong to the wrapper (env)
161
+ // operands positional words after the options (timeout's DURATION)
162
+ // Taken from sudo(8), doas(1), env(1), nice(1), time(1), timeout(1),
163
+ // stdbuf(1), setsid(1) and bash's `command`/`exec`. A wrong entry fails open
164
+ // (the option's value becomes the command word), so keep it to the man pages.
165
+ const WRAPPERS = new Map([
166
+ ['sudo', { short: 'CDghpRrTtUu', long: ['--close-from', '--chdir', '--group', '--host', '--prompt', '--chroot', '--role', '--command-timeout', '--type', '--other-user', '--user'] }],
167
+ ['doas', { short: 'Cu' }],
168
+ ['env', { short: 'uCSaP', long: ['--unset', '--chdir', '--split-string', '--argv0'], assigns: true }],
169
+ ['nice', { short: 'n', long: ['--adjustment'] }],
170
+ ['nohup', {}],
171
+ ['command', {}],
172
+ ['time', { short: 'fo', long: ['--format', '--output'] }],
173
+ ['timeout', { short: 'sk', long: ['--signal', '--kill-after'], operands: 1 }],
174
+ ['stdbuf', { short: 'ioe', long: ['--input', '--output', '--error'] }],
175
+ ['setsid', {}],
176
+ ['exec', { short: 'a' }],
177
+ ]);
153
178
 
154
179
  // Commands whose ordinary operands are file NAMES, never file CONTENTS. A
155
180
  // closed set on purpose: anything not listed is assumed to read.
@@ -405,6 +430,20 @@ function classifyGrepGlob(glob) {
405
430
  // Bash command scan — pass 1: tokenizer
406
431
  // ---------------------------------------------------------------------------
407
432
 
433
+ // Which reading of a heredoc the scan is making (#5513). The guard scans a command that
434
+ // opens a heredoc under every reading below and denies if any of them finds a secret read
435
+ // (see findSecretReadUnderOtherReadings), so a change of reading can add a denial but
436
+ // never remove one.
437
+ // - heredocBodiesSkippedInSpans: bash 5 skips the bodies of heredocs opened inside a
438
+ // `$( )` span when it looks for the closing `)`; bash 3.2.57 (still `/bin/bash` on
439
+ // macOS) does not, so a `)` on a body line closes the substitution and the rest of
440
+ // that line runs as the OUTER command.
441
+ // - heredocTagsAsWords: an unquoted tag is a bash word (a CR and a substitution belong
442
+ // to it). false is the earlier reading, where the tag stopped at any whitespace and at
443
+ // `(`: kept as a reading so nothing the guard denied before is allowed now.
444
+ let heredocBodiesSkippedInSpans = true;
445
+ let heredocTagsAsWords = true;
446
+
408
447
  // Index of the `)` closing a `$(` / `<(` / `>(` opened just before `i`, or
409
448
  // str.length when unterminated. Quote- and heredoc-aware so a `)` inside a
410
449
  // quoted string or a heredoc body never closes the span early.
@@ -439,7 +478,7 @@ function findParenClose(str, i) {
439
478
  i = j === -1 ? str.length : j + 1;
440
479
  continue;
441
480
  }
442
- if (ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
481
+ if (heredocBodiesSkippedInSpans && ch === '<' && str[i + 1] === '<' && str[i + 2] !== '<') {
443
482
  const tag = readHeredocTag(str, i + 2);
444
483
  heredocTags.push(tag);
445
484
  i = tag.end;
@@ -475,7 +514,34 @@ function readHeredocTag(str, i) {
475
514
  i = j === -1 ? str.length : j + 1;
476
515
  } else {
477
516
  if (str[i] === '\\') { quoted = true; i++; }
478
- while (i < str.length && !/[\s;&|<>()]/.test(str[i])) tag += str[i++];
517
+ // #5513: an unquoted tag is a bash WORD. It ends only at a metacharacter (space, tab,
518
+ // newline, `;&|<>()`): a carriage return is not one, so `EOF` + CR is the tag and the
519
+ // CRLF terminator line matches it; and a substitution inside the word belongs to it,
520
+ // taken literally (`E$(x)F` waits for the line `E$(x)F`).
521
+ while (i < str.length && !(heredocTagsAsWords ? /[ \t\n;&|<>()]/ : /[\s;&|<>()]/).test(str[i])) {
522
+ if (!heredocTagsAsWords) { tag += str[i++]; continue; }
523
+ if (str[i] === '$' && str[i + 1] === '(') {
524
+ const e = findParenClose(str, i + 2);
525
+ tag += str.slice(i, e + 1);
526
+ i = e + 1;
527
+ continue;
528
+ }
529
+ if (str[i] === '$' && str[i + 1] === '{') {
530
+ const j = str.indexOf('}', i);
531
+ const e = j === -1 ? str.length - 1 : j;
532
+ tag += str.slice(i, e + 1);
533
+ i = e + 1;
534
+ continue;
535
+ }
536
+ if (str[i] === '`') {
537
+ const j = str.indexOf('`', i + 1);
538
+ const e = j === -1 ? str.length - 1 : j;
539
+ tag += str.slice(i, e + 1);
540
+ i = e + 1;
541
+ continue;
542
+ }
543
+ tag += str[i++];
544
+ }
479
545
  }
480
546
  return { tag, quoted, stripTabs, end: i };
481
547
  }
@@ -621,6 +687,7 @@ function tokenize(str) {
621
687
  hasWord = true;
622
688
  const e = findParenClose(str, i + 2);
623
689
  nested.push(str.slice(i + 2, e));
690
+ if (expectTag && heredocTagsAsWords) buf += str.slice(i, e + 1); // #5513: the tag is taken literally
624
691
  i = e + 1;
625
692
  continue;
626
693
  }
@@ -639,6 +706,7 @@ function tokenize(str) {
639
706
  const j = str.indexOf('`', i + 1);
640
707
  const e = j === -1 ? str.length : j;
641
708
  nested.push(str.slice(i + 1, e));
709
+ if (expectTag && heredocTagsAsWords) buf += str.slice(i, e + 1); // #5513: the tag is taken literally
642
710
  i = e + 1;
643
711
  continue;
644
712
  }
@@ -673,6 +741,14 @@ function tokenize(str) {
673
741
  continue;
674
742
  }
675
743
 
744
+ // #5513: a CR is not a bash metacharacter, so inside a heredoc tag it is part of the word
745
+ // (a CRLF command's terminator line is `EOF` + CR).
746
+ if (ch === '\r' && expectTag && hasWord && heredocTagsAsWords) {
747
+ buf += ch;
748
+ i++;
749
+ continue;
750
+ }
751
+
676
752
  if (ch === ' ' || ch === '\t' || ch === '\r') {
677
753
  flush();
678
754
  i++;
@@ -769,17 +845,33 @@ function tokenize(str) {
769
845
  // Bash command scan — pass 2: per-segment evaluation
770
846
  // ---------------------------------------------------------------------------
771
847
 
772
- // `@file` (curl -d), `--flag=value`, `-Xvalue` → the operand that names the file.
773
- function normalizeOperand(text) {
848
+ // Option characters of a bundled short cluster (`-2nif`).
849
+ const CLUSTER_FLAG_CHAR_RE = /[A-Za-z0-9]/;
850
+
851
+ // True when an operand word names a secret file. `@file` (curl -d) and
852
+ // `--flag=value` are stripped to the value. In a single-dash word
853
+ // (`/^-[^-]./`, so `-2if.env` counts too) the value of a bundled cluster
854
+ // starts right after its run of option characters (#5046): `-if.env` holds
855
+ // `.env`, `-nf=.env` holds `.env` after the `=`. That value is checked as
856
+ // well as the historical two-character strip, so the verdict is never looser
857
+ // than base and costs a fixed number of `namesSecret` calls (at most three,
858
+ // whatever the word's length). The option run also swallows a value's leading
859
+ // letters and digits, so `-fapp.env` is denied although `cat app.env` is not:
860
+ // a fail-safe false positive, pinned in tests.
861
+ function operandNamesSecret(text) {
774
862
  let v = text;
775
863
  if (v.startsWith('@')) v = v.slice(1);
776
864
  if (v.startsWith('--')) {
777
865
  const eq = v.indexOf('=');
778
866
  if (eq !== -1) v = v.slice(eq + 1);
779
- } else if (/^-[A-Za-z]./.test(v)) {
780
- v = v.slice(2);
867
+ return namesSecret(v);
781
868
  }
782
- return v;
869
+ if (!/^-[^-]./.test(v)) return namesSecret(v);
870
+ if (namesSecret(v.slice(2))) return true;
871
+ let end = 1;
872
+ while (end < v.length && CLUSTER_FLAG_CHAR_RE.test(v[end])) end++;
873
+ if (end > 2 && namesSecret(v.slice(end))) return true;
874
+ return v[end] === '=' && namesSecret(v.slice(end + 1));
783
875
  }
784
876
 
785
877
  const ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=/;
@@ -789,18 +881,55 @@ const DASH_C_RE = /^-[A-Za-z]*c$/;
789
881
 
790
882
  const GROUPING_SEPS = new Set(['(', ')', '{', '}']);
791
883
 
884
+ // How many words the option `word` occupies for wrapper `spec`: 2 when it
885
+ // takes a separate value word, else 1.
886
+ function wrapperOptionWidth(word, spec) {
887
+ if (word.startsWith('--')) {
888
+ if (word.includes('=')) return 1;
889
+ // getopt_long accepts any unambiguous prefix (`--adj 5`).
890
+ return word.length > 2 && (spec.long || []).some((l) => l.startsWith(word)) ? 2 : 1;
891
+ }
892
+ const valued = spec.short || '';
893
+ for (let i = 1; i < word.length; i++) {
894
+ if (valued.includes(word[i])) return i === word.length - 1 ? 2 : 1; // `-n5` carries its value
895
+ }
896
+ return 1;
897
+ }
898
+
899
+ // Index of the first word after a wrapper's own options, assignments and
900
+ // positional operands (`-n 5`, `-i A=1`, `-s KILL 5`, `--`).
901
+ function skipWrapperArgs(words, start, spec) {
902
+ let idx = start;
903
+ let optionsEnded = false;
904
+ while (idx < words.length) {
905
+ const text = words[idx].text;
906
+ if (spec.assigns && ASSIGNMENT_RE.test(text)) {
907
+ idx++;
908
+ } else if (!optionsEnded && text === '--') {
909
+ optionsEnded = true;
910
+ idx++;
911
+ } else if (!optionsEnded && text.length > 1 && text.startsWith('-')) {
912
+ idx += wrapperOptionWidth(text, spec);
913
+ } else {
914
+ break;
915
+ }
916
+ }
917
+ if (!spec.operands) return idx;
918
+ idx = Math.min(idx + spec.operands, words.length);
919
+ // `timeout 5 -- cmd`: some getopt builds still accept `--` after DURATION.
920
+ return idx < words.length && words[idx].text === '--' ? idx + 1 : idx;
921
+ }
922
+
792
923
  // Command base + operands after leading `VAR=val` assignments and prefix
793
- // wrappers (`sudo`, `env VAR=x`, …), or null when nothing but prefixes remain.
924
+ // wrappers with their own options (`sudo -u root`, `env -i VAR=x`,
925
+ // `timeout -s KILL 5`, `nice -n 5`, …), or null when nothing but prefixes remain.
794
926
  function resolveCommand(words) {
795
927
  let idx = 0;
796
928
  while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
797
929
  while (idx < words.length) {
798
- const base = lastSegment(words[idx].text).toLowerCase();
799
- if (!CMD_PREFIXES.has(base)) break;
800
- idx++;
801
- if (base === 'env') {
802
- while (idx < words.length && ASSIGNMENT_RE.test(words[idx].text)) idx++;
803
- }
930
+ const spec = WRAPPERS.get(lastSegment(words[idx].text).toLowerCase());
931
+ if (!spec) break;
932
+ idx = skipWrapperArgs(words, idx + 1, spec);
804
933
  }
805
934
  if (idx >= words.length) return null;
806
935
  return { base: lastSegment(words[idx].text).toLowerCase(), operands: words.slice(idx + 1) };
@@ -962,6 +1091,26 @@ function precedingOp(s, bySeg, sepAfter) {
962
1091
  return { op, prevSeg: p };
963
1092
  }
964
1093
 
1094
+ // #5513: the same scan under the other readings of a heredoc: bash 3.2.57's `$( )` span
1095
+ // (bodies not skipped) and the earlier tag reading (tag ends at any whitespace). Only a
1096
+ // command that opens a heredoc can read differently, so the extra passes are skipped for
1097
+ // every other command.
1098
+ function findSecretReadUnderOtherReadings(command) {
1099
+ if (!command.includes('<<')) return null;
1100
+ for (const [bodiesSkipped, tagsAsWords] of [[false, true], [true, false]]) {
1101
+ heredocBodiesSkippedInSpans = bodiesSkipped;
1102
+ heredocTagsAsWords = tagsAsWords;
1103
+ try {
1104
+ const hit = findSecretRead(command, 0);
1105
+ if (hit) return hit;
1106
+ } finally {
1107
+ heredocBodiesSkippedInSpans = true;
1108
+ heredocTagsAsWords = true;
1109
+ }
1110
+ }
1111
+ return null;
1112
+ }
1113
+
965
1114
  // Returns the offending token text, or null.
966
1115
  function findSecretRead(command, depth) {
967
1116
  const { tokens, nested } = tokenize(command);
@@ -1040,7 +1189,7 @@ function findSecretRead(command, depth) {
1040
1189
  const hit = findSecretRead(src, depth + 1);
1041
1190
  if (hit) return hit;
1042
1191
  }
1043
- } else if (namesSecret(normalizeOperand(w.text))) return w.text;
1192
+ } else if (operandNamesSecret(w.text)) return w.text;
1044
1193
  }
1045
1194
  continue;
1046
1195
  }
@@ -1070,7 +1219,7 @@ function findSecretRead(command, depth) {
1070
1219
  const nameOnly = nameOnlyOperandIndices(base, operands);
1071
1220
  for (let k = 0; k < operands.length; k++) {
1072
1221
  if (nameOnly.has(k)) continue;
1073
- if (namesSecret(normalizeOperand(operands[k].text))) return operands[k].text;
1222
+ if (operandNamesSecret(operands[k].text)) return operands[k].text;
1074
1223
  }
1075
1224
  }
1076
1225
  return null;
@@ -1088,21 +1237,6 @@ function isDebuggerAgent(value) {
1088
1237
  return value.slice(value.lastIndexOf(':') + 1) === DEBUGGER_AGENT_NAME;
1089
1238
  }
1090
1239
 
1091
- // `timeout [flags] DURATION cmd …` is not in CMD_PREFIXES because its first
1092
- // operand is a duration, not the command. Returns the command that follows it.
1093
- function unwrapTimeout(cmd) {
1094
- let { base, operands } = cmd;
1095
- while (base === 'timeout') {
1096
- let k = 0;
1097
- while (k < operands.length && operands[k].text.startsWith('-')) k++;
1098
- k++; // the DURATION operand
1099
- const rest = resolveCommand(operands.slice(k));
1100
- if (!rest) return null;
1101
- ({ base, operands } = rest);
1102
- }
1103
- return { base, operands };
1104
- }
1105
-
1106
1240
  // Returns the `--agent` value text of a debugger launch, or null.
1107
1241
  function findDebuggerCliSpawn(command, depth) {
1108
1242
  const { tokens, nested } = tokenize(command);
@@ -1134,8 +1268,7 @@ function findDebuggerCliSpawn(command, depth) {
1134
1268
  }
1135
1269
  if (!words.length) continue;
1136
1270
 
1137
- const first = resolveCommand(words);
1138
- const cmd = first && unwrapTimeout(first);
1271
+ const cmd = resolveCommand(words);
1139
1272
  if (!cmd) continue;
1140
1273
  const { base, operands } = cmd;
1141
1274
 
@@ -1248,7 +1381,7 @@ function scanXargsPipe(operands, s, bySeg, sepAfter, depth) {
1248
1381
  const prevCmd = resolveCommand((bySeg.get(prevSeg) || []).filter((t) => t.kind === 'word'));
1249
1382
  if (!prevCmd) return null;
1250
1383
  for (const w of prevCmd.operands) {
1251
- if (namesSecret(normalizeOperand(w.text))) return w.text;
1384
+ if (operandNamesSecret(w.text)) return w.text;
1252
1385
  }
1253
1386
  return null;
1254
1387
  }
@@ -1423,7 +1556,7 @@ process.stdin.on('end', () => {
1423
1556
  const command = typeof data.tool_input.command === 'string' ? data.tool_input.command : '';
1424
1557
  if (command === '') allow(undefined);
1425
1558
  if (command.length > MAX_COMMAND_LENGTH) emitBlock('command-too-large', tool, '');
1426
- const hit = findSecretRead(command, 0);
1559
+ const hit = findSecretRead(command, 0) || findSecretReadUnderOtherReadings(command);
1427
1560
  if (hit !== null) emitBlock('secret-read', tool, hit);
1428
1561
  const spawn = findDebuggerCliSpawn(command, 0);
1429
1562
  if (spawn !== null) emitBlock('debugger-cli-spawn', tool, spawn);
@@ -18,6 +18,7 @@ const os = require('os');
18
18
  // monkeypatching childProcess.execFileSync.
19
19
  const childProcess = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, crash } = require('./lib/hook-exit.js');
21
+ const { gitEnv } = require('./lib/git-probe.js');
21
22
 
22
23
  // This hook's build-seam outer catch (require.main guard just below) has
23
24
  // always exited 0 (fail open — the statusline renders on EVERY prompt, so a
@@ -889,8 +890,40 @@ function compactModelName(name) {
889
890
  // are a fixed array, and the workspace dir is passed via -C. Fails silently
890
891
  // (segment absent) outside a repo, without git, or on timeout.
891
892
 
893
+ // #5048: bound for the `git status` spawn below. With GIT_OPTIONAL_LOCKS=0 the
894
+ // spawn no longer writes the refreshed index back, so what is left is reading
895
+ // the index and stat-ing the tracked paths. The bound keeps a hung network
896
+ // filesystem from stalling a render for long while leaving room for a cold stat
897
+ // cache on a large repo. The figure was chosen, not measured: time
898
+ // `git status --porcelain=v2 --branch` on a large tree with a cold cache before
899
+ // changing it. Exported so the test can pin the bound instead of restating the
900
+ // literal (which is how the constant and its assertion drifted before).
892
901
  const GIT_STATUS_TIMEOUT_MS = 1500;
893
902
 
903
+ /**
904
+ * #5048: env for the read-only git spawn in this file.
905
+ *
906
+ * By default `git status` refreshes the index and takes an *optional*
907
+ * `.git/index.lock` to write the refreshed copy back. That is a write the
908
+ * caller never asked for, and it turns a pure read into a contender for the
909
+ * lock a real `git add` / `git commit` needs — a read-only statusline render
910
+ * can then make a concurrent commit fail with
911
+ * `Unable to create '.git/index.lock': File exists`. The statusline spawns
912
+ * git on every render, so the lock is re-created continuously.
913
+ *
914
+ * The env block itself is owned by src/read-only-git-env.cts and shared with
915
+ * every other read-only seam (`execGit` in src/shell-command-projection.cts,
916
+ * `readGitSignals` in src/smart-entry.cts, `gitExec` in
917
+ * src/pristine-baseline.cts, the two blocking guards). This hook reads it per
918
+ * call rather than snapshotting at load, so a later `process.env` mutation is
919
+ * visible and the seams behave identically.
920
+ *
921
+ * Deliberately NOT applied to the `git rev-list` spawn in
922
+ * readStateHeadCommits(): that walks the commit graph and never opens the
923
+ * index, so the variable would be a no-op there.
924
+ */
925
+ const { readOnlyGitEnv } = require('../gsd-core/bin/lib/read-only-git-env.cjs');
926
+
894
927
  /**
895
928
  * Run `git status --porcelain=v2 --branch` in dir.
896
929
  * Returns raw stdout, or null when git is missing, dir isn't a repo, or the
@@ -902,7 +935,7 @@ function readGitStatus(dir) {
902
935
  // or untracked files; overflow still degrades safely to segment-absent via
903
936
  // the catch below.
904
937
  return childProcess.execFileSync('git', ['-C', dir, 'status', '--porcelain=v2', '--branch'],
905
- { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
938
+ { encoding: 'utf8', timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: 8 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: readOnlyGitEnv(gitEnv()) });
906
939
  } catch (e) {
907
940
  return null;
908
941
  }
@@ -995,9 +1028,9 @@ function isValidStateHeadStamp(raw) {
995
1028
  */
996
1029
  function readStateHeadCommits(root, stamp) {
997
1030
  try {
998
- return childProcess.execFileSync('git',
1031
+ return childProcess.execFileSync('git', // allow-index-refresh: rev-list walks the commit graph and never opens the index
999
1032
  ['-C', root, 'rev-list', '--left-right', '--count', `${stamp}...HEAD`],
1000
- { encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
1033
+ { env: gitEnv(), encoding: 'utf8', timeout: STATE_FRESHNESS_GIT_TIMEOUT_MS,
1001
1034
  stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
1002
1035
  } catch (e) {
1003
1036
  return null;
@@ -1396,6 +1429,7 @@ module.exports = {
1396
1429
  isAutoCompactDisabled, AUTO_COMPACT_DISABLE_ENV_KEYS, AUTO_COMPACT_ENV_TRUTHY,
1397
1430
  resolveAutoCompactThreshold, contextMeter, SETTINGS_FILE_MAX_BYTES,
1398
1431
  parseAutoCompactWindowEnv, canonicalModelKey, isAutoCompactWindowValue,
1432
+ GIT_STATUS_TIMEOUT_MS,
1399
1433
  };
1400
1434
 
1401
1435
  /**
@@ -31,7 +31,7 @@ const fs = require('fs');
31
31
  const path = require('path');
32
32
  const { spawnSync } = require('child_process');
33
33
  const { allow, deny } = require('./lib/hook-exit.js');
34
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
34
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
35
35
 
36
36
  // #3911 (ADR-3889 Phase 7): the exit(2) call site (block(), below) is
37
37
  // migrated to hook-exit.js's deny(undefined, reason) — see
@@ -49,8 +49,35 @@ const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib
49
49
  // timeout does. The guard keeps its fail-open posture either way.
50
50
  const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: BLOCKING_GUARD_PROBE_TIMEOUT_MS, windowsHide: true };
51
51
 
52
+ // #5048: GIT_OPTIONAL_LOCKS=0 on every git call from this hook. `git rev-parse`
53
+ // never opens the index, so this is inert for today's callers; it is set anyway
54
+ // so the read-only invariant has no holes if a `status` / `diff` caller is ever
55
+ // added here. git refreshes the index and takes an *optional*
56
+ // `.git/index.lock` on some reads, and this hook runs on every tool call, so a
57
+ // read here can otherwise fail a concurrent `git add` / `git commit` with
58
+ // `Unable to create '.git/index.lock': File exists`.
59
+ //
60
+ // The env block is owned by src/read-only-git-env.cts and shared with every
61
+ // other read-only seam (built artifact, not a sibling hooks/-tree file — same
62
+ // rule hooks/lib/git-cmd.js follows for token-scanner). It is read per spawn,
63
+ // not snapshotted at load, so the two guards behave identically.
64
+ // Parity: tests/git-optional-locks-parity.test.cjs.
65
+ // A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
66
+ // (#3582), so the compiled owner can be absent. This guard only runs
67
+ // `git rev-parse`, which never opens the index, so the scrubbed gitEnv() base
68
+ // loses nothing; requiring the artifact unguarded would throw at load and the
69
+ // containment guard would silently stop running.
70
+ let readOnlyGitEnv = (base = process.env) => base;
71
+ try {
72
+ const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
73
+ ensureRuntimeBuild();
74
+ ({ readOnlyGitEnv } = require('../gsd-core/bin/lib/read-only-git-env.cjs'));
75
+ } catch {
76
+ // artifact absent and not buildable: keep the inert fallback above
77
+ }
78
+
52
79
  function git(args, cwd) {
53
- return spawnSync('git', args, { ...SPAWNOPT, cwd });
80
+ return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv(gitEnv()) });
54
81
  }
55
82
 
56
83
  // Walk up from `start` to find the nearest existing DIRECTORY (not merely an
@@ -22,7 +22,7 @@ const path = require('path');
22
22
  const { spawnSync } = require('child_process');
23
23
  const { tokenize, skipToSubcommand } = require('./lib/git-cmd.js');
24
24
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
25
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
25
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
26
26
 
27
27
  // This guard is almost entirely advisory (fail open — a broken advisory must
28
28
  // never wedge every tool call), with ONE hard block (#3504 force-add-on-
@@ -81,8 +81,9 @@ function forceGitAddCwds(command, defaultCwd) {
81
81
  }
82
82
 
83
83
  function currentBranch(cwd) {
84
- const result = spawnSync('git', ['branch', '--show-current'], {
84
+ const result = spawnSync('git', ['branch', '--show-current'], { // allow-index-refresh: reads HEAD only, never opens the index
85
85
  cwd,
86
+ env: gitEnv(),
86
87
  encoding: 'utf8',
87
88
  stdio: ['ignore', 'pipe', 'ignore'],
88
89
  windowsHide: true,
@@ -18,7 +18,7 @@ const fs = require('fs');
18
18
  const path = require('path');
19
19
  const { spawnSync } = require('child_process');
20
20
  const { HOOK_ON_CRASH, allow, deny, crash } = require('./lib/hook-exit.js');
21
- const { reportIfUndetermined, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
21
+ const { reportIfUndetermined, gitEnv, BLOCKING_GUARD_PROBE_TIMEOUT_MS } = require('./lib/git-probe.js');
22
22
 
23
23
  // This guard's outer catch has always exited 0 (fail open): a path guard that
24
24
  // cannot resolve the worktree must not block the user's edit — its whole job
@@ -34,8 +34,35 @@ const ON_CRASH = HOOK_ON_CRASH.ALLOW;
34
34
  // hooks/lib/git-probe.js, #5180). This guard runs at most 3 sequential probes.
35
35
  const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: BLOCKING_GUARD_PROBE_TIMEOUT_MS, windowsHide: true };
36
36
 
37
+ // #5048: GIT_OPTIONAL_LOCKS=0 on every git call from this hook. `git rev-parse`
38
+ // never opens the index, so this is inert for today's callers; it is set anyway
39
+ // so the read-only invariant has no holes if a `status` / `diff` caller is ever
40
+ // added here. git refreshes the index and takes an *optional*
41
+ // `.git/index.lock` on some reads, and this hook runs on every tool call, so a
42
+ // read here can otherwise fail a concurrent `git add` / `git commit` with
43
+ // `Unable to create '.git/index.lock': File exists`.
44
+ //
45
+ // The env block is owned by src/read-only-git-env.cts and shared with every
46
+ // other read-only seam (built artifact, not a sibling hooks/-tree file — same
47
+ // rule hooks/lib/git-cmd.js follows for token-scanner). It is read per spawn,
48
+ // not snapshotted at load, so the two guards behave identically.
49
+ // Parity: tests/git-optional-locks-parity.test.cjs.
50
+ // A raw plugin-marketplace or git-clone install never ran `npm run build:lib`
51
+ // (#3582), so the compiled owner can be absent. This guard only runs
52
+ // `git rev-parse`, which never opens the index, so the scrubbed gitEnv() base
53
+ // loses nothing; requiring the artifact unguarded would throw at load and the
54
+ // containment guard would silently stop running.
55
+ let readOnlyGitEnv = (base = process.env) => base;
56
+ try {
57
+ const { ensureRuntimeBuild } = require('../gsd-core/bin/ensure-runtime-build.cjs');
58
+ ensureRuntimeBuild();
59
+ ({ readOnlyGitEnv } = require('../gsd-core/bin/lib/read-only-git-env.cjs'));
60
+ } catch {
61
+ // artifact absent and not buildable: keep the inert fallback above
62
+ }
63
+
37
64
  function git(args, cwd) {
38
- return spawnSync('git', args, { ...SPAWNOPT, cwd });
65
+ return spawnSync('git', args, { ...SPAWNOPT, cwd, env: readOnlyGitEnv(gitEnv()) });
39
66
  }
40
67
 
41
68
  // Walk up from `start` to find the nearest existing directory.
package/hooks/hooks.json CHANGED
@@ -4,6 +4,7 @@
4
4
  {
5
5
  "hooks": [
6
6
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-ensure-canonical-path.js\"", "timeout": 5 },
7
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-path-export.js\"", "timeout": 5 },
7
8
  { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gsd-check-update.js\"" }
8
9
  ]
9
10
  }
@@ -92,7 +92,23 @@ function reportIfUndetermined(hookName, probeLabel, result) {
92
92
  const BLOCKING_GUARD_PROBE_TIMEOUT_MS = 5000;
93
93
  const BLOCKING_GUARD_MAX_SEQUENTIAL_PROBES = 3;
94
94
 
95
+ // #5548: repo-location vars git sets in hooks; inherited, they override the
96
+ // probe's cwd/-C. Parity-tested copy of GIT_LOCATION_ENV_VARS in
97
+ // src/shell-command-projection.cts (hooks ship standalone and cannot import it).
98
+ const GIT_LOCATION_ENV_VARS = [
99
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_OBJECT_DIRECTORY', 'GIT_DIR', 'GIT_WORK_TREE',
100
+ 'GIT_IMPLICIT_WORK_TREE', 'GIT_GRAFT_FILE', 'GIT_INDEX_FILE', 'GIT_NO_REPLACE_OBJECTS',
101
+ 'GIT_REPLACE_REF_BASE', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_COMMON_DIR',
102
+ ];
103
+
104
+ /** `env` (default process.env) minus the repo-location vars (case-insensitive, for Windows). */
105
+ function gitEnv(env = process.env) {
106
+ return Object.fromEntries(Object.entries(env).filter(([k]) => !GIT_LOCATION_ENV_VARS.includes(k.toUpperCase())));
107
+ }
108
+
95
109
  module.exports = {
110
+ GIT_LOCATION_ENV_VARS,
111
+ gitEnv,
96
112
  classifyGitProbe,
97
113
  reportIfUndetermined,
98
114
  BLOCKING_GUARD_PROBE_TIMEOUT_MS,
@@ -91,6 +91,11 @@ const SENTINEL_RELATIVE_PATH = path.join('.gsd', 'dispatch-isolation-sentinel.js
91
91
  // later, unrelated phase.
92
92
  const SENTINEL_STALE_MS = 10 * 60 * 1000; // 10 minutes
93
93
 
94
+ // #5479: the longest a recorded sequential degrade keeps governing its own phase after the trust
95
+ // window (see agedNoneGovernsDispatch). A single executor run is minutes to a couple of hours; a
96
+ // day is a generous bound that still ends a stale or forged record's authority.
97
+ const AGED_NONE_MAX_MS = 24 * 60 * 60 * 1000;
98
+
94
99
  function sentinelPath(cwd) {
95
100
  return path.join(cwd, SENTINEL_RELATIVE_PATH);
96
101
  }
@@ -280,6 +285,43 @@ function sentinelAppliesToDispatch(sentinel, dispatchIds) {
280
285
  return true;
281
286
  }
282
287
 
288
+ /**
289
+ * #5479: does an AGED recorded sequential degrade still govern THIS dispatch?
290
+ *
291
+ * The trust window (`SENTINEL_STALE_MS`) exists so a leftover sentinel cannot authorize an unisolated
292
+ * executor for a LATER, unrelated phase (#3045 F2). It also made the guard forget a legitimate
293
+ * degrade (the #683/#3060 base-check, the project opt-out) the moment the previous executor ran
294
+ * longer than the window: the next flag-less dispatch of the SAME phase was denied because the
295
+ * registry fallback answers "this host CAN isolate" and cannot see the shell-side degrade.
296
+ *
297
+ * True only when ALL of these hold, so the aged path can only ever loosen toward `none` for the exact
298
+ * phase the dispatch itself names:
299
+ * - the sentinel is present, well-formed, and records `isolation: 'none'` (an aged
300
+ * `harness-worktree`/`orchestrator-worktree` record authorizes nothing);
301
+ * - its `written_at` is not in the future (a forged or clock-skewed record is never trusted);
302
+ * - it carries a phase, the dispatch names a phase, and they are equal (a dispatch whose phase
303
+ * cannot be established is "cannot compare", which keeps today's fallback, never an allow).
304
+ * A plan-scoped record speaks only for its own plan (or a plan-less dispatch): a `none` recorded for
305
+ * a submodule-degraded plan A must not authorize a genuinely isolated plan B of the same phase. The
306
+ * phase-level degrade (the base-check, the project opt-out) is the record with no plan.
307
+ * - it is at most `AGED_NONE_MAX_MS` old: a static record, forged or left by a long-finished run,
308
+ * does not govern indefinitely. The normal flow re-records at every phase start and plan gate,
309
+ * so a live phase is always far younger.
310
+ * A fresh sentinel is handled by the existing path and never reaches here.
311
+ *
312
+ * `clock` is the injectable `{ now(): number }` seam `readSentinel` uses.
313
+ */
314
+ function agedNoneGovernsDispatch(sentinel, dispatchIds, { clock = Date } = {}) {
315
+ if (!sentinel || sentinel.present !== true || sentinel.malformed) return false;
316
+ if (sentinel.isolation !== 'none') return false;
317
+ if (typeof sentinel.writtenAt !== 'number') return false;
318
+ const age = clock.now() - sentinel.writtenAt;
319
+ if (age < -5000 || age > AGED_NONE_MAX_MS) return false;
320
+ if (!sentinel.phase || !dispatchIds || !dispatchIds.phase) return false;
321
+ if (sentinel.plan && dispatchIds.plan && sentinel.plan !== dispatchIds.plan) return false;
322
+ return sentinel.phase === dispatchIds.phase;
323
+ }
324
+
283
325
  /**
284
326
  * #4594 F3: build the structured "a fresh sentinel was present but did not
285
327
  * apply to this dispatch" descriptor, mirroring the exact comparison
@@ -307,10 +349,12 @@ module.exports = {
307
349
  VALID_ISOLATION,
308
350
  SENTINEL_RELATIVE_PATH,
309
351
  SENTINEL_STALE_MS,
352
+ AGED_NONE_MAX_MS,
310
353
  sentinelPath,
311
354
  resolveSentinelRoot,
312
355
  readSentinel,
313
356
  extractDispatchIdentifiers,
314
357
  sentinelAppliesToDispatch,
358
+ agedNoneGovernsDispatch,
315
359
  buildSentinelDiscard,
316
360
  };
@@ -29,6 +29,7 @@ const MANAGED_HOOKS = [
29
29
  'gsd-cursor-subagent-stop.js',
30
30
  'gsd-ensure-canonical-path.js',
31
31
  'gsd-graphify-update.sh',
32
+ 'gsd-path-export.js',
32
33
  // #3662: portable node resolver (helper staged in hooks/; managed JS hook
33
34
  // commands route through it under --portable-hooks).
34
35
  'gsd-node-runner.sh',