jackherd 0.0.1 → 1.0.1

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 (250) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1122 -3
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,438 @@
1
+ import { posix } from 'node:path'
2
+ import { parseShell, ShellParseError, type SimpleCommand, type Word } from './shell'
3
+
4
+ export type GitGuardDecision = { kind: 'pass' } | { kind: 'deny'; reason: string }
5
+
6
+ /** The git verbs a project guards with permission rules: each one changes a ref or the working tree. */
7
+ const PROTECTED_VERB = {
8
+ push: 'push',
9
+ commit: 'commit',
10
+ rebase: 'rebase',
11
+ reset: 'reset',
12
+ checkout: 'checkout',
13
+ merge: 'merge',
14
+ } as const
15
+
16
+ type ProtectedVerb = (typeof PROTECTED_VERB)[keyof typeof PROTECTED_VERB]
17
+
18
+ /** Where a script runs: at the top, where permission rules look, or inside `sh -c`/`eval`, where none does. */
19
+ const SCRIPT_CONTEXT = { top: 'top', nested: 'nested' } as const
20
+
21
+ type ScriptContext = (typeof SCRIPT_CONTEXT)[keyof typeof SCRIPT_CONTEXT]
22
+
23
+ /**
24
+ * Where a `git` word stands: written plainly where a permission rule compares (`ruleMatched`), run
25
+ * as the program in a form no rule matches (`program`), or a mere argument (`grep git file`).
26
+ */
27
+ const GIT_POSITION = { ruleMatched: 'ruleMatched', program: 'program', argument: 'argument' } as const
28
+
29
+ type GitPosition = (typeof GIT_POSITION)[keyof typeof GIT_POSITION]
30
+
31
+ type GitInvocation = {
32
+ assignments: string[]
33
+ options: Word[]
34
+ directories: string[]
35
+ verb: Word
36
+ verbArguments: Word[]
37
+ }
38
+
39
+ /** A dangerous use of a verb: a long option (or a git-accepted prefix of it), a short letter, an operand. */
40
+ type DangerRule = {
41
+ long: string[]
42
+ short?: string
43
+ operand?: (operand: string) => boolean
44
+ message: string
45
+ }
46
+
47
+ /**
48
+ * How a verb's options take values: a letter in `shortWithValue` takes the rest of its cluster, or
49
+ * the next word when it ends the cluster and is also in `shortSeparate`; a long option in
50
+ * `longWithValue` takes the next word unless it carries `=`.
51
+ */
52
+ type VerbSyntax = { shortWithValue: string; shortSeparate: string; longWithValue: string[] }
53
+
54
+ type SplitArguments = { options: string[]; operands: string[] }
55
+
56
+ const PASS: GitGuardDecision = { kind: 'pass' }
57
+
58
+ const FORCE_PUSH =
59
+ 'A force push is refused. Use `--force-with-lease` instead, and only after the user agreed to rewrite the remote branch.'
60
+ const SKIP_HOOKS =
61
+ 'Skipping the git hooks (`--no-verify`, `HUSKY=0` and the like) is refused. Fix what the hook reports instead.'
62
+ const HARD_RESET = '`git reset --hard` is refused: it discards uncommitted work. Ask the user how to proceed.'
63
+ const VERB_HINT =
64
+ 'Refused: the git verb is quoted, escaped or expanded, so neither the permission rules nor this hook can tell what it is. Write the verb plainly.'
65
+ const PROGRAM_HINT =
66
+ 'Refused: the program is expanded from a variable or a substitution and is followed by a protected git verb, so the permission rules cannot see it. Write `git` plainly.'
67
+ const BRACE_HINT =
68
+ 'Refused: an option of this git call is written as a brace expansion, so neither the permission rules nor this hook can tell what git receives. Write the option plainly.'
69
+ const ALIAS_HINT =
70
+ 'Refused: `-c alias.…` defines a git alias inline, which hides the verb from the permission rules. Run the git command it stands for directly.'
71
+
72
+ const DANGER_RULES: Record<ProtectedVerb, DangerRule[]> = {
73
+ push: [
74
+ { long: ['--force', '--mirror'], short: 'f', operand: (operand) => operand.startsWith('+'), message: FORCE_PUSH },
75
+ { long: ['--no-verify'], message: SKIP_HOOKS },
76
+ ],
77
+ commit: [{ long: ['--no-verify'], short: 'n', message: SKIP_HOOKS }],
78
+ rebase: [{ long: ['--no-verify'], message: SKIP_HOOKS }],
79
+ merge: [{ long: ['--no-verify'], message: SKIP_HOOKS }],
80
+ reset: [{ long: ['--hard'], message: HARD_RESET }],
81
+ checkout: [],
82
+ }
83
+
84
+ const VERB_SYNTAX: Record<ProtectedVerb, VerbSyntax> = {
85
+ push: {
86
+ shortWithValue: 'o',
87
+ shortSeparate: 'o',
88
+ longWithValue: ['--push-option', '--repo', '--receive-pack', '--exec'],
89
+ },
90
+ commit: {
91
+ shortWithValue: 'mFCctuS',
92
+ shortSeparate: 'mFCct',
93
+ longWithValue: [
94
+ '--message',
95
+ '--file',
96
+ '--author',
97
+ '--date',
98
+ '--template',
99
+ '--reuse-message',
100
+ '--reedit-message',
101
+ '--fixup',
102
+ '--squash',
103
+ '--trailer',
104
+ '--cleanup',
105
+ '--pathspec-from-file',
106
+ ],
107
+ },
108
+ rebase: {
109
+ shortWithValue: 'sxX',
110
+ shortSeparate: 'sxX',
111
+ longWithValue: ['--onto', '--strategy', '--strategy-option', '--exec'],
112
+ },
113
+ merge: {
114
+ shortWithValue: 'mFsX',
115
+ shortSeparate: 'mFsX',
116
+ longWithValue: ['--message', '--file', '--strategy', '--strategy-option'],
117
+ },
118
+ reset: { shortWithValue: '', shortSeparate: '', longWithValue: ['--pathspec-from-file'] },
119
+ checkout: { shortWithValue: 'bB', shortSeparate: 'bB', longWithValue: ['--orphan', '--pathspec-from-file'] },
120
+ }
121
+
122
+ /** Global git options that take the next word as their value. */
123
+ const OPTIONS_WITH_VALUE = new Set([
124
+ '-C',
125
+ '-c',
126
+ '--git-dir',
127
+ '--work-tree',
128
+ '--namespace',
129
+ '--super-prefix',
130
+ '--config-env',
131
+ '--attr-source',
132
+ ])
133
+ const CONFIG_OPTIONS = new Set(['-c', '--config-env'])
134
+ const CONFIG_ENV_ATTACHED = '--config-env='
135
+
136
+ /**
137
+ * Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
138
+ * permission matching also reaches into control-flow bodies, which the reserved words open.
139
+ */
140
+ const RULE_TRANSPARENT_WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'builtin', 'noglob'])
141
+ const RESERVED_WORDS = new Set(['!', '{', '}', 'if', 'then', 'elif', 'else', 'fi', 'do', 'done', 'while', 'until'])
142
+ const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
143
+ const DURATION = /^\d+(\.\d+)?[smhd]?$/
144
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
145
+ const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
146
+ const INLINE_ALIAS = /^alias\./i
147
+ /** Variables that make git or its hook manager skip the hooks. */
148
+ const HOOK_SKIPPING_VARIABLE = /^(HUSKY|LEFTHOOK|SKIP)\+?=/
149
+ /** Variables that point git at other config, another repository or another work tree, like options do. */
150
+ const REDIRECTING_VARIABLE = /^(GIT_CONFIG\w*|GIT_DIR|GIT_WORK_TREE|GIT_INDEX_FILE)\+?=/
151
+ /** An option the shell rewrites before git sees it: `--forc{e,}` reaches git as `--force`. */
152
+ const BRACE_IN_OPTION = /^-.*\{.*(,|\.\.).*\}/
153
+ /** Programs that run their arguments as a command, so a `git` right after them is the program. */
154
+ const COMMAND_RUNNERS = new Set([
155
+ ...RULE_TRANSPARENT_WRAPPERS,
156
+ 'env',
157
+ 'sudo',
158
+ 'doas',
159
+ 'exec',
160
+ 'xargs',
161
+ 'timeout',
162
+ 'stdbuf',
163
+ 'setsid',
164
+ 'watch',
165
+ ])
166
+ const PROTECTED_MENTION = new RegExp(`\\bgit\\b[\\s\\S]*\\b(${Object.values(PROTECTED_VERB).join('|')})\\b`)
167
+
168
+ const isProtectedVerb = (value: string): value is ProtectedVerb =>
169
+ (Object.values(PROTECTED_VERB) as string[]).includes(value)
170
+
171
+ const deny = (reason: string): GitGuardDecision => ({ kind: 'deny', reason })
172
+
173
+ const transparentWidth = (words: SimpleCommand, index: number): number => {
174
+ const { value, literal } = words[index]
175
+ if (ASSIGNMENT.test(value)) return 1
176
+ if (!literal) return 0
177
+ if (RESERVED_WORDS.has(value) || RULE_TRANSPARENT_WRAPPERS.has(value)) return 1
178
+ return value === 'timeout' && DURATION.test(words[index + 1]?.value ?? '') ? 2 : 0
179
+ }
180
+
181
+ /** The index of the word a permission rule compares against: past assignments and stripped wrappers. */
182
+ const ruleStart = (words: SimpleCommand): number => {
183
+ let index = 0
184
+ while (index < words.length) {
185
+ const width = transparentWidth(words, index)
186
+ if (width === 0) break
187
+ index += width
188
+ }
189
+ return index
190
+ }
191
+
192
+ /** The program a word names, as a case-insensitive file system would find it. */
193
+ const programName = (word: Word): string =>
194
+ posix
195
+ .basename(word.value.replace(/^=/, ''))
196
+ .toLowerCase()
197
+ .replace(/\.exe$/, '')
198
+
199
+ const isGit = (word: Word): boolean => programName(word) === 'git'
200
+
201
+ /** At the rule start, or anywhere after a runner such as `sudo -u me` or `env FOO=1`: git runs as the program. */
202
+ const isProgramPosition = (words: SimpleCommand, index: number): boolean =>
203
+ index === ruleStart(words) || words.slice(0, index).some((word) => COMMAND_RUNNERS.has(programName(word)))
204
+
205
+ const gitPosition = (words: SimpleCommand, index: number, context: ScriptContext): GitPosition => {
206
+ const word = words[index]
207
+ const plain = context === SCRIPT_CONTEXT.top && index === ruleStart(words) && word.literal && word.value === 'git'
208
+ if (plain) return GIT_POSITION.ruleMatched
209
+ return isProgramPosition(words, index) ? GIT_POSITION.program : GIT_POSITION.argument
210
+ }
211
+
212
+ const isOption = (word: Word | undefined): boolean => word?.value.startsWith('-') ?? false
213
+
214
+ const readInvocation = (words: SimpleCommand, gitIndex: number): GitInvocation | undefined => {
215
+ const options: Word[] = []
216
+ const directories: string[] = []
217
+ let index = gitIndex + 1
218
+ while (isOption(words[index])) {
219
+ const option = words[index]
220
+ const value = OPTIONS_WITH_VALUE.has(option.value) ? words[index + 1] : undefined
221
+ options.push(option, ...(value ? [value] : []))
222
+ if (option.value === '-C' && value) directories.push(value.value)
223
+ index += value ? 2 : 1
224
+ }
225
+ const verb = words[index]
226
+ if (!verb) return undefined
227
+ const assignments = words
228
+ .slice(0, gitIndex)
229
+ .map(({ value }) => value)
230
+ .filter((value) => ASSIGNMENT.test(value))
231
+ return { assignments, options, directories, verb, verbArguments: words.slice(index + 1) }
232
+ }
233
+
234
+ /** `--for` counts as `--force`: git accepts any unambiguous prefix of a long option. */
235
+ const matchesLong = (word: string, name: string): boolean => {
236
+ const flag = word.split('=')[0]
237
+ return flag.startsWith('--') && flag.length >= 3 && name.startsWith(flag)
238
+ }
239
+
240
+ /** The cluster's letters up to and including the first one that takes a value. */
241
+ const clusterLetters = (word: string, { shortWithValue }: VerbSyntax): string => {
242
+ const letters = word.slice(1)
243
+ const valueAt = [...letters].findIndex((letter) => shortWithValue.includes(letter))
244
+ return valueAt === -1 ? letters : letters.slice(0, valueAt + 1)
245
+ }
246
+
247
+ const isShortCluster = (word: string): boolean => /^-[^-]/.test(word)
248
+
249
+ const takesNextWord = (word: string, syntax: VerbSyntax): boolean => {
250
+ if (isShortCluster(word)) {
251
+ const letters = clusterLetters(word, syntax)
252
+ return letters === word.slice(1) && syntax.shortSeparate.includes(letters.at(-1) ?? '')
253
+ }
254
+ return syntax.longWithValue.includes(word)
255
+ }
256
+
257
+ /** Options and operands after the verb; option values are neither, and `--` ends the options. */
258
+ const splitArguments = (words: Word[], syntax: VerbSyntax): SplitArguments => {
259
+ const split: SplitArguments = { options: [], operands: [] }
260
+ let optionsEnded = false
261
+ for (let index = 0; index < words.length; index++) {
262
+ const { value } = words[index]
263
+ if (optionsEnded || !value.startsWith('-')) split.operands.push(value)
264
+ else if (value === '--') optionsEnded = true
265
+ else {
266
+ split.options.push(value)
267
+ if (takesNextWord(value, syntax)) index++
268
+ }
269
+ }
270
+ return split
271
+ }
272
+
273
+ const holdsShortFlag = (option: string, short: string | undefined, syntax: VerbSyntax): boolean =>
274
+ short !== undefined &&
275
+ isShortCluster(option) &&
276
+ [...clusterLetters(option, syntax)].some((letter) => short.includes(letter))
277
+
278
+ const breaksRule = (rule: DangerRule, { options, operands }: SplitArguments, syntax: VerbSyntax): boolean =>
279
+ options.some((option) => rule.long.some((name) => matchesLong(option, name))) ||
280
+ options.some((option) => holdsShortFlag(option, rule.short, syntax)) ||
281
+ (rule.operand !== undefined && operands.some(rule.operand))
282
+
283
+ const dangerOf = (verb: ProtectedVerb, { assignments, verbArguments }: GitInvocation): string | undefined => {
284
+ if (assignments.some((assignment) => HOOK_SKIPPING_VARIABLE.test(assignment))) return SKIP_HOOKS
285
+ const syntax = VERB_SYNTAX[verb]
286
+ const split = splitArguments(verbArguments, syntax)
287
+ return DANGER_RULES[verb].find((rule) => breaksRule(rule, split, syntax))?.message
288
+ }
289
+
290
+ /** The `key=value` entries the global options set, from `-c k=v`, `--config-env k=V` and `--config-env=k=V`. */
291
+ const configEntries = ({ options }: GitInvocation): string[] =>
292
+ options.flatMap(({ value }, index) => {
293
+ if (value.startsWith(CONFIG_ENV_ATTACHED)) return [value.slice(CONFIG_ENV_ATTACHED.length)]
294
+ const previous = options[index - 1]?.value
295
+ return previous !== undefined && CONFIG_OPTIONS.has(previous) ? [value] : []
296
+ })
297
+
298
+ const definesAlias = (invocation: GitInvocation): boolean =>
299
+ configEntries(invocation).some((entry) => INLINE_ALIAS.test(entry))
300
+
301
+ const targetDirectory = (directories: string[]): string =>
302
+ directories.reduce((joined, directory) => (directory.startsWith('/') ? directory : posix.join(joined, directory)), '')
303
+
304
+ const plainInstead = (verb: string): string => `Run \`git ${verb} …\` as a plain command of its own.`
305
+
306
+ const optionsHint = (verb: string, { options, directories }: GitInvocation): string => {
307
+ const written = `\`git ${options.map(({ value }) => value).join(' ')} ${verb}\``
308
+ const instead =
309
+ directories.length > 0
310
+ ? `Run \`cd ${targetDirectory(directories)}\` as a command of its own, then \`git ${verb} …\` with the same arguments.`
311
+ : `Run \`git ${verb} …\` without them; if one is really needed, ask the user first.`
312
+ return `Refused: ${written} puts options between git and ${verb}, so the permission rules for \`git ${verb}\` never see it. ${instead}`
313
+ }
314
+
315
+ const variablesHint = (verb: string): string =>
316
+ `Refused: a \`GIT_CONFIG…\`, \`GIT_DIR\`, \`GIT_WORK_TREE\` or \`GIT_INDEX_FILE\` assignment changes what \`git ${verb}\` acts on, like an option before the verb. ${plainInstead(verb)}`
317
+
318
+ const formHint = (verb: string): string =>
319
+ `Refused: this \`git ${verb}\` is written in a form the permission rules do not match (behind a wrapper, through a path, quoted, split across lines or inside \`sh -c\`). ${plainInstead(verb)}`
320
+
321
+ const judgeProtectedVerb = (verb: ProtectedVerb, invocation: GitInvocation, ruleMatched: boolean): GitGuardDecision => {
322
+ if (invocation.verbArguments.some(({ value }) => BRACE_IN_OPTION.test(value))) return deny(BRACE_HINT)
323
+ const danger = dangerOf(verb, invocation)
324
+ if (danger) return deny(`Refused: ${danger}`)
325
+ if (invocation.options.length > 0) return deny(optionsHint(verb, invocation))
326
+ if (invocation.assignments.some((assignment) => REDIRECTING_VARIABLE.test(assignment))) {
327
+ return deny(variablesHint(verb))
328
+ }
329
+ return ruleMatched ? PASS : deny(formHint(verb))
330
+ }
331
+
332
+ /**
333
+ * A quoted, escaped or expanded verb could be a protected one. Where git runs as the program it is
334
+ * refused; as a mere argument, as in `grep git "$file"`, it is no git call at all.
335
+ */
336
+ const judgeInvocation = (invocation: GitInvocation, position: GitPosition): GitGuardDecision => {
337
+ if (definesAlias(invocation)) return deny(ALIAS_HINT)
338
+ const { value, literal } = invocation.verb
339
+ if (isProtectedVerb(value)) {
340
+ return judgeProtectedVerb(value, invocation, position === GIT_POSITION.ruleMatched && literal)
341
+ }
342
+ return literal || position === GIT_POSITION.argument ? PASS : deny(VERB_HINT)
343
+ }
344
+
345
+ const isShell = (word: Word): boolean => SHELLS.has(programName(word))
346
+
347
+ /** The script word after `-c`: the first one that is neither an option nor `--`. */
348
+ const shellCommandScript = (words: SimpleCommand, shellIndex: number): string | undefined => {
349
+ const flag = words.findIndex((word, position) => position > shellIndex && SHELL_COMMAND_FLAG.test(word.value))
350
+ if (flag === -1) return undefined
351
+ return words.slice(flag + 1).find((word) => !isOption(word))?.value
352
+ }
353
+
354
+ const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
355
+ const word = words[index]
356
+ if (word.literal && word.value === 'eval') return words.slice(index + 1).map(({ value }) => value).join(' ')
357
+ return isShell(word) ? shellCommandScript(words, index) : undefined
358
+ }
359
+
360
+ /** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
361
+ const readsScriptFromInput = (words: SimpleCommand): boolean => {
362
+ const start = ruleStart(words)
363
+ const shell = words[start]
364
+ return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
365
+ }
366
+
367
+ /**
368
+ * `HUSKY=0`, `export HUSKY=0`, `local -x HUSKY=0`, `set -a; HUSKY=0`: wherever such an assignment
369
+ * stands, it may reach a later git call of the script, not only the one it precedes.
370
+ */
371
+ const setsGitVariable = (words: SimpleCommand): boolean =>
372
+ words.some(({ value }) => HOOK_SKIPPING_VARIABLE.test(value) || REDIRECTING_VARIABLE.test(value))
373
+
374
+ /** A program expanded from a variable or a substitution could be git. */
375
+ const runsExpandedProgram = (words: SimpleCommand): boolean => words[ruleStart(words)]?.literal === false
376
+
377
+ /** An expanded program (`"$tool" push`, `sudo "$tool" push`) followed by a protected verb could be git. */
378
+ const judgeExpandedProgram = (words: SimpleCommand): GitGuardDecision => {
379
+ const program = words.findIndex((word, index) => !word.literal && isProgramPosition(words, index))
380
+ if (program === -1) return PASS
381
+ return words.slice(program + 1).some(({ value }) => isProtectedVerb(value)) ? deny(PROGRAM_HINT) : PASS
382
+ }
383
+
384
+ const firstDenial = (decisions: GitGuardDecision[]): GitGuardDecision =>
385
+ decisions.find((decision) => decision.kind === 'deny') ?? PASS
386
+
387
+ const denyIfMentionsProtectedVerb = (script: string, reason: string): GitGuardDecision => {
388
+ const verb = script.match(PROTECTED_MENTION)?.[1]
389
+ return verb ? deny(`Refused: ${reason}, and it mentions \`git ${verb}\`. ${plainInstead(verb)}`) : PASS
390
+ }
391
+
392
+ const judgeWord = (words: SimpleCommand, index: number, context: ScriptContext): GitGuardDecision => {
393
+ const script = inlineScript(words, index)
394
+ if (script !== undefined) return judgeScript(script, SCRIPT_CONTEXT.nested)
395
+ if (!isGit(words[index])) return PASS
396
+ const invocation = readInvocation(words, index)
397
+ return invocation ? judgeInvocation(invocation, gitPosition(words, index, context)) : PASS
398
+ }
399
+
400
+ const judgeCommand = (words: SimpleCommand, context: ScriptContext): GitGuardDecision =>
401
+ firstDenial([...words.map((_word, index) => judgeWord(words, index, context)), judgeExpandedProgram(words)])
402
+
403
+ /**
404
+ * What no single command shows. Each check reads the whole script, so a harmless git call beside
405
+ * a shell that reads stdin, a set git variable or an expanded program is refused too: an accepted
406
+ * over-refusal for rare shapes.
407
+ */
408
+ const judgeScriptWide = (script: string, commands: SimpleCommand[]): GitGuardDecision => {
409
+ if (commands.some(readsScriptFromInput)) {
410
+ return denyIfMentionsProtectedVerb(script, 'a shell reads its script from input here, which no rule inspects')
411
+ }
412
+ if (commands.some(setsGitVariable)) {
413
+ return denyIfMentionsProtectedVerb(script, 'a `HUSKY`, `LEFTHOOK`, `SKIP` or `GIT_*` variable is set, which changes the git calls')
414
+ }
415
+ if (commands.some(runsExpandedProgram)) {
416
+ return denyIfMentionsProtectedVerb(script, 'a program is expanded from a variable or a substitution and could be git')
417
+ }
418
+ return PASS
419
+ }
420
+
421
+ const judgeScript = (script: string, context: ScriptContext): GitGuardDecision => {
422
+ try {
423
+ const commands = parseShell(script)
424
+ return firstDenial([...commands.map((words) => judgeCommand(words, context)), judgeScriptWide(script, commands)])
425
+ } catch (error) {
426
+ if (error instanceof ShellParseError) return denyIfMentionsProtectedVerb(script, 'the command could not be parsed')
427
+ throw error
428
+ }
429
+ }
430
+
431
+ /**
432
+ * Decides on a Bash command before it runs. Denies a protected git verb that a permission rule
433
+ * written as `git <verb> …` would not match (options or `GIT_*` variables before the verb, a
434
+ * wrapper, a path to git, a quoted verb, a line continuation, `sh -c`, `eval`, a script piped into
435
+ * a shell), and denies a force push, a skipped hook and a hard reset in any form. Everything else
436
+ * passes on to the project's own permission rules.
437
+ */
438
+ export const decideBashCommand = (command: string): GitGuardDecision => judgeScript(command, SCRIPT_CONTEXT.top)
@@ -0,0 +1,38 @@
1
+ // PreToolUse hook for Bash, shipped by jackherd. Reads the hook input on stdin and
2
+ // answers with a deny decision on stdout, or with nothing to leave the call to the permission rules.
3
+ // Any failure denies: a guard that crashes must not wave the command through.
4
+ import { decideBashCommand, type GitGuardDecision } from './decision'
5
+
6
+ type HookInput = { tool_input?: { command?: unknown } }
7
+
8
+ const decideInput = (text: string): GitGuardDecision | undefined => {
9
+ const command = (JSON.parse(text) as HookInput).tool_input?.command
10
+ return typeof command === 'string' ? decideBashCommand(command) : undefined
11
+ }
12
+
13
+ const failureDecision = (error: unknown): GitGuardDecision => ({
14
+ kind: 'deny',
15
+ reason: `Refused: the git guard hook failed (${error instanceof Error ? error.message : String(error)}), so the command is blocked. Tell the user.`,
16
+ })
17
+
18
+ const decide = async (): Promise<GitGuardDecision | undefined> => {
19
+ try {
20
+ return decideInput(await Bun.stdin.text())
21
+ } catch (error) {
22
+ return failureDecision(error)
23
+ }
24
+ }
25
+
26
+ const decision = await decide()
27
+
28
+ if (decision?.kind === 'deny') {
29
+ console.log(
30
+ JSON.stringify({
31
+ hookSpecificOutput: {
32
+ hookEventName: 'PreToolUse',
33
+ permissionDecision: 'deny',
34
+ permissionDecisionReason: decision.reason,
35
+ },
36
+ }),
37
+ )
38
+ }