session-orchestrator 5.12.0 → 5.13.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 (385) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +4 -1
  3. package/.codex-plugin/agents/explorer.toml +1 -1
  4. package/.codex-plugin/agents/session-reviewer.toml +2 -2
  5. package/.codex-plugin/agents/wave-worker.toml +1 -1
  6. package/.codex-plugin/plugin.json +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/.orchestrator/policy/blocked-commands.json +8 -0
  9. package/.orchestrator/policy/public-export.json +30 -0
  10. package/CHANGELOG.md +162 -4
  11. package/README.md +126 -127
  12. package/agents/analyst.md +7 -2
  13. package/agents/architect-reviewer.md +7 -2
  14. package/agents/code-implementer.md +4 -2
  15. package/agents/db-specialist.md +9 -2
  16. package/agents/dialectic-deriver.md +9 -3
  17. package/agents/docs-writer.md +7 -2
  18. package/agents/eval-judge.md +7 -1
  19. package/agents/qa-strategist.md +7 -2
  20. package/agents/security-reviewer.md +9 -2
  21. package/agents/session-reviewer.md +18 -2
  22. package/agents/skill-applied-judge.md +7 -1
  23. package/agents/test-writer.md +9 -2
  24. package/agents/ui-developer.md +9 -2
  25. package/agents/ux-evaluator.md +6 -1
  26. package/commands/session.md +1 -1
  27. package/commands/templates-ack.md +89 -18
  28. package/docs/agent-authoring.md +30 -11
  29. package/docs/ci-setup.md +38 -2
  30. package/docs/codex-setup.md +33 -9
  31. package/docs/components.md +13 -2
  32. package/docs/cursor-setup.md +3 -3
  33. package/docs/events-schema.md +5 -4
  34. package/docs/evidence-candidates.md +63 -0
  35. package/docs/fleet-checkin-audit.md +7 -4
  36. package/docs/github-mirror-protection.md +150 -3
  37. package/docs/install.md +90 -17
  38. package/docs/instruction-delivery.md +1 -1
  39. package/docs/migration-v4.md +2 -2
  40. package/docs/mods.md +118 -0
  41. package/docs/owner-config-schema.md +1 -0
  42. package/docs/plugin-architecture-v3.md +1 -1
  43. package/docs/pm-skills-marketplace.md +4 -3
  44. package/docs/rule-authoring.md +24 -13
  45. package/docs/scope-collision-guard.md +2 -1
  46. package/docs/session-config-reference.md +49 -15
  47. package/docs/session-config-template.md +30 -22
  48. package/docs/sessions-ledger-repair.md +137 -0
  49. package/docs/vault-docs-architecture.md +1 -1
  50. package/hooks/_lib/hook-import-set.json +406 -73
  51. package/hooks/_lib/hook-root.mjs +301 -0
  52. package/hooks/_lib/lock-bootstrap.mjs +434 -4
  53. package/hooks/_lib/orchestrator-ignore.mjs +3 -0
  54. package/hooks/_lib/subagent-transcript.mjs +2 -2
  55. package/hooks/_lib/vcs-create-matcher.mjs +13 -17
  56. package/hooks/cwd-change-restore.mjs +5 -1
  57. package/hooks/enforce-commands.mjs +4 -3
  58. package/hooks/enforce-scope.mjs +11 -1
  59. package/hooks/hooks-codex.json +48 -2
  60. package/hooks/hooks.json +31 -31
  61. package/hooks/on-session-end.mjs +38 -15
  62. package/hooks/on-session-start.mjs +139 -49
  63. package/hooks/on-stop.mjs +11 -6
  64. package/hooks/post-bash-issue-budget-refund.mjs +6 -1
  65. package/hooks/post-bash-write-verify.mjs +8 -4
  66. package/hooks/post-edit-validate.mjs +45 -52
  67. package/hooks/post-subagent-discovery-validator.mjs +2 -2
  68. package/hooks/post-tool-batch-wave-signal.mjs +8 -4
  69. package/hooks/post-tool-failure-corrective-context.mjs +5 -1
  70. package/hooks/pre-bash-destructive-guard.mjs +220 -8
  71. package/hooks/pre-bash-issue-budget.mjs +6 -1
  72. package/hooks/pre-bash-sessions-ledger-guard.mjs +691 -123
  73. package/hooks/pre-bash-staging-fence.mjs +258 -14
  74. package/hooks/pre-bash-templates-first.mjs +28 -27
  75. package/hooks/run-node.sh +1 -1
  76. package/hooks/skill-invocation-telemetry.mjs +15 -8
  77. package/hooks/subagent-telemetry.mjs +77 -14
  78. package/hooks/wave-scope-commit-guard.mjs +143 -14
  79. package/mods/so-hud/.claude-plugin/plugin.json +11 -0
  80. package/mods/so-hud/README.md +57 -0
  81. package/mods/so-hud/hooks/beacon-io.mjs +101 -0
  82. package/mods/so-hud/hooks/heredoc-scan.ts +394 -0
  83. package/mods/so-hud/hooks/hooks.json +1 -0
  84. package/mods/so-hud/hooks/logic.ts +182 -0
  85. package/mods/so-hud/hooks/register.tsx +670 -0
  86. package/mods/so-hud/hooks/rules.ts +2998 -0
  87. package/mods/so-hud/tsconfig.json +8 -0
  88. package/mods/so-hud/types/index.d.ts +36 -0
  89. package/package.json +4 -2
  90. package/rules/_index.md +1 -1
  91. package/rules/always-on/ask-via-tool.md +1 -1
  92. package/rules/always-on/loop-and-monitor.md +17 -194
  93. package/rules/always-on/parallel-sessions.md +3 -1
  94. package/rules/opt-in-stack/backend-data.md +1 -1
  95. package/rules/opt-in-stack/backend.md +1 -1
  96. package/rules/opt-in-stack/security-web.md +5 -1
  97. package/scripts/agent-usage-census.mjs +79 -0
  98. package/scripts/apply-session-learnings.mjs +34 -6
  99. package/scripts/archive-closed-prds.mjs +21 -5
  100. package/scripts/autopilot.mjs +11 -5
  101. package/scripts/backfill-abandoned-sessions.mjs +13 -3
  102. package/scripts/backfill-evidence-digest.mjs +2 -1
  103. package/scripts/backfill-learnings-from-vault.mjs +61 -6
  104. package/scripts/backfill-sessions.mjs +16 -2
  105. package/scripts/check-package-manager.mjs +26 -8
  106. package/scripts/check-sessions-integrity.mjs +4 -1
  107. package/scripts/ci/assert-vitest-green.mjs +3 -0
  108. package/scripts/ci/windows-smoke.mjs +201 -0
  109. package/scripts/close-precheck.mjs +55 -0
  110. package/scripts/dialectic-deriver.mjs +20 -12
  111. package/scripts/emit-event.mjs +2 -2
  112. package/scripts/emit-session.mjs +239 -20
  113. package/scripts/eval-session.mjs +17 -3
  114. package/scripts/export-hw-learnings.mjs +29 -2
  115. package/scripts/fleet-checkin-audit.mjs +8 -4
  116. package/scripts/fleet-checkin.mjs +39 -27
  117. package/scripts/gc-stale-worktrees.mjs +6 -1
  118. package/scripts/github-export.mjs +128 -0
  119. package/scripts/github-protection-audit.mjs +3 -1
  120. package/scripts/handover-queue-check.mjs +209 -0
  121. package/scripts/harness-audit.mjs +6 -4
  122. package/scripts/lib/agent-frontmatter.mjs +57 -10
  123. package/scripts/lib/agent-status.mjs +125 -7
  124. package/scripts/lib/agent-usage-census.mjs +394 -0
  125. package/scripts/lib/auto-dialectic.mjs +17 -10
  126. package/scripts/lib/auto-dream.mjs +22 -14
  127. package/scripts/lib/autopilot/loop.mjs +3 -1
  128. package/scripts/lib/autopilot/recent-runs.mjs +4 -2
  129. package/scripts/lib/autopilot/stall-sampler.mjs +2 -1
  130. package/scripts/lib/autopilot/telemetry.mjs +5 -1
  131. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  132. package/scripts/lib/baseline-archetypes.mjs +10 -2
  133. package/scripts/lib/blocked-commands-policy.mjs +4 -3
  134. package/scripts/lib/build-live-signals.mjs +13 -9
  135. package/scripts/lib/ci-status-banner.mjs +329 -24
  136. package/scripts/lib/codex/plugin-contract.mjs +8 -0
  137. package/scripts/lib/cold-start-detector.mjs +5 -2
  138. package/scripts/lib/command-blocker.mjs +603 -43
  139. package/scripts/lib/common.mjs +3 -2
  140. package/scripts/lib/config/cross-repo.mjs +74 -7
  141. package/scripts/lib/config/host-paths.mjs +6 -2
  142. package/scripts/lib/config/section-extractor.mjs +65 -11
  143. package/scripts/lib/config/template-keys.mjs +153 -0
  144. package/scripts/lib/config/vault-integration.mjs +1 -0
  145. package/scripts/lib/config.mjs +2 -2
  146. package/scripts/lib/coordinator-snapshot.mjs +1 -1
  147. package/scripts/lib/discovery/triage-state.mjs +2 -1
  148. package/scripts/lib/dispatcher/rank.mjs +5 -7
  149. package/scripts/lib/ecosystem-health.mjs +2 -2
  150. package/scripts/lib/eval/engine.mjs +3 -2
  151. package/scripts/lib/eval/session-resolve.mjs +6 -2
  152. package/scripts/lib/eval/sink.mjs +4 -3
  153. package/scripts/lib/events-retention-banner.mjs +3 -2
  154. package/scripts/lib/events-schema.mjs +10 -0
  155. package/scripts/lib/events.mjs +23 -12
  156. package/scripts/lib/evolve/autonomy-verdict.mjs +4 -9
  157. package/scripts/lib/evolve/autopilot-effectiveness.mjs +8 -18
  158. package/scripts/lib/fleet-protocol.mjs +4 -0
  159. package/scripts/lib/gates/gate-full.mjs +172 -11
  160. package/scripts/lib/gates/gate-helpers.mjs +236 -23
  161. package/scripts/lib/git-config-drift.mjs +141 -7
  162. package/scripts/lib/github-export.mjs +447 -0
  163. package/scripts/lib/gitlab-portfolio/cli.mjs +16 -5
  164. package/scripts/lib/handover-queue-marks.mjs +159 -0
  165. package/scripts/lib/hardware-pattern-detector.mjs +103 -13
  166. package/scripts/lib/harness-audit/categories/category1.mjs +16 -4
  167. package/scripts/lib/harness-audit/categories/category4.mjs +11 -8
  168. package/scripts/lib/harness-audit/categories/category6.mjs +25 -5
  169. package/scripts/lib/harness-audit/categories/category9.mjs +10 -4
  170. package/scripts/lib/host-identity.mjs +9 -5
  171. package/scripts/lib/http-request.mjs +64 -0
  172. package/scripts/lib/instruction-budget-guard.mjs +30 -22
  173. package/scripts/lib/io.mjs +6 -3
  174. package/scripts/lib/issue-budget-reconcile.mjs +3 -2
  175. package/scripts/lib/issue-budget.mjs +36 -7
  176. package/scripts/lib/learnings/evidence-candidates.mjs +154 -0
  177. package/scripts/lib/learnings/expiry-sweep.mjs +99 -7
  178. package/scripts/lib/learnings/io.mjs +289 -15
  179. package/scripts/lib/learnings/select.mjs +53 -4
  180. package/scripts/lib/learnings/store-role.mjs +209 -0
  181. package/scripts/lib/learnings/surface.mjs +46 -7
  182. package/scripts/lib/ledger-path.mjs +113 -0
  183. package/scripts/lib/maintenance-due-banner.mjs +63 -12
  184. package/scripts/lib/memory-banner.mjs +5 -6
  185. package/scripts/lib/memory-proposals/collector.mjs +5 -2
  186. package/scripts/lib/memory-proposals/sink.mjs +7 -4
  187. package/scripts/lib/memory-proposals/store.mjs +22 -5
  188. package/scripts/lib/mode-selector-accuracy.mjs +2 -1
  189. package/scripts/lib/no-follow-open.mjs +57 -0
  190. package/scripts/lib/orphan-reaper/reaper-audit.mjs +4 -2
  191. package/scripts/lib/own-lock-root.mjs +95 -0
  192. package/scripts/lib/owner-interview.mjs +2 -1
  193. package/scripts/lib/owner-yaml.mjs +70 -7
  194. package/scripts/lib/platform.mjs +161 -9
  195. package/scripts/lib/plugin-update-banner.mjs +15 -11
  196. package/scripts/lib/pre-push-local-partial.mjs +117 -0
  197. package/scripts/lib/pre-push-remote-gate.mjs +151 -25
  198. package/scripts/lib/process-group.mjs +270 -34
  199. package/scripts/lib/project-hygiene.mjs +28 -0
  200. package/scripts/lib/quality-gate.mjs +33 -9
  201. package/scripts/lib/reconcile/backlog.mjs +29 -15
  202. package/scripts/lib/reconcile/engine.mjs +26 -2
  203. package/scripts/lib/reconcile/idempotency.mjs +65 -3
  204. package/scripts/lib/reconcile/rule-expiry-sweep.mjs +53 -12
  205. package/scripts/lib/reconcile/verdict-ledger.mjs +224 -0
  206. package/scripts/lib/reconcile-nudge-banner.mjs +40 -6
  207. package/scripts/lib/resource-probe/evaluate.mjs +32 -9
  208. package/scripts/lib/resource-probe/probe-platform.mjs +3 -1
  209. package/scripts/lib/rule-loader.mjs +20 -10
  210. package/scripts/lib/scope-baseline.mjs +123 -8
  211. package/scripts/lib/scope-echo.mjs +5 -2
  212. package/scripts/lib/scope-gate.mjs +55 -20
  213. package/scripts/lib/session-close-backfill.mjs +75 -7
  214. package/scripts/lib/session-end/close-precheck.mjs +151 -0
  215. package/scripts/lib/session-end/phase-skip.mjs +8 -7
  216. package/scripts/lib/session-end/tail-runner.mjs +7 -2
  217. package/scripts/lib/session-id.mjs +9 -1
  218. package/scripts/lib/session-identity/own-session.mjs +63 -0
  219. package/scripts/lib/session-lock.mjs +114 -0
  220. package/scripts/lib/session-record-repair.mjs +245 -35
  221. package/scripts/lib/session-registry.mjs +72 -9
  222. package/scripts/lib/session-schema/constants.mjs +9 -0
  223. package/scripts/lib/session-schema/filters.mjs +56 -1
  224. package/scripts/lib/session-schema/validator.mjs +15 -5
  225. package/scripts/lib/session-start-probes.mjs +149 -22
  226. package/scripts/lib/session-token-rollup.mjs +2 -1
  227. package/scripts/lib/session-work-trees.mjs +166 -0
  228. package/scripts/lib/sessions-canonical.mjs +61 -3
  229. package/scripts/lib/sessions-integrity-banner.mjs +17 -39
  230. package/scripts/lib/sessions-staleness-banner.mjs +8 -5
  231. package/scripts/lib/sh-quote.mjs +1 -0
  232. package/scripts/lib/skill-evidence-window.mjs +5 -1
  233. package/scripts/lib/skill-health/join.mjs +10 -9
  234. package/scripts/lib/skill-invocations-schema.mjs +13 -0
  235. package/scripts/lib/skill-judgments-schema.mjs +6 -2
  236. package/scripts/lib/snapshot-probe.mjs +414 -0
  237. package/scripts/lib/so-hud/collect.mjs +49 -5
  238. package/scripts/lib/so-hud/runtime.mjs +15 -4
  239. package/scripts/lib/spiral-carryover.mjs +8 -1
  240. package/scripts/lib/state-md/frontmatter-mutators.mjs +266 -8
  241. package/scripts/lib/state-md/idle-reset.mjs +14 -5
  242. package/scripts/lib/state-md/mission-status.mjs +5 -1
  243. package/scripts/lib/state-md-peer-guard.mjs +2 -0
  244. package/scripts/lib/state-md.mjs +31 -8
  245. package/scripts/lib/sunset/walker.mjs +4 -3
  246. package/scripts/lib/telemetry/consent.mjs +4 -2
  247. package/scripts/lib/telemetry/queue.mjs +3 -3
  248. package/scripts/lib/telemetry/sync.mjs +45 -9
  249. package/scripts/lib/test-runner/artifact-paths.mjs +10 -2
  250. package/scripts/lib/tmux-layout/layouts.mjs +20 -3
  251. package/scripts/lib/ux-grill/paths.mjs +5 -1
  252. package/scripts/lib/validate/check-agents.mjs +37 -6
  253. package/scripts/lib/validate/check-component-paths.mjs +25 -1
  254. package/scripts/lib/validate/check-guard-requires-parity.mjs +3 -3
  255. package/scripts/lib/validate/check-hooks-symmetry.mjs +53 -32
  256. package/scripts/lib/validate/check-json-files.mjs +3 -1
  257. package/scripts/lib/validate/check-learning-provenance.mjs +3 -2
  258. package/scripts/lib/validate/check-learnings-shell-writes.mjs +10 -4
  259. package/scripts/lib/validate/check-metrics-ledger-root.mjs +234 -0
  260. package/scripts/lib/validate/check-owner-leakage.mjs +159 -11
  261. package/scripts/lib/validate/check-test-posix-shell.mjs +246 -0
  262. package/scripts/lib/validate/check-unwired-features.mjs +4 -2
  263. package/scripts/lib/validate/check-vcs-repo-flag-positional.mjs +58 -0
  264. package/scripts/lib/validate/check-vcs-repo-flag.mjs +259 -73
  265. package/scripts/lib/validate/check-workflow-continue-on-error.mjs +63 -0
  266. package/scripts/lib/validate/confidential-names.mjs +3 -1
  267. package/scripts/lib/vault-mirror/migrate-ids.mjs +321 -0
  268. package/scripts/lib/vault-mirror/namespace.mjs +19 -7
  269. package/scripts/lib/vault-mirror/process.mjs +56 -27
  270. package/scripts/lib/vault-mirror/prune.mjs +5 -2
  271. package/scripts/lib/vault-mirror/render-learnings.mjs +3 -3
  272. package/scripts/lib/vault-mirror/render-sessions.mjs +36 -53
  273. package/scripts/lib/vault-mirror/retention.mjs +2 -2
  274. package/scripts/lib/vault-mirror/utils.mjs +79 -0
  275. package/scripts/lib/vault-staleness-banner.mjs +4 -7
  276. package/scripts/lib/vault-status/board-writer.mjs +2 -1
  277. package/scripts/lib/vault-status/narrative-mirror.mjs +66 -7
  278. package/scripts/lib/wave-executor/foreign-dispatch.mjs +20 -0
  279. package/scripts/lib/win-process-tree.mjs +137 -0
  280. package/scripts/lib/workspace.mjs +1 -1
  281. package/scripts/lib/worktree/constants.mjs +1 -1
  282. package/scripts/lib/worktree/lifecycle.mjs +1 -1
  283. package/scripts/lib/worktree/listing.mjs +1 -1
  284. package/scripts/lib/zx.mjs +50 -0
  285. package/scripts/mcp-server.mjs +16 -7
  286. package/scripts/mcp-server.sh +13 -83
  287. package/scripts/measure-policy-cache-effectiveness.mjs +7 -5
  288. package/scripts/memory-propose.mjs +10 -3
  289. package/scripts/migrate-cold-start-seed.mjs +7 -0
  290. package/scripts/migrate-legacy-learnings.sh +22 -0
  291. package/scripts/migrate-sessions-jsonl.mjs +16 -2
  292. package/scripts/migrate-vault-paths.mjs +29 -13
  293. package/scripts/parse-config.mjs +71 -14
  294. package/scripts/print-learnings-index.mjs +25 -2
  295. package/scripts/release-session-lock.mjs +94 -7
  296. package/scripts/release.mjs +451 -24
  297. package/scripts/relocate-vault-corpus.mjs +11 -7
  298. package/scripts/repair-invalid-sessions.mjs +68 -3
  299. package/scripts/resolve-session-invocation.mjs +10 -17
  300. package/scripts/run-migrate-v2-cross-repo.mjs +56 -1
  301. package/scripts/run-quality-gate.mjs +33 -7
  302. package/scripts/self-update.mjs +56 -7
  303. package/scripts/session-commit-files.mjs +133 -0
  304. package/scripts/site-numbers.mjs +52 -9
  305. package/scripts/snapshot-probe.mjs +156 -0
  306. package/scripts/so-hud.mjs +29 -6
  307. package/scripts/state-md-idle-reset.mjs +2 -1
  308. package/scripts/sweep-expired-learnings.mjs +54 -6
  309. package/scripts/sweep-expired-rules.mjs +4 -1
  310. package/scripts/validate-plugin.mjs +22 -0
  311. package/scripts/vault-integration-watcher.mjs +54 -6
  312. package/scripts/vault-mirror.mjs +9 -0
  313. package/skills/_shared/fleet-protocol.md +142 -1
  314. package/skills/_shared/instruction-file-resolution.md +1 -1
  315. package/skills/_shared/monitor-patterns.md +44 -25
  316. package/skills/_shared/platform-tools.md +7 -0
  317. package/skills/_shared/state-ownership.md +3 -2
  318. package/skills/architecture/references/domain-model.md +1 -1
  319. package/skills/bootstrap/SKILL.md +3 -1
  320. package/skills/bootstrap/_shared-template.md +4 -4
  321. package/skills/bootstrap/fast-template.md +1 -1
  322. package/skills/bootstrap/private-contract.md +1 -1
  323. package/skills/bootstrap/references/bootstrap-retroactive-flow.md +17 -9
  324. package/skills/bootstrap/templates/agents/project-code-review.md +5 -1
  325. package/skills/bootstrap/templates/agents/project-discovery.md +5 -1
  326. package/skills/bootstrap/templates/agents/project-quality-gate.md +5 -1
  327. package/skills/claude-md-drift-check/SKILL.md +3 -3
  328. package/skills/claude-md-drift-check/checker.mjs +61 -101
  329. package/skills/close/SKILL.md +20 -7
  330. package/skills/discovery/probes/docs-staleness.mjs +7 -5
  331. package/skills/discovery/probes/frontend-slop.mjs +7 -3
  332. package/skills/discovery/probes/ssot-code-diff.mjs +5 -3
  333. package/skills/discovery/probes/supply-chain-slopcheck.mjs +10 -8
  334. package/skills/discovery/probes/vault-narrative-staleness.mjs +30 -10
  335. package/skills/discovery/probes/vault-staleness.mjs +5 -3
  336. package/skills/discovery/probes-session.md +3 -0
  337. package/skills/discovery/probes-vault.md +2 -0
  338. package/skills/dispatcher/SKILL.md +1 -1
  339. package/skills/eval/SKILL.md +3 -1
  340. package/skills/evolve/SKILL.md +2 -0
  341. package/skills/evolve/references/evolve-analyze-mode.md +3 -3
  342. package/skills/evolve/references/evolve-dialectic-mode.md +1 -1
  343. package/skills/gitlab-ops/SKILL.md +7 -6
  344. package/skills/gitlab-portfolio/SKILL.md +2 -2
  345. package/skills/go/SKILL.md +13 -1
  346. package/skills/npm-publish/SKILL.md +5 -2
  347. package/skills/plan/SKILL.md +23 -10
  348. package/skills/plan/mode-feature.md +13 -8
  349. package/skills/plan/mode-new.md +6 -14
  350. package/skills/plan/prd-feature-template.md +19 -0
  351. package/skills/plan/prd-full-template.md +12 -5
  352. package/skills/plan/prd-premortem-panel.md +48 -0
  353. package/skills/plan/prd-reviewer-prompt.md +11 -3
  354. package/skills/plan/soul.md +1 -1
  355. package/skills/portfolio/SKILL.md +5 -6
  356. package/skills/quality-gates/SKILL.md +2 -4
  357. package/skills/reconcile/SKILL.md +27 -4
  358. package/skills/release/SKILL.md +16 -8
  359. package/skills/session-end/SKILL.md +94 -11
  360. package/skills/session-end/phase-3-2-docs-verification.md +1 -0
  361. package/skills/session-end/phase-3-6-tail.md +11 -7
  362. package/skills/session-end/phase-3-7a-recommendations.md +9 -2
  363. package/skills/session-end/plan-verification.md +16 -1
  364. package/skills/session-end/references/phase-3-documentation-updates.md +14 -12
  365. package/skills/session-end/references/phase-5-issue-cleanup.md +1 -1
  366. package/skills/session-end/references/session-summary-template.md +4 -0
  367. package/skills/session-end/session-metrics-write.md +7 -4
  368. package/skills/session-plan/SKILL.md +5 -3
  369. package/skills/session-start/SKILL.md +9 -3
  370. package/skills/session-start/phase-4-5-resource-health.md +11 -5
  371. package/skills/session-start/phase-8-5-express-path.md +4 -3
  372. package/skills/session-start/references/phase-1-2-session-lock.md +7 -5
  373. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -2
  374. package/skills/session-start/references/phase-1-7-vault-status-board.md +1 -1
  375. package/skills/session-start/references/phase-4-ssot-environment-check.md +3 -2
  376. package/skills/tmux-layout/SKILL.md +2 -2
  377. package/skills/vault-sync/validator.mjs +3 -2
  378. package/skills/wave-executor/SKILL.md +5 -4
  379. package/skills/wave-executor/references/wave-executor-state-init.md +3 -3
  380. package/skills/wave-executor/references/wave-loop-dispatch.md +11 -5
  381. package/skills/wave-executor/references/wave-loop-review.md +13 -5
  382. package/skills/wave-executor/references/wave-loop-scope-manifest.md +1 -1
  383. package/assets/og-card.png +0 -0
  384. package/assets/og-card.svg +0 -47
  385. package/assets/wave-lifecycle.svg +0 -98
@@ -0,0 +1,2998 @@
1
+ // The pure half of the so-hud protection gates (#1551): reading a shell command, a tool's output
2
+ // and a peer's scope. Nothing here touches `$`, the engine or node:, so every rule runs inside the
3
+ // mod engine and is testable on its own. Ported from an internal mod; only its general rules are
4
+ // carried over.
5
+
6
+ import { scanHeredocs, type HeredocStage } from './heredoc-scan'
7
+
8
+ // ---- Reading a shell command ------------------------------------------------
9
+
10
+ // One part of a shell word: a double-quoted run, a single-quoted run, a backslash escape, plain text,
11
+ // or a quote that never closes (kept as written).
12
+ const WORD_PART = /"((?:[^"\\]|\\.)*)"|'([^']*)'|\\(.)|([^"'\\]+)|(["'\\])/gs
13
+
14
+ /** A shell word with its quotes and escapes removed, as the shell passes it on: `"HEAD:ma"in` is `HEAD:main`. */
15
+ function dequote(word: string): string {
16
+ return word.replace(WORD_PART, (_m: string, dq?: string, sq?: string, esc?: string, plain?: string, lone?: string) =>
17
+ dq !== undefined ? dq.replace(/\\(["\\$`])/g, '$1') : (sq ?? esc ?? plain ?? lone ?? ''))
18
+ }
19
+
20
+ /**
21
+ * Splits one segment into shell words: each word is a run of `"…"`, `'…'` and unquoted characters
22
+ * joined together, quotes and escapes removed. Good enough to read flags and paths.
23
+ */
24
+ export function tokenize(text: string): string[] {
25
+ return rawWords(text).map(dequote)
26
+ }
27
+
28
+ /** The shell words of a segment as written, quotes and escapes kept. */
29
+ function rawWords(text: string): string[] {
30
+ return [...text.matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\]+|["'\\])+/gs)].map(m => m[0])
31
+ }
32
+
33
+ // An output redirection or descriptor copy as written: `2>&1`, `>/dev/null`, `&>>log`, `<&3`, or an
34
+ // operator alone (`>`, `2>`, `&>`) whose target is the next word. Input redirections (`< .env`) stay
35
+ // words: the file they name is read by the command.
36
+ const REDIRECT = /^(?:(?:\d+|&)?>[>|&]?|\d*<&)(.*)$/s
37
+ // An input redirection as written: `<file`, `0< in`, `<>file`, a here-string `<<<word`, a heredoc's
38
+ // `<<EOF` / `<<-EOF`. Only the outward reader drops them (`</dev/null git push` is `git push`); a
39
+ // trap still reads the file a command is fed (`cat < .env`).
40
+ const IN_REDIRECT = /^\d*(?:<<<|<<-?|<>|<)(.*)$/s
41
+
42
+ /**
43
+ * The raw words without their output redirections — and, when `input`, their input redirections:
44
+ * the shell removes them before the command sees its words, so `git 2>/dev/null push` is `git push`.
45
+ * Checked before quote removal, so a quoted `">"` stays an argument; `>(…)` and `<(…)` (process
46
+ * substitution) stay words too.
47
+ */
48
+ function dropRedirections(raw: readonly string[], input: boolean): string[] {
49
+ const out: string[] = []
50
+ for (let i = 0; i < raw.length; i += 1) {
51
+ const word = raw[i] ?? ''
52
+ const target = REDIRECT.exec(word)?.[1] ?? (input ? IN_REDIRECT.exec(word)?.[1] : undefined)
53
+ if (target === undefined || target.startsWith('(')) out.push(word)
54
+ else if (target === '') i += 1
55
+ }
56
+ return out
57
+ }
58
+
59
+ // Where a redirection or process substitution starts after its `<` or `>`, longest form first.
60
+ const REDIRECT_OP = /<<<|<<-?|<>|<&|<\(|<|>\(|>>|>&|>\||>/y
61
+ // The characters that end a redirection target unquoted in every shell; also where the next one may
62
+ // start. An unquoted `&` inside a target starts `&>` (a lone one already ended the segment): `x&>y`.
63
+ const TARGET_END = /[<>()&]/g
64
+ const ANGLE = /[<>]/g
65
+
66
+ /** The word with every quoted run and backslash escape blanked to `_`, same length: what is left is unquoted. */
67
+ function maskQuoted(word: string): string {
68
+ return word.replace(WORD_PART, (m: string, _dq?: string, _sq?: string, _esc?: string, plain?: string) => (plain === undefined ? '_'.repeat(m.length) : m))
69
+ }
70
+
71
+ /**
72
+ * Whether an output redirection writes nothing: `>`, `>>` or `&>` (any descriptor) onto exactly
73
+ * `/dev/null`, or `>&` copying stdout or stderr onto the other. Every other form, target or descriptor
74
+ * (`>&3`, `3>&1`, `&>>`, `<>`, `<&`, `>|`, a quoted or `$` target) counts as a write.
75
+ */
76
+ function harmlessOutput(op: string, fd: string, target: string): boolean {
77
+ if (op === '>' || op === '>>' || op === '&>') return target === '/dev/null'
78
+ return op === '>&' && (fd === '' || fd === '1' || fd === '2') && (target === '1' || target === '2')
79
+ }
80
+
81
+ /**
82
+ * Whether a segment carries an output redirection that may write a file (#1569, MR !133): `git log
83
+ * --format=… >> .git/config` wrote the push configuration the facts never saw. Read quote-aware over the
84
+ * same words as {@link readCommand}, also where the reader keeps the word (`--short>>.git/config`), so a
85
+ * quoted `"a > b"` is no redirection. Input redirections (`<`, `N<`, `<<`, `<<<`) and process
86
+ * substitutions (`<(…)`, `>(…)`, read by {@link gitInSubstitution}) write nothing here. One pass per word.
87
+ */
88
+ function writesOutput(segment: string): boolean {
89
+ // An operator whose target is the next word: `> .git/HEAD`. `in` skips that word, `out` judges it.
90
+ let pending: { kind: 'in' | 'out'; op: string; fd: string } | null = null
91
+ for (const word of rawWords(stripGroup(segment))) {
92
+ const masked = maskQuoted(word)
93
+ let at = 0
94
+ // The target runs to the next unquoted `<`, `>`, `(`, `)` or `&`: `/dev/null)` closes a `$(…)`.
95
+ const targetEnd = (from: number): number => {
96
+ TARGET_END.lastIndex = from
97
+ return TARGET_END.exec(masked)?.index ?? word.length
98
+ }
99
+ if (pending !== null) {
100
+ at = targetEnd(0)
101
+ if (pending.kind === 'out' && !harmlessOutput(pending.op, pending.fd, word.slice(0, at))) return true
102
+ pending = null
103
+ }
104
+ for (;;) {
105
+ ANGLE.lastIndex = at
106
+ const angle = ANGLE.exec(masked)?.index
107
+ if (angle === undefined) break
108
+ REDIRECT_OP.lastIndex = angle
109
+ let op = REDIRECT_OP.exec(masked)?.[0] ?? ''
110
+ let opEnd = angle + op.length
111
+ if (op === '<(' || op === '>(') {
112
+ at = opEnd
113
+ continue
114
+ }
115
+ // `&>` / `&>>`: the `&` belongs to the operator; otherwise the digits in front are its descriptor.
116
+ let fd = ''
117
+ if (op.startsWith('>') && masked[angle - 1] === '&') op = `&${op}`
118
+ else {
119
+ let start = angle
120
+ while (start > at && /\d/.test(masked[start - 1] ?? '')) start -= 1
121
+ fd = masked.slice(start, angle)
122
+ }
123
+ if (op === '&>' && masked[opEnd] === '>') {
124
+ op = '&>>'
125
+ opEnd += 1
126
+ }
127
+ const kind = op.startsWith('<') && op !== '<>' && op !== '<&' ? 'in' : 'out'
128
+ if (opEnd >= word.length) {
129
+ pending = { kind, op, fd }
130
+ break
131
+ }
132
+ at = targetEnd(opEnd)
133
+ if (kind === 'out' && !harmlessOutput(op, fd, word.slice(opEnd, at))) return true
134
+ }
135
+ }
136
+ // An output operator with no target left (`git log >`, cut at a `|`): fail-closed.
137
+ return pending?.kind === 'out'
138
+ }
139
+
140
+ /** The words of a segment as the command receives them: redirections dropped (input ones when `input`), quotes removed. */
141
+ function argWords(text: string, input = false): string[] {
142
+ return dropRedirections(rawWords(text), input).map(dequote)
143
+ }
144
+
145
+ // Words that can come before the real command without changing what it does. `eval` with unquoted
146
+ // words runs exactly those words; its quoted form is read by {@link extractInner}. `if`, `while`,
147
+ // `until` and `elif` run their condition as a command, `then`, `do` and `else` their body.
148
+ const PREFIXES = new Set(['command', 'exec', 'nohup', 'time', 'if', 'elif', 'while', 'until', 'then', 'do', 'else', '!', 'eval'])
149
+
150
+ // The options of the transparent prefixes and runners that take a separate value.
151
+ const ENV_VALUED = new Set(['-u', '--unset'])
152
+ const SUDO_VALUED = new Set(['-u', '-g', '-h', '-p', '-C', '-D', '-r', '-t', '-U', '-T', '--user', '--group', '--host', '--prompt', '--close-from', '--chdir', '--role', '--type', '--other-user', '--command-timeout'])
153
+ const NICE_VALUED = new Set(['-n', '--adjustment'])
154
+ const TIMEOUT_VALUED = new Set(['-s', '--signal', '-k', '--kill-after'])
155
+ const RUNNER_VALUED = new Set(['-p', '--package'])
156
+ // caffeinate's -t <seconds> and -w <pid>, also as the last letter of a group (`-it 5`).
157
+ const CAFFEINATE_VALUED = /^-[a-z]*[tw]$/
158
+
159
+ // A variable assignment word: `NAME=value`, `NAME+=value`, and an array element `NAME[i]=value` (zsh: `HOME[1,-1]=x`
160
+ // rewrites HOME, #1571); group 1 is the name.
161
+ const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?\+?=/
162
+
163
+ /**
164
+ * A segment without the `(`/`{` in front and the `)`/`}` behind it. Trimmed by hand: `\s*[)}]+$`
165
+ * retried from every blank of a long run, quadratic on attacker-controlled length.
166
+ */
167
+ function stripGroup(segment: string): string {
168
+ const text = segment.trim().replace(/^[({]+\s*/, '')
169
+ let end = text.length
170
+ while (end > 0 && (text[end - 1] === ')' || text[end - 1] === '}')) end -= 1
171
+ return end === text.length ? text : text.slice(0, end).trimEnd()
172
+ }
173
+
174
+ /**
175
+ * The command a segment runs, read with an index (a `shift()` per word was quadratic: 535 ms for 50k
176
+ * characters of `npx -y`): its words with redirections, leading `VAR=x`, `env -u X`, `sudo`, `nice`,
177
+ * `timeout`, `caffeinate`, the prefixes above and a runner's own options removed, and the names those
178
+ * leading assignments set. `input` also drops input redirections (the outward reader).
179
+ */
180
+ function readCommand(segment: string, input: boolean): { words: string[]; assigned: string[] } {
181
+ const words = argWords(stripGroup(segment), input)
182
+ const assigned: string[] = []
183
+ let i = 0
184
+ // Skips options (and, for those `valued` names, their value) up to the first word that is none, or past `--`.
185
+ // `env -u NAME` / `--unset=NAME` removes NAME for the command: recorded like an assignment (`env -u HOME git push`).
186
+ const skipOptions = (valued: ReadonlySet<string> | RegExp, unsets = false): void => {
187
+ while ((words[i] ?? '').startsWith('-')) {
188
+ const option = words[i] ?? ''
189
+ i += 1
190
+ if (option === '--') break
191
+ if (unsets && option.startsWith('--unset=')) assigned.push(option.slice('--unset='.length))
192
+ else if (unsets && /^-u./.test(option)) assigned.push(option.slice(2))
193
+ if (valued instanceof RegExp ? valued.test(option) : valued.has(option)) {
194
+ if (unsets) assigned.push(words[i] ?? '')
195
+ i += 1
196
+ }
197
+ }
198
+ }
199
+ for (;;) {
200
+ const word = words[i]
201
+ if (word === undefined) break
202
+ const assignment = ASSIGNMENT.exec(word)
203
+ if (assignment !== null || PREFIXES.has(word)) {
204
+ if (assignment !== null) assigned.push(assignment[1] ?? '')
205
+ i += 1
206
+ continue
207
+ }
208
+ if (word === 'env' || word === 'sudo' || word === 'nice') {
209
+ i += 1
210
+ skipOptions(word === 'env' ? ENV_VALUED : word === 'sudo' ? SUDO_VALUED : NICE_VALUED, word === 'env')
211
+ continue
212
+ }
213
+ if (word === 'timeout' || word === 'gtimeout') {
214
+ i += 1
215
+ skipOptions(TIMEOUT_VALUED)
216
+ i += 1 // the duration
217
+ continue
218
+ }
219
+ if (word === 'caffeinate') {
220
+ i += 1
221
+ skipOptions(CAFFEINATE_VALUED)
222
+ continue
223
+ }
224
+ break
225
+ }
226
+ // npx vercel, npx --yes npm publish, pnpm dlx vercel, npm exec -- changeset: the tool is the next
227
+ // word after the runner's own options.
228
+ if (words[i] === 'npx' || words[i] === 'bunx') {
229
+ i += 1
230
+ skipOptions(RUNNER_VALUED)
231
+ } else if ((words[i] === 'pnpm' && (words[i + 1] === 'dlx' || words[i + 1] === 'exec')) || (words[i] === 'npm' && (words[i + 1] === 'exec' || words[i + 1] === 'x'))) {
232
+ i += 2
233
+ skipOptions(RUNNER_VALUED)
234
+ }
235
+ return { words: words.slice(i), assigned }
236
+ }
237
+
238
+ /** The words of one segment, redirections, leading `VAR=x`, `env -u X`, `sudo`, `nice`, `timeout`, `caffeinate` and the prefixes above removed. */
239
+ export function commandWords(segment: string): string[] {
240
+ return readCommand(segment, false).words
241
+ }
242
+
243
+ /** `\` + newline removed, as the shell does before it reads words: `git \<LF>push` is `git push`. */
244
+ function joinContinuations(command: string): string {
245
+ return command.replace(/\\\r?\n/g, '')
246
+ }
247
+
248
+ // ---- What the shell never runs: comments and data heredocs (#1572, #1574) -----
249
+
250
+ // The characters after which an unquoted `#` starts a comment (the start of a word).
251
+ const WORD_BREAK = /[\s;&|()<>]/
252
+ // A heredoc operator and its delimiter: `<<EOF`, `<<-EOF`, `<< 'EOF'`, `<<"EOF"`, `<<\EOF`. Group 1 is `-`, groups
253
+ // 2 and 3 a single- or double-quoted delimiter, group 4 a backslash-quoted one, group 5 an unquoted one.
254
+ const HEREDOC_OP = /<<(-?)[ \t]*(?:(?:'([A-Za-z_][\w.-]*)'|"([A-Za-z_][\w.-]*)")|\\([A-Za-z_][\w.-]*)|([A-Za-z_][\w.-]*))/y
255
+ // `$(cat <<'EOF'` up to the end of its line: a substitution whose only command is `cat` reading a heredoc with a quoted delimiter.
256
+ const CAT_HEREDOC = /\$\([ \t]*cat[ \t]+<<(-?)[ \t]*(?:'([A-Za-z_][\w.-]*)'|"([A-Za-z_][\w.-]*)")[ \t]*\n/y
257
+ // What may follow a heredoc operator on its own line for the body to stay data: words, `>`, `&&`, `||`, `;`, `&` and
258
+ // simple quotes — no `|` into another command, no second heredoc, no `$`, backtick or backslash.
259
+ const HEREDOC_REST = /^(?:[^'"`$\\|<\n]|'[^'\n]*'|"[^"\\$`\n]*"|\|\|)*$/
260
+ // A `${name}` that nests nothing; any other `${` stops the view like `$(`.
261
+ const PLAIN_BRACE = /\$\{[A-Za-z_][A-Za-z0-9_]*\}/y
262
+ // Heredocs whose closing line was not found before the view gives up (each miss reads to the end of the line).
263
+ const MAX_HEREDOC_MISSES = 4
264
+ // An `eval` word: what it is given is parsed again as shell code, so nothing quoted behind it is text.
265
+ const EVAL_WORD = /(?:^|[\s;&|(){}])eval(?=\s)/
266
+
267
+ /**
268
+ * Whether the command a heredoc or `$(cat <<'EOF' …)` feeds treats it as text: `cat` printing it (no file
269
+ * written), `git commit` (its message), or a forge's issue/merge-request/pull-request command (a body). Never a
270
+ * shell, an interpreter, `gh api` (a GraphQL body) or anything else (#1574: `bash <<'EOF'` still runs its body).
271
+ */
272
+ function readsAsText(words: readonly string[], writes: boolean): boolean {
273
+ const [first, ...args] = words
274
+ const cmd = baseName(first ?? '')
275
+ if (cmd === 'cat') return !writes && args.every(arg => arg === '-')
276
+ if (cmd === 'git') {
277
+ const git = readGit(args, null)
278
+ return git.sub === 'commit' && git.config.length === 0
279
+ }
280
+ return (cmd === 'gh' || cmd === 'glab') && (args[0] === 'issue' || args[0] === 'mr' || args[0] === 'pr')
281
+ }
282
+
283
+ /**
284
+ * Whether the body of a heredoc with an unquoted delimiter runs nothing: no backtick, `$(`, `$((`, `$[` or `${…}` other
285
+ * than `${name}` — a plain `$name` only expands. (A quoted delimiter keeps every body literal.)
286
+ */
287
+ function inertBody(body: string): boolean {
288
+ return !/`|\$[([]|\$\{(?![A-Za-z_][A-Za-z0-9_]*\})/.test(body)
289
+ }
290
+
291
+ /** The index just past the closing line of a heredoc whose body starts at `from`, or -1 (`tabs`: `<<-`, leading tabs stripped). */
292
+ function heredocEnd(line: string, from: number, delimiter: string, tabs: boolean): number {
293
+ let at = from
294
+ while (at <= line.length) {
295
+ const nl = line.indexOf('\n', at)
296
+ const end = nl === -1 ? line.length : nl
297
+ const text = line.slice(at, end)
298
+ if ((tabs ? text.replace(/^\t+/, '') : text) === delimiter) return end
299
+ if (nl === -1) return -1
300
+ at = nl + 1
301
+ }
302
+ return -1
303
+ }
304
+
305
+ /**
306
+ * The line as the shell runs it, for every reader below: each comment removed, and each heredoc that is only text
307
+ * replaced by the word `<_` (an input redirection the readers drop), its body and closing line removed — so a commit
308
+ * message, an issue body or a comment that mentions `git push` is no command, and `git commit -m "$(cat <<'EOF' …
309
+ * EOF)"` (Claude Code's own recipe) is one git step, not a `cat` before the push (#1574). A heredoc is text only with
310
+ * a quoted delimiter (or an unquoted one with an {@link inertBody}) and a reader per
311
+ * {@link readsAsText}; `$(cat <<'EOF' … EOF)` only inside double quotes, its closing `)` right after the closing line.
312
+ * Read with the quote tracking of {@link scanLine}: from the first newline inside a quote or construct that nests its
313
+ * own quoting (`$(`, a backtick, `${…}` other than `${name}`, `$'`, `<(`, `>(`, a heredoc that is not text) the rest
314
+ * is kept as written — over-reading only asks. A `#` starts a comment at the start of a word outside quotes: a push
315
+ * between `# bash -c '` and `# '` runs (#1572 E1).
316
+ */
317
+ function dataView(line: string): string {
318
+ const out: string[] = []
319
+ let quote: '"' | "'" | null = null
320
+ let segStart = 0
321
+ let misses = 0
322
+ let copied = 0
323
+ let consumerAt = -1
324
+ let consumerText = false
325
+ const flush = (to: number): void => {
326
+ if (to > copied) out.push(line.slice(copied, to))
327
+ copied = to
328
+ }
329
+ for (let i = 0; i < line.length; i += 1) {
330
+ const ch = line[i] ?? ''
331
+ if (quote === "'") {
332
+ if (ch === "'") quote = null
333
+ else if (ch === '\n') break
334
+ continue
335
+ }
336
+ if (ch === '\\') {
337
+ i += 1
338
+ continue
339
+ }
340
+ if (ch === '\n' && quote !== null) break
341
+ if (ch === '`') break
342
+ if (ch === '$') {
343
+ const next = line[i + 1]
344
+ if (next === '{') {
345
+ PLAIN_BRACE.lastIndex = i
346
+ if (!PLAIN_BRACE.test(line)) break
347
+ i = PLAIN_BRACE.lastIndex - 1
348
+ continue
349
+ }
350
+ if (next === "'") break
351
+ if (next !== '(') continue
352
+ CAT_HEREDOC.lastIndex = i
353
+ const cat = CAT_HEREDOC.exec(line)
354
+ const delimiter = cat?.[2] ?? cat?.[3]
355
+ if (cat === null || delimiter === undefined || quote !== '"' || misses >= MAX_HEREDOC_MISSES) break
356
+ const end = heredocEnd(line, CAT_HEREDOC.lastIndex, delimiter, cat[1] === '-')
357
+ const close = end === -1 ? null : /^\s*\)/.exec(line.slice(end, end + 64))
358
+ if (close === null) {
359
+ misses += 1
360
+ break
361
+ }
362
+ // Judged once per segment: many substitutions in one quoted word would re-read its growing prefix (quadratic).
363
+ if (consumerAt !== segStart) {
364
+ const prefix = line.slice(segStart, i)
365
+ const consumer = readCommand(prefix, true).words
366
+ consumerAt = segStart
367
+ // `eval git commit -m "$(cat …)"` parses the message again as shell code.
368
+ consumerText = readsAsText(consumer, false) && baseName(consumer[0] ?? '') !== 'cat' && !EVAL_WORD.test(prefix)
369
+ }
370
+ if (!consumerText) break
371
+ flush(i)
372
+ out.push('_')
373
+ copied = end + close[0].length
374
+ i = copied - 1
375
+ continue
376
+ }
377
+ if (quote === '"') {
378
+ if (ch === '"') quote = null
379
+ continue
380
+ }
381
+ if (ch === '"' || ch === "'") quote = ch
382
+ else if ((ch === '<' || ch === '>') && line[i + 1] === '(') break
383
+ else if (ch === '#' && (i === 0 || WORD_BREAK.test(line[i - 1] ?? ''))) {
384
+ flush(i)
385
+ const nl = line.indexOf('\n', i)
386
+ copied = nl === -1 ? line.length : nl
387
+ i = copied - 1
388
+ } else if (ch === '<' && line[i + 1] === '<' && line[i + 2] === '<') i += 2
389
+ else if (ch === '<' && line[i + 1] === '<') {
390
+ HEREDOC_OP.lastIndex = i
391
+ const op = HEREDOC_OP.exec(line)
392
+ const delimiter = op?.[2] ?? op?.[3] ?? op?.[4] ?? op?.[5]
393
+ if (op === null || delimiter === undefined || misses >= MAX_HEREDOC_MISSES) break
394
+ const opEnd = HEREDOC_OP.lastIndex
395
+ const nl = line.indexOf('\n', opEnd)
396
+ const rest = line.slice(opEnd, nl === -1 ? line.length : nl)
397
+ if (nl === -1 || !HEREDOC_REST.test(rest)) break
398
+ const end = heredocEnd(line, nl + 1, delimiter, op[1] === '-')
399
+ if (end === -1) {
400
+ misses += 1
401
+ break
402
+ }
403
+ const reader = `${line.slice(segStart, i)} ${rest}`
404
+ const body = line.slice(nl + 1, end)
405
+ const literal = op[5] === undefined || inertBody(body)
406
+ if (!literal || !readsAsText(readCommand(reader, true).words, writesOutput(reader))) break
407
+ flush(i)
408
+ out.push(`<_${rest}`)
409
+ copied = end
410
+ i = end - 1
411
+ } else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '(' || ch === ')') segStart = i + 1
412
+ }
413
+ flush(line.length)
414
+ return out.join('')
415
+ }
416
+
417
+ /**
418
+ * One pass over a line. `marked` is the line with each background `&` turned into `;`, same length: a
419
+ * lone `&` ends a command like `;` does (`true & git push`). Not `&&`, `&>`, `>&`, `<&` or `|&`, and
420
+ * not after a backslash or inside quotes, where `&` is a character (`curl "…?a=1&b=2"`, a commit
421
+ * message) — but only while the quote tracking is certain. It stops being certain (`doubt`) at the
422
+ * first newline inside an open quote (a heredoc body or a multi-line string: which of the two, this
423
+ * cannot tell, and resetting the quote there flipped parity on the next line, so `"msg<NL>x=1"; git
424
+ * status & git switch main` hid the `&`) and at the first construct that nests its own quoting or is
425
+ * no shell word: `$(`, `${`, `$'`, a backtick, `<(`, `>(`, `<<`. From there every lone `&` is cut,
426
+ * quoted or not: over-reading finds more commands, never fewer.
427
+ * `unsure[i]` is 1 where position `i` may sit inside a quote — or after doubt began, or after an
428
+ * unquoted `(` or `{` (a group: a `cd` there may not reach what follows); `unsure[length]` is the state
429
+ * at the end.
430
+ */
431
+ function scanLine(line: string): { marked: string; unsure: Uint8Array } {
432
+ const out: string[] = new Array<string>(line.length)
433
+ const unsure = new Uint8Array(line.length + 1)
434
+ let quote: string | null = null
435
+ let doubt = false
436
+ let group = false
437
+ for (let i = 0; i < line.length; i += 1) {
438
+ const ch = line[i] ?? ''
439
+ const next = line[i + 1] ?? ''
440
+ if (!doubt && ((ch === '$' && (next === '(' || next === '{' || next === "'")) || ch === '`' || ((ch === '<' || ch === '>') && next === '(') || (ch === '<' && next === '<') || (ch === '\n' && quote !== null))) doubt = true
441
+ unsure[i] = doubt || group || quote !== null ? 1 : 0
442
+ out[i] = ch
443
+ if (ch === '&' && (quote === null || doubt) && !/[&<>|]/.test(line[i - 1] ?? '') && !/[&>]/.test(next)) out[i] = ';'
444
+ else if (quote === "'") {
445
+ if (ch === "'") quote = null
446
+ } else if (ch === '\\') {
447
+ i += 1
448
+ if (i < line.length) {
449
+ out[i] = line[i] ?? ''
450
+ unsure[i] = unsure[i - 1] ?? 1
451
+ }
452
+ } else if (quote === '"') {
453
+ if (ch === '"') quote = null
454
+ } else if (ch === '"' || ch === "'") quote = ch
455
+ else if (ch === '(' || ch === '{') group = true
456
+ }
457
+ unsure[line.length] = doubt || group || quote !== null ? 1 : 0
458
+ return { marked: out.join(''), unsure }
459
+ }
460
+
461
+ /** The line with each background `&` turned into `;` ({@link scanLine}). */
462
+ function markBackground(line: string): string {
463
+ return scanLine(line).marked
464
+ }
465
+
466
+ // Where splitSegments cuts: `&&`, `||`, `|` and `|&`, `;`, newlines (a background `&` is a `;` by then).
467
+ const SEPARATOR = /&&|\|\||\|&?|;|\n/g
468
+
469
+ /** The command line cut at `&&`, `||`, `;`, `&`, `|`, `|&` and newlines. Quotes are honoured for `&` only: detection only. */
470
+ export function splitSegments(command: string): string[] {
471
+ return markBackground(joinContinuations(command)).split(SEPARATOR)
472
+ }
473
+
474
+ // A separator as written; '' is the start or end of the line.
475
+ type Sep = '' | '&&' | '||' | '|' | '|&' | ';' | '\n' | '&'
476
+ // The separators that end an and-or list (`a && b || c`): what follows runs whatever the list returned.
477
+ const LIST_END: ReadonlySet<Sep> = new Set<Sep>([';', '\n', '&'])
478
+ const PIPE: ReadonlySet<Sep> = new Set<Sep>(['|', '|&'])
479
+
480
+ /**
481
+ * A segment as {@link splitSegments} cuts it, with the separators around it. `sure`: at neither end can
482
+ * it sit inside a quote, after doubt began, or inside a group ({@link scanLine}) — so it is a command
483
+ * of its own in this shell, not a piece of a string a separator inside quotes cut out. `start`: its index in the line.
484
+ */
485
+ type Seg = { text: string; before: Sep; after: Sep; sure: boolean; start: number }
486
+
487
+ function segmentsOf(command: string): Seg[] {
488
+ const line = joinContinuations(command)
489
+ const { marked, unsure } = scanLine(line)
490
+ const segs: Seg[] = []
491
+ let start = 0
492
+ let before: Sep = ''
493
+ const push = (end: number, after: Sep): void => {
494
+ segs.push({ text: line.slice(start, end), before, after, sure: unsure[start] === 0 && unsure[end] === 0, start })
495
+ }
496
+ for (const m of marked.matchAll(SEPARATOR)) {
497
+ const sep = (m[0] === ';' && line[m.index] === '&' ? '&' : m[0]) as Sep
498
+ push(m.index, sep)
499
+ start = m.index + m[0].length
500
+ before = sep
501
+ }
502
+ push(line.length, '')
503
+ return segs
504
+ }
505
+
506
+ /** The pipelines of a command line, each a list of its stages. */
507
+ export function splitPipelines(command: string): string[][] {
508
+ return markBackground(joinContinuations(command)).split(/&&|\|\||;|\n/).map(pipeline => pipeline.split(/\|&?/))
509
+ }
510
+
511
+ // A quoted command line run by another shell (`bash -c "…"`, `sh -lc '…'`), by a runner's shell mode
512
+ // (`npx -c '…'`, `pnpm exec -c '…'`) or by `eval "…"`. Its separators sit inside quotes, which
513
+ // splitSegments does not honour, so it is cut out first and read as a command line of its own.
514
+ // The shell runs one word after `-c` (the rest become $0, $1, …); `eval` joins all its words. A word
515
+ // that starts with `-` (`bash -c -- "…"`) or a shell this cannot cut is left whole: {@link unreadableOutward} reads it.
516
+ const SHELL_WORD = String.raw`(?:"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\;&|()<>]+)+`
517
+ const INNER_SOURCE = String.raw`(?<![\w./-])(?:(?:(?:bash|sh|zsh|dash|ksh)\s+(?:-[-\w]+\s+)*?-(?=[A-Za-z]*c)[A-Za-z]+|(?:npx|bunx|pnpm\s+(?:dlx|exec)|npm\s+(?:exec|x))\s+(?:-[-\w=]+\s+)*?(?:-c|--call|--shell-mode))\s+(?!-)(${SHELL_WORD})|eval((?:[ \t]+${SHELL_WORD})+))`
518
+ const INNER = new RegExp(INNER_SOURCE, 'g')
519
+ const INNER_AT = new RegExp(INNER_SOURCE, 'y')
520
+ const INNER_WORD = /^__SOHUD_INNER_(\d+)__$/
521
+ // Nesting deeper than this is read without cutting, so its words still split (and over-detect).
522
+ const MAX_DEPTH = 3
523
+
524
+ // `expanded`: the part of the inner line the OUTER shell expands before running it — all of an `eval`, and the
525
+ // double-quoted or bare parts of a `-c` word (a single-quoted `$x` is read by the child, which has no unexported x).
526
+ type Inner = { command: string; sameShell: boolean; expanded: string }
527
+
528
+ /** The line with each inner command line replaced by a placeholder word, and those command lines. */
529
+ function extractInner(line: string, depth: number): { text: string; inners: Inner[] } {
530
+ const inners: Inner[] = []
531
+ if (depth >= MAX_DEPTH) return { text: line, inners }
532
+ const text = line.replace(INNER, (_match: string, shellWord?: string, evalWords?: string) => {
533
+ if (shellWord !== undefined) {
534
+ const expanded = shellWord.replace(/"(?:[^"\\]|\\.)*"|'[^']*'|\\./g, part => (part.startsWith("'") ? '' : part))
535
+ inners.push({ command: dequote(shellWord), sameShell: false, expanded })
536
+ } else {
537
+ const command = tokenize(evalWords ?? '').join(' ')
538
+ inners.push({ command, sameShell: true, expanded: command })
539
+ }
540
+ return ` __SOHUD_INNER_${inners.length - 1}__ `
541
+ })
542
+ return { text, inners }
543
+ }
544
+
545
+ /** The inner command line a segment runs, when its first word is a placeholder. */
546
+ function innerAt(word: string, inners: readonly Inner[]): Inner | null {
547
+ const m = INNER_WORD.exec(word)
548
+ return m ? (inners[Number(m[1])] ?? null) : null
549
+ }
550
+
551
+ function baseName(word: string): string {
552
+ return word.replace(/^\\/, '').split('/').pop() ?? word
553
+ }
554
+
555
+ /** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
556
+ function joinDir(dir: string | null, arg: string | undefined): string {
557
+ if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) {
558
+ return arg ?? '~'
559
+ }
560
+ return dir ? `${dir}/${arg}` : arg
561
+ }
562
+
563
+ /** The positional arguments, skipping each option and, for the options in `valued`, its value. */
564
+ export function positionals(args: readonly string[], valued: ReadonlySet<string>): string[] {
565
+ const out: string[] = []
566
+ for (let i = 0; i < args.length; i += 1) {
567
+ const arg = args[i] ?? ''
568
+ if (arg === '--') {
569
+ out.push(...args.slice(i + 1))
570
+ break
571
+ }
572
+ if (arg.startsWith('-') && arg !== '-') {
573
+ if (valued.has(arg)) i += 1
574
+ continue
575
+ }
576
+ out.push(arg)
577
+ }
578
+ return out
579
+ }
580
+
581
+ /** The value of `--name value`, `--name=value` or `-n value`, or undefined. */
582
+ export function optionValue(args: readonly string[], ...names: string[]): string | undefined {
583
+ for (let i = 0; i < args.length; i += 1) {
584
+ const arg = args[i] ?? ''
585
+ for (const name of names) {
586
+ if (arg === name) return args[i + 1]
587
+ if (arg.startsWith(`${name}=`)) return arg.slice(name.length + 1)
588
+ }
589
+ }
590
+ return undefined
591
+ }
592
+
593
+ // ---- Actions with an effect outside this machine -----------------------------
594
+
595
+ export type OutwardKind = 'push' | 'merge' | 'publish' | 'deploy' | 'release'
596
+
597
+ export const KIND_LABEL: Record<OutwardKind, string> = {
598
+ push: 'Push',
599
+ merge: 'Merge',
600
+ publish: 'Paket veröffentlichen',
601
+ deploy: 'Produktiv-Deploy',
602
+ release: 'Release',
603
+ }
604
+
605
+ export type Outward = {
606
+ kind: OutwardKind
607
+ /** The detected action. */
608
+ label: string
609
+ /** The command's own words after the subcommand. */
610
+ args: string[]
611
+ /**
612
+ * Where a `cd` earlier on the line moved to; null means the session folder. A `cd`, `pushd` or
613
+ * `popd` whose effect on this command is not certain — inside `( )`, `{ }` or quotes, in a pipeline
614
+ * stage, after `||` or followed by one before the command, ending a background list, behind a
615
+ * prefix (`if`, `then`, `command`, `VAR=x`), after `&&` in an earlier list, an option or `-`/`+N` —
616
+ * makes it {@link UNKNOWN_DIR} (or a folder below it), which resolves to no folder, so the push asks.
617
+ */
618
+ dir: string | null
619
+ /** For merges and releases: which forge CLI. */
620
+ forge?: 'glab' | 'gh'
621
+ /** For publishes and deploys: which tool. */
622
+ tool?: string
623
+ /**
624
+ * For pushes: every git invocation earlier on the line as a step ({@link gitStep}), child shells and
625
+ * command substitutions included: any of them may have moved what the facts measured. A git step run
626
+ * in a folder other than {@link dir}, or in an unknown one, is recorded as `<step> [other folder]`: its
627
+ * hooks and its configuration are not the ones {@link PushFacts.hooks} and {@link PushFacts.repoConfig}
628
+ * measured (`git -C ../b diff` runs ../b's `diff.external`, #1573 review).
629
+ */
630
+ gitBefore?: string[]
631
+ /**
632
+ * For pushes: some earlier command on the line is not a git call — in this shell, a child shell or a
633
+ * command substitution (`gh pr checkout 5 && …`, `./switch.sh; …`, `x=$(./x)`) — so it may have moved
634
+ * HEAD or written the push configuration (`printf … >> .git/config`): every push form behind it asks.
635
+ * `cd`/`pushd`/`popd` (read as {@link dir}) and a bare assignment (`x=1`) run no command and do not set it.
636
+ * Also set by an earlier output redirection on any segment, git steps included ({@link writesOutput}):
637
+ * `git log --format=… >> .git/config`, `cd . > .git/HEAD`, `exec 3>>.git/config`.
638
+ */
639
+ otherBefore?: true
640
+ /**
641
+ * For pushes: an earlier `git commit` on the line signs explicitly (`-S`, `-S<key>`, `--gpg-sign`, a short
642
+ * option bundle holding `S`; the last of these and `--no-gpg-sign` wins), so it runs `gpg.program` whatever
643
+ * `commit.gpgSign` says ({@link repoConfigStandard}). `tag -s` and `merge -S` are no local step.
644
+ */
645
+ signsBefore?: true
646
+ /**
647
+ * For pushes: git configuration the push runs with that the measured facts did not see — each
648
+ * `-c` / `--config-env` entry as written (`key=value`), `--git-dir` / `--work-tree` / `--namespace`
649
+ * as `--option=value`, every other global option as written, the names of `GIT_CONFIG*` /
650
+ * `GIT_DIR`-style variables anywhere on the line, and the name of every variable assigned, exported,
651
+ * declared or unset on the line (in front of the push or as a statement) that is not
652
+ * {@link HARMLESS_VAR} (`HOME=x` moves the config git reads), plus `source`/`.`/`alias`/`hash` as written.
653
+ */
654
+ gitConfig?: string[]
655
+ /** For pushes: the destination is not read from git's own words (an unreadable line, a forge API ref write): always asks. */
656
+ opaque?: true
657
+ }
658
+
659
+ // stepDirs: the folder each git step of gitBefore ran in, by its index.
660
+ type WalkState = { dir: string | null; gitBefore: string[]; stepDirs: Map<number, string | null>; otherBefore: boolean; signs: boolean; gitEnv: Set<string> }
661
+
662
+ // Variables that route a push past what the facts measured: extra config, another repository.
663
+ const GIT_ENV = /(?<![\w])(GIT_(?:CONFIG\w*|DIR|WORK_TREE|NAMESPACE|COMMON_DIR))=/g
664
+
665
+ /**
666
+ * The folder a push gets when a `cd` before it may or may not have run ({@link Outward.dir}): a path
667
+ * below `/dev/null`, a device and never a directory, so `cd` into it (or into any folder below it)
668
+ * fails on every system and the wiring resolves no folder — the push asks.
669
+ */
670
+ export const UNKNOWN_DIR = '/dev/null/so-hud-unknown-folder'
671
+
672
+ /**
673
+ * The variable names an assignment before a push may set without asking — an allowlist (#1569 M2a):
674
+ * a single lowercase letter, or a name an agent's one-liner uses to hold a value. bash gives no
675
+ * lowercase name a meaning; none of these is a zsh special parameter (zshparam(1), zsh/parameter —
676
+ * `path`, `cdpath`, `commands`, `aliases` are, and stay out), and none is read by git, ssh or curl.
677
+ * Also one or two uppercase characters (`D`, `WT`, `L1`, `TZ`): agents hold paths in them, and no
678
+ * shell gives such a name a meaning (measured 2026-10-12: zsh 5.9 `${(k)parameters}`, 185 names, and
679
+ * bash `compgen -v` hold none matching `^[A-Z][A-Z0-9]?$`); `TZ` only changes how times print. A later
680
+ * `$D` after `cd` or in the push's own words is not read by the rules and asks on its own.
681
+ * Everything else asks: `HOME`, `XDG_*`, `GIT_*`, `PATH`, `PREFIX`, `CDPATH`, `BASH_ENV` … change
682
+ * which config git reads, which git runs, or where `cd` goes.
683
+ */
684
+ const HARMLESS_VAR = /^(?:[a-z]|rc|out|msg|sha|ref|head|base|branch|res|ok|tmp|[A-Z][A-Z0-9]?)$/
685
+ // Statements that set or remove variables by name, and those that change how the shell finds a command.
686
+ const DECLARERS = new Set(['export', 'declare', 'typeset', 'local', 'readonly', 'unset', 'let'])
687
+ // Builtins that set the variables they name without `=` (#1571): `read -r GIT_CONFIG_COUNT … <<< "…"`, `printf -v HOME`,
688
+ // `mapfile`/`readarray`, `getopts`, zsh `vared`. Every word that starts like a name counts (a prompt word too: that only asks).
689
+ const NAME_SETTERS = new Set(['read', 'printf', 'mapfile', 'readarray', 'getopts', 'vared'])
690
+ const SHELL_STATE = new Set(['source', '.', 'alias', 'hash'])
691
+
692
+ /**
693
+ * Every action with an effect outside, one per segment: `git push origin feat && git push origin main`
694
+ * holds two, and a skippable first one must not let the second run (review R1, gate-host). Command
695
+ * lines run by `bash -c`, `sh -c`, `zsh -c`, `eval` and a runner's `-c` are read too, up to depth 3;
696
+ * a line this cannot read adds one fail-closed hit from {@link unreadableOutward}.
697
+ */
698
+ export function classifyAllOutward(command: string): Outward[] {
699
+ const hits: Outward[] = []
700
+ const line = dataView(joinContinuations(command))
701
+ walkOutward(line, 0, { dir: null, gitBefore: [], stepDirs: new Map(), otherBefore: false, signs: false, gitEnv: new Set() }, hits)
702
+ const kind = unreadKind(line) ?? unformedKind(line, 0)
703
+ if (kind !== null) hits.push({ kind, label: UNREADABLE_LABEL, args: [], dir: null, opaque: true })
704
+ return hits
705
+ }
706
+
707
+ // ---- The fail-closed fallback: an outward word no hit was formed for (#1570) ---
708
+
709
+ // Commands that never run what they are given: their words are data (`echo "… git push …"`, `grep -rn publish`).
710
+ // Not here: `sed` (GNU `e`), `awk` (`system()`), `find` (`-ok`), `xargs`, any shell or interpreter, and every
711
+ // command not listed — an allowlist, so a wrapper this does not know (`flock`, `stdbuf`, `doas`) is read as code.
712
+ const DATA_COMMANDS = new Set(['echo', 'printf', 'cat', 'grep', 'egrep', 'fgrep', 'rg', 'head', 'tail', 'wc', 'ls', 'sort', 'uniq', 'cut', 'tr', 'diff', 'cmp', 'less', 'more', 'file', 'stat', 'du', 'df', 'test', '[', '[[', ':', 'true', 'false', 'sleep', 'wait', 'date', 'pwd', 'which', 'type', 'whence', 'mkdir', 'touch', 'rm', 'rmdir', 'cp', 'mv', 'ln', 'chmod', 'basename', 'dirname', 'realpath', 'readlink', 'jq', 'yq', 'column', 'nl', 'fold', 'cd', 'pushd', 'popd', 'export', 'declare', 'typeset', 'local', 'readonly', 'unset', 'set', 'shift', 'return', 'exit', 'break', 'continue', 'ps', 'pgrep', 'pkill', 'kill', 'lsof'])
713
+ // Commands that read a pipe as data and never run it; a pipe into anything else (`| sh`, `| awk`, `| sed`) runs its input.
714
+ const PIPE_DATA = new Set(['cat', 'grep', 'egrep', 'fgrep', 'rg', 'head', 'tail', 'wc', 'sort', 'uniq', 'cut', 'tr', 'less', 'more', 'jq', 'yq', 'tee', 'column', 'nl', 'fold', 'diff', 'cmp', 'base64', 'shasum', 'sha256sum', 'md5', 'od', 'xxd', 'hexdump', 'strings'])
715
+ // The forge subcommands whose words are titles, bodies and filters; `merge`, `release create` and `api` writes are read as hits.
716
+ const FORGE_TEXT = new Set(['issue', 'mr', 'pr', 'api', 'label', 'search', 'browse', 'status', 'auth', 'config'])
717
+ const FORGE_READ_ACTIONS = new Set(['view', 'list', 'download', 'status', 'watch', 'trace', 'get', 'clone'])
718
+ // The tools whose outward subcommands the classifier knows exactly: a `--dry-run` of theirs, and any vercel call that is
719
+ // no production deploy, was read and judged.
720
+ const OUTWARD_TOOLS = new Set(['npm', 'pnpm', 'yarn', 'cargo', 'changeset', 'vercel', 'supabase', 'fly', 'flyctl', 'railway'])
721
+ // Interpreters whose inline code (`node -e "…"`, `python3 - <<'EOF'`) reaches git only through a process API: without
722
+ // one of {@link SPAWN_API} on the line, their code is data to the fallback (an analysis script that mentions `git push`).
723
+ // Ceiling: a process API spelled so this does not match (`require('child' + '_process')`), or a module the code loads.
724
+ const GATED_INTERPRETERS = new Set(['node', 'deno', 'bun', 'tsx', 'python', 'python3', 'osascript', 'perl', 'ruby'])
725
+ // perl and ruby also run a backtick string, and a backtick is ordinary text to node (a template literal).
726
+ const BACKTICK_RUNNERS = new Set(['perl', 'ruby'])
727
+ const SPAWN_API = /\bsystem\s*\(|\bqx\b|\bpopen\b|\bOpen3\b|%x[({[]|child_process|\b(?:exec|execSync|execFile|execFileSync|spawn|spawnSync|fork)\s*\(|\bexeca\b|\$`|\bBun\.(?:spawn|\$)|\bDeno\.(?:run|Command)|\bzx\b|\bsubprocess\b|\bos\.(?:system|popen|exec|spawn|posix_spawn)|\bpty\b|\bplumbum\b|\bpexpect\b|do shell script/
728
+
729
+ // What a command word from a variable may hide behind it: an outward subcommand whose tool is unreadable (`${X}t push`).
730
+ const OUTWARD_SUBWORDS = new Set(['push', 'publish', 'merge', 'accept', 'deploy', 'promote', 'rollback', 'up', '--prod'])
731
+
732
+ // git subcommands that run a shell command given as an argument (`git rebase -x "…"`, `git submodule foreach '…'`): their
733
+ // words are code, not judged.
734
+ const GIT_RUNS_SHELL = new Set(['rebase', 'bisect', 'submodule', 'filter-branch', 'difftool', 'mergetool'])
735
+
736
+ /** Whether the classifier read this command and judged it, so its words need no second look ({@link unformedKind}). */
737
+ function judged(cmd: string, args: string[]): boolean {
738
+ if (classifyWords(cmd, args, null) !== null || cmd === 'git-push') return true
739
+ if (cmd === 'git') return !GIT_RUNS_SHELL.has(readGit(args, null).sub ?? '')
740
+ if (cmd === 'gh' || cmd === 'glab' || /^glab-[a-z0-9-]+$/.test(cmd)) {
741
+ return FORGE_TEXT.has(args[0] ?? '') || FORGE_READ_ACTIONS.has(args[1] ?? '')
742
+ }
743
+ if (!OUTWARD_TOOLS.has(cmd)) return false
744
+ // `npm help publish`, `npm i publish-x`: a subcommand the publish reader rules out.
745
+ const pm = (cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') && PM_NOT_PUBLISHING.has(positionals(args, PM_VALUED)[0] ?? '')
746
+ return cmd === 'vercel' || pm || isDryRun(args, PM_VALUED, false)
747
+ }
748
+
749
+ /** The segment with each inner command line put back where its placeholder stands. */
750
+ function withInners(text: string, inners: readonly Inner[]): string {
751
+ return text.replace(/__SOHUD_INNER_(\d+)__/g, (m: string, n: string) => inners[Number(n)]?.command ?? m)
752
+ }
753
+
754
+ /**
755
+ * The fail-closed fallback after the walker (#1570): the outward kind of the first segment that names an outward
756
+ * action ({@link classKindIn}) but formed no hit and was not judged ({@link judged}); null when there is none. A
757
+ * denylist of spellings does not converge (each review found new wrappers: `time -p`, `flock … bash -c`, `nohup --`,
758
+ * `sh <<<'…'`, `python3 -c`, `case x in a|x)`), so this asks for every segment it cannot account for instead. Not read
759
+ * as code: a segment whose command is in {@link DATA_COMMANDS} (unless its output is piped into a command outside
760
+ * {@link PIPE_DATA}: `echo git push origin main | sh`), one with no command (`msg="deploy"`), and what {@link dataView}
761
+ * removed. A placeholder of a cut-out `bash -c '…'` that is not the first word (`flock /tmp/l bash -c "…"`) counts
762
+ * as code; a first-word one is read as its own line. Inline code of a {@link GATED_INTERPRETERS} interpreter counts
763
+ * only on a line with a process API ({@link SPAWN_API}). A git subcommand from a variable (`git ${X}sh`) always asks,
764
+ * a command word from one asks when its subcommand is an outward word ({@link OUTWARD_SUBWORDS}: `${X}t push`).
765
+ */
766
+ function unformedKind(line: string, depth: number): OutwardKind | null {
767
+ const { text, inners } = extractInner(line, depth)
768
+ const joined = joinContinuations(text)
769
+ const segs = segmentsOf(joined)
770
+ const spawns = SPAWN_API.test(joined)
771
+ const openers = quoteOpeners(joined, spawns)
772
+ const words = segs.map(seg => readCommand(seg.text, true).words)
773
+ // Per segment, the first segment from it on whose cut is outside every quote (or not followed): where its command ends.
774
+ const realEnd = new Int32Array(segs.length)
775
+ for (let j = segs.length - 1; j >= 0; j -= 1) {
776
+ const cut = openers[(segs[j]?.start ?? 0) + (segs[j]?.text.length ?? 0)] ?? UNKNOWN_QUOTE
777
+ realEnd[j] = cut < 0 || j === segs.length - 1 ? j : (realEnd[j + 1] ?? j)
778
+ }
779
+ // The segment holding index `at` (the last one starting at or before it).
780
+ const segAt = (at: number): number => {
781
+ let lo = 0
782
+ let hi = segs.length - 1
783
+ while (lo < hi) {
784
+ const mid = (lo + hi + 1) >> 1
785
+ if ((segs[mid]?.start ?? 0) <= at) lo = mid
786
+ else hi = mid - 1
787
+ }
788
+ return lo
789
+ }
790
+ for (let k = 0; k < segs.length; k += 1) {
791
+ const seg = segs[k]
792
+ if (seg === undefined) continue
793
+ const opener = openers[seg.start] ?? UNKNOWN_QUOTE
794
+ if (opener === HEREDOC_DATA) continue
795
+ // A segment cut out of a quoted word (a commit body's line, `-m "a; b"`) is that word's command's argument —
796
+ // unless an `eval` parses that word again.
797
+ const quotedIn = opener >= 0 ? segAt(opener) : k
798
+ const owner = EVAL_WORD.test(segs[quotedIn]?.text ?? '') ? k : quotedIn
799
+ const [first, ...args] = words[owner] ?? []
800
+ if (first === undefined) continue
801
+ const inner = innerAt(first, inners)
802
+ if (inner !== null) {
803
+ if (owner !== k) continue
804
+ const kind = unformedKind(inner.command, depth + 1)
805
+ if (kind !== null) return kind
806
+ continue
807
+ }
808
+ const cmd = baseName(first)
809
+ if (owner === k && cmd === 'git' && (readGit(args, null).sub ?? '').includes('$')) return 'push'
810
+ if (judged(cmd, args)) continue
811
+ // The command ends at the first cut outside its quotes: a `|` inside `grep -E "a|b"` is no pipe.
812
+ const last = realEnd[owner] ?? owner
813
+ const next = words[last + 1] ?? []
814
+ const piped = PIPE.has(segs[last]?.after ?? '') && !PIPE_DATA.has(baseName(next[0] ?? '')) && !judged(baseName(next[0] ?? ''), next.slice(1))
815
+ if (DATA_COMMANDS.has(cmd) && !piped) continue
816
+ if (GATED_INTERPRETERS.has(cmd) && !spawns && !(BACKTICK_RUNNERS.has(cmd) && joined.includes('`'))) continue
817
+ const code = withInners(seg.text, inners)
818
+ const kind = classKindIn(code)
819
+ if (kind !== null) return kind
820
+ if (owner === k && first.includes('$') && args.slice(0, 2).some(word => OUTWARD_SUBWORDS.has(word))) return 'push'
821
+ }
822
+ return null
823
+ }
824
+
825
+ // A command that may run a file written earlier on the line, in command position (line start or after `;`, `&`, `|`,
826
+ // `(`, `{`, a newline, `then`/`do`): a shell or interpreter, `source`/`.`, `exec`, `xargs`, or a path like `./x`, `/tmp/x.sh`.
827
+ const RUNS_FILE = /(?:^|[;&|(){}\n]|\b(?:then|do|else)\s)\s*(?:sudo\s+|env\s+|nohup\s+|exec\s+)*(sh|bash|zsh|dash|ksh|fish|source|\.|python3?|node|deno|bun|tsx|perl|ruby|exec|xargs|\.{0,2}\/\S+)(?=[\s;&|()]|$)/g
828
+
829
+ /**
830
+ * Whether a line may run a file it wrote ({@link RUNS_FILE}): a runner that is no {@link GATED_INTERPRETERS} one, or a
831
+ * gated one on a line that can start a process (`spawns`) — `cat > x.mjs <<'EOF' … EOF; node x.mjs` with no process API
832
+ * runs no git.
833
+ */
834
+ function runsWrittenFile(line: string, spawns: boolean): boolean {
835
+ for (const m of line.matchAll(RUNS_FILE)) {
836
+ const runner = m[1] ?? ''
837
+ if (spawns || !GATED_INTERPRETERS.has(runner) || (BACKTICK_RUNNERS.has(runner) && line.includes('`'))) return true
838
+ }
839
+ return false
840
+ }
841
+
842
+ // Values of {@link quoteOpeners} that are no index: outside every quote, not followed (after a construct that nests its
843
+ // own quoting), and inside the body of a heredoc read as text.
844
+ const NO_QUOTE = -1
845
+ const UNKNOWN_QUOTE = -2
846
+ const HEREDOC_DATA = -3
847
+
848
+ /**
849
+ * Per index of `line`: where the quote it sits inside opened, {@link NO_QUOTE}, {@link HEREDOC_DATA} inside the body of
850
+ * a heredoc with a literal body that `cat` or `tee` reads (also into a file, on a line that runs no file
851
+ * ({@link runsWrittenFile}): the walker still reads those lines, this only keeps the fallback from asking for them) or a
852
+ * {@link GATED_INTERPRETERS} one does on a line without `spawns`,
853
+ * or {@link UNKNOWN_QUOTE} from the first construct whose quoting this does not follow (a backtick, `${…}` other than
854
+ * `${name}`, `$'`, `<(`, `>(`, any other heredoc). Quotes are followed
855
+ * across newlines (a multi-line commit message is one word) and into `$(…)`, whose body starts unquoted and whose `)`
856
+ * restores the quote around it; a `case` or a comment inside it stops the tracking (their `)` would end it early). Every
857
+ * `$(…)` body is read by {@link unreadRegions} as well. One pass.
858
+ */
859
+ function quoteOpeners(line: string, spawns: boolean): Int32Array {
860
+ const owner = new Int32Array(line.length + 1).fill(UNKNOWN_QUOTE)
861
+ let quote: '"' | "'" | null = null
862
+ let opened = NO_QUOTE
863
+ let segStart = 0
864
+ // One frame per open `$(`: the quote around it, and the plain `(` opened inside it, so its `)` is found.
865
+ const frames: { quote: '"' | "'" | null; opened: number; parens: number }[] = []
866
+ let i = 0
867
+ const here = (): number => (quote === null ? NO_QUOTE : opened)
868
+ for (; i < line.length; i += 1) {
869
+ const ch = line[i] ?? ''
870
+ owner[i] = here()
871
+ if (quote === "'") {
872
+ if (ch === "'") quote = null
873
+ continue
874
+ }
875
+ if (ch === '\\') {
876
+ i += 1
877
+ if (i < line.length) owner[i] = here()
878
+ continue
879
+ }
880
+ if (ch === '`') break
881
+ if (ch === '$') {
882
+ const next = line[i + 1]
883
+ if (next === "'") break
884
+ if (next === '(') {
885
+ frames.push({ quote, opened, parens: 0 })
886
+ quote = null
887
+ owner[i + 1] = NO_QUOTE
888
+ i += 1
889
+ segStart = i + 1
890
+ } else if (next === '{') {
891
+ PLAIN_BRACE.lastIndex = i
892
+ if (!PLAIN_BRACE.test(line)) break
893
+ }
894
+ continue
895
+ }
896
+ if (quote === '"') {
897
+ if (ch === '"') quote = null
898
+ continue
899
+ }
900
+ const frame = frames.at(-1)
901
+ // Inside `$(…)` a `case` pattern's `)` or a comment's `)` would end the frame early and shift the quotes after it.
902
+ if (frame !== undefined && (i === 0 || WORD_BREAK.test(line[i - 1] ?? '')) && (ch === '#' || /^case(?=[\s;&|()<>]|$)/.test(line.slice(i, i + 5)))) break
903
+ if (ch === '"' || ch === "'") {
904
+ quote = ch
905
+ opened = i
906
+ } else if ((ch === '<' || ch === '>') && line[i + 1] === '(') break
907
+ else if (ch === '(' && frame !== undefined) frame.parens += 1
908
+ else if (ch === ')' && frame !== undefined && frame.parens > 0) frame.parens -= 1
909
+ else if (ch === ')' && frame !== undefined) {
910
+ frames.pop()
911
+ quote = frame.quote
912
+ opened = frame.opened
913
+ owner[i] = here()
914
+ } else if (ch === '<' && line[i + 1] === '<' && line[i + 2] === '<') {
915
+ owner[i + 1] = NO_QUOTE
916
+ owner[i + 2] = NO_QUOTE
917
+ i += 2
918
+ } else if (ch === '<' && line[i + 1] === '<') {
919
+ HEREDOC_OP.lastIndex = i
920
+ const op = HEREDOC_OP.exec(line)
921
+ const delimiter = op?.[2] ?? op?.[3] ?? op?.[4] ?? op?.[5]
922
+ const nl = line.indexOf('\n', HEREDOC_OP.lastIndex)
923
+ if (op === null || delimiter === undefined || nl === -1) break
924
+ const rest = line.slice(HEREDOC_OP.lastIndex, nl)
925
+ const end = HEREDOC_REST.test(rest) ? heredocEnd(line, nl + 1, delimiter, op[1] === '-') : -1
926
+ if (end === -1) break
927
+ const readerText = `${line.slice(segStart, i)} ${rest}`
928
+ const reader = readCommand(readerText, true).words
929
+ const tool = baseName(reader[0] ?? '')
930
+ const literal = op[5] === undefined || inertBody(line.slice(nl + 1, end))
931
+ // A body `cat`/`tee` writes to a file is code when the line may run a file (`cat > f <<'EOF' … EOF; sh f`).
932
+ const written = (tool === 'cat' || tool === 'tee') && !runsWrittenFile(line, spawns)
933
+ if (!literal || !(written || readsAsText(reader, writesOutput(readerText)) || (GATED_INTERPRETERS.has(tool) && !spawns && !(BACKTICK_RUNNERS.has(tool) && line.includes('`'))))) break
934
+ owner.fill(NO_QUOTE, i, nl + 1)
935
+ owner.fill(HEREDOC_DATA, nl + 1, end)
936
+ i = end - 1
937
+ } else if (ch === ';' || ch === '&' || ch === '|' || ch === '\n' || ch === '(' || ch === ')') segStart = i + 1
938
+ }
939
+ if (i >= line.length) owner[line.length] = here()
940
+ return owner
941
+ }
942
+
943
+ // ---- Shapes this parser does not read (fail-closed) --------------------------
944
+
945
+ const UNREADABLE_LABEL = 'nicht lesbare Befehlsform'
946
+
947
+ // Constructs whose effect the word reader above cannot see. Each is judged on the segment it sits in.
948
+ const UNREAD_CONSTRUCTS = [
949
+ /\bxargs\b/g,
950
+ /\bssh\s/g,
951
+ /\bwatch\s/g,
952
+ // The lookbehind anchors the name at a word start: without it the engine retries from every
953
+ // character of a long word, quadratic (1524 ms on 50k chars, Lead review of !132).
954
+ /(?<![\w.-])[\w.-]+\s*\(\)\s*\{/g,
955
+ /\bfunction\s+[\w.-]+/g,
956
+ /\/(?:bash|sh|zsh|dash|ksh|env)(?=\s)/g,
957
+ /\$'/g,
958
+ /\balias\./gi,
959
+ ]
960
+ // Constructs of a head and a later tail in the same run (no `;`, `&`, `|` or newline between):
961
+ // `find … -exec`, `env … -S`, `case … in`. Matched as two linear scans ({@link headsWithTail}): as one
962
+ // regex `head[^\n;&|]*tail` each head re-scanned the run to its end, quadratic (`env -i` ×N).
963
+ const UNREAD_PAIRS: readonly (readonly [head: RegExp, tail: RegExp])[] = [
964
+ [/\bfind\b/g, /\s-exec(?:dir)?\b/g],
965
+ [/\benv\b/g, /\s(?:-[A-Za-z]*S|--split-string)/g],
966
+ [/\bcase\b/g, /\bin\b/g],
967
+ ]
968
+ // A shell given a command with -c — a shell name followed by whitespace, and later in the same run
969
+ // without `;`, `&` or `|` a word of letters holding `c` (`-c`, `-lc`) — unreadable when INNER cannot
970
+ // cut it at the same place. Matched as two linear scans ({@link shellCStarts}): as one regex with a
971
+ // lazy word run it retried from every shell name to the run's end, 680 ms for 10k `bash ` words.
972
+ const SHELL_NAME = /(?<![\w./-])(?:bash|sh|zsh|dash|ksh)(?=\s)/g
973
+ // `-(?=[A-Za-z]*c)[A-Za-z]+` is `-[A-Za-z]*c[A-Za-z]*` without its quadratic backtracking on a long word.
974
+ const C_OPTION = /\s-(?=[A-Za-z]*c)[A-Za-z]+(?=\s|$)/g
975
+
976
+ /** The `[start, end)` of each run of the line between the characters `cut` matches. */
977
+ function runsOf(line: string, cut: RegExp): Span[] {
978
+ const runs: Span[] = []
979
+ let start = 0
980
+ for (const m of line.matchAll(cut)) {
981
+ runs.push([start, m.index])
982
+ start = m.index + 1
983
+ }
984
+ runs.push([start, line.length])
985
+ return runs
986
+ }
987
+
988
+ /** The start of each `head` match with a `tail` match starting at or after its end in the same run: two linear scans. */
989
+ function headsWithTail(line: string, runs: readonly Span[], head: RegExp, tail: RegExp): number[] {
990
+ const lastTail = new Map<number, number>()
991
+ for (const m of line.matchAll(tail)) lastTail.set(spanIndexAt(runs, m.index), m.index)
992
+ const starts: number[] = []
993
+ for (const m of line.matchAll(head)) {
994
+ const t = lastTail.get(spanIndexAt(runs, m.index))
995
+ if (t !== undefined && t >= m.index + m[0].length) starts.push(m.index)
996
+ }
997
+ return starts
998
+ }
999
+
1000
+ /** Where a shell is given `-c`: a SHELL_NAME with a C_OPTION after it and no `;`, `&` or `|` between. */
1001
+ function shellCStarts(line: string): number[] {
1002
+ return headsWithTail(line, runsOf(line, /[;&|]/g), SHELL_NAME, C_OPTION)
1003
+ }
1004
+
1005
+ type Span = [start: number, end: number]
1006
+
1007
+ /** The `[start, end)` of every segment of the line, cut at the separators splitSegments cuts at, in order. */
1008
+ function segmentSpans(line: string): Span[] {
1009
+ const spans: Span[] = []
1010
+ let start = 0
1011
+ for (const m of markBackground(line).matchAll(SEPARATOR)) {
1012
+ spans.push([start, m.index])
1013
+ start = m.index + m[0].length
1014
+ }
1015
+ spans.push([start, line.length])
1016
+ return spans
1017
+ }
1018
+
1019
+ /** The index in `spans` of the segment holding `index` (binary search: the last segment starting at or before it). */
1020
+ function spanIndexAt(spans: readonly Span[], index: number): number {
1021
+ let lo = 0
1022
+ let hi = spans.length - 1
1023
+ while (lo < hi) {
1024
+ const mid = (lo + hi + 1) >> 1
1025
+ if ((spans[mid]?.[0] ?? 0) <= index) lo = mid
1026
+ else hi = mid - 1
1027
+ }
1028
+ return lo
1029
+ }
1030
+
1031
+ /**
1032
+ * The `[start, end)` body of every `$(…)` (parentheses balanced, quotes not honoured) and after every
1033
+ * backtick (to the next one), each to the end of the line when unclosed. One pass with a stack, so a
1034
+ * line of 25,000 unclosed `$(` costs no more than one of plain words.
1035
+ */
1036
+ function substitutionSpans(line: string): Span[] {
1037
+ const spans: Span[] = []
1038
+ // Per open parenthesis: the index in `spans` of the `$(` it opened, or -1 for a plain `(`.
1039
+ const open: number[] = []
1040
+ let lastTick = -1
1041
+ for (let i = 0; i < line.length; i += 1) {
1042
+ const ch = line[i]
1043
+ if (ch === '$' && line[i + 1] === '(') {
1044
+ open.push(spans.push([i + 2, line.length]) - 1)
1045
+ i += 1
1046
+ } else if (ch === '(') open.push(-1)
1047
+ else if (ch === ')') {
1048
+ const k = open.pop()
1049
+ const span = k === undefined || k === -1 ? undefined : spans[k]
1050
+ if (span !== undefined) span[1] = i
1051
+ } else if (ch === '`') {
1052
+ const prev = lastTick === -1 ? undefined : spans[lastTick]
1053
+ if (prev !== undefined) prev[1] = i
1054
+ lastTick = spans.push([i + 1, line.length]) - 1
1055
+ }
1056
+ }
1057
+ return spans
1058
+ }
1059
+
1060
+ /** The spans merged where they overlap or touch, sorted. */
1061
+ function mergeSpans(spans: Span[]): Span[] {
1062
+ const merged: Span[] = []
1063
+ for (const [start, end] of [...spans].sort((a, b) => a[0] - b[0])) {
1064
+ const last = merged.at(-1)
1065
+ if (last !== undefined && start <= last[1]) last[1] = Math.max(last[1], end)
1066
+ else merged.push([start, end])
1067
+ }
1068
+ return merged
1069
+ }
1070
+
1071
+ /** The text with `$'…'` escapes decoded and every quote and backslash dropped, so `pu"sh"` and `$'\x70ush'` read as `push`. */
1072
+ function normalizeWords(text: string): string {
1073
+ return text
1074
+ .replace(/\$'((?:[^'\\]|\\.)*)'/g, (_m: string, body: string) =>
1075
+ body.replace(/\\x([0-9a-fA-F]{1,2})|\\u([0-9a-fA-F]{1,4})|\\([0-7]{1,3})|\\(.)/g, (_e: string, hex?: string, uni?: string, oct?: string, ch?: string) =>
1076
+ hex !== undefined ? String.fromCharCode(parseInt(hex, 16)) : uni !== undefined ? String.fromCharCode(parseInt(uni, 16)) : oct !== undefined ? String.fromCharCode(parseInt(oct, 8)) : (ch ?? '')))
1077
+ .replace(/["'\\]/g, '')
1078
+ }
1079
+
1080
+ /** The outward kind a piece of unread text names, or null — wide on purpose: any `deploy` or `publish`, any forge `merge`/`release`. */
1081
+ function outwardWordIn(text: string): OutwardKind | null {
1082
+ const t = normalizeWords(text)
1083
+ const forge = /\b(?:gh|glab)\b/.test(t)
1084
+ if (/\bgit\b/.test(t) && /\bpush\b/.test(t)) return 'push'
1085
+ if (/\bpublish\b/.test(t)) return 'publish'
1086
+ if (forge && /\b(?:merge|accept)\b/.test(t)) return 'merge'
1087
+ if (/\bdeploy\b/.test(t) || /--prod(?:uction)?\b/.test(t)) return 'deploy'
1088
+ if (forge && /\brelease\b/.test(t)) return 'release'
1089
+ return classKindIn(text)
1090
+ }
1091
+
1092
+ // The words of unread text, split at blanks and at shell and code punctuation: `os.system(git push)` gives `git`, `push`;
1093
+ // `args.push(x)` stays one word, so a JS `.push(` next to a `git` word is no push.
1094
+ const CODE_WORD = /[^\s()[\]{}<>;|&,`]+/g
1095
+ // git's global options that take the next word as their value (`git -C dir push`).
1096
+ const GIT_VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--exec-path', '--attr-source'])
1097
+ const FORGE_VALUED = new Set(['-R', '--repo'])
1098
+ const NONE_VALUED: ReadonlySet<string> = new Set()
1099
+
1100
+ /**
1101
+ * The outward kind of the first action the classifier holds ({@link classifyWords}) that a piece of unread text spells
1102
+ * as a tool word with its action word in subcommand position, options skipped — or null. `git [options] push`,
1103
+ * `git-push`; `gh|glab [options] mr|pr merge|accept`, `… release create`, `… api` with a method or field and a ref, branch or
1104
+ * merge path; npm/pnpm/yarn/changeset/cargo with a later `publish` word; vercel/vc with `--prod`, `--production`,
1105
+ * `promote` or `rollback`; `supabase db push`, `supabase db reset … --linked`, `supabase migration up … --linked`;
1106
+ * `fly deploy`; `railway up` (#1572 E3/E4). A piece holding `--dry-run` names no publish or supabase write. Linear: one
1107
+ * pass over the words, a later word looked up by its last index.
1108
+ */
1109
+ function classKindIn(text: string): OutwardKind | null {
1110
+ const plain = normalizeWords(text)
1111
+ const words = plain.match(CODE_WORD) ?? []
1112
+ const last = new Map<string, number>()
1113
+ words.forEach((word, i) => last.set(word, i))
1114
+ const later = (i: number, ...names: string[]): boolean => names.some(name => (last.get(name) ?? -1) > i)
1115
+ const dry = last.has('--dry-run')
1116
+ // Judged for the whole piece, once: a slice per `api` word was quadratic on `gh api gh api …`.
1117
+ // An API call writes only with a method or a field (`gh api repos/o/r/git/refs/heads/main` alone reads).
1118
+ const apiWrites = /(?:^|\s)(?:-X\s*|--method[=\s]+)(?:POST|PUT|PATCH|DELETE)\b|(?:^|\s)(?:-[fF]|--(?:raw-)?field|--input)(?:[=\s]|\S)/i.test(plain)
1119
+ const refWrite = apiWrites && /refs\/heads\/|\bbranch=/.test(plain)
1120
+ const mergePath = apiWrites && /\/merges?\b/.test(plain)
1121
+ // The index of the first word from `j` that is no option (a valued option skips its value too).
1122
+ const skip = (j: number, valued: ReadonlySet<string>): number => {
1123
+ let k = j
1124
+ while ((words[k] ?? '').startsWith('-')) k += valued.has(words[k] ?? '') ? 2 : 1
1125
+ return k
1126
+ }
1127
+ for (let i = 0; i < words.length; i += 1) {
1128
+ const tool = baseName(words[i] ?? '')
1129
+ if (tool === 'git-push') return 'push'
1130
+ if (tool === 'git' && words[skip(i + 1, GIT_VALUED)] === 'push') return 'push'
1131
+ if (tool === 'gh' || tool === 'glab') {
1132
+ const j = skip(i + 1, FORGE_VALUED)
1133
+ const [sub, action] = [words[j], words[j + 1]]
1134
+ if ((sub === 'mr' || sub === 'pr') && (action === 'merge' || action === 'accept')) return 'merge'
1135
+ if (sub === 'release' && action === 'create') return 'release'
1136
+ if (sub === 'api' && refWrite) return 'push'
1137
+ if (sub === 'api' && mergePath) return 'merge'
1138
+ }
1139
+ if (['npm', 'pnpm', 'yarn', 'changeset', 'cargo'].includes(tool) && later(i, 'publish') && !dry) return 'publish'
1140
+ if ((tool === 'vercel' || tool === 'vc') && later(i, '--prod', '--production', 'promote', 'rollback')) return 'deploy'
1141
+ if (tool === 'supabase' && !dry) {
1142
+ const j = skip(i + 1, NONE_VALUED)
1143
+ const [sub, action] = [words[j], words[j + 1]]
1144
+ if (sub === 'db' && (action === 'push' || (action === 'reset' && later(j, '--linked')))) return 'deploy'
1145
+ if (sub === 'migration' && action === 'up' && later(j, '--linked')) return 'deploy'
1146
+ }
1147
+ if ((tool === 'fly' || tool === 'flyctl') && words[skip(i + 1, NONE_VALUED)] === 'deploy') return 'deploy'
1148
+ if (tool === 'railway' && words[skip(i + 1, NONE_VALUED)] === 'up') return 'deploy'
1149
+ }
1150
+ return null
1151
+ }
1152
+
1153
+ /**
1154
+ * The pieces of a line this parser does not read: each construct's segment, a substitution's segment
1155
+ * joined with its body, and the whole line for a `$var` command. Each segment is read once however
1156
+ * many constructs it holds, and a substitution's segment and body, with every span overlapping them,
1157
+ * form one contiguous piece — a superset of reading each pair on its own, in linear time (a construct
1158
+ * per match over the whole line was quadratic: 317 ms at 8k characters of `$(`, about 12 s at 50k).
1159
+ */
1160
+ function unreadRegions(line: string): string[] {
1161
+ const regions: string[] = []
1162
+ const segments = segmentSpans(line)
1163
+ const constructSegments = new Set<number>()
1164
+ for (const re of UNREAD_CONSTRUCTS) for (const m of line.matchAll(re)) constructSegments.add(spanIndexAt(segments, m.index))
1165
+ const runs = runsOf(line, /[\n;&|]/g)
1166
+ for (const [head, tail] of UNREAD_PAIRS) for (const at of headsWithTail(line, runs, head, tail)) constructSegments.add(spanIndexAt(segments, at))
1167
+ for (const at of shellCStarts(line)) {
1168
+ INNER_AT.lastIndex = at
1169
+ if (!INNER_AT.test(line)) constructSegments.add(spanIndexAt(segments, at))
1170
+ }
1171
+ for (const k of constructSegments) {
1172
+ const span = segments[k]
1173
+ if (span !== undefined) regions.push(line.slice(...span))
1174
+ }
1175
+ const bodies = substitutionSpans(line)
1176
+ const owners = new Set<number>()
1177
+ for (const [start] of bodies) owners.add(spanIndexAt(segments, start - 1))
1178
+ const pieces = [...bodies]
1179
+ for (const k of owners) {
1180
+ const span = segments[k]
1181
+ if (span !== undefined) pieces.push(span)
1182
+ }
1183
+ for (const [start, end] of mergeSpans(pieces)) regions.push(line.slice(start, end))
1184
+ // `p=push; git $p origin main`: a command or git subcommand from a variable is set anywhere on the
1185
+ // line, so the whole line is read — once (once per such segment re-read the line, quadratic).
1186
+ const expanded = (word: string | undefined) => word !== undefined && word.includes('$') && !word.startsWith('$(')
1187
+ for (const segment of splitSegments(line)) {
1188
+ const [first, ...args] = readCommand(segment, true).words
1189
+ if (expanded(first) || (baseName(first ?? '') === 'git' && expanded(readGit(args, null).sub))) {
1190
+ regions.push(line)
1191
+ break
1192
+ }
1193
+ }
1194
+ return regions
1195
+ }
1196
+
1197
+ /**
1198
+ * One fail-closed hit for a line holding a construct this parser does not read (`$(…)`, a backtick,
1199
+ * `xargs`, `find -exec`, `ssh`, `env -S`, `watch`, a function, `case`, a shell by path or one `-c`
1200
+ * cannot be cut from, `$'…'`, a git alias, a command from a variable) whose unread part names an
1201
+ * outward action; null otherwise. Its push always asks ({@link Outward.opaque}).
1202
+ */
1203
+ export function unreadableOutward(command: string): Outward | null {
1204
+ const kind = unreadKind(dataView(joinContinuations(command)))
1205
+ return kind === null ? null : { kind, label: UNREADABLE_LABEL, args: [], dir: null, opaque: true }
1206
+ }
1207
+
1208
+ /** The outward kind the first unread region of a line ({@link unreadRegions}, after {@link dataView}) names, or null. */
1209
+ function unreadKind(line: string): OutwardKind | null {
1210
+ for (const region of unreadRegions(line)) {
1211
+ const kind = outwardWordIn(region)
1212
+ if (kind !== null) return kind
1213
+ }
1214
+ return null
1215
+ }
1216
+
1217
+ function walkOutward(line: string, depth: number, state: WalkState, hits: Outward[]): void {
1218
+ const { text, inners } = extractInner(line, depth)
1219
+ for (const m of text.matchAll(GIT_ENV)) if (m[1] !== undefined) state.gitEnv.add(m[1])
1220
+ walkSegments(text, inners, depth, state, hits)
1221
+ }
1222
+
1223
+ // Where a command substitution or process substitution opens: it runs before its segment's command.
1224
+ const SUBSTITUTION_OPEN = /\$\(|[<>]\(|`/
1225
+
1226
+ /**
1227
+ * The git calls inside a segment's `$(…)`, backticks, `<(…)` or `>(…)` count as earlier git calls:
1228
+ * `echo $(git switch main) && git push` moves HEAD before the push runs. Read from the first opener to
1229
+ * the segment's end (over-reading only adds steps, which asks). Its pushes are not collected — the
1230
+ * line is held as unreadable for them ({@link unreadableOutward}) — and its `cd` ends with the
1231
+ * subshell. Past {@link MAX_DEPTH} any git word left counts as a step that is not local, and any
1232
+ * text left as an earlier command that is not git ({@link Outward.otherBefore}).
1233
+ */
1234
+ function gitInSubstitution(segment: string, inners: readonly Inner[], depth: number, state: WalkState): void {
1235
+ const at = segment.search(SUBSTITUTION_OPEN)
1236
+ if (at === -1) return
1237
+ const body = segment.slice(at + (segment[at] === '`' ? 1 : 2))
1238
+ if (depth + 1 >= MAX_DEPTH) {
1239
+ if (/\bgit\b|__SOHUD_INNER_/.test(body)) state.gitBefore.push('$(…)')
1240
+ if (/\S/.test(body)) state.otherBefore = true
1241
+ return
1242
+ }
1243
+ const dir = state.dir
1244
+ walkSegments(body, inners, depth + 1, state, [])
1245
+ state.dir = dir
1246
+ }
1247
+
1248
+ // The options of `git log`, `show` and `diff` that only choose what is printed — an allowlist (MR !133
1249
+ // HOCH 1): `--output` writes a file (`.git/HEAD`, `.git/config`), `--ext-diff` and `--textconv` run a
1250
+ // configured program, and an option git adds later is not here either. git takes none of these
1251
+ // commands' long options abbreviated (measured, git 2.56.0: `git show --outp=x` is refused).
1252
+ const DISPLAY_OPTIONS = new Set(['--oneline', '--stat', '--shortstat', '--numstat', '--name-only', '--name-status', '--summary', '--cached', '--staged', '--no-color', '--decorate', '--graph', '--abbrev-commit', '-s', '--no-patch', '-p', '-u', '--patch', '-q', '--quiet', '--', '--no-ext-diff', '--no-textconv'])
1253
+ // …and these, with any value in the same word: a count (`-3`, `-n3`), `--format=`, `--pretty=`, `--color=`, `--max-count=`.
1254
+ const DISPLAY_VALUED = /^(?:-n?\d+$|--(?:format|pretty|color|max-count)=)/
1255
+
1256
+ /**
1257
+ * How an earlier git call is recorded for {@link LOCAL_GIT}: its subcommand, with three exact
1258
+ * read-only forms spelled out — `branch --show-current`; `tag --list` for `tag -l` or `tag --list`
1259
+ * with no other word (a pattern or option is not needed for the everyday form, so none is admitted);
1260
+ * and `checkout -- <paths>` for `checkout` whose first word is `--` with at least one path after it
1261
+ * (it restores files and cannot switch the branch; `checkout main -- f` and a bare `checkout --` stay
1262
+ * `checkout`). `fetch` is the local step only with plain words (no option — `--upload-pack`, also as
1263
+ * `--upload=`, runs a program, `--set-upstream` moves the upstream — and no `ext::`-style URL, whose
1264
+ * transport runs a command where `protocol.ext.allow` permits it); `log`, `show` and `diff` only with every option in {@link DISPLAY_OPTIONS}
1265
+ * or {@link DISPLAY_VALUED} (a value given as its own word, `--output x`, is caught by its option). Any
1266
+ * other form is recorded as `<sub> [options]`, which is not local.
1267
+ */
1268
+ /**
1269
+ * Whether a `git commit` with these words signs explicitly: the last of `-S`/`-S<key>`/`--gpg-sign[=<key>]`/a short
1270
+ * bundle holding `S` (`-aS`), or `--no-gpg-sign`, before `--`. Over-reads a bundle whose `S` is a value (`-mS`):
1271
+ * that only asks, and only where a `gpg.program` is set.
1272
+ */
1273
+ function signsCommit(rest: readonly string[]): boolean {
1274
+ let signs = false
1275
+ for (const word of rest) {
1276
+ if (word === '--') break
1277
+ if (word === '--gpg-sign' || word.startsWith('--gpg-sign=') || (/^-[^-]/.test(word) && word.includes('S'))) signs = true
1278
+ else if (word === '--no-gpg-sign') signs = false
1279
+ }
1280
+ return signs
1281
+ }
1282
+
1283
+ // `git commit` options that open no editor, run no pager or diff filter and read no patch interactively — an allowlist
1284
+ // (#1573 input), matched as whole words; the `=`-forms of the valued ones are taken too. Not here: `-e`/`--edit`,
1285
+ // `-c`/`--reedit-message`, `-t`/`--template`, `-p`/`--patch`/`--interactive`, `--squash`, `--fixup=amend:`/`reword:`.
1286
+ const COMMIT_FLAGS = new Set(['-a', '--all', '-q', '--quiet', '-s', '--signoff', '--no-signoff', '-n', '--no-verify', '--verify', '--allow-empty', '--allow-empty-message', '--amend', '--no-edit', '-o', '--only', '-i', '--include', '-v', '--verbose', '--no-verbose', '--status', '--no-status', '--dry-run', '--short', '--porcelain', '--long', '--branch', '-z', '--null', '--reset-author', '--no-post-rewrite', '-S', '--gpg-sign', '--no-gpg-sign', '--untracked-files'])
1287
+ const COMMIT_VALUED = new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '--author', '--date', '--cleanup', '--trailer', '--fixup'])
1288
+ // The message options: with one of them (or `--amend --no-edit`, or a dry run) commit opens no editor.
1289
+ const COMMIT_MESSAGE = new Set(['-m', '--message', '-F', '--file', '-C', '--reuse-message', '--fixup'])
1290
+ // The short options of a bundle (`-am`, `-sm"msg"`): flags, then one that takes the rest of the word or the next word.
1291
+ const COMMIT_SHORT_FLAGS = 'aqsnoivz'
1292
+ const COMMIT_SHORT_VALUED = 'mFCSu'
1293
+ // `git add` options that only choose what is staged; `-p`, `-i`, `-e`, `--renormalize` and the rest ask.
1294
+ const ADD_FLAGS = new Set(['-A', '--all', '--no-all', '-u', '--update', '-f', '--force', '-v', '--verbose', '-n', '--dry-run', '-N', '--intent-to-add', '--ignore-errors', '--ignore-missing', '--ignore-removal', '--no-ignore-removal', '--sparse', '--refresh', '--chmod=+x', '--chmod=-x'])
1295
+ const ADD_SHORT = 'AufvnN'
1296
+
1297
+ /** Whether a `git commit` runs neither an editor nor an interactive patch read: every option allowed, a message given. */
1298
+ function plainCommit(rest: readonly string[]): boolean {
1299
+ const seen = new Set<string>()
1300
+ for (let i = 0; i < rest.length; i += 1) {
1301
+ const word = rest[i] ?? ''
1302
+ if (word === '--') break
1303
+ if (!word.startsWith('-') || word === '-') continue
1304
+ const long = word.startsWith('--') ? word.split('=')[0] ?? word : null
1305
+ if (long !== null) {
1306
+ if (long === '--fixup' && /^--fixup=(?:amend|reword):/.test(word)) return false
1307
+ if (COMMIT_FLAGS.has(word) || ((long === '--untracked-files' || long === '--gpg-sign') && word.includes('='))) seen.add(long)
1308
+ else if (COMMIT_VALUED.has(long)) {
1309
+ seen.add(long)
1310
+ if (!word.includes('=')) i += 1
1311
+ } else return false
1312
+ continue
1313
+ }
1314
+ // A short option or bundle: `-a`, `-am`, `-mmsg`, `-S`, `-Skey`, `-uno`.
1315
+ for (let k = 1; k < word.length; k += 1) {
1316
+ const letter = word[k] ?? ''
1317
+ if (COMMIT_SHORT_FLAGS.includes(letter)) seen.add(`-${letter}`)
1318
+ else if (COMMIT_SHORT_VALUED.includes(letter)) {
1319
+ seen.add(`-${letter}`)
1320
+ // -S and -u take their value only in the same word; -m, -F, -C take the rest of it or the next word.
1321
+ if (k === word.length - 1 && 'mFC'.includes(letter)) i += 1
1322
+ break
1323
+ } else return false
1324
+ }
1325
+ }
1326
+ const message = [...seen].some(option => COMMIT_MESSAGE.has(option))
1327
+ return message || (seen.has('--amend') && seen.has('--no-edit')) || ['--dry-run', '--short', '--porcelain'].some(option => seen.has(option))
1328
+ }
1329
+
1330
+ /** Whether a `git add` only stages: every option in {@link ADD_FLAGS} or a short bundle of {@link ADD_SHORT}. */
1331
+ function plainAdd(rest: readonly string[]): boolean {
1332
+ for (const word of rest) {
1333
+ if (word === '--') break
1334
+ if (!word.startsWith('-') || word === '-') continue
1335
+ if (!ADD_FLAGS.has(word) && !(/^-[^-]+$/.test(word) && [...word.slice(1)].every(letter => ADD_SHORT.includes(letter)))) return false
1336
+ }
1337
+ return true
1338
+ }
1339
+
1340
+ // `--format=`/`--pretty=` placeholders that verify a signature (`%G?`, `%GG`, `%GS`, `%GK`, `%GF`, `%GP`, `%GT`): they run gpg.program.
1341
+ const SIGNATURE_FORMAT = /^--(?:format|pretty)=.*%G/
1342
+
1343
+ function gitStep(sub: string | undefined, rest: readonly string[]): string {
1344
+ if (sub === 'commit' && !plainCommit(rest)) return 'commit [options]'
1345
+ if (sub === 'add' && !plainAdd(rest)) return 'add [options]'
1346
+ if (sub === 'branch' && rest.length === 1 && rest[0] === '--show-current') return 'branch --show-current'
1347
+ if (sub === 'tag' && rest.length === 1 && (rest[0] === '-l' || rest[0] === '--list')) return 'tag --list'
1348
+ if (sub === 'checkout' && rest[0] === '--' && rest.length > 1) return 'checkout -- <paths>'
1349
+ if (sub === 'fetch' && rest.some(word => word.startsWith('-') || word.includes('::'))) return 'fetch [options]'
1350
+ const display = (word: string): boolean => !word.startsWith('-') || DISPLAY_OPTIONS.has(word) || DISPLAY_VALUED.test(word)
1351
+ // A signature check runs gpg.program: `log [signature]` is local, but makes the wide scopes' gpg.program count.
1352
+ const signature = (word: string): boolean => word === '--show-signature' || SIGNATURE_FORMAT.test(word)
1353
+ if ((sub === 'log' || sub === 'show') && rest.some(signature)) return rest.every(word => signature(word) || display(word)) ? `${sub} [signature]` : `${sub} [options]`
1354
+ if ((sub === 'log' || sub === 'show' || sub === 'diff') && !rest.every(display)) return `${sub} [options]`
1355
+ return sub ?? ''
1356
+ }
1357
+
1358
+ /** For each segment, the separator that ends its and-or list ('' at the end of the line). */
1359
+ function listEnds(segs: readonly Seg[]): Sep[] {
1360
+ const ends: Sep[] = new Array<Sep>(segs.length)
1361
+ let end: Sep = ''
1362
+ for (let k = segs.length - 1; k >= 0; k -= 1) {
1363
+ const after = segs[k]?.after ?? ''
1364
+ if (after === '' || LIST_END.has(after)) end = after
1365
+ ends[k] = end
1366
+ }
1367
+ return ends
1368
+ }
1369
+
1370
+ /**
1371
+ * Walks the segments of a line in order. The folder ({@link WalkState.dir}) follows a `cd` only where
1372
+ * its effect on what follows is certain: a plain `cd`, `pushd` or `eval` that is {@link Seg.sure}, not
1373
+ * a pipeline stage and not after `||`. One after `&&`, or in a list that runs in the background, holds
1374
+ * for the rest of its and-or list only (`a && cd x; git push` runs the push wherever `a` left it); an
1375
+ * `||` later in the list (`cd x || cd y`, `cd x || echo; git push`) means it may not have run. Every
1376
+ * other case makes the folder {@link UNKNOWN_DIR}.
1377
+ */
1378
+ function walkSegments(text: string, inners: readonly Inner[], depth: number, state: WalkState, hits: Outward[]): void {
1379
+ const segs = segmentsOf(text)
1380
+ const ends = listEnds(segs)
1381
+ let moved = false // a folder move happened in this and-or list
1382
+ let movedForList = false // … and it holds only until the list ends
1383
+ const move = (seg: Seg, k: number, to: string | null): void => {
1384
+ if (to === null || !seg.sure || seg.before === '||' || PIPE.has(seg.before) || PIPE.has(seg.after)) state.dir = UNKNOWN_DIR
1385
+ else {
1386
+ state.dir = to
1387
+ if (seg.before === '&&' || ends[k] === '&') movedForList = true
1388
+ }
1389
+ moved = true
1390
+ }
1391
+ const walkSegment = (seg: Seg, k: number): void => {
1392
+ if (LIST_END.has(seg.before)) {
1393
+ if (movedForList) state.dir = UNKNOWN_DIR
1394
+ moved = false
1395
+ movedForList = false
1396
+ } else if (seg.before === '||' && moved) state.dir = UNKNOWN_DIR
1397
+ const segment = seg.text
1398
+ gitInSubstitution(segment, inners, depth, state)
1399
+ const { words, assigned } = readCommand(segment, true)
1400
+ const [first, ...args] = words
1401
+ for (const name of assigned) if (!HARMLESS_VAR.test(name)) state.gitEnv.add(name)
1402
+ if (first === undefined) return
1403
+ // The command as written, prefixes kept: a `cd` behind `then`, `command` or `VAR=x` is not plain.
1404
+ const written = argWords(segment, true)
1405
+ const inner = innerAt(first, inners)
1406
+ if (inner !== null) {
1407
+ const dir = state.dir
1408
+ walkOutward(inner.command, depth + 1, state, hits)
1409
+ // A child shell's `cd` ends with it; `eval` runs in this shell. Its git calls count either way.
1410
+ if (!inner.sameShell) state.dir = dir
1411
+ else if (state.dir !== dir) {
1412
+ const to = state.dir
1413
+ state.dir = dir
1414
+ move(seg, k, written[0] === first ? to : null)
1415
+ }
1416
+ return
1417
+ }
1418
+ const cmd = baseName(first)
1419
+ if (cmd === 'cd' || cmd === 'pushd' || cmd === 'popd') {
1420
+ // `cd`, `cd dir`, `pushd dir`; never `popd`, `cd -`, `pushd +1`, an option or a second word.
1421
+ const arg = args[0]
1422
+ const plain = written[0] === cmd && written.length === words.length && cmd !== 'popd' && args.length <= 1 && (cmd === 'cd' || arg !== undefined) && !/^[-+]/.test(arg ?? '')
1423
+ move(seg, k, plain ? joinDir(state.dir, arg) : null)
1424
+ return
1425
+ }
1426
+ if (DECLARERS.has(cmd)) {
1427
+ // `declare -n r=HOME` makes r a name for HOME: its value is a name being set too.
1428
+ const nameref = args.some(word => /^-[a-zA-Z]*n/.test(word))
1429
+ for (const word of args) {
1430
+ const m = /^([A-Za-z_][A-Za-z0-9_]*)(?:\[[^\]]*\])?(?:\+?=(.*))?$/s.exec(word)
1431
+ for (const name of [m?.[1], nameref ? /^[A-Za-z_][A-Za-z0-9_]*$/.exec(m?.[2] ?? '')?.[0] : undefined]) {
1432
+ if (name !== undefined && !HARMLESS_VAR.test(name)) state.gitEnv.add(name)
1433
+ }
1434
+ }
1435
+ } else if (NAME_SETTERS.has(cmd)) {
1436
+ // printf sets a name only with -v; its format and arguments are text.
1437
+ const named = cmd === 'printf' ? args.flatMap((word, j) => (word === '-v' ? [args[j + 1] ?? ''] : word.startsWith('-v') ? [word.slice(2)] : [])) : args.map(word => word.replace(/^-[A-Za-z]*[aA](?=[A-Za-z_])/, ''))
1438
+ for (const word of named) {
1439
+ const name = /^[A-Za-z_][A-Za-z0-9_]*/.exec(word)?.[0]
1440
+ if (name !== undefined && !HARMLESS_VAR.test(name)) state.gitEnv.add(name)
1441
+ }
1442
+ } else if (SHELL_STATE.has(cmd)) state.gitEnv.add(cmd)
1443
+ const hit = classifyWords(cmd, args, state.dir)
1444
+ if (hit !== null) hits.push(hit.kind === 'push' ? withPushContext(hit, state) : hit)
1445
+ if (cmd === 'git') {
1446
+ const { sub, rest, dir, config } = readGit(args, state.dir)
1447
+ const step = gitStep(sub, rest)
1448
+ // `git -c diff.external=./x diff` runs a program of its choice: local only with the push's own config allowlist.
1449
+ state.gitBefore.push(config.every(entry => SAFE_CONFIG.test(entry)) ? step : `${step} [${config.join(' ')}]`)
1450
+ // Its hooks and its config are those of the folder it ran in (`cd`, then `-C`): compared with the push's in withPushContext.
1451
+ state.stepDirs.set(state.gitBefore.length - 1, dir)
1452
+ if (sub === 'commit' && signsCommit(rest)) state.signs = true
1453
+ } else if (cmd.startsWith('git-')) {
1454
+ // `git-push`, or a git builtin run by path (`$(git --exec-path)/git-switch main`): never local.
1455
+ state.gitBefore.push(cmd === 'git-push' ? 'push' : cmd)
1456
+ } else if (runsOwnCommand(segment)) state.otherBefore = true
1457
+ }
1458
+ segs.forEach((seg, k) => {
1459
+ walkSegment(seg, k)
1460
+ // Judged after the segment's own hit is recorded, so it counts for the pushes after it: an output
1461
+ // redirection on any segment (a git step, `cd`, `exec`, an assignment) may write .git/config or
1462
+ // .git/HEAD. Ceiling: one on the push itself (`git push … > .git/config`) is not judged — the shell
1463
+ // opens it before git starts, so it can only truncate the file, and git writes into it only after
1464
+ // it read its refs and config.
1465
+ if (writesOutput(seg.text)) state.otherBefore = true
1466
+ })
1467
+ if (movedForList) state.dir = UNKNOWN_DIR
1468
+ }
1469
+
1470
+ // A `$(…)` or backtick span holding no quote, parenthesis, backtick or backslash, so where it ends is certain.
1471
+ const PLAIN_SUBSTITUTION = /\$\([^()'"`\\]*\)|`[^`'"\\$]*`/g
1472
+
1473
+ /**
1474
+ * Whether a segment whose first word is not git runs a command of its own: read again with each plain
1475
+ * substitution as one word, so `h=$(git rev-parse HEAD)` runs none (its body was read by
1476
+ * {@link gitInSubstitution}). Any word left — a command, or a word split out of a substitution this
1477
+ * cannot bound — counts, and so does a git call the first read missed (`h=$(echo x) git switch main`).
1478
+ */
1479
+ function runsOwnCommand(segment: string): boolean {
1480
+ return readCommand(segment.replace(PLAIN_SUBSTITUTION, '_'), true).words.length > 0
1481
+ }
1482
+
1483
+ /**
1484
+ * Resolves a `cd` target to an absolute folder (`bash -c CD_SCRIPT so-hud <dir>` in the session folder; the target is an
1485
+ * argument, never source): prints it and exits 0, or exits non-zero. A relative target that does not start with `.`
1486
+ * exits 3 while `CDPATH` is set (#1573 input): the session's shell looks it up there first, so `cd sub` may land
1487
+ * elsewhere than the folder this would resolve — the push then asks. Ceiling: a zsh `cdpath` set only in the session
1488
+ * shell's own startup files is not in this environment.
1489
+ */
1490
+ export const CD_SCRIPT = `d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; /*|.|..|./*|../*) ;; *) [ -z "\${CDPATH:-}" ] || exit 3;; esac; unset CDPATH; cd -- "$d" 2>/dev/null && pwd -P`
1491
+
1492
+ /** Whether a folder the walker could not follow ({@link UNKNOWN_DIR}, or one below it). */
1493
+ const unknownDir = (dir: string | null): boolean => dir !== null && (dir === UNKNOWN_DIR || dir.startsWith(`${UNKNOWN_DIR}/`))
1494
+
1495
+ /** A push hit with what earlier segments of the line changed under the measured facts. */
1496
+ function withPushContext(hit: Outward, state: WalkState): Outward {
1497
+ const gitConfig = [...(hit.gitConfig ?? []), ...state.gitEnv]
1498
+ // A git step elsewhere ran hooks and config the facts never measured: compared as written, so `-C .` asks too.
1499
+ // A step without a recorded folder (`git-<name>`, `$(…)` past the depth) is not local anyway.
1500
+ const sameDir = (i: number): boolean => !state.stepDirs.has(i) || (state.stepDirs.get(i) === hit.dir && !unknownDir(hit.dir))
1501
+ return {
1502
+ ...hit,
1503
+ ...(state.gitBefore.length > 0 ? { gitBefore: state.gitBefore.map((step, i) => (sameDir(i) ? step : `${step} [other folder]`)) } : {}),
1504
+ ...(state.otherBefore ? { otherBefore: true as const } : {}),
1505
+ ...(state.signs ? { signsBefore: true as const } : {}),
1506
+ ...(gitConfig.length > 0 ? { gitConfig } : {}),
1507
+ }
1508
+ }
1509
+
1510
+ // git's own options before the subcommand that point it at another repository or ref namespace.
1511
+ const GIT_REDIRECTS = ['--git-dir', '--work-tree', '--namespace']
1512
+ // git's global options that change nothing about where a push goes; every other one is reported as config.
1513
+ const GIT_GLOBAL_PLAIN = new Set(['--no-pager', '-P', '-p', '--paginate', '--no-optional-locks'])
1514
+
1515
+ /** git's global options read off: the subcommand, its words, the folder `-C` moved to, and the config the facts did not see. */
1516
+ function readGit(args: readonly string[], dir: string | null): { sub: string | undefined; rest: string[]; dir: string | null; config: string[] } {
1517
+ let gitDir = dir
1518
+ const config: string[] = []
1519
+ let i = 0
1520
+ while (i < args.length && (args[i] ?? '').startsWith('-')) {
1521
+ const arg = args[i] ?? ''
1522
+ const value = args[i + 1]
1523
+ if (value !== undefined && arg === '-C') {
1524
+ gitDir = joinDir(gitDir, value)
1525
+ i += 2
1526
+ } else if (value !== undefined && (arg === '-c' || arg === '--config-env')) {
1527
+ config.push(value)
1528
+ i += 2
1529
+ } else if (value !== undefined && GIT_REDIRECTS.includes(arg)) {
1530
+ config.push(`${arg}=${value}`)
1531
+ i += 2
1532
+ } else if (value !== undefined && arg === '--attr-source') {
1533
+ config.push(`${arg}=${value}`)
1534
+ i += 2
1535
+ } else {
1536
+ if (arg.startsWith('--config-env=')) config.push(arg.slice('--config-env='.length))
1537
+ else if (!GIT_GLOBAL_PLAIN.has(arg)) config.push(arg)
1538
+ i += 1
1539
+ }
1540
+ }
1541
+ return { sub: args[i], rest: args.slice(i + 1), dir: gitDir, config }
1542
+ }
1543
+
1544
+ // Without -X, gh and glab send POST as soon as a field or --input is given.
1545
+ const API_FIELDS = /^(?:-f|-F|--field|--raw-field|--input)(?:=|$)|^-[fF]./
1546
+ const GRAPHQL_MERGE = /\b(?:mergePullRequest|enablePullRequestAutoMerge|mergeBranch|mergeRequestAccept)\b/
1547
+ const API_REF_MAIN = /(?:^|\/)git\/refs\/heads\/(?:main|master)\/?(?:\?.*)?$/
1548
+
1549
+ const decodePath = (word: string): string => {
1550
+ try {
1551
+ return decodeURIComponent(word)
1552
+ } catch {
1553
+ return word
1554
+ }
1555
+ }
1556
+
1557
+ /**
1558
+ * What a forge API call that writes does, or null: a write to `git/refs/heads/main|master` (or a
1559
+ * `git/refs` create naming it), and any write carrying the field `branch=main|master` (a contents
1560
+ * or commits write onto the default branch), is a push; a REST `…/merge` or `…/merges`, a GraphQL
1561
+ * merge mutation, and a GraphQL body this cannot read (`-F query=@file`, `--input`) are merges.
1562
+ */
1563
+ function apiAction(apiArgs: readonly string[]): { kind: 'push' | 'merge'; path: string } | null {
1564
+ let method: string | undefined
1565
+ const fields: string[] = []
1566
+ // Indices of the words that are an option's value, so the endpoint is the first word that is none.
1567
+ const values = new Set<number>()
1568
+ for (let i = 0; i < apiArgs.length; i += 1) {
1569
+ const arg = apiArgs[i] ?? ''
1570
+ if (arg === '-X' || arg === '--method') {
1571
+ method = apiArgs[i + 1]
1572
+ values.add(i + 1)
1573
+ } else if (arg.startsWith('--method=')) method = arg.slice('--method='.length)
1574
+ else if (/^-X./.test(arg)) method = arg.slice(2)
1575
+ else if (arg === '-f' || arg === '-F' || arg === '--field' || arg === '--raw-field') {
1576
+ fields.push(apiArgs[i + 1] ?? '')
1577
+ values.add(i + 1)
1578
+ } else if (/^-[fF]./.test(arg)) fields.push(arg.slice(2))
1579
+ else if (/^--(?:raw-)?field=/.test(arg)) fields.push(arg.slice(arg.indexOf('=') + 1))
1580
+ }
1581
+ const writes = method !== undefined ? /^(?:PUT|POST|PATCH|DELETE)$/i.test(method) : apiArgs.some(a => API_FIELDS.test(a))
1582
+ if (!writes) return null
1583
+ const words = apiArgs.filter(a => !a.startsWith('-')).map(decodePath)
1584
+ const ref = words.find(a => API_REF_MAIN.test(a))
1585
+ if (ref !== undefined) return { kind: 'push', path: ref }
1586
+ const refs = words.find(a => /(?:^|\/)git\/refs\/?$/.test(a))
1587
+ if (refs !== undefined && fields.some(v => /^ref=refs\/heads\/(?:main|master)$/.test(v))) return { kind: 'push', path: refs }
1588
+ if (fields.some(v => /^branch=(?:refs\/heads\/)?(?:main|master)$/.test(v))) {
1589
+ return { kind: 'push', path: apiArgs.find((a, i) => !a.startsWith('-') && !values.has(i)) ?? 'api' }
1590
+ }
1591
+ const merge = words.find(a => /\/merges?\/?(?:\?.*)?$/.test(a))
1592
+ if (merge !== undefined) return { kind: 'merge', path: merge }
1593
+ const unreadBody = apiArgs.some(a => a === '--input' || a.startsWith('--input=')) || fields.some(v => v.startsWith('query=@'))
1594
+ return words.includes('graphql') && (unreadBody || apiArgs.some(a => GRAPHQL_MERGE.test(a))) ? { kind: 'merge', path: 'graphql' } : null
1595
+ }
1596
+
1597
+ // The options of git push that take a separate value.
1598
+ const PUSH_VALUED = new Set(['-o', '--push-option', '--receive-pack', '--exec', '--repo'])
1599
+
1600
+ /**
1601
+ * Whether the last dry-run switch before `--` turns dry-run on: `--dry-run`, `--dry-run=true` and,
1602
+ * when `shortN`, a lone `-n` turn it on; `--no-dry-run` and any other `--dry-run=` turn it off. A
1603
+ * value of an option in `valued` (`git push -o -n`) is no switch.
1604
+ */
1605
+ function isDryRun(args: readonly string[], valued: ReadonlySet<string>, shortN: boolean): boolean {
1606
+ let dry = false
1607
+ for (let i = 0; i < args.length; i += 1) {
1608
+ const arg = args[i] ?? ''
1609
+ if (arg === '--') break
1610
+ if (valued.has(arg)) i += 1
1611
+ else if (arg === '--dry-run' || arg === '--dry-run=true' || (shortN && arg === '-n')) dry = true
1612
+ else if (arg === '--no-dry-run' || arg.startsWith('--dry-run=')) dry = false
1613
+ }
1614
+ return dry
1615
+ }
1616
+
1617
+ // npm/pnpm/yarn options before the subcommand that take a separate value (`-w pkg`, `--filter x`).
1618
+ const PM_VALUED = new Set(['-w', '--workspace', '--prefix', '--registry', '--tag', '--otp', '--access', '--userconfig', '--cache', '--filter', '-F', '-C', '--dir', '--cwd', '--loglevel', '--reporter', '--include', '--exclude', '--from', '--since', '-j', '--jobs'])
1619
+ // Subcommands that only name `publish` as a word (`npm help publish`) or a package (`npm i publish`).
1620
+ const PM_NOT_PUBLISHING = new Set(['help', 'view', 'info', 'show', 'v', 'search', 's', 'docs', 'home', 'repo', 'bugs', 'why', 'explain', 'ls', 'list', 'outdated', 'audit', 'install', 'i', 'add', 'remove', 'rm', 'uninstall', 'un'])
1621
+
1622
+ /** Whether npm/pnpm/yarn publishes: `publish` among the positionals before `--`, so `-w pkg`, `--filter x` or `yarn workspace x` in front cannot hide it. */
1623
+ function pmPublishes(args: readonly string[]): boolean {
1624
+ const end = args.indexOf('--')
1625
+ const pos = positionals(end === -1 ? args : args.slice(0, end), PM_VALUED)
1626
+ return pos.includes('publish') && !PM_NOT_PUBLISHING.has(pos[0] ?? '')
1627
+ }
1628
+
1629
+ function classifyWords(cmd: string, args: string[], dir: string | null): Outward | null {
1630
+ const [sub, action] = args
1631
+ if (cmd === 'git' || cmd === 'git-push') {
1632
+ const git = cmd === 'git' ? readGit(args, dir) : { sub: 'push', rest: args, dir, config: [] }
1633
+ if (git.sub !== 'push') return null
1634
+ if (isDryRun(git.rest, PUSH_VALUED, true)) return null
1635
+ return { kind: 'push', label: 'git push', args: git.rest, dir: git.dir, ...(git.config.length > 0 ? { gitConfig: git.config } : {}) }
1636
+ }
1637
+ // Wrappers like glab-<name> speak the same API as glab.
1638
+ if (cmd === 'glab' || cmd === 'gh' || /^glab-[a-z0-9-]+$/.test(cmd)) {
1639
+ const forge = cmd === 'gh' ? 'gh' : 'glab'
1640
+ // `glab mr accept` is glab's alias of `glab mr merge`.
1641
+ if ((sub === 'mr' || sub === 'pr') && (action === 'merge' || action === 'accept')) {
1642
+ return { kind: 'merge', label: `${cmd} ${sub} ${action}`, args: args.slice(2), dir, forge }
1643
+ }
1644
+ if (sub === 'release' && action === 'create') {
1645
+ return { kind: 'release', label: `${cmd} release create`, args: args.slice(2), dir, forge }
1646
+ }
1647
+ const api = sub === 'api' ? apiAction(args.slice(1)) : null
1648
+ if (api !== null) {
1649
+ return { kind: api.kind, label: `${cmd} api ${api.path}`, args: args.slice(1), dir, forge, ...(api.kind === 'push' ? { opaque: true as const } : {}) }
1650
+ }
1651
+ return null
1652
+ }
1653
+ const joined = [cmd, ...args].join(' ')
1654
+ if (isDryRun(args, PM_VALUED, false)) return null
1655
+ if (((cmd === 'npm' || cmd === 'pnpm' || cmd === 'yarn') && pmPublishes(args)) || /^changeset publish\b/.test(joined) || /^cargo publish\b/.test(joined)) {
1656
+ return { kind: 'publish', label: [cmd, ...args.slice(0, args.indexOf('publish') + 1)].join(' '), args, dir, tool: cmd }
1657
+ }
1658
+ if (cmd === 'vercel' && (args.includes('--prod') || args.includes('--production') || sub === 'promote' || sub === 'rollback')) {
1659
+ return { kind: 'deploy', label: `vercel ${args.filter(a => !a.startsWith('--token')).join(' ')}`.trim(), args, dir, tool: 'vercel' }
1660
+ }
1661
+ if (cmd === 'supabase' && ((sub === 'db' && (action === 'push' || (action === 'reset' && args.includes('--linked')))) || (sub === 'migration' && action === 'up' && args.includes('--linked')))) {
1662
+ return { kind: 'deploy', label: `supabase ${sub} ${action}`, args, dir, tool: 'supabase' }
1663
+ }
1664
+ if ((cmd === 'fly' || cmd === 'flyctl') && sub === 'deploy') {
1665
+ return { kind: 'deploy', label: 'fly deploy', args, dir, tool: 'fly' }
1666
+ }
1667
+ if (cmd === 'railway' && sub === 'up') {
1668
+ return { kind: 'deploy', label: 'railway up', args, dir, tool: 'railway' }
1669
+ }
1670
+ return null
1671
+ }
1672
+
1673
+ // ---- Which pushes may skip the confirmation ----------------------------------
1674
+
1675
+ const PROTECTED_BRANCHES = ['main', 'master']
1676
+ // The only options a push may carry and skip the confirmation, matched as exact words.
1677
+ const PUSH_FLAGS = new Set(['-u', '--set-upstream', '-q', '--quiet', '-v', '--verbose', '--progress', '--no-progress'])
1678
+ // The only git steps that may run earlier on the line (see {@link gitStep}): none moves the branch, its upstream or the config.
1679
+ const LOCAL_GIT = new Set(['add', 'commit', 'status', 'log', 'diff', 'show', 'log [signature]', 'show [signature]', 'rev-parse', 'fetch', 'ls-files', 'branch --show-current', 'tag --list', 'checkout -- <paths>'])
1680
+ // The steps of LOCAL_GIT that can fire a hook (pre-/post-commit, reference-transaction,
1681
+ // post-index-change, post-checkout), which runs any program: local only when no hook can run ({@link PushFacts.hooks}).
1682
+ const HOOK_GIT = new Set(['add', 'commit', 'status', 'diff', 'fetch', 'checkout -- <paths>'])
1683
+ // The only `-c` / `--config-env` keys a push may carry and skip the confirmation.
1684
+ const SAFE_CONFIG = /^(?:user\.(?:name|email)|core\.quotepath|color\.[^=]+)(?:=|$)/i
1685
+ const PLAIN_REMOTE = /^[A-Za-z0-9._-]+$/
1686
+
1687
+ /**
1688
+ * What the wiring measured with `git` in the push's folder (`hit.dir`), each read independently.
1689
+ * Any read that failed, printed nothing, or was not run is `null` / `false`: the push then needs
1690
+ * confirmation (fail-closed).
1691
+ */
1692
+ export type PushFacts = {
1693
+ /** `git rev-parse --abbrev-ref HEAD` (`HEAD` when detached). */
1694
+ current: string | null
1695
+ /** `git rev-parse --abbrev-ref @{u}`, e.g. `origin/feat/x`. */
1696
+ upstream: string | null
1697
+ /** `git config branch.<current>.remote`; null when unset, so {@link pushRemote} falls back to `origin`. */
1698
+ remote: string | null
1699
+ /** `git remote get-url <pushRemote(hit, facts.remote)>` exited 0 with a non-empty URL. */
1700
+ remoteUrlKnown: boolean
1701
+ /**
1702
+ * Each destination from {@link pushDestinations} for which `git rev-parse --verify --quiet refs/tags/<dst>`
1703
+ * exited 0. null when the probes were not run.
1704
+ */
1705
+ tagRefs: readonly string[] | null
1706
+ /**
1707
+ * The push configuration a previous call may have set (#1569). Optional only so a caller from before
1708
+ * it still type-checks: absent (`undefined`) or `null` (any of its reads failed) asks, never runs.
1709
+ */
1710
+ pushConfig?: PushConfig | null
1711
+ /**
1712
+ * Whether a hook the earlier git steps can fire exists in the push's folder (#1574): only the hook
1713
+ * names those steps fire are looked up in the hooks directory (`git rev-parse --git-path hooks`), a
1714
+ * verified husky v9 stub counts only when its `.husky/<name>` exists, and a config hook only for those
1715
+ * events; `false` only when measured absent; `null` when a read failed or the layout is unknown. Absent or `null` asks like
1716
+ * `true` — for a push behind a step in {@link HOOK_GIT} (MR !133: a post-commit hook of
1717
+ * `git commit && git push origin HEAD` may switch HEAD or write the push configuration). A hook step
1718
+ * in another folder asks whatever this holds ({@link Outward.gitBefore}).
1719
+ */
1720
+ hooks?: boolean | null
1721
+ /**
1722
+ * Every configured key with the scope git read it from ({@link REPO_CONFIG_READ}, parsed by
1723
+ * {@link repoConfigEntries}). A key such as `core.fsmonitor`, `diff.external`, `filter.<x>.clean` or
1724
+ * `core.sshCommand` makes a local step (`status`, `diff`, `add`, `fetch`) run a program, which may move
1725
+ * HEAD or write the push configuration after the facts were measured (#1573). Absent or `null` (the read
1726
+ * failed, or its output did not parse) asks for every push behind a git step, like a key outside
1727
+ * {@link repoConfigStandard}.
1728
+ */
1729
+ repoConfig?: RepoConfig | null
1730
+ /**
1731
+ * Whether the index of the push's folder holds a gitlink (mode 160000: a submodule, or an embedded
1732
+ * repository added without `.gitmodules`), counted by {@link GITLINKS_SCRIPT}. `git status`, `git diff`
1733
+ * and `git fetch` step into such a repository and run its own `core.fsmonitor`, filters and transport,
1734
+ * which {@link repoConfig} never read (#1573 review; measured with git 2.56.0: an embedded `sub/` with
1735
+ * its own `core.fsmonitor` ran it four times during the outer repository's `git status` and `git diff`).
1736
+ * `false` only when counted zero; absent or `null` (the count failed) asks like `true` for every push
1737
+ * behind a git step. This also covers `submodule.recurse`, which only acts where gitlinks exist.
1738
+ */
1739
+ gitlinks?: boolean | null
1740
+ /**
1741
+ * `git rev-parse --symbolic-full-name <src>` for each source of {@link pushSources}, in order (#1570); null
1742
+ * entry when the read failed or printed nothing (an ambiguous name exits 0 with nothing on stdout, git 2.56.0).
1743
+ * A push naming its branch runs only when each is exactly `refs/heads/<name>`: a symbolic ref `feat/x` →
1744
+ * `refs/heads/main` is pushed as `feat/x -> main`, and a `refs/feat/x` without that branch is pushed to `refs/feat/x`
1745
+ * (beside the branch the name is ambiguous and git refuses the push; both measured, git 2.56.0). Absent
1746
+ * or `null` asks for every push that names a source; `HEAD` and a bare push name none ({@link pushSources}).
1747
+ */
1748
+ sourceRefs?: readonly (string | null)[] | null
1749
+ }
1750
+
1751
+ /**
1752
+ * Counts the gitlinks of the index in the folder it runs in (`bash -c <script> so-hud-gitlinks`): prints
1753
+ * the count and exits 0, or exits non-zero when `git ls-files` or `grep` failed (pipefail; grep's exit 1
1754
+ * for no match is turned into 0). Paths with unusual characters are quoted by git, so each entry is one
1755
+ * line starting with its mode. Reads the whole index but prints one number, so no output limit is hit.
1756
+ */
1757
+ export const GITLINKS_SCRIPT = 'set -o pipefail; git ls-files --stage | { LC_ALL=C grep -c "^160000 " || [ $? -eq 1 ]; }'
1758
+
1759
+ /** {@link PushFacts.gitlinks} from a {@link GITLINKS_SCRIPT} run: null unless it exited 0 and printed one count. */
1760
+ export function gitlinksFromRun(exitCode: number, stdout: string): boolean | null {
1761
+ const count = /^(\d+)\n?$/.exec(stdout)?.[1]
1762
+ return exitCode === 0 && count !== undefined ? Number(count) > 0 : null
1763
+ }
1764
+
1765
+ /**
1766
+ * `[scope, key, value]` per configured entry, as `git config --show-scope` prints them (`local`,
1767
+ * `core.fsmonitor`, `./x`); the value is null for a key written without `=`.
1768
+ */
1769
+ export type RepoConfig = readonly (readonly [scope: string, key: string, value: string | null])[]
1770
+
1771
+ /**
1772
+ * The read behind {@link PushFacts.repoConfig}, run as `git <words>` in the push's folder: every entry of
1773
+ * every scope, `<scope>\0<key>\n<value>\0` each (`<scope>\0<key>\0` without `=`), entries of an included
1774
+ * file under the scope of the file including it (measured, git 2.56.0). One process per hook call, and
1775
+ * only when the push would otherwise run. The values stay inside the hook; nothing of them is shown.
1776
+ */
1777
+ export const REPO_CONFIG_READ: readonly string[] = ['config', '--null', '--show-scope', '--list']
1778
+
1779
+ /** The entries of a {@link REPO_CONFIG_READ} output (exit 0); null when it does not parse. Nothing printed is no key. */
1780
+ export function repoConfigEntries(stdout: string): RepoConfig | null {
1781
+ if (stdout === '') return []
1782
+ if (!stdout.endsWith('\0')) return null
1783
+ const fields = stdout.slice(0, -1).split('\0')
1784
+ if (fields.length % 2 !== 0) return null
1785
+ const entries: (readonly [string, string, string | null])[] = []
1786
+ for (let i = 0; i < fields.length; i += 2) {
1787
+ const entry = fields[i + 1] ?? ''
1788
+ const cut = entry.indexOf('\n')
1789
+ entries.push(cut === -1 ? [fields[i] ?? '', entry, null] : [fields[i] ?? '', entry.slice(0, cut), entry.slice(cut + 1)])
1790
+ }
1791
+ return entries
1792
+ }
1793
+
1794
+ /**
1795
+ * The repository keys that run no program — an allowlist (#1573), measured 2026-10-16 as every key set in
1796
+ * the 27 local configs of one owner's repos (`git config --local --name-only --list`), plus the push keys
1797
+ * {@link pushConfigStandard} judges. git prints section and key in lower case and the subsection as
1798
+ * written. `core.hookspath` is judged by {@link PushFacts.hooks}. `include.path` and `includeIf` ask:
1799
+ * nothing more is needed, as the included keys are listed too. Program keys outside: `core.fsmonitor`,
1800
+ * `core.sshcommand`, `core.pager`, `core.editor`, `diff.external`, `diff.<x>.textconv`, `filter.<x>.*`,
1801
+ * `merge.<x>.driver`, `gpg.program`, `credential.helper`, `remote.<x>.uploadpack`, `protocol.*`,
1802
+ * `submodule.<x>.update`, `alias.*`, `url.*` — and any key git adds later. A remote URL is judged by its
1803
+ * value too ({@link TRANSPORT_URL}).
1804
+ */
1805
+ const PLAIN_REPO_KEY = /^(?:core\.(?:repositoryformatversion|filemode|bare|logallrefupdates|ignorecase|precomposeunicode|hookspath|sparsecheckout|sparsecheckoutcone)|index\.sparse|extensions\.worktreeconfig|user\.(?:name|email)|gc\.auto|maintenance\.auto|submodule\.recurse|push\.[a-z]+|remote\.pushdefault|branch\..+\.(?:remote|merge|pushremote|vscode-merge-base)|remote\..+\.(?:url|pushurl|fetch|push|mirror|glab-resolved[a-z-]*))$/
1806
+
1807
+ // A remote URL in git's `<transport>::<address>` form: `git fetch` starts `git-remote-<transport>` from
1808
+ // PATH (`ext::` is refused by default, every other transport allowed) — the same `::` word gitStep refuses
1809
+ // on `fetch`. A `url.<base>.insteadOf` whose base has one rewrites a plain remote into such a URL.
1810
+ const TRANSPORT_URL = /^remote\..+\.(?:url|pushurl)$/
1811
+ const TRANSPORT_REWRITE = /^url\..*::.*\.(?:insteadof|pushinsteadof)$/
1812
+
1813
+ // Keys of the global and system scopes that make a local step run a program whatever their value (#1573
1814
+ // review): `diff.external` and textconv for `diff`/`log -p`, `core.sshCommand`, `core.askPass`,
1815
+ // `core.gitProxy`, an upload-pack override and `uploadpack.packObjectsHook` (read only from these scopes,
1816
+ // run when fetching from a local path) for `fetch`, and `remote.<r>.vcs`, which routes a remote through
1817
+ // `git-remote-<vcs>` from PATH.
1818
+ const PROGRAM_KEY = /^(?:diff\.external|core\.(?:sshcommand|askpass|gitproxy)|diff\..+\.(?:textconv|command)|remote\..+\.(?:uploadpack|receivepack|vcs)|uploadpack\.packobjectshook)$/
1819
+ // git's spellings of a boolean; `core.fsmonitor` set to one uses git's own daemon or none, never a program.
1820
+ const GIT_BOOL = /^(?:true|yes|on|1|false|no|off|0|)$/i
1821
+ // The values git-lfs writes with `git lfs install` — the only filter programs the wide scopes may name.
1822
+ const LFS_FILTERS = new Set(['git-lfs clean -- %f', 'git-lfs smudge -- %f', 'git-lfs filter-process'])
1823
+ // The credential helpers git and the forge CLIs install (`gh`/`glab auth setup-git`), and `''` (resets the
1824
+ // list). A helper anywhere else, or `!` with any other command, asks. Measured on one host 2026-10-16:
1825
+ // osxkeychain, `!/opt/homebrew/bin/gh auth git-credential`, `!glab auth git-credential`, `''`.
1826
+ const KNOWN_HELPER = /^(?:|osxkeychain|manager|manager-core|wincred|libsecret|store|cache(?: --timeout=\d+)?|!(?:(?:\/opt\/homebrew\/bin|\/usr\/local\/bin|\/usr\/bin)\/)?(?:gh|glab) auth git-credential)$/
1827
+
1828
+ /**
1829
+ * Whether an entry of the `global` or `system` scope makes a local step run a program — a denylist,
1830
+ * because these scopes hold the host's own helpers. `gpg.program` counts only when a `commit` runs on the
1831
+ * line and signs: `commit.gpgSign` on there (last value wins) or an explicit `-S` ({@link Outward.signsBefore}) — or when
1832
+ * a `log`/`show` step verifies signatures (`--show-signature`, a `%G` placeholder, or `log.showSignature` on: `verifies`);
1833
+ * `log.showSignature` itself counts behind any `log`/`show` step (`logs`). Not judged: `core.pager` (no step
1834
+ * here pages: its output is no terminal), `core.editor` (a commit message given with `-m` opens none; the
1835
+ * host's editor is set on most hosts), `include.path`/`includeIf` (the included entries are listed and
1836
+ * judged themselves), `core.hooksPath`/`hook.*` ({@link PushFacts.hooks}).
1837
+ */
1838
+ function wideProgram(key: string, value: string | null, signing: boolean, commits: boolean, verifies: boolean, logs: boolean): boolean {
1839
+ const k = key.toLowerCase()
1840
+ // `log.showSignature` makes a plain `git log`/`git show` verify signatures, which runs gpg (#1573 input): a denylist entry.
1841
+ if (k === 'log.showsignature') return logs && !/^(?:false|no|off|0)$/i.test(value ?? 'true')
1842
+ if (PROGRAM_KEY.test(k) || TRANSPORT_REWRITE.test(k)) return true
1843
+ if (TRANSPORT_URL.test(k)) return (value ?? '').includes('::')
1844
+ if (k === 'core.fsmonitor') return !GIT_BOOL.test(value ?? '')
1845
+ if (/^filter\..+\.(?:clean|smudge|process)$/.test(k)) return !LFS_FILTERS.has(value ?? '')
1846
+ if (/^credential\.(?:.+\.)?helper$/.test(k)) return !KNOWN_HELPER.test(value ?? '')
1847
+ if (/^gpg\.(?:.+\.)?program$/.test(k)) return (signing && commits) || verifies
1848
+ return false
1849
+ }
1850
+
1851
+ /**
1852
+ * Whether no configured entry can make a local git step (`steps`, {@link Outward.gitBefore}) run a program:
1853
+ * every entry of the repository's own scopes (`local`, `worktree`, `command`, and any other git reports,
1854
+ * includes followed) is in {@link PLAIN_REPO_KEY}, with no remote URL in the `<transport>::` form; and no
1855
+ * entry of `global` or `system` is a program per {@link wideProgram}.
1856
+ * Ceiling: the wide scopes are judged by a denylist, so a program key git adds later passes there; the
1857
+ * repository's own scopes stay an allowlist.
1858
+ */
1859
+ function repoConfigStandard(config: RepoConfig | null | undefined, steps: readonly string[], signs: boolean): boolean {
1860
+ if (!Array.isArray(config) || !config.every(entry => Array.isArray(entry))) return false
1861
+ const wide = (scope: unknown): boolean => scope === 'global' || scope === 'system'
1862
+ const signing = config.reduce((on, [scope, key, value]) => (wide(scope) && String(key).toLowerCase() === 'commit.gpgsign' ? /^(?:true|yes|on|1)$/i.test(value ?? 'true') : on), false)
1863
+ const commits = steps.includes('commit')
1864
+ const verifies = steps.some(step => step.endsWith(' [signature]'))
1865
+ const logs = steps.some(step => /^(?:log|show)(?: \[signature\])?$/.test(step))
1866
+ // A log or show that verifies signatures, or any log or show where the wide scopes turn log.showSignature on, runs gpg.program.
1867
+ const showsSignature = logs && config.some(([scope, key, value]) => wide(scope) && String(key).toLowerCase() === 'log.showsignature' && !/^(?:false|no|off|0)$/i.test(value ?? 'true'))
1868
+ return config.every(([scope, key, value]) => {
1869
+ if (wide(scope)) return !wideProgram(String(key), value, signing || signs, commits, verifies || showsSignature, logs)
1870
+ return PLAIN_REPO_KEY.test(String(key)) && !(TRANSPORT_URL.test(String(key)) && (value ?? '').includes('::'))
1871
+ })
1872
+ }
1873
+
1874
+ /**
1875
+ * Push configuration read with `git config --null` in the push's folder, each value as measured. Exit 1
1876
+ * with no output means unset (`null`, or `[]`); any other failure (exit code, timeout, output that does
1877
+ * not parse) makes the whole {@link PushFacts.pushConfig} `null`. `<target>` is
1878
+ * `pushRemote(hit, facts.remote)`, `<current>` is `facts.current`. `--null` keeps a value that is empty
1879
+ * or holds a newline one entry, so a set-but-empty `remote.<target>.push` never reads as unset.
1880
+ */
1881
+ export type PushConfig = {
1882
+ /**
1883
+ * `git config --null --get-regexp '^push\.'`: every `push.*` entry as `[key, value]`, the key as git
1884
+ * prints it (`push.default`), the value null for a key written without `=`. `[]` when none is set.
1885
+ */
1886
+ push: readonly (readonly [key: string, value: string | null])[]
1887
+ /** `git config --get-all remote.<target>.push`: every value (several are possible). `[]` when unset. */
1888
+ remotePush: readonly string[]
1889
+ /** `git config --type=bool --get remote.<target>.mirror`: `'true'`, `'false'`, or null when unset. */
1890
+ remoteMirror: string | null
1891
+ /** `git config --get remote.pushDefault`, or null when unset. */
1892
+ remotePushDefault: string | null
1893
+ /** `git config --get branch.<current>.pushRemote`, or null when unset (and when detached: not read). */
1894
+ branchPushRemote: string | null
1895
+ /** `git config --type=bool --get submodule.recurse`: `'true'`, `'false'`, or null when unset. */
1896
+ submoduleRecurse: string | null
1897
+ }
1898
+
1899
+ // The push.* keys that change nothing about where a plain push goes: autoSetupRemote acts only without
1900
+ // an upstream, and a bare push without one already asks ({@link pushDestinations} is null).
1901
+ const SAFE_PUSH_KEYS = new Set(['push.autosetupremote'])
1902
+
1903
+ const measuredValue = (value: unknown): value is string | null => value === null || typeof value === 'string'
1904
+
1905
+ /**
1906
+ * Whether the measured push configuration is the standard one for this push — an allowlist, and every
1907
+ * read must have happened (`undefined` anywhere asks):
1908
+ * - `remote.<target>.push` is unset: it maps even a named branch (`feat/x` → `refs/heads/main`).
1909
+ * - `remote.<target>.mirror` and `submodule.recurse` are unset or false: either pushes more than named.
1910
+ * - With no remote named, the remote git picks (`branch.<b>.pushRemote`, then `remote.pushDefault`, then
1911
+ * the measured one) is the measured target; a named remote overrides both, as in git.
1912
+ * - Every `push.*` key is `push.autosetupremote` or `push.default`. `push.default` must be `simple` —
1913
+ * git's default, which pushes the current branch only to its same-named upstream and refuses
1914
+ * otherwise, the model {@link pushDestinations} implements — or, when the push names its branch,
1915
+ * `current`. git reads push.default for a named branch too: under `upstream`/`tracking` a single
1916
+ * named branch goes to its upstream (measured, git 2.56.0: `git push -u origin feat/y` with feat/y
1917
+ * tracking origin/main printed `feat/y -> main`, #1569 H1). So `upstream`, `tracking`, `matching`
1918
+ * (every same-named branch, main included), `nothing` and any value git may add later ask, named or
1919
+ * not, as does any other `push.*` key (`pushOption` can merge on GitLab, `followTags` pushes tags,
1920
+ * `recurseSubmodules` pushes other repositories).
1921
+ */
1922
+ function pushConfigStandard(config: PushConfig | null | undefined, target: string, remoteNamed: boolean, specNamed: boolean): boolean {
1923
+ if (config === null || config === undefined || typeof config !== 'object') return false
1924
+ const { push, remotePush, remoteMirror, remotePushDefault, branchPushRemote, submoduleRecurse } = config
1925
+ if (!Array.isArray(push) || !Array.isArray(remotePush) || ![remoteMirror, remotePushDefault, branchPushRemote, submoduleRecurse].every(measuredValue)) return false
1926
+ if (remotePush.length > 0 || (remoteMirror ?? 'false') !== 'false' || (submoduleRecurse ?? 'false') !== 'false') return false
1927
+ if (!remoteNamed && (branchPushRemote ?? remotePushDefault ?? target) !== target) return false
1928
+ return push.every(entry => {
1929
+ if (!Array.isArray(entry)) return false
1930
+ const key = String(entry[0]).toLowerCase()
1931
+ return SAFE_PUSH_KEYS.has(key) || (key === 'push.default' && (entry[1] === 'simple' || (specNamed && entry[1] === 'current')))
1932
+ })
1933
+ }
1934
+
1935
+ function readPush(args: readonly string[]): { pos: string[]; specs: string[] } {
1936
+ const pos = positionals(args, PUSH_VALUED)
1937
+ return { pos, specs: pos.slice(1) }
1938
+ }
1939
+
1940
+ const PLAIN_BRANCH = /^[A-Za-z0-9._/-]+$/
1941
+
1942
+ /**
1943
+ * A destination git writes to as exactly `refs/heads/<name>`: no `@`, no `HEAD`, no prefix git
1944
+ * expands on its own (`heads/main` is refs/heads/main, `tags/v1` a tag), no other `refs/…`, and not
1945
+ * the `tag` of `git push origin tag v1`. `HEAD` in any case: on a case-insensitive filesystem git reads
1946
+ * `head` as HEAD (measured on macOS 2026-10-12: `git push origin head` on main printed `head -> main`).
1947
+ */
1948
+ function isPlainBranch(name: string): boolean {
1949
+ return PLAIN_BRANCH.test(name) && !name.startsWith('-') && name.toLowerCase() !== 'head' && name !== 'tag' && !/^(?:refs|heads|tags|remotes)\//.test(name)
1950
+ }
1951
+
1952
+ /** `main` or `master` in any case: a case-insensitive filesystem on either end may store `Main` as `main`. */
1953
+ function isProtected(name: string): boolean {
1954
+ return PROTECTED_BRANCHES.includes(name.toLowerCase())
1955
+ }
1956
+
1957
+ /** The remote a push goes to: the first positional, `--repo`, the branch's configured remote, else `origin`. */
1958
+ export function pushRemote(hit: Outward, configured: string | null): string {
1959
+ return readPush(hit.args).pos[0] ?? optionValue(hit.args, '--repo') ?? (configured || 'origin')
1960
+ }
1961
+
1962
+ /**
1963
+ * The branches a push writes to, or null when one cannot be named as a plain branch (no upstream for
1964
+ * a bare push, detached HEAD, a wildcard, `@`, `heads/…`, a `refs/` refspec other than refs/heads/):
1965
+ * such a push always needs confirmation. The wiring probes each returned name for a tag to fill
1966
+ * {@link PushFacts.tagRefs}.
1967
+ */
1968
+ export function pushDestinations(hit: Outward, current: string | null, upstream: string | null, remote: string): string[] | null {
1969
+ if (hit.opaque === true) return null
1970
+ const { specs } = readPush(hit.args)
1971
+ const here = current ?? ''
1972
+ if (specs.length === 0) {
1973
+ if (!isPlainBranch(here) || upstream === null || upstream === '') return null
1974
+ const dst = upstream.startsWith(`${remote}/`) ? upstream.slice(remote.length + 1) : here
1975
+ return isPlainBranch(dst) ? [dst] : null
1976
+ }
1977
+ const dsts = specs.map(spec => {
1978
+ const bare = spec.replace(/^\+/, '')
1979
+ const target = (bare.includes(':') ? bare.slice(bare.indexOf(':') + 1) : bare).replace(/^refs\/heads\//, '')
1980
+ return target === '' || target === 'HEAD' ? here : target
1981
+ })
1982
+ return dsts.every(isPlainBranch) ? dsts : null
1983
+ }
1984
+
1985
+ /**
1986
+ * The source of each refspec a push names (the part before `:`, without `+`), whose ref the wiring
1987
+ * resolves into {@link PushFacts.sourceRefs}. `HEAD`, an empty source and a bare push name none:
1988
+ * `facts.current` (`--abbrev-ref HEAD`) already follows a symbolic ref to its branch, and git pushes
1989
+ * HEAD to that same branch (measured, git 2.56.0: HEAD → `feat/sym` → main printed `main` and
1990
+ * pushed `HEAD -> main`), so a HEAD on a symbolic ref is judged by its real branch already.
1991
+ */
1992
+ export function pushSources(hit: Outward): string[] {
1993
+ return readPush(hit.args).specs
1994
+ .map(spec => spec.replace(/^\+/, '').split(':')[0] ?? '')
1995
+ .filter(src => src !== '' && src !== 'HEAD')
1996
+ }
1997
+
1998
+ /** The full name a source must resolve to: `refs/heads/<name>`, also when spelled with that prefix. */
1999
+ function branchRef(src: string): string {
2000
+ return src.startsWith('refs/heads/') ? src : `refs/heads/${src}`
2001
+ }
2002
+
2003
+ /** The {@link PushFacts.sourceRefs} of a push whose every source resolves as written; the wiring's permissive value. */
2004
+ export function plainSourceRefs(hit: Outward): string[] {
2005
+ return pushSources(hit).map(branchRef)
2006
+ }
2007
+
2008
+ /**
2009
+ * Whether an outward action needs the owner's confirmation — an allowlist: a push skips it only
2010
+ * when every option is one of {@link PUSH_FLAGS} exactly; it names at most a plain remote and one
2011
+ * plain branch (no `:`, `+`, `@`, `main`/`master`, tag), or exactly `HEAD`, or with no branch the
2012
+ * measured destination is such a branch; every earlier command on the line is a git call in
2013
+ * {@link LOCAL_GIT}, and none is in {@link HOOK_GIT} unless `facts.hooks` is `false` and it ran in the
2014
+ * push's own folder ({@link Outward.gitBefore}: `<step> [other folder]` is not local), and with any of them
2015
+ * the repository's configuration names no program ({@link repoConfigStandard}); its config is in
2016
+ * {@link SAFE_CONFIG}; the push configuration is the standard one ({@link pushConfigStandard}); and every
2017
+ * fact was measured. Everything else, and every other kind, asks.
2018
+ *
2019
+ * The facts are measured before the line runs, so an earlier command may change them for every push
2020
+ * form (#1569 P3, MR !133 HOCH 2): one that is not git ({@link Outward.otherBefore}: `gh pr checkout 5`,
2021
+ * `./switch.sh`, `printf … >> .git/config`, one inside `$(…)`) may move HEAD or write the push
2022
+ * configuration, and so may a hook a git step fires. `HEAD` (`git push -u origin HEAD`, the usual first
2023
+ * push) and a bare push (no refspec, #1569 M1) then stand for `facts.current`; the wiring probes HEAD for
2024
+ * a tag like a named branch.
2025
+ * Detached (`current` is `HEAD` or null) it is no plain branch and asks; `HEAD:x`, `@` and
2026
+ * `refs/heads/HEAD` are not this form and ask.
2027
+ */
2028
+ export function pushNeedsConfirm(hit: Outward, facts: PushFacts): boolean {
2029
+ if (hit.kind !== 'push' || hit.opaque === true) return true
2030
+ // `git switch main && git push`: the facts describe what was checked out before the line ran.
2031
+ if (hit.gitBefore?.some(step => !LOCAL_GIT.has(step)) === true) return true
2032
+ if (hit.otherBefore === true) return true
2033
+ if (facts.hooks !== false && hit.gitBefore?.some(step => HOOK_GIT.has(step)) === true) return true
2034
+ // #1573: `git status` runs core.fsmonitor, `git diff` diff.external, `git fetch` core.sshCommand — any of them may move HEAD.
2035
+ if ((hit.gitBefore?.length ?? 0) > 0 && !repoConfigStandard(facts.repoConfig, hit.gitBefore ?? [], hit.signsBefore === true)) return true
2036
+ // …and so does a submodule or embedded repository a step walks into: its own configuration was never read.
2037
+ if ((hit.gitBefore?.length ?? 0) > 0 && facts.gitlinks !== false) return true
2038
+ if (hit.gitConfig?.some(entry => !SAFE_CONFIG.test(entry)) === true) return true
2039
+ if (hit.args.some(arg => arg.startsWith('-') && !PUSH_FLAGS.has(arg))) return true
2040
+ const [remote, spec, ...extra] = hit.args.filter(arg => !arg.startsWith('-'))
2041
+ if (extra.length > 0 || (remote !== undefined && !PLAIN_REMOTE.test(remote))) return true
2042
+ if (!facts.remoteUrlKnown || facts.tagRefs === null) return true
2043
+ const target = pushRemote(hit, facts.remote)
2044
+ if (!pushConfigStandard(facts.pushConfig, target, remote !== undefined, spec !== undefined)) return true
2045
+ const named = spec === 'HEAD' ? facts.current : spec?.replace(/^refs\/heads\//, '')
2046
+ const dsts = spec === undefined ? pushDestinations(hit, facts.current, facts.upstream, target) : typeof named === 'string' ? [named] : null
2047
+ const tags = facts.tagRefs
2048
+ // #1570: a named source must be the branch it names, not a symbolic ref or a `refs/<name>` git picks first.
2049
+ const sourceRefs = facts.sourceRefs
2050
+ if (pushSources(hit).some((src, i) => !Array.isArray(sourceRefs) || sourceRefs[i] !== branchRef(src))) return true
2051
+ return dsts?.length !== 1 || dsts.some(d => !isPlainBranch(d) || isProtected(d) || tags.includes(d))
2052
+ }
2053
+
2054
+ // ---- Traps the rules name ---------------------------------------------------
2055
+
2056
+ export type Trap = { id: string; klasse: 'sec008' | 'regel'; title: string; why: string; fix: string }
2057
+
2058
+ // `$NAME`, `${NAME…}`, and zsh's parameter flags in front of the name that still expand the value: `${(U)NAME}`,
2059
+ // `${=NAME}`, `${~NAME}`, `${^NAME}` (#1699). Not `${#NAME}` (the length) or `${+NAME}` (set or not).
2060
+ const PARAM_OPEN = String.raw`\$(?:\{(?:\([^()]*\)|[=~^])*)?`
2061
+ const SECRET_VAR = new RegExp(String.raw`${PARAM_OPEN}((?:[A-Z][A-Z0-9_]*)?(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|_KEY|_PAT))\b`)
2062
+
2063
+ // .env, .envrc, .env.<x>, .env-<x>, .env_<x>, <name>.env, and a glob reaching them (`.env*`) — but not the committed templates.
2064
+ const ENV_FILE = /^(?:\.env(?:rc)?|\.env[._-][\w.-]+|[\w.-]+\.env|\.env[._-]?[*?[].*)$/
2065
+ const ENV_TEMPLATE = /(?:^|[._-])(?:example|sample|template|schema)(?:[._-]|$)/i
2066
+
2067
+ function isEnvFile(arg: string): boolean {
2068
+ const base = arg.replace(/^\d*</, '').split('/').pop() ?? ''
2069
+ return ENV_FILE.test(base) && !ENV_TEMPLATE.test(base)
2070
+ }
2071
+
2072
+ // Commands that print the content of a file they are given.
2073
+ const ENV_READERS = new Set(['cat', 'less', 'more', 'head', 'tail', 'bat', 'nl', 'strings', 'grep', 'egrep', 'fgrep', 'rg', 'sed', 'awk', 'gawk', 'cut', 'sort', 'uniq', 'tac', 'rev', 'xxd', 'od', 'hexdump', 'hd', 'base64', 'base32', 'basenc', 'diff', 'paste', 'column', 'jq', 'yq', 'tr', 'fold', 'fmt', 'pr', 'expand', 'iconv'])
2074
+
2075
+ // `s/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p` under -n: the key of each KEY=value line and nothing else
2076
+ // (each class holds only the exact tokens A-Z, a-z, 0-9 and _, so it cannot span `=`: `[--z]` does).
2077
+ const SED_KEY_GROUP = /^s\/\^?\\\((?:\[(?:A-Z|a-z|0-9|_)+\][*+]?)+\\\)=\.\*\/\\1\/p$/
2078
+ // `s/=.*//`: strips the value of each line; accepted only as a filter behind an env dump.
2079
+ const SED_STRIP = /^s\/=\.\*\/\/g?$/
2080
+ const AWK_KEY = /^\{\s*print\s+\$1\s*;?\s*\}$/
2081
+
2082
+ const unquote = (value: string | undefined): string | undefined => value?.replace(/["']/g, '')
2083
+
2084
+ // A sed call whose one script prints keys only: the -n key-group form, and the value strip when `allowStrip`.
2085
+ function sedKeysOnly(args: readonly string[], allowStrip: boolean): boolean {
2086
+ let quiet = false
2087
+ const scripts: string[] = []
2088
+ for (let i = 0; i < args.length; i += 1) {
2089
+ const arg = args[i] ?? ''
2090
+ if (arg === '-n' || arg === '--quiet' || arg === '--silent') quiet = true
2091
+ else if (arg === '-e' || arg === '--expression') {
2092
+ scripts.push(args[i + 1] ?? '')
2093
+ i += 1
2094
+ } else if (arg === '-E' || arg === '-r' || arg === '--regexp-extended') continue
2095
+ else if (arg.startsWith('-')) return false
2096
+ else scripts.push(arg)
2097
+ }
2098
+ const [script] = scripts
2099
+ return scripts.length === 1 && script !== undefined && ((quiet && SED_KEY_GROUP.test(script)) || (allowStrip && SED_STRIP.test(script)))
2100
+ }
2101
+
2102
+ /** `cut -d= -f1` exactly (also `-d '='`, `--fields=1`, `-s`): `-f1-`, `-f1,2` and `--complement` print values. */
2103
+ function cutKeysOnly(args: readonly string[]): boolean {
2104
+ let delimiter: string | undefined
2105
+ let fields: string | undefined
2106
+ for (let i = 0; i < args.length; i += 1) {
2107
+ const arg = args[i] ?? ''
2108
+ if (arg === '-d' || arg === '--delimiter') {
2109
+ delimiter = args[i + 1]
2110
+ i += 1
2111
+ } else if (arg.startsWith('--delimiter=')) delimiter = arg.slice('--delimiter='.length)
2112
+ else if (arg.startsWith('-d')) delimiter = arg.slice(2)
2113
+ else if (arg === '-f' || arg === '--fields') {
2114
+ fields = args[i + 1]
2115
+ i += 1
2116
+ } else if (arg.startsWith('--fields=')) fields = arg.slice('--fields='.length)
2117
+ else if (arg.startsWith('-f')) fields = arg.slice(2)
2118
+ else if (arg !== '-s' && arg !== '--only-delimited') return false
2119
+ }
2120
+ return unquote(delimiter) === '=' && unquote(fields) === '1'
2121
+ }
2122
+
2123
+ /** `awk -F= '{print $1}'` exactly: any other program (`{print $1, $2}`) prints values. */
2124
+ function awkKeysOnly(args: readonly string[]): boolean {
2125
+ let separator: string | undefined
2126
+ const programs: string[] = []
2127
+ for (let i = 0; i < args.length; i += 1) {
2128
+ const arg = args[i] ?? ''
2129
+ if (arg === '-F') {
2130
+ separator = args[i + 1]
2131
+ i += 1
2132
+ } else if (arg.startsWith('-F')) separator = arg.slice(2)
2133
+ else if (arg.startsWith('-')) return false
2134
+ else programs.push(arg)
2135
+ }
2136
+ return unquote(separator) === '=' && programs.length === 1 && AWK_KEY.test(programs[0] ?? '')
2137
+ }
2138
+
2139
+ /** A pipeline stage that cuts an env dump down to its keys. */
2140
+ function isKeysOnlyFilter(stage: string): boolean {
2141
+ const [first, ...args] = commandWords(stage)
2142
+ const cmd = baseName(first ?? '')
2143
+ if (cmd === 'cut') return cutKeysOnly(args)
2144
+ if (cmd === 'awk' || cmd === 'gawk' || cmd === 'mawk') return awkKeysOnly(args)
2145
+ return cmd === 'sed' && sedKeysOnly(args, true)
2146
+ }
2147
+
2148
+ /** A reader of a .env file that prints no value: `grep -q`/`-c`/`-l`, or the keys-only sed of the trap's own fix. */
2149
+ function readsKeysOnly(cmd: string, args: readonly string[]): boolean {
2150
+ if (cmd === 'grep' || cmd === 'egrep' || cmd === 'fgrep' || cmd === 'rg') {
2151
+ return args.some(a => /^-[A-Za-z]*[qcl]/.test(a) || ['--quiet', '--silent', '--count', '--files-with-matches', '--files-without-match'].includes(a))
2152
+ }
2153
+ return cmd === 'sed' && sedKeysOnly(args.filter(a => !isEnvFile(a)), false)
2154
+ }
2155
+
2156
+ /**
2157
+ * Where a stage's output goes, from every output redirection read in order: `stdout` when fd 1 is redirected and
2158
+ * EVERY fd-1 target is `/dev/null` (zsh MULTIOS writes to each listed target, so `>&2 >/dev/null` still prints),
2159
+ * `stderr` likewise for fd 2, where `2>&1` counts only when fd 1 already points at `/dev/null` alone. `&>`, `>&file`
2160
+ * set both. A quoted, unknown or closed (`>&-`) target, or a descriptor copy elsewhere, keeps it printing (fail-closed).
2161
+ * `moved`: fd 1 itself is redirected anywhere (`>&2`, `>file`, `&>`), so a pipe after the stage receives none of it (#1649);
2162
+ * `>&1` copies fd 1 onto itself and moves nothing. An fd-1 or fd-2 `<&` / `<>` reopens that descriptor too: bash's
2163
+ * `>/dev/null 1<&2` points fd 1 back at stderr and prints (#1696), so it is read as `>&` / `>`; `0<&2` is stdin and moves no output.
2164
+ * `fileOk`: a file target that also counts as silent (the heredoc file writes, {@link safeFileTarget}); default none.
2165
+ */
2166
+ function outputSilence(stage: string, fileOk: (target: string) => boolean = () => false): { stdout: boolean; stderr: boolean; moved: boolean } {
2167
+ const raw = rawWords(stripGroup(stage))
2168
+ const out = { set: false, silent: true }
2169
+ const err = { set: false, silent: true }
2170
+ let moved = false
2171
+ const send = (fd: typeof out, silent: boolean): void => {
2172
+ fd.set = true
2173
+ fd.silent &&= silent
2174
+ }
2175
+ for (let i = 0; i < raw.length; i += 1) {
2176
+ const m = /^(\d*|&)(>&|>>|>\||>)(.*)$/s.exec(raw[i] ?? '') ?? /^([12])(<&|<>)(.*)$/s.exec(raw[i] ?? '')
2177
+ if (m === null) continue
2178
+ const [, fd = '', written = '', inline = ''] = m
2179
+ const op = written === '<&' ? '>&' : written === '<>' ? '>' : written
2180
+ let to = inline
2181
+ if (to === '') {
2182
+ i += 1
2183
+ to = raw[i] ?? ''
2184
+ }
2185
+ const copy = op === '>&' && /^(?:\d+|-)$/.test(to)
2186
+ const silent = to === '/dev/null' || fileOk(to)
2187
+ if (fd === '2') send(err, copy ? to === '1' && out.set && out.silent : silent)
2188
+ else if (fd === '' || fd === '1') {
2189
+ moved ||= !(copy && to === '1')
2190
+ send(out, !copy && silent)
2191
+ if (op === '>&' && !copy) send(err, silent)
2192
+ } else if (fd === '&') {
2193
+ moved = true
2194
+ send(out, silent)
2195
+ send(err, silent)
2196
+ } else {
2197
+ // fd 3 and above: a copy of stdout or stderr (`3>&1 >/dev/null`) lets the program write past the /dev/null.
2198
+ send(out, false)
2199
+ send(err, false)
2200
+ }
2201
+ }
2202
+ return { stdout: out.set && out.silent, stderr: err.set && err.silent, moved }
2203
+ }
2204
+
2205
+ // Digests print a hash of what they read, never the input: `-c`/`--check` (reads a checklist, prints its
2206
+ // names) and BSD md5's `-p` (echoes stdin) are excluded below.
2207
+ const DIGEST = /^(?:sha\d*sum|shasum|md5sum|md5|b2sum|b3sum|cksum)$/
2208
+
2209
+ /** `curl --config -` / `-K -` / `--config=-` / `-K-`: the config, and the secret in it, is read from stdin. */
2210
+ function curlConfigStdin(args: readonly string[]): boolean {
2211
+ return args.some((arg, i) => ((arg === '-K' || arg === '--config') && args[i + 1] === '-') || arg === '-K-' || arg === '--config=-')
2212
+ }
2213
+
2214
+ // The curl options that print what the config set: `-v` / `--verbose` and `--trace*` the request headers,
2215
+ // `--libcurl` the options as C code (#1706 item 1). A short group counts when its letters reach a `v` (`-sv`).
2216
+ // `-w` / `--write-out` is read by {@link curlPrints}. curl 8.x accepts the same long options that take an
2217
+ // argument under an `--expand-` prefix (`--expand-libcurl`, `--expand-write-out`), so the prefix counts too.
2218
+ // NAMED CEILING (BV-004): a `verbose` / `trace` / `write-out` line inside the stdin config itself is not read; revisit
2219
+ // if a config that sets them shows up.
2220
+ const CURL_PRINTS = /^(?:-[A-Za-z]*v|--(?:verbose|(?:expand-)?(?:trace(?:-ascii)?|libcurl))(?:=|$))/
2221
+ // The `-w` variables that print only status, counts, sizes and timings, never a value the config set. An allow list
2222
+ // on purpose: `%{url}`, `%{url_effective}`, `%{url.*}`, `%{urle.*}`, `%{json}`, `%{header_json}`, `%header{…}`,
2223
+ // `%{referer}`, `%{errormsg}` and every variable a later curl adds count as printing.
2224
+ const CURL_QUIET_VARS: ReadonlySet<string> = new Set([
2225
+ 'http_code', 'response_code', 'http_connect', 'http_version', 'exitcode', 'num_connects', 'num_redirects',
2226
+ 'num_headers', 'num_retries', 'size_download', 'size_upload', 'size_header', 'size_request', 'speed_download',
2227
+ 'speed_upload', 'ssl_verify_result', 'proxy_ssl_verify_result', 'time_appconnect', 'time_connect',
2228
+ 'time_namelookup', 'time_pretransfer', 'time_posttransfer', 'time_queue', 'time_redirect', 'time_starttransfer',
2229
+ 'time_total', 'stdout', 'stderr',
2230
+ ])
2231
+
2232
+ /**
2233
+ * Whether a `-w` / `--write-out` format can print what the config set: a `@file` or `$`/backtick format (unreadable
2234
+ * here), or any `%` that is not `%%` or a {@link CURL_QUIET_VARS} variable (`-w "%{http_code}\n"` prints nothing of it).
2235
+ */
2236
+ function writeOutPrints(format: string | undefined): boolean {
2237
+ if (format === undefined || format.startsWith('@') || /[$`]/.test(format)) return true
2238
+ for (const m of format.replace(/%%/g, '').matchAll(/%(?:\{([^}]*)\})?/g)) if (!CURL_QUIET_VARS.has(m[1] ?? '')) return true
2239
+ return false
2240
+ }
2241
+
2242
+ /**
2243
+ * Whether a curl command line prints the config it read: {@link CURL_PRINTS}, or a `-w` / `--write-out` whose format
2244
+ * {@link writeOutPrints} (`-w FMT`, `-wFMT`, `-sw FMT`, `--write-out FMT`, `--write-out=FMT`, and the same with
2245
+ * `--expand-write-out`, whose `{{variable}}` may hold any format and so counts as printing).
2246
+ */
2247
+ function curlPrints(args: readonly string[]): boolean {
2248
+ return args.some((arg, i) => {
2249
+ if (CURL_PRINTS.test(arg)) return true
2250
+ const long = /^--(expand-)?write-out(?:=(.*))?$/s.exec(arg)
2251
+ if (long !== null) {
2252
+ const format = long[2] ?? args[i + 1]
2253
+ return (long[1] !== undefined && format?.includes('{{') === true) || writeOutPrints(format)
2254
+ }
2255
+ // A short group: the letters after `w` are its value, otherwise the next word is.
2256
+ const group = /^-[A-Za-z]*?w(.*)$/s.exec(arg)
2257
+ if (group === null || arg.startsWith('--')) return false
2258
+ return writeOutPrints(group[1] === '' ? args[i + 1] : group[1])
2259
+ })
2260
+ }
2261
+
2262
+ // Registry clients whose `login --password-stdin` reads the password from stdin and prints only a status line.
2263
+ const STDIN_LOGIN = /^(?:docker|podman|nerdctl|buildah|skopeo|oras)$/
2264
+ // Commands that run other commands or shell code: what they run may print stdin anywhere (`sh -c "cat >/dev/tty"`).
2265
+ const RUNNERS = new Set(['xargs', 'parallel', 'sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'su', 'script', 'env', 'exec', 'command', 'nohup', 'time', 'eval', 'sudo', 'nice', 'timeout', 'gtimeout', 'caffeinate', 'builtin'])
2266
+
2267
+ /**
2268
+ * A named login that takes its secret on stdin silently, the flag read at the login's own position:
2269
+ * `docker|podman|nerdctl|buildah|skopeo|oras login … --password-stdin`, `helm registry login … --password-stdin`,
2270
+ * `crane auth login … --password-stdin`, `gh auth login … --with-token`, `glab auth login … --stdin`.
2271
+ */
2272
+ function stdinLogin(cmd: string, args: readonly string[]): boolean {
2273
+ if (STDIN_LOGIN.test(cmd)) return args[0] === 'login' && args.includes('--password-stdin')
2274
+ if (cmd === 'helm') return args[0] === 'registry' && args[1] === 'login' && args.includes('--password-stdin')
2275
+ if (cmd === 'crane') return args[0] === 'auth' && args[1] === 'login' && args.includes('--password-stdin')
2276
+ if (cmd === 'gh') return args[0] === 'auth' && args[1] === 'login' && args.includes('--with-token')
2277
+ if (cmd === 'glab') return args[0] === 'auth' && args[1] === 'login' && args.includes('--stdin')
2278
+ return false
2279
+ }
2280
+
2281
+ // The compound commands whose body inherits the stdin of their head (`while read -r l; do echo "$l"; done`).
2282
+ const COMPOUND_HEADS = new Set(['while', 'until', 'if', 'for', 'select', 'case'])
2283
+
2284
+ /**
2285
+ * Whether a stage is only the head of a group or compound command that the pipeline split cut at its `;` or `&&`
2286
+ * (`{ true >/dev/null 2>&1; cat; }`, `while read -r l; do echo "$l"; done`): the commands behind the cut read the
2287
+ * same stdin and this stage cannot see them, so it never counts as silent (#1649, fail-closed). More leading `(`/`{`
2288
+ * than trailing `)`/`}` means the group runs on; one pass from each end. NAMED CEILING (BV-004): a whole group of
2289
+ * silent commands (`{ sha256sum; }`) is refused too instead of being read across the cut; revisit if such a form is
2290
+ * needed in practice.
2291
+ */
2292
+ function cutCompound(stage: string): boolean {
2293
+ let open = 0
2294
+ let i = 0
2295
+ for (; i < stage.length && /[\s({]/.test(stage[i] ?? ''); i += 1) if (stage[i] === '(' || stage[i] === '{') open += 1
2296
+ let close = 0
2297
+ for (let j = stage.length; j > i && /[\s)}]/.test(stage[j - 1] ?? ''); j -= 1) if (stage[j - 1] === ')' || stage[j - 1] === '}') close += 1
2298
+ if (open > close) return true
2299
+ for (const word of argWords(stripGroup(stage))) {
2300
+ if (COMPOUND_HEADS.has(word)) return true
2301
+ if (!PREFIXES.has(word)) break
2302
+ }
2303
+ return false
2304
+ }
2305
+
2306
+ /**
2307
+ * The allow list part of {@link consumesSilently}: a command that prints no part of what it reads wherever its
2308
+ * output goes (a digest, `openssl dgst`, `wc`, `read`, `curl --config -`, a named stdin login).
2309
+ */
2310
+ function printsNoInput(stage: string): boolean {
2311
+ if (cutCompound(stage)) return false
2312
+ const [first, ...args] = commandWords(stage)
2313
+ const cmd = baseName(first ?? '')
2314
+ if (DIGEST.test(cmd)) return !args.some(a => a === '--check' || /^-[A-Za-z]*[cp]/.test(a))
2315
+ if (cmd === 'openssl') return /^(?:dgst|sha\d*|md5)$/.test(args[0] ?? '')
2316
+ if (cmd === 'wc' || cmd === 'read') return true
2317
+ if (cmd === 'curl' && curlConfigStdin(args)) return !curlPrints(args)
2318
+ return stdinLogin(cmd, args)
2319
+ }
2320
+
2321
+ /**
2322
+ * The variables a `read` stage sets (`read -r a b`, `IFS= read -r l`, `command read x`, `builtin read x`, zsh's
2323
+ * `read 'x?prompt'`), or null when the stage is no `read`. REPLY always counts: an option value (`-p Enter`) is
2324
+ * not told apart from a name, so the default variable is never lost. NAMED CEILING (BV-004): option values are read
2325
+ * as names too (more names, never fewer); revisit if that taints a name that is printed in practice.
2326
+ */
2327
+ function readNames(stage: string): string[] | null {
2328
+ if (cutCompound(stage)) return null
2329
+ const words = commandWords(stage)
2330
+ const at = words[0] === 'builtin' ? 1 : 0
2331
+ if (baseName(words[at] ?? '') !== 'read') return null
2332
+ const names = words.slice(at + 1).map(w => w.split('?')[0] ?? '').filter(w => /^[A-Za-z_][A-Za-z0-9_]*$/.test(w))
2333
+ return [...names, 'REPLY']
2334
+ }
2335
+
2336
+ const VAR_REF = new RegExp(String.raw`${PARAM_OPEN}([A-Za-z_][A-Za-z0-9_]*)`, 'g')
2337
+
2338
+ /** The first secret variable a stage expands: a secret-shaped name, or one a `read` filled with a secret (#1695). */
2339
+ function secretRef(text: string, tainted: ReadonlySet<string>): string | undefined {
2340
+ const named = SECRET_VAR.exec(text)?.[1]
2341
+ if (named !== undefined || tainted.size === 0) return named
2342
+ for (const m of text.matchAll(VAR_REF)) if (tainted.has(m[1] ?? '')) return m[1]
2343
+ return undefined
2344
+ }
2345
+
2346
+ /**
2347
+ * A stage that reads a secret from stdin and prints none of it (an allow list, #1569 point 1): a digest,
2348
+ * `openssl dgst`, `wc`, `read`, `curl --config -`, a named stdin login ({@link stdinLogin}), or a command other than
2349
+ * `tee` and a runner whose stdout AND stderr both go only to `/dev/null` ({@link outputSilence}). Everything else
2350
+ * (`perl -pe1`, `gzip`, `base64`, `python3 -c …`, `xargs echo --password-stdin`) counts as re-printing.
2351
+ */
2352
+ function consumesSilently(stage: string): boolean {
2353
+ if (cutCompound(stage)) return false
2354
+ if (printsNoInput(stage)) return true
2355
+ const [first] = commandWords(stage)
2356
+ const cmd = baseName(first ?? '')
2357
+ // A runner stripped as a prefix by commandWords (`eval`, `exec`, `sudo`) still counts: any word before the command.
2358
+ // Unlocated (-1): every word is read, fail-closed. A cut-out `sh -c '…'` / `eval '…'` sits here as its placeholder word.
2359
+ const words = argWords(stripGroup(stage))
2360
+ const at = words.indexOf(first ?? '')
2361
+ if (cmd === 'tee' || (at === -1 ? words : words.slice(0, at + 1)).some(w => RUNNERS.has(baseName(w)) || INNER_WORD.test(w))) return false
2362
+ const silence = outputSilence(stage)
2363
+ return silence.stdout && silence.stderr
2364
+ }
2365
+
2366
+ // The env options that take a separate value; `-S` / `--split-string` runs its string as a command.
2367
+ const ENV_DUMP_VALUED = new Set(['-u', '--unset', '-C', '--chdir'])
2368
+
2369
+ /**
2370
+ * Whether the words (prefixes already removed) print variable values, judged by the command and its
2371
+ * arguments, not by their number: `env`/`env -0`/`env -i` with no command left, `printenv` with no name or a
2372
+ * secret one, bare `export`/`readonly` (options only), bare `set` (`set -e` sets options), `declare`/`typeset`
2373
+ * with no name (`declare -x`, `-p`) or with a set secret name and no `=` (zsh prints it). `env FOO=1 cmd`
2374
+ * runs cmd; `export FOO=bar` and `declare -i n` print nothing. A secret name is secret-shaped or one of
2375
+ * `tainted` (a `read` or an assignment filled it with a secret: `read x; printenv x`, #1699).
2376
+ */
2377
+ function dumpsEnvironment(words: readonly string[], tainted: ReadonlySet<string> = new Set()): boolean {
2378
+ const [first, ...args] = words
2379
+ const cmd = baseName(first ?? '')
2380
+ const secretName = (name: string): boolean => SECRET_VAR.test(`$${name}`) || tainted.has(name)
2381
+ if (cmd === 'env') {
2382
+ let i = 0
2383
+ while (i < args.length) {
2384
+ const arg = args[i] ?? ''
2385
+ if (arg === '--') {
2386
+ i += 1
2387
+ break
2388
+ }
2389
+ if (arg.startsWith('-S') || arg.startsWith('--split-string')) return false
2390
+ if (arg === '-' || !arg.startsWith('-')) break
2391
+ i += ENV_DUMP_VALUED.has(arg) ? 2 : 1
2392
+ }
2393
+ while (i < args.length && ASSIGNMENT.test(args[i] ?? '')) i += 1
2394
+ return i >= args.length
2395
+ }
2396
+ // printenv prints every variable it is given: one secret name among several still prints (#1572 T2).
2397
+ if (cmd === 'printenv') {
2398
+ const names = args.filter(a => !a.startsWith('-'))
2399
+ return names.length === 0 || names.some(secretName)
2400
+ }
2401
+ if (cmd === 'export' || cmd === 'readonly') return args.every(a => a.startsWith('-'))
2402
+ if (cmd === 'set') return args.length === 0
2403
+ if (cmd === 'declare' || cmd === 'typeset') {
2404
+ if (args.some(a => /^-[A-Za-z]*[fF]/.test(a))) return false
2405
+ const names = args.filter(a => !/^[-+]/.test(a))
2406
+ return names.length === 0 || names.some(name => !name.includes('=') && secretName(name))
2407
+ }
2408
+ return false
2409
+ }
2410
+
2411
+ /**
2412
+ * Every trap the rules name in a shell command, once per id, in reading order. All of them, not the
2413
+ * first: on `locker` a `regel` trap only warns, so `git add -A && cat .env` must still carry the SEC-008 one.
2414
+ */
2415
+ export function findTraps(command: string): Trap[] {
2416
+ const line = dataView(joinContinuations(command))
2417
+ const found = collectTraps(line, 0, line, new Set(), command)
2418
+ return found.filter((trap, i) => found.findIndex(other => other.id === trap.id) === i)
2419
+ }
2420
+
2421
+ /**
2422
+ * `raw`: the text the heredoc reader scans — the command as written at the top (the data view drops text heredocs,
2423
+ * bodies and all), the inner command line itself below it.
2424
+ */
2425
+ function collectTraps(line: string, depth: number, root: string, inherited: ReadonlySet<string> = new Set(), raw = line): Trap[] {
2426
+ const found: Trap[] = []
2427
+ const { text, inners } = extractInner(line, depth)
2428
+ // The read taint of the whole line, so an inner `sh -c "echo $x"` / `eval "echo $x"` sees the `read x` before it
2429
+ // (#1695). Ceiling: a `read` AFTER the inner command taints it too (fail-closed over-detection); revisit if that
2430
+ // false alarm shows up in practice.
2431
+ const lineTaint = new Set(inherited)
2432
+ for (const stages of splitPipelines(text)) echoedSecrets(stages, lineTaint)
2433
+ // A child shell (`sh -c '…'`) reads an exported name from its environment, single quotes or not (#1699):
2434
+ // `read x; export x; sh -c 'echo $x'`. Ceiling as above: an export after the inner command counts too; `set -a`
2435
+ // (export everything) is not read — revisit if it shows up in practice.
2436
+ const exported = new Set<string>()
2437
+ for (const segment of splitSegments(text)) {
2438
+ const [head, ...rest] = commandWords(segment)
2439
+ if (head !== 'export' && !(DECLARERS.has(head ?? '') && rest.some(arg => /^-[A-Za-z]*x/.test(arg)))) continue
2440
+ for (const arg of rest) exported.add(/^[A-Za-z_][A-Za-z0-9_]*/.exec(arg)?.[0] ?? '')
2441
+ }
2442
+ const innerTaint = (inner: Inner): Set<string> => {
2443
+ const names = [...inner.expanded.matchAll(VAR_REF)].map(m => m[1] ?? '').filter(name => lineTaint.has(name))
2444
+ if (!inner.sameShell) for (const m of inner.command.matchAll(VAR_REF)) if (exported.has(m[1] ?? '') && lineTaint.has(m[1] ?? '')) names.push(m[1] ?? '')
2445
+ return new Set(names)
2446
+ }
2447
+ for (const segment of splitSegments(text)) {
2448
+ const words = commandWords(segment)
2449
+ const [first, ...args] = words
2450
+ if (first === undefined) continue
2451
+ const inner = innerAt(first, inners)
2452
+ if (inner !== null) {
2453
+ found.push(...collectTraps(inner.command, depth + 1, root, innerTaint(inner)))
2454
+ continue
2455
+ }
2456
+ // A cut-out `bash -c '…'` behind another word still runs (`flock /tmp/l bash -c "cat .env"`, #1572 T1).
2457
+ for (const word of DATA_COMMANDS.has(baseName(first)) ? [] : args) {
2458
+ const later = innerAt(word, inners)
2459
+ if (later !== null) found.push(...collectTraps(later.command, depth + 1, root, innerTaint(later)))
2460
+ }
2461
+ const cmd = baseName(first)
2462
+
2463
+ if (
2464
+ cmd === 'claude' &&
2465
+ args.some(a => a === '-p' || a === '--print') &&
2466
+ !/env\s+(-\S+\s+)*-u\s+ANTHROPIC_API_KEY\b|unset\s+ANTHROPIC_API_KEY\b/.test(root)
2467
+ ) {
2468
+ found.push({
2469
+ id: 'claude-p-oauth',
2470
+ klasse: 'regel',
2471
+ title: 'claude -p ohne env -u ANTHROPIC_API_KEY',
2472
+ why: 'claude -p erbt sonst den ANTHROPIC_API_KEY aus älteren Shells und rechnet über die bezahlte API ab statt über OAuth (Memory "claude -p nur über OAuth").',
2473
+ fix: `env -u ANTHROPIC_API_KEY ${segment.trim()}`,
2474
+ })
2475
+ }
2476
+
2477
+ if (ENV_READERS.has(cmd) && args.some(isEnvFile) && !readsKeysOnly(cmd, args)) {
2478
+ const file = args.find(isEnvFile) ?? '.env'
2479
+ found.push({
2480
+ id: 'sec008-env-datei',
2481
+ klasse: 'sec008',
2482
+ title: `${cmd} ${file}`,
2483
+ why: 'Gibt Secret-Werte in Transkript und Log aus (SEC-008: never emit, do not mask).',
2484
+ fix: `sed -n 's/^\\([A-Z_][A-Z0-9_]*\\)=.*/\\1/p' ${file} # nur die Schlüssel`,
2485
+ })
2486
+ }
2487
+
2488
+ if (cmd === 'docker' && (args[0] === 'inspect' || (args[0] === 'container' && args[1] === 'inspect'))) {
2489
+ const formatted = args.some(a => a === '-f' || a === '--format' || a.startsWith('--format=') || /^-f./.test(a))
2490
+ if (!formatted) {
2491
+ found.push({
2492
+ id: 'sec008-docker-inspect',
2493
+ klasse: 'sec008',
2494
+ title: 'docker inspect ohne --format',
2495
+ why: 'docker inspect druckt Config.Env im Klartext (SEC-008).',
2496
+ fix: "docker exec <container> printenv | cut -s -d= -f1 | sort # oder: docker inspect -f '{{.State.Status}}' <container>",
2497
+ })
2498
+ }
2499
+ }
2500
+
2501
+ if (cmd === 'git' && args[0] === 'add' && args.slice(1).some(a => a === '.' || a === '-A' || a === '--all' || a === ':/')) {
2502
+ found.push({
2503
+ id: 'commit-discipline-add',
2504
+ klasse: 'regel',
2505
+ title: 'git add . / -A / --all',
2506
+ why: 'Pauschales Stagen nimmt die Arbeit paralleler Sessions mit (commit-discipline, PSA-004).',
2507
+ fix: 'git add <datei> <datei> … # einzeln nach Namen, dann git diff --cached prüfen',
2508
+ })
2509
+ }
2510
+
2511
+ if (cmd === 'curl') {
2512
+ if (/-H\s*["']?Authorization:\s*(Bearer|token)\s+\$|--header\s*["']?Authorization:\s*(Bearer|token)\s+\$/i.test(segment)) {
2513
+ found.push({
2514
+ id: 'sec008-secret-argv',
2515
+ klasse: 'sec008',
2516
+ title: 'Token in curl-Argumenten',
2517
+ why: 'Argumente sind für jeden lokalen User in ps lesbar und landen in Shell-History und Logs (SEC-008).',
2518
+ fix: `printf 'header = "Authorization: Bearer %s"' "$TOKEN" | curl --config - <url>`,
2519
+ })
2520
+ }
2521
+ if (/[?&](access_token|private_token|api_key|apikey|token|key)=\$/i.test(segment)) {
2522
+ found.push({
2523
+ id: 'sec008-token-in-url',
2524
+ klasse: 'sec008',
2525
+ title: 'Token in der URL',
2526
+ why: 'Query-Strings landen in Server-, Proxy- und Referer-Logs (SEC-008).',
2527
+ fix: 'Token als Header über curl --config - übergeben, nie in der URL.',
2528
+ })
2529
+ }
2530
+ }
2531
+ }
2532
+
2533
+ if (/\$\{?PIPESTATUS\[/.test(text)) {
2534
+ found.push({
2535
+ id: 'zsh-pipestatus',
2536
+ klasse: 'regel',
2537
+ title: '${PIPESTATUS[0]} in zsh',
2538
+ why: 'Die Shell hier ist zsh: ${PIPESTATUS[0]} ist leer, und ein leeres EXIT= liest sich wie Erfolg (bash-harness-pitfalls §6).',
2539
+ fix: 'cmd > /tmp/out.log 2>&1; rc=$?; tail -5 /tmp/out.log; echo "EXIT=$rc"',
2540
+ })
2541
+ }
2542
+
2543
+ if (/\bgrep\s+(?:-[A-Za-z]*c[A-Za-z]*|--count)\b[^\n]*?\|\|\s*echo\s+["']?0["']?/.test(text)) {
2544
+ found.push({
2545
+ id: 'grep-c-echo-0',
2546
+ klasse: 'regel',
2547
+ title: 'grep -c … || echo 0',
2548
+ why: 'grep -c druckt bei keinem Treffer selbst 0 und endet mit 1: das Ergebnis ist "0\\n0" und bricht jeden Zahlenvergleich (bash-harness-pitfalls §1).',
2549
+ fix: 'count=$(grep -c "MUSTER" datei || true)',
2550
+ })
2551
+ }
2552
+
2553
+ const tainted = new Set(inherited)
2554
+ for (const stages of splitPipelines(text)) {
2555
+ // Not commandWords: a bare `env` is the command here, not a prefix. `if env; then` runs it too,
2556
+ // and so do `(env)`, `command env`, `builtin set` and `FOO=1 env`.
2557
+ const head = argWords(stripGroup(stages[0] ?? ''), true)
2558
+ while (PREFIXES.has(head[0] ?? '') || head[0] === 'builtin' || ASSIGNMENT.test(head[0] ?? '')) head.shift()
2559
+ // The keys-only filter must read the dump directly: `env | tee /dev/stderr | cut -d= -f1` printed it first.
2560
+ // `printenv NAME` prints the bare value with no `=`, so no filter makes it keys-only.
2561
+ const filtered = !(baseName(head[0] ?? '') === 'printenv' && head.slice(1).some(a => !a.startsWith('-'))) && isKeysOnlyFilter(stages[1] ?? '')
2562
+ if (dumpsEnvironment(head, tainted) && !filtered) {
2563
+ found.push({
2564
+ id: 'sec008-env-dump',
2565
+ klasse: 'sec008',
2566
+ title: `${head.join(' ')} ohne Filter`,
2567
+ why: 'Ein Umgebungs-Dump druckt jeden Token-Wert ins Transkript (SEC-008).',
2568
+ fix: 'env | cut -s -d= -f1 | sort # Schlüssel; Einzelwert: test -n "${VAR:-}" && echo "VAR: set"',
2569
+ })
2570
+ }
2571
+ found.push(...echoedSecrets(stages, tainted))
2572
+ }
2573
+ const heredoc = heredocTrap(raw, lineTaint)
2574
+ if (heredoc !== null) found.push(heredoc)
2575
+ return found
2576
+ }
2577
+
2578
+ // A plain file name the heredoc file exemption accepts: relative segments or `~/` in front, no `..`, no leading `/`
2579
+ // (`//dev/stdout`, `/dev/fd/1`, `/proc/self/fd/1`), no quote, `$`, glob or leading `-`. NAMED CEILING (BV-004): an
2580
+ // absolute path (`/tmp/x`) and a symlink planted by an earlier command are not told apart from a device, so the
2581
+ // first is refused and the second allowed; revisit if `cat > /tmp/x <<EOF` with a secret is needed in practice.
2582
+ // A relative name resolves against the cwd and `~/` against HOME, so a text that changes either (`cd /dev; cat >
2583
+ // stdout`, `HOME=/dev; cat > ~/stdout`, #1696 M6) gets no exemption at all ({@link movesTargets}). The cwd a
2584
+ // previous Bash call left behind, and zsh AUTO_CD (a bare directory name changes it), stay invisible: named ceiling.
2585
+ // movesTargets is a denylist over the raw text, so deliberate obfuscation passes it (`c\d /dev`, `'c'd /dev`,
2586
+ // `eval 'HO''ME=/dev'`, zsh `chdir` and `${HOME::=/dev}`, measured by the #1696 review round 2; CDPATH is matched in
2587
+ // every form, `${CDPATH:=…}` included, see {@link MOVES_HOME}); revisit with a
2588
+ // one-statement-only exemption when that becomes a real attack shape rather than a constructed one.
2589
+ const SAFE_FILE = /^(?:~\/)?(?!-)(?:[\w.@+,=%-]+\/)*[\w.@+,=%-]+$/
2590
+ const TEE_OPTIONS = new Set(['-a', '--append', '-i', '--ignore-interrupts'])
2591
+ // A `cd` / `pushd` / `popd` word anywhere (quoted or inner commands included), the word after it, and the start of a
2592
+ // second operand on the same line (zsh `cd old new` swaps `old` for `new` inside the cwd).
2593
+ const MOVES_CWD = /(?<![\w./-])(cd|pushd|popd)(?![\w./-])[ \t]*([^\s;&|()<>]*)([ \t]+[^\s;&|()<>])?/g
2594
+ // A HOME that is not only expanded, and any mention of CDPATH or zsh's tied `cdpath` array: CDPATH sends a relative
2595
+ // `cd fd` to `/dev/fd` (`CDPATH=/dev cd fd`, `cdpath=(/dev)`, `: ${CDPATH:=/dev}`). CDPATH is usually unset, so an
2596
+ // assign-on-expand form takes effect; it counts even only expanded (`$CDPATH`), a false alarm accepted (fail-closed).
2597
+ // HOME is always set, so `${HOME:=…}` changes nothing; `${HOME::=…}` stays the obfuscation ceiling above.
2598
+ // NAMED CEILING (BV-004): a CDPATH exported by an earlier Bash call is not in the text; revisit if one shows up.
2599
+ const MOVES_HOME = /(?<![\w${])HOME\b|(?<!\w)(?:CDPATH|cdpath)\b/
2600
+ // A directory a `cd` may move to and keep the exemption: plain segments, absolute, relative or `~/`, no `..`.
2601
+ const SAFE_DIR = /^(?:~\/|\/)?(?!-)(?:[\w.@+,=%-]+\/)*[\w.@+,=%-]+\/?$/
2602
+ // The first segments under which a device sits (`/dev`, `/dev/fd`, `/proc/self/fd`), also relative to a cwd of `/`;
2603
+ // compared lower-cased, since APFS resolves `/DEV/stdout` and `/Dev/Fd` to the devices.
2604
+ const DEVICE_ROOTS: ReadonlySet<string> = new Set(['dev', 'proc'])
2605
+
2606
+ function safeFileTarget(target: string): boolean {
2607
+ return SAFE_FILE.test(target) && !target.split('/').includes('..')
2608
+ }
2609
+
2610
+ /**
2611
+ * Whether the text may move the cwd or HOME a relative heredoc file target resolves against (#1696 M6). A `cd` /
2612
+ * `pushd` into a plain directory outside `/dev` and `/proc` keeps the exemption (`cd ~/repo && cat > .env`, #1706
2613
+ * item 6): the word after it is judged, not where the `cd` stands, so a quoted or `eval`-ed `cd /dev` still counts.
2614
+ * Any other word after it (none, `-`, an option, `..`, a quote or `$`), a second operand (zsh `cd old new`), and
2615
+ * every `popd` count as moving.
2616
+ * NAMED CEILING (BV-004): a `cd` written only as text (`git commit -m "cd x"`) with an unplain word after it still
2617
+ * withholds the exemption (fail-closed), and a plain directory that is a symlink to a device is the planted-symlink
2618
+ * ceiling above; revisit with a quote-aware command-position reading if the false alarm shows up in practice.
2619
+ */
2620
+ function movesTargets(text: string): boolean {
2621
+ if (MOVES_HOME.test(text)) return true
2622
+ for (const [, cmd, dir = '', second] of text.matchAll(MOVES_CWD)) {
2623
+ // The first segment that names a directory: `/./dev` and `./dev` reach `dev` too.
2624
+ const top = dir.replace(/^~\//, '').split('/').find(segment => segment !== '' && segment !== '.') ?? ''
2625
+ if (cmd === 'popd' || second !== undefined || !SAFE_DIR.test(dir) || dir.split('/').includes('..') || DEVICE_ROOTS.has(top.toLowerCase())) return true
2626
+ }
2627
+ return false
2628
+ }
2629
+
2630
+ // A run of characters a file name is written with, and the output operator (`>`, `>>`, `>|`) right before it.
2631
+ const NAME_RUN = /(>[|>]?[ \t]*)?([^\s;&|()<>"'`$\\]+)/g
2632
+
2633
+ // Commands that name a file without printing what is in it (`chmod 600 ~/.npmrc` after writing it): a denylist, so
2634
+ // any other command that names the file counts as reading it (fail-closed).
2635
+ // `source` / `.` are not among them: they run the file, and a body line run that way prints (`command not found: …`).
2636
+ const FILE_META: ReadonlySet<string> = new Set(['chmod', 'chown', 'chgrp', 'rm', 'unlink', 'touch', 'test', '[', '[[', 'ls', 'stat'])
2637
+
2638
+ /**
2639
+ * How often each file base name appears in the text other than as an output redirect target (`cat .npmrc`,
2640
+ * `< .npmrc`, `"$HOME/.npmrc"`, an operand of `tee`) outside a {@link FILE_META} command, and the base names of the
2641
+ * output targets. One pass over the segments.
2642
+ */
2643
+ function fileNames(text: string): { reads: Map<string, number>; targets: string[] } {
2644
+ const reads = new Map<string, number>()
2645
+ const targets: string[] = []
2646
+ for (const segment of splitSegments(text)) {
2647
+ const meta = FILE_META.has(baseName(commandWords(segment)[0] ?? ''))
2648
+ for (const [, op, name = ''] of segment.matchAll(NAME_RUN)) {
2649
+ const base = name.split('/').pop() ?? name
2650
+ if (op !== undefined) targets.push(base)
2651
+ else if (!meta) reads.set(base, (reads.get(base) ?? 0) + 1)
2652
+ }
2653
+ }
2654
+ return { reads, targets }
2655
+ }
2656
+
2657
+ /**
2658
+ * Whether a file the stage writes is named again elsewhere in the text, where another command may print it:
2659
+ * `cat > .npmrc <<EOF … EOF; cat .npmrc` (#1706 item 2). Judged by base name over the whole text, read once.
2660
+ * NAMED CEILING (BV-004): order is not read (a read BEFORE the write withholds too) and a mention in a body or a
2661
+ * quote counts (both fail-closed); a read through another name (`cat ~/.n*`) is not seen. Revisit if either shows up.
2662
+ */
2663
+ function namedAgain(stage: string, reads: ReadonlyMap<string, number>): boolean {
2664
+ const own = fileNames(stage)
2665
+ const [first, ...args] = commandWords(stage)
2666
+ // `tee` names the files it writes as operands; they count as written, and once among the stage's own names.
2667
+ const operands = baseName(first ?? '') === 'tee' ? args.filter(arg => !arg.startsWith('-')).map(arg => arg.split('/').pop() ?? arg) : []
2668
+ return [...own.targets, ...operands].some(base => (reads.get(base) ?? 0) > (own.reads.get(base) ?? 0))
2669
+ }
2670
+
2671
+ /**
2672
+ * A stage that writes what it reads into files only: `cat` (no operand but `-`) or `tee` (its options and plain
2673
+ * file operands) with every fd-1 target a {@link safeFileTarget} or `/dev/null`, and no pipe after it — zsh MULTIOS
2674
+ * writes `cat >o <<EOF | cat` into both. `1<&2` / `>&2` keep fd 1 on a terminal ({@link outputSilence}).
2675
+ */
2676
+ function writesFilesOnly(stage: string): boolean {
2677
+ if (cutCompound(stage)) return false
2678
+ const [first, ...args] = commandWords(stage)
2679
+ const cmd = baseName(first ?? '')
2680
+ const operands = cmd === 'cat' ? args.every(arg => arg === '-') : cmd === 'tee' && args.every(arg => TEE_OPTIONS.has(arg) || safeFileTarget(arg))
2681
+ return operands && outputSilence(stage, safeFileTarget).stdout
2682
+ }
2683
+
2684
+ /**
2685
+ * Whether the stage a heredoc feeds prints its body: the {@link echoedSecrets} reading of a here-string (a stage
2686
+ * that prints none of its input, or a silent consumer after the pipe, prints nothing), plus the file writes of
2687
+ * {@link writesFilesOnly}. A heredoc inside `$(…)`, a backtick or `<(…)`, a stage the scanner could not delimit,
2688
+ * and a `read` (it fills a variable a later command may print) count as printing — fail-closed. `filesExempt`
2689
+ * false (the text moves the cwd or HOME, {@link movesTargets}) withholds the file exemption, and so does a written
2690
+ * file the text names again ({@link namedAgain}, `reads` from {@link fileNames} over the whole text).
2691
+ */
2692
+ function heredocPrints({ text: stage, next, nested }: HeredocStage, filesExempt: boolean, reads: ReadonlyMap<string, number>): boolean {
2693
+ if (nested || stage === null || readNames(stage) !== null) return true
2694
+ if (filesExempt && next === undefined && writesFilesOnly(stage) && !namedAgain(stage, reads)) return false
2695
+ if (printsNoInput(stage)) return false
2696
+ const quiet = consumesSilently(stage)
2697
+ if (next === undefined) return !quiet
2698
+ return next === null || !consumesSilently(next) || (outputSilence(stage).moved && !quiet)
2699
+ }
2700
+
2701
+ // A `printenv` substitution in a body line and its words up to the closing `)` or backtick.
2702
+ const PRINTENV_SUBST = /(?:\$\(|`)[ \t]*(?:command[ \t]+)?printenv((?:[ \t]+[^\s)`]+)+)/g
2703
+
2704
+ /**
2705
+ * The first secret a heredoc body line expands: a secret-shaped or tainted `$name` / `${name`, or a `printenv` of
2706
+ * one in a substitution, not after an odd run of `\`.
2707
+ */
2708
+ function bodySecret(line: string, tainted: ReadonlySet<string>): string | undefined {
2709
+ const unescaped = (at: number): boolean => {
2710
+ let slashes = 0
2711
+ for (let j = at - 1; j >= 0 && line[j] === '\\'; j -= 1) slashes += 1
2712
+ return slashes % 2 === 0
2713
+ }
2714
+ const secret = (name: string): boolean => SECRET_VAR.test(`$${name}`) || tainted.has(name)
2715
+ for (const m of line.matchAll(VAR_REF)) {
2716
+ const name = m[1] ?? ''
2717
+ if (secret(name) && unescaped(m.index ?? 0)) return name
2718
+ }
2719
+ // `$(printenv NAME)` / `` `printenv NAME` `` substitutes the value as `echo $(printenv NAME)` prints it (#1706 item 3).
2720
+ for (const m of line.matchAll(PRINTENV_SUBST)) {
2721
+ const name = (m[1] ?? '').split(/\s+/).find(secret)
2722
+ if (name !== undefined && unescaped(m.index ?? 0)) return name
2723
+ }
2724
+ return undefined
2725
+ }
2726
+
2727
+ /**
2728
+ * The SEC-008 trap of a heredoc whose unquoted body expands a secret into a stage that prints it (#1696), read by
2729
+ * {@link scanHeredocs} in one linear pass. A text the scanner cannot read safely (an unbalanced quote, more
2730
+ * heredocs on a line than it queues, an operator without a delimiter) traps only when it names a secret anywhere.
2731
+ */
2732
+ function heredocTrap(text: string, tainted: ReadonlySet<string>): Trap | null {
2733
+ if (!text.includes('<<') && !text.includes("'") && !text.includes('"')) return null
2734
+ const filesExempt = !movesTargets(text)
2735
+ // Read once, and only when a body with a secret asks for a verdict.
2736
+ let reads: Map<string, number> | undefined
2737
+ const finding = scanHeredocs(text, line => bodySecret(line, tainted), stage => heredocPrints(stage, filesExempt, (reads ??= fileNames(text).reads)))
2738
+ if (finding === null) return null
2739
+ const name = finding.kind === 'body' ? finding.name : (SECRET_VAR.exec(text)?.[1] ?? bodySecret(text, tainted))
2740
+ if (name === undefined) return null
2741
+ return {
2742
+ id: 'sec008-echo-secret',
2743
+ klasse: 'sec008',
2744
+ title: finding.kind === 'body' ? `<<${finding.delimiter} $${name}` : `unlesbare Quote-/Heredoc-Form mit $${name}`,
2745
+ why: finding.kind === 'body'
2746
+ ? 'Ein Heredoc mit ungequotetem Delimiter setzt den Wert ein und druckt ihn ins Transkript (SEC-008).'
2747
+ : 'Quotes oder Heredocs sind nicht sicher lesbar, und eine Secret-Variable kommt vor (SEC-008, fail-closed).',
2748
+ fix: `test -n "\${${name}:-}" && echo "${name}: set"; echo "\${#${name}}" # gesetzt? Länge?`,
2749
+ }
2750
+ }
2751
+
2752
+ // The builtins that print their arguments: echo, printf and zsh's print (#1699).
2753
+ const PRINTERS: ReadonlySet<string> = new Set(['echo', 'printf', 'print'])
2754
+ // A function definition head as one word: `f()`, `f(){` (#1699).
2755
+ const FUNCTION_HEAD = /^[\w.:-]+\(\)\{?$/
2756
+
2757
+ /**
2758
+ * The command a stage runs, read past `builtin` (`builtin echo`) and past a function definition head (`f() {`,
2759
+ * `f () (`, `function f {`): a printer in a function body is reported where it is defined, whether or not the
2760
+ * function is called later (#1699) — simpler than following calls, and a defined body is meant to run.
2761
+ * NAMED CEILING (BV-004): a redirect after the closing brace (`f() { echo "$T"; } >/dev/null`) sits in another stage
2762
+ * and is not seen, so that silenced body still reports; revisit if such a definition shows up in practice.
2763
+ */
2764
+ function printerCommand(stage: string): string {
2765
+ const words = commandWords(stage)
2766
+ let at = 0
2767
+ if (words[0] === 'function') at = words[2] === '()' ? 3 : 2
2768
+ else if (FUNCTION_HEAD.test(words[0] ?? '')) at = 1
2769
+ else if (words[1] === '()') at = 2
2770
+ if (at > 0 && (words[at] === '{' || words[at] === '(')) at += 1
2771
+ if (words[at] === 'builtin') at += 1
2772
+ return baseName(words[at] ?? '')
2773
+ }
2774
+
2775
+ /**
2776
+ * Adds to `tainted` the names a stage assigns a secret or tainted value (`x=$T_TOKEN`, `y=$x`, `export x="$T"`),
2777
+ * so a later `echo "$x"` reads as printing it (#1699), like a name a `read` filled. Only assignments the shell keeps:
2778
+ * a stage of nothing but assignments, or the operands of a declaring builtin — `x=$T cmd` sets x for cmd's
2779
+ * environment only. NAMED CEILING (BV-004): a value that reaches the secret through a command (`x=$(printenv T)`)
2780
+ * or an expansion operator (`: ${x:=$T}`) is not followed, and a single-quoted `x='$T'` taints too (fail-closed);
2781
+ * revisit if one of them shows up in practice.
2782
+ */
2783
+ function assignedSecrets(stage: string, tainted: Set<string>): void {
2784
+ const words = rawWords(stripGroup(stage))
2785
+ const declarer = DECLARERS.has(words[0] ?? '')
2786
+ if (!declarer && !words.every(word => ASSIGNMENT.test(word))) return
2787
+ for (const word of declarer ? words.slice(1) : words) {
2788
+ const name = ASSIGNMENT.exec(word)?.[1]
2789
+ if (name !== undefined && secretRef(word.slice(word.indexOf('=') + 1), tainted) !== undefined) tainted.add(name)
2790
+ }
2791
+ }
2792
+
2793
+ /**
2794
+ * The stages of one pipeline that print a secret: an echoed secret, or one fed as a here-string (`cat <<< "$TOKEN"`),
2795
+ * reaches the transcript unless the stage itself discards it or the stage directly after consumes it silently (an
2796
+ * allow list: anything else re-prints). With a pipe after it the stage writes into the pipe even when its fd 1 is
2797
+ * redirected (zsh MULTIOS: `echo "$TOKEN" >/dev/null | cat` prints, #1696), and the redirect target receives it too
2798
+ * (`echo "$TOKEN" >&2 | sha256sum` prints it on stderr, #1649) — both shells' readings are judged.
2799
+ * `tainted` carries the variables a `read` filled with a secret across the pipelines of the line: zsh runs the last
2800
+ * stage in the current shell, so `echo "$TOKEN" | read -r x; echo "$x"` prints it (#1695); a here-string `read`
2801
+ * keeps it in every shell. A `read` in an earlier stage runs in a subshell and sets nothing here.
2802
+ */
2803
+ function echoedSecrets(stages: readonly string[], tainted: Set<string>): Trap[] {
2804
+ const found: Trap[] = []
2805
+ let carries = false
2806
+ for (let k = 0; k < stages.length; k += 1) {
2807
+ const stage = stages[k] ?? ''
2808
+ assignedSecrets(stage, tainted)
2809
+ const stageCmd = printerCommand(stage)
2810
+ const echoed = PRINTERS.has(stageCmd) ? secretRef(stage, tainted) : undefined
2811
+ const hereString = stage.indexOf('<<<')
2812
+ const fed = hereString === -1 ? undefined : secretRef(stage.slice(hereString), tainted)
2813
+ // Fail-closed: any earlier stage that names a secret may feed it to a `read` at the end, redirected or not (MULTIOS).
2814
+ if (k === stages.length - 1 && (carries || fed !== undefined)) for (const name of readNames(stage) ?? []) tainted.add(name)
2815
+ carries ||= secretRef(stage, tainted) !== undefined
2816
+ const name = echoed ?? fed
2817
+ if (name === undefined || (echoed === undefined && printsNoInput(stage))) continue
2818
+ // echo writes nothing to stderr, so its stdout alone decides; bash's printf prints a bad numeric argument
2819
+ // (`printf %d "$TOKEN"`) on stderr and zsh's `print -u2` writes there, so both need both.
2820
+ const silence = outputSilence(stage)
2821
+ const quiet = echoed !== undefined ? silence.stdout && (stageCmd === 'echo' || silence.stderr) : consumesSilently(stage)
2822
+ const piped = k + 1 < stages.length
2823
+ const printed = piped ? !consumesSilently(stages[k + 1] ?? '') || (silence.moved && !quiet) : !quiet
2824
+ if (printed) {
2825
+ found.push({
2826
+ id: 'sec008-echo-secret',
2827
+ klasse: 'sec008',
2828
+ title: echoed !== undefined ? `echo $${name}` : `<<< $${name}`,
2829
+ why: 'Druckt den Wert ins Transkript (SEC-008). Prüfen geht ohne Ausgabe.',
2830
+ fix: `test -n "\${${name}:-}" && echo "${name}: set"; echo "\${#${name}}" # gesetzt? Länge?`,
2831
+ })
2832
+ }
2833
+ }
2834
+ return found
2835
+ }
2836
+
2837
+ export type Profil = 'locker' | 'ausgewogen' | 'streng'
2838
+
2839
+ export type Verdict = { aktion: 'laufen' } | { aktion: 'warnen'; text: string } | { aktion: 'ablehnen'; text: string }
2840
+
2841
+ /**
2842
+ * What the gate does with the traps of one command. A SEC-008 trap refuses on every profile; rule
2843
+ * traps only warn on `locker` and refuse otherwise. Any profile not exactly `locker` refuses (fail-closed).
2844
+ */
2845
+ export function trapVerdict(traps: readonly Trap[], profil: Profil): Verdict {
2846
+ if (traps.length === 0) return { aktion: 'laufen' }
2847
+ const text = traps.map(trap => `[so-hud · Regel-Falle ${trap.id}] ${trap.why}\nStattdessen: ${trap.fix}`).join('\n')
2848
+ return profil === 'locker' && traps.every(trap => trap.klasse === 'regel') ? { aktion: 'warnen', text } : { aktion: 'ablehnen', text }
2849
+ }
2850
+
2851
+ // ---- Collisions with a peer's wave scope (F8) --------------------------------
2852
+
2853
+ /**
2854
+ * Whether a path matches one SO wave-scope entry, with the semantics of SO's own
2855
+ * `pathMatchesPattern` (scripts/lib/scope-gate.mjs), mirrored because a mod cannot import it:
2856
+ * exact path; a trailing `/` is a directory prefix; `*` stays within one segment; `**` spans any
2857
+ * depth, `**\/` also zero segments. Case-sensitive; an empty pattern never matches.
2858
+ */
2859
+ export function scopeMatches(relPath: string, pattern: string): boolean {
2860
+ if (!pattern) return false
2861
+ if (pattern.endsWith('/')) return relPath.startsWith(pattern)
2862
+ const regex = pattern
2863
+ .replace(/[.+?|[\](){}\\^$]/g, ch => `\\${ch}`)
2864
+ .replace(/\*\*\//g, '<<DBLS>>')
2865
+ .replace(/\*\*/g, '<<DBLG>>')
2866
+ .replace(/\*/g, '[^/]*')
2867
+ .replace(/<<DBLS>>/g, '(.*\\/)?')
2868
+ .replace(/<<DBLG>>/g, '.*')
2869
+ return new RegExp(`^${regex}$`).test(relPath)
2870
+ }
2871
+
2872
+ /** An absolute path with `.`, `..` and repeated slashes resolved, by string operations only. */
2873
+ function normalizeAbsolute(path: string): string {
2874
+ const parts: string[] = []
2875
+ for (const part of path.split('/')) {
2876
+ if (part === '' || part === '.') continue
2877
+ if (part === '..') parts.pop()
2878
+ else parts.push(part)
2879
+ }
2880
+ return `/${parts.join('/')}`
2881
+ }
2882
+
2883
+ export type ScopePeer = { session_id: string; gleiches_working_copy: boolean; scope_paths: readonly string[] | null }
2884
+ export type Collision = { session_id: string; pattern: string }
2885
+
2886
+ /**
2887
+ * Which live peers in this working copy hold the file in their wave scope. Peers in another working
2888
+ * copy and this session itself never collide; a peer without a readable scope is listed in
2889
+ * `unknownScope` instead (it can neither be proven disjoint nor shown to collide). A file outside
2890
+ * `repoRoot` lies in no peer's working copy: no hits and no unknown scopes. Absolute scope entries
2891
+ * are matched against the absolute path, all others against the repo-relative one.
2892
+ */
2893
+ export function findCollisions(input: {
2894
+ filePath: string
2895
+ repoRoot: string
2896
+ ownSessionId: string | null
2897
+ peers: readonly ScopePeer[]
2898
+ }): { hits: Collision[]; unknownScope: string[] } {
2899
+ const hits: Collision[] = []
2900
+ const unknownScope: string[] = []
2901
+ const root = normalizeAbsolute(input.repoRoot)
2902
+ const file = normalizeAbsolute(input.filePath)
2903
+ if (root === '/' || !file.startsWith(`${root}/`)) return { hits, unknownScope }
2904
+ const rel = file.slice(root.length + 1)
2905
+ for (const peer of input.peers) {
2906
+ if (peer.gleiches_working_copy !== true || peer.session_id === input.ownSessionId) continue
2907
+ if (peer.scope_paths === null) {
2908
+ unknownScope.push(peer.session_id)
2909
+ continue
2910
+ }
2911
+ for (const pattern of peer.scope_paths) {
2912
+ if (scopeMatches(pattern.startsWith('/') ? file : rel, pattern)) hits.push({ session_id: peer.session_id, pattern })
2913
+ }
2914
+ }
2915
+ return { hits, unknownScope }
2916
+ }
2917
+
2918
+ export const COLLISION_OPTIONS = ['Trotzdem', 'Worktree vorschlagen', 'Abbrechen'] as const
2919
+
2920
+ export type CollisionVerdict =
2921
+ | { aktion: 'laufen' }
2922
+ | { aktion: 'warnen'; text: string }
2923
+ | { aktion: 'fragen'; text: string; optionen: typeof COLLISION_OPTIONS }
2924
+ | { aktion: 'ablehnen'; text: string }
2925
+
2926
+ /** What the collision guard does per profile: locker warns, ausgewogen asks, streng (and any unknown profile) refuses. */
2927
+ export function collisionVerdict(hits: readonly Collision[], profil: Profil): CollisionVerdict {
2928
+ if (hits.length === 0) return { aktion: 'laufen' }
2929
+ const text = hits.map(hit => `[so-hud · Kollision] Peer-Session ${hit.session_id} hat ${hit.pattern} im Wellen-Scope.`).join('\n')
2930
+ if (profil === 'locker') return { aktion: 'warnen', text }
2931
+ if (profil === 'ausgewogen') return { aktion: 'fragen', text, optionen: COLLISION_OPTIONS }
2932
+ return { aktion: 'ablehnen', text: `${text}\nStattdessen: in einem eigenen Worktree weiterarbeiten (git worktree add <pfad> -b <branch>).` }
2933
+ }
2934
+
2935
+ // ---- Secrets in output ------------------------------------------------------
2936
+
2937
+ export type SecretHit = { name: string; fp: string }
2938
+
2939
+ export const SECRET_PATTERNS: readonly { name: string; re: RegExp }[] = [
2940
+ { name: 'Private Key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g },
2941
+ { name: 'GitLab-Token', re: /\bgl(?:pat|dt|rt|ptt|cbt|soat|ffct|imt|oas|agent)-[A-Za-z0-9_-]{20,}/g },
2942
+ { name: 'GitHub-Token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g },
2943
+ { name: 'Anthropic-Key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}/g },
2944
+ { name: 'OpenAI-Key', re: /\bsk-(?:proj-|svcacct-)?(?!ant-)[A-Za-z0-9_-]{32,}/g },
2945
+ { name: 'Stripe-Key', re: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}/g },
2946
+ { name: 'Stripe-Webhook-Secret', re: /\bwhsec_[A-Za-z0-9]{24,}/g },
2947
+ { name: 'AWS-Access-Key', re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
2948
+ { name: 'Slack-Token', re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
2949
+ { name: 'Google-API-Key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
2950
+ ]
2951
+
2952
+ export function hasSecret(text: string): boolean {
2953
+ return SECRET_PATTERNS.some(({ re }) => (text.match(re)?.length ?? 0) > 0)
2954
+ }
2955
+
2956
+ // The mod's lib is es2023 without DOM types (tsconfig `types: []`), yet Web Crypto and TextEncoder
2957
+ // are globals in both the mod engine and Node: read them off globalThis in the one shape used here.
2958
+ type WebGlobals = {
2959
+ crypto: { subtle: { digest(algorithm: string, data: Uint8Array): Promise<ArrayBuffer> } }
2960
+ TextEncoder: new () => { encode(input: string): Uint8Array }
2961
+ }
2962
+
2963
+ async function fingerprint(secret: string): Promise<string> {
2964
+ const { crypto, TextEncoder } = globalThis as unknown as WebGlobals
2965
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(secret))
2966
+ return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 12)
2967
+ }
2968
+
2969
+ /** The text with every secret replaced by its kind and a sha256 fingerprint. */
2970
+ export async function redactText(text: string, hits: SecretHit[]): Promise<string> {
2971
+ let out = text
2972
+ for (const { name, re } of SECRET_PATTERNS) {
2973
+ for (const secret of new Set(out.match(re) ?? [])) {
2974
+ const fp = await fingerprint(secret)
2975
+ if (!hits.some(hit => hit.fp === fp)) hits.push({ name, fp })
2976
+ out = out.split(secret).join(`[${name} entfernt · sha256:${fp}]`)
2977
+ }
2978
+ }
2979
+ return out
2980
+ }
2981
+
2982
+ /** A copy of any JSON-like value with every string redacted. */
2983
+ export async function redactDeep(value: unknown, hits: SecretHit[]): Promise<unknown> {
2984
+ if (typeof value === 'string') {
2985
+ return hasSecret(value) ? redactText(value, hits) : value
2986
+ }
2987
+ if (Array.isArray(value)) {
2988
+ const out: unknown[] = []
2989
+ for (const item of value) out.push(await redactDeep(item, hits))
2990
+ return out
2991
+ }
2992
+ if (value !== null && typeof value === 'object') {
2993
+ const out: Record<string, unknown> = {}
2994
+ for (const [key, item] of Object.entries(value)) out[key] = await redactDeep(item, hits)
2995
+ return out
2996
+ }
2997
+ return value
2998
+ }