jackherd 1.2.0 → 1.4.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.
- package/CHANGELOG.md +11 -0
- package/README.md +29 -4
- package/dist/{chunk-R6MGHCD7.js → chunk-UI3AZNZF.js} +5 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +23 -1
- package/dist/plan-format.d.ts +7 -1
- package/dist/plan-format.js +3 -1
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/ask-hint/decision.ts +28 -0
- package/kit/hooks/hooks/ask-hint/index.ts +44 -0
- package/kit/hooks/hooks/ask-hint/settings.ts +21 -0
- package/kit/hooks/hooks/ask-hint/transcript.ts +55 -0
- package/kit/hooks/hooks/git-guard/decision.ts +10 -79
- package/kit/hooks/hooks/merge-guard/approval.ts +107 -0
- package/kit/hooks/hooks/merge-guard/decision.ts +286 -0
- package/kit/hooks/hooks/merge-guard/index.ts +56 -0
- package/kit/hooks/hooks/merge-guard/pull-request.ts +33 -0
- package/kit/hooks/hooks/merge-guard/run.ts +60 -0
- package/kit/hooks/hooks/merge-guard/settings.ts +56 -0
- package/kit/hooks/hooks/merge-guard/worker.ts +14 -0
- package/kit/hooks/hooks/shared/program-position.ts +82 -0
- package/kit/hooks/hooks/watch-guard/shell-input.ts +1 -1
- package/kit/hooks/hooks/watch-guard/starts.ts +1 -1
- package/kit/hooks/hooks.json +18 -0
- package/kit/workflow/skills/orchestrate/SKILL.md +66 -4
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +46 -0
- package/src/config/schema.ts +30 -0
- package/src/sync/command-context.ts +8 -4
- package/src/sync/command.ts +27 -9
- package/src/sync/render.ts +4 -2
- package/src/sync/report.ts +21 -0
- package/src/sync/sync.ts +7 -3
- package/src/vocabulary/index.ts +10 -0
- /package/kit/hooks/hooks/{git-guard → shared}/shell.ts +0 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
inlineScript,
|
|
3
|
+
isOption,
|
|
4
|
+
isProgramPosition,
|
|
5
|
+
isShell,
|
|
6
|
+
programName,
|
|
7
|
+
} from '../shared/program-position'
|
|
8
|
+
import { parseShell, ShellParseError, type SimpleCommand, type Word } from '../shared/shell'
|
|
9
|
+
|
|
10
|
+
export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
|
|
11
|
+
|
|
12
|
+
export type GuardPolicy = {
|
|
13
|
+
/** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
|
|
14
|
+
commands: readonly string[]
|
|
15
|
+
/**
|
|
16
|
+
* Whether the user approved the merge of this pull request; asked only for a plain `gh pr merge <number>`.
|
|
17
|
+
* A throw counts as not approved.
|
|
18
|
+
*/
|
|
19
|
+
isMergeApproved: (pullRequest: number) => boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly. */
|
|
23
|
+
type Finding = { command: string; pullRequest: number | undefined }
|
|
24
|
+
|
|
25
|
+
/** The one command whose guard an approval can lift. Every other guarded command asks each time. */
|
|
26
|
+
const MERGE_COMMAND = 'gh pr merge'
|
|
27
|
+
|
|
28
|
+
/** gh options that take the next word, so it is not a command word. */
|
|
29
|
+
const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The only words a merge may carry besides `gh pr merge <number>` and still count as "pull request <number> of this
|
|
33
|
+
* repository, after its checks". Anything else (another repository, a flag with a value, `--admin`, `--auto`) asks.
|
|
34
|
+
*/
|
|
35
|
+
const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
|
|
36
|
+
const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
|
|
37
|
+
const GLOB = /[*?[]/
|
|
38
|
+
|
|
39
|
+
/** Programs that run text they are given or read from elsewhere, so the words may not stand at the program. */
|
|
40
|
+
const OPAQUE_RUNNERS = new Set([
|
|
41
|
+
'xargs',
|
|
42
|
+
'find',
|
|
43
|
+
'trap',
|
|
44
|
+
'coproc',
|
|
45
|
+
'repeat',
|
|
46
|
+
'script',
|
|
47
|
+
'su',
|
|
48
|
+
'flock',
|
|
49
|
+
'ssh',
|
|
50
|
+
'parallel',
|
|
51
|
+
'ionice',
|
|
52
|
+
'chrt',
|
|
53
|
+
'taskset',
|
|
54
|
+
'strace',
|
|
55
|
+
'unbuffer',
|
|
56
|
+
'chroot',
|
|
57
|
+
'runuser',
|
|
58
|
+
'systemd-run',
|
|
59
|
+
'nsenter',
|
|
60
|
+
'unshare',
|
|
61
|
+
'firejail',
|
|
62
|
+
'pkexec',
|
|
63
|
+
'entr',
|
|
64
|
+
'watchexec',
|
|
65
|
+
'busybox',
|
|
66
|
+
])
|
|
67
|
+
|
|
68
|
+
const SOURCING = new Set(['source', '.'])
|
|
69
|
+
|
|
70
|
+
/** Interpreters that run program text given on the command line, and the options that give it. */
|
|
71
|
+
const INTERPRETERS = new Set(['python', 'python3', 'node', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
|
|
72
|
+
const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '-r'])
|
|
73
|
+
const INPUT_PATH = /^(\/dev\/stdin|\/dev\/fd\/|\/proc\/self\/fd\/)/
|
|
74
|
+
|
|
75
|
+
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The words after the program that are not options, nor the value of one. A word right after an option this guard
|
|
79
|
+
* does not know could be its value (`kubectl -n prod delete`), so a second list leaves out every such word.
|
|
80
|
+
*/
|
|
81
|
+
const operandLists = (words: SimpleCommand): Word[][] => {
|
|
82
|
+
const all: Word[] = []
|
|
83
|
+
const withoutPossibleValues: Word[] = []
|
|
84
|
+
for (let index = 0; index < words.length; index++) {
|
|
85
|
+
if (isOption(words[index])) {
|
|
86
|
+
if (OPTIONS_WITH_VALUE.has(words[index].value)) index++
|
|
87
|
+
else if (index + 1 < words.length && !isOption(words[index + 1])) {
|
|
88
|
+
all.push(words[index + 1])
|
|
89
|
+
index++
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
all.push(words[index])
|
|
93
|
+
withoutPossibleValues.push(words[index])
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [all, withoutPossibleValues]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A word the shell expands or globs could be any word, or several, or none. */
|
|
100
|
+
const isWildcard = ({ literal, value }: Word): boolean => !literal || GLOB.test(value)
|
|
101
|
+
|
|
102
|
+
/** Whether the operands, from their start, can be the expected words: a wildcard stands for any number of them. */
|
|
103
|
+
const matchesFrom = (operands: Word[], expected: string[]): boolean => {
|
|
104
|
+
if (expected.length === 0) return true
|
|
105
|
+
const [operand, ...rest] = operands
|
|
106
|
+
if (operand === undefined) return false
|
|
107
|
+
if (isWildcard(operand)) return matchesFrom(rest, expected) || matchesFrom(operands, expected.slice(1))
|
|
108
|
+
return operand.value === expected[0] && matchesFrom(rest, expected.slice(1))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The number of `gh pr merge <number>` in its plain form: literal words only, merge-method and delete-branch flags. */
|
|
112
|
+
const plainPullRequest = (words: SimpleCommand, expected: string[]): number | undefined => {
|
|
113
|
+
const after = words.slice(1)
|
|
114
|
+
const [first, second, ...rest] = after
|
|
115
|
+
if (after.some(isWildcard) || first?.value !== expected[0] || second?.value !== expected[1]) return undefined
|
|
116
|
+
const numbers = rest.filter(({ value }) => !APPROVABLE_FLAGS.has(value))
|
|
117
|
+
const only = numbers.length === 1 && PULL_REQUEST_NUMBER.test(numbers[0].value) ? numbers[0].value : undefined
|
|
118
|
+
return only === undefined ? undefined : Number(only)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
|
|
122
|
+
const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
|
|
123
|
+
const expected = command.split(' ').slice(1)
|
|
124
|
+
if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
|
|
125
|
+
const plain = sole && words[index].literal && words[index].value === 'gh'
|
|
126
|
+
return { command, pullRequest: plain ? plainPullRequest(words.slice(index), expected) : undefined }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const callsOfWord = (words: SimpleCommand, index: number, commands: readonly string[], sole: boolean): Finding[] => {
|
|
130
|
+
if (!isProgramPosition(words, index)) return []
|
|
131
|
+
const program = programName(words[index])
|
|
132
|
+
return commands
|
|
133
|
+
.filter((command) => command.split(' ')[0] === program)
|
|
134
|
+
.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
|
|
138
|
+
|
|
139
|
+
const findWord = (script: string, word: string, from: number): number => {
|
|
140
|
+
for (let at = script.indexOf(word, from); at !== -1; at = script.indexOf(word, at + 1)) {
|
|
141
|
+
if (!isWordCharacter(script[at - 1]) && !isWordCharacter(script[at + word.length])) return at
|
|
142
|
+
}
|
|
143
|
+
return -1
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Whether the script names the words as whole words in this order; a linear search, so a long script cannot stall it. */
|
|
147
|
+
const namesInOrder = (script: string, words: string[]): boolean => {
|
|
148
|
+
let from = 0
|
|
149
|
+
for (const word of words) {
|
|
150
|
+
const at = findWord(script, word, from)
|
|
151
|
+
if (at === -1) return false
|
|
152
|
+
from = at + word.length
|
|
153
|
+
}
|
|
154
|
+
return true
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const callsNaming = (script: string, commands: readonly string[], wordsOf: (command: string) => string[]): Finding[] =>
|
|
158
|
+
commands
|
|
159
|
+
.filter((command) => namesInOrder(script, wordsOf(command)))
|
|
160
|
+
.map((command) => ({ command, pullRequest: undefined }))
|
|
161
|
+
|
|
162
|
+
/** Where the script cannot be parsed, a guarded program named as a whole word is enough to ask. */
|
|
163
|
+
const namedPrograms = (script: string, commands: readonly string[]): Finding[] =>
|
|
164
|
+
callsNaming(script, commands, (command) => command.split(' ').slice(0, 1))
|
|
165
|
+
|
|
166
|
+
/** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
|
|
167
|
+
const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
|
|
168
|
+
callsNaming(script, commands, (command) => command.split(' ').slice(1))
|
|
169
|
+
|
|
170
|
+
/** A program expanded from a variable or a substitution could be the guarded one. */
|
|
171
|
+
const runsExpandedProgram = (words: SimpleCommand): boolean =>
|
|
172
|
+
words.some((word, index) => !word.literal && isProgramPosition(words, index))
|
|
173
|
+
|
|
174
|
+
const runsOpaquely = (words: SimpleCommand): boolean =>
|
|
175
|
+
words.some((word, index) => isProgramPosition(words, index) && OPAQUE_RUNNERS.has(programName(word)))
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
|
|
179
|
+
* substitution or from a file descriptor; or `source` of such a thing: the text comes from somewhere else.
|
|
180
|
+
*/
|
|
181
|
+
const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
|
|
182
|
+
words.some((word, index) => {
|
|
183
|
+
if (!isProgramPosition(words, index)) return false
|
|
184
|
+
const shell = isShell(word)
|
|
185
|
+
if (!shell && !SOURCING.has(programName(word))) return false
|
|
186
|
+
const rest = words.slice(index + 1)
|
|
187
|
+
const operands = rest.filter((each) => !isOption(each))
|
|
188
|
+
const fromInput = shell && (operands.length === 0 || rest.some(({ value }) => value === '-s'))
|
|
189
|
+
return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
/** An interpreter handed program text (`python3 -c`, `node -e`) or git handed an alias runs text the parse cannot see. */
|
|
193
|
+
const runsProgramText = (words: SimpleCommand): boolean =>
|
|
194
|
+
words.some((word, index) => {
|
|
195
|
+
if (!isProgramPosition(words, index)) return false
|
|
196
|
+
const rest = words.slice(index + 1)
|
|
197
|
+
if (programName(word) === 'git') return rest.some(({ value }) => value.startsWith('alias.'))
|
|
198
|
+
return INTERPRETERS.has(programName(word)) && rest.some(({ value }) => INLINE_PROGRAM_OPTIONS.has(value))
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
/** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
|
|
202
|
+
const runsInlineExpansion = (words: SimpleCommand): boolean =>
|
|
203
|
+
words.some((_word, index) => inlineScript(words, index)?.includes('$') === true)
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether the command's words contain the guarded command's words in this order, anything between them: the
|
|
207
|
+
* fallback for a wrapper nobody listed (`mise exec -- gh pr merge 5`), which hides the program from the position test.
|
|
208
|
+
*/
|
|
209
|
+
const containsInOrder = (words: SimpleCommand, command: string): boolean => {
|
|
210
|
+
const [program, ...rest] = command.split(' ')
|
|
211
|
+
const at = words.findIndex((word) => programName(word) === program)
|
|
212
|
+
let from = at + 1
|
|
213
|
+
if (at === -1) return false
|
|
214
|
+
for (const expected of rest) {
|
|
215
|
+
const next = words.findIndex((word, index) => index >= from && word.literal && word.value === expected)
|
|
216
|
+
if (next === -1) return false
|
|
217
|
+
from = next + 1
|
|
218
|
+
}
|
|
219
|
+
return true
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const callsInCommand = (words: SimpleCommand, commands: readonly string[], sole: boolean, whole: string): Finding[] => {
|
|
223
|
+
const direct = words.flatMap((_word, index) => {
|
|
224
|
+
const script = inlineScript(words, index)
|
|
225
|
+
return script === undefined ? callsOfWord(words, index, commands, sole) : callsIn(script, commands, false, whole)
|
|
226
|
+
})
|
|
227
|
+
const found = new Set(direct.map(({ command }) => command))
|
|
228
|
+
const byWords = commands
|
|
229
|
+
.filter((command) => !found.has(command) && containsInOrder(words, command))
|
|
230
|
+
.map((command) => ({ command, pullRequest: undefined }))
|
|
231
|
+
return [...direct, ...byWords]
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Each check reads the whole script: a shell that reads stdin, an expanded program or an opaque runner hides what runs. */
|
|
235
|
+
const hidesWhatRuns = (words: SimpleCommand): boolean =>
|
|
236
|
+
runsExpandedProgram(words) ||
|
|
237
|
+
runsOpaquely(words) ||
|
|
238
|
+
readsScriptFromElsewhere(words) ||
|
|
239
|
+
runsInlineExpansion(words) ||
|
|
240
|
+
runsProgramText(words)
|
|
241
|
+
|
|
242
|
+
const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
|
|
243
|
+
parsed.some(hidesWhatRuns) ? mentionedCommandWords(whole, commands) : []
|
|
244
|
+
|
|
245
|
+
/** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
|
|
246
|
+
const callsIn = (script: string, commands: readonly string[], top: boolean, whole: string): Finding[] => {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = parseShell(script)
|
|
249
|
+
const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
|
|
250
|
+
return [
|
|
251
|
+
...parsed.flatMap((words) => callsInCommand(words, commands, sole, whole)),
|
|
252
|
+
...callsInScriptWide(whole, parsed, commands),
|
|
253
|
+
]
|
|
254
|
+
} catch (error) {
|
|
255
|
+
if (error instanceof ShellParseError) return namedPrograms(whole, commands)
|
|
256
|
+
throw error
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const isApproved = ({ command, pullRequest }: Finding, policy: GuardPolicy): boolean => {
|
|
261
|
+
if (command !== MERGE_COMMAND || pullRequest === undefined) return false
|
|
262
|
+
try {
|
|
263
|
+
return policy.isMergeApproved(pullRequest)
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const askBecause = (commands: string[]): MergeGuardDecision => ({
|
|
270
|
+
kind: 'ask',
|
|
271
|
+
reason: `${commands.map((command) => `\`${command}\``).join(' and ')} is on the project's guarded list (guard.commands in .jackherd/config.json), so the user confirms it.`,
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Decides on a Bash command before it runs: `ask` when it runs, or could run, a guarded command (`gh pr merge`,
|
|
276
|
+
* `gh workflow run` by default), also behind options before the verb, a chain, a wrapper, `sh -c`, `eval`, a
|
|
277
|
+
* script piped into a shell or a program held in a variable. A script that is exactly
|
|
278
|
+
* `gh pr merge <number>` with merge-method and delete-branch flags passes when the user approved it
|
|
279
|
+
* (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely names the words (an argument, a commit message) is none.
|
|
280
|
+
*/
|
|
281
|
+
export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
|
|
282
|
+
const findings = callsIn(command, policy.commands, true, command)
|
|
283
|
+
if (findings.length === 0) return PASS
|
|
284
|
+
const unapproved = findings.filter((finding) => !isApproved(finding, policy))
|
|
285
|
+
return unapproved.length === 0 ? PASS : askBecause([...new Set(unapproved.map(({ command: name }) => name))])
|
|
286
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// PreToolUse hook for Bash, shipped by jackherd. Reads the hook input on stdin and answers with an ask
|
|
2
|
+
// decision on stdout when the command runs a guarded one, or with nothing to leave the call to the
|
|
3
|
+
// permission rules. Any failure asks: a guard that crashes, or takes too long, must not wave the command through.
|
|
4
|
+
// The decision runs in a worker that is ended after DEADLINE_MS, because Claude Code lets a call through when a hook
|
|
5
|
+
// runs into its own timeout. argv[2] is the exact option label that approves a merge, rendered into the hook entry by `sync`.
|
|
6
|
+
import type { MergeGuardDecision } from './decision'
|
|
7
|
+
import { failureDecision } from './run'
|
|
8
|
+
|
|
9
|
+
/** Below the 10 s the hook entry allows, with room for the start of Bun and for printing. */
|
|
10
|
+
const DEADLINE_MS = 7000
|
|
11
|
+
|
|
12
|
+
type Job = { text: string; label: string; projectDirectory: string }
|
|
13
|
+
|
|
14
|
+
const timeoutDecision: MergeGuardDecision = {
|
|
15
|
+
kind: 'ask',
|
|
16
|
+
reason: 'The merge guard hook did not finish in time, so it cannot tell whether this command is guarded. Confirm it only if you know it is harmless.',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const decideWithin = (job: Job, deadline: number): Promise<MergeGuardDecision> =>
|
|
20
|
+
new Promise((resolve) => {
|
|
21
|
+
const worker = new Worker(new URL('./worker.ts', import.meta.url).href)
|
|
22
|
+
const finish = (decision: MergeGuardDecision): void => {
|
|
23
|
+
clearTimeout(timer)
|
|
24
|
+
worker.terminate()
|
|
25
|
+
resolve(decision)
|
|
26
|
+
}
|
|
27
|
+
const timer = setTimeout(() => finish(timeoutDecision), deadline)
|
|
28
|
+
worker.onmessage = ({ data }: MessageEvent<MergeGuardDecision>) => finish(data)
|
|
29
|
+
worker.onerror = (event) => finish(failureDecision(event.error ?? event.message))
|
|
30
|
+
worker.postMessage(job)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const decide = async (): Promise<MergeGuardDecision> => {
|
|
34
|
+
try {
|
|
35
|
+
const text = await Bun.stdin.text()
|
|
36
|
+
const projectDirectory = process.env.CLAUDE_PROJECT_DIR ?? process.cwd()
|
|
37
|
+
return await decideWithin({ text, label: process.argv[2] ?? '', projectDirectory }, DEADLINE_MS)
|
|
38
|
+
} catch (error) {
|
|
39
|
+
return failureDecision(error)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const decision = await decide()
|
|
44
|
+
|
|
45
|
+
if (decision.kind === 'ask') {
|
|
46
|
+
console.log(
|
|
47
|
+
JSON.stringify({
|
|
48
|
+
hookSpecificOutput: {
|
|
49
|
+
hookEventName: 'PreToolUse',
|
|
50
|
+
permissionDecision: 'ask',
|
|
51
|
+
permissionDecisionReason: decision.reason,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
process.exit(0)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The branch a pull request comes from, asked of GitHub. The approval of a merge hangs on the branch the
|
|
3
|
+
* user saw in the question; the pull request does not exist yet at that point, so GitHub, not the agent,
|
|
4
|
+
* says which branch pull request <number> carries.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const LOOKUP_TIMEOUT_MS = 4000
|
|
8
|
+
|
|
9
|
+
type PullRequestView = { headRefName?: unknown; isCrossRepository?: unknown; state?: unknown }
|
|
10
|
+
|
|
11
|
+
/** The head branch of an open pull request from this repository; a fork's branch name proves nothing. */
|
|
12
|
+
export const parseHeadBranch = (json: string): string | undefined => {
|
|
13
|
+
try {
|
|
14
|
+
const view: unknown = JSON.parse(json)
|
|
15
|
+
if (typeof view !== 'object' || view === null) return undefined
|
|
16
|
+
const { headRefName, isCrossRepository, state } = view as PullRequestView
|
|
17
|
+
const ours = state === 'OPEN' && isCrossRepository === false
|
|
18
|
+
return ours && typeof headRefName === 'string' ? headRefName : undefined
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Runs `gh pr view <number>` in `directory`, the directory the command itself runs in, without a shell; any failure answers nothing. */
|
|
25
|
+
export const headBranchOf = (pullRequest: number, directory: string): string | undefined => {
|
|
26
|
+
const result = Bun.spawnSync(['gh', 'pr', 'view', String(pullRequest), '--json', 'headRefName,isCrossRepository,state'], {
|
|
27
|
+
cwd: directory,
|
|
28
|
+
timeout: LOOKUP_TIMEOUT_MS,
|
|
29
|
+
stdout: 'pipe',
|
|
30
|
+
stderr: 'ignore',
|
|
31
|
+
})
|
|
32
|
+
return result.exitCode === 0 ? parseHeadBranch(result.stdout.toString()) : undefined
|
|
33
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { isAbsolute, relative } from 'node:path'
|
|
3
|
+
import { approvedBranches, parseTranscript } from './approval'
|
|
4
|
+
import { decideBashCommand, type GuardPolicy, type MergeGuardDecision } from './decision'
|
|
5
|
+
import { headBranchOf } from './pull-request'
|
|
6
|
+
import { type GuardSettings, readGuardSettings } from './settings'
|
|
7
|
+
|
|
8
|
+
type HookInput = { tool_input?: { command?: unknown }; transcript_path?: unknown; cwd?: unknown }
|
|
9
|
+
|
|
10
|
+
const PASS: MergeGuardDecision = { kind: 'pass' }
|
|
11
|
+
|
|
12
|
+
const approvedBranchesFrom = (transcriptPath: unknown, settings: GuardSettings, label: string): Set<string> => {
|
|
13
|
+
if (!settings.selfMerge.offer || typeof transcriptPath !== 'string') return new Set()
|
|
14
|
+
return approvedBranches({
|
|
15
|
+
entries: parseTranscript(readFileSync(transcriptPath, 'utf8')),
|
|
16
|
+
label,
|
|
17
|
+
now: Date.now(),
|
|
18
|
+
windowMinutes: settings.selfMerge.approvalMinutes,
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Where the command runs must lie in the project: the same branch name in another checkout is another pull request. */
|
|
23
|
+
const isInside = (directory: string, project: string): boolean => {
|
|
24
|
+
const path = relative(project, directory)
|
|
25
|
+
return path === '' || (!path.startsWith('..') && !isAbsolute(path))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const policyFor = (input: HookInput, label: string, projectDirectory: string): GuardPolicy => {
|
|
29
|
+
const settings = readGuardSettings(projectDirectory)
|
|
30
|
+
const directory = typeof input.cwd === 'string' && input.cwd !== '' ? input.cwd : projectDirectory
|
|
31
|
+
const headBranches = new Map<number, string | undefined>()
|
|
32
|
+
const headBranch = (pullRequest: number): string | undefined => {
|
|
33
|
+
if (!headBranches.has(pullRequest)) headBranches.set(pullRequest, headBranchOf(pullRequest, directory))
|
|
34
|
+
return headBranches.get(pullRequest)
|
|
35
|
+
}
|
|
36
|
+
let approved: Set<string> | undefined
|
|
37
|
+
return {
|
|
38
|
+
commands: settings.commands,
|
|
39
|
+
isMergeApproved: (pullRequest) => {
|
|
40
|
+
if (!isInside(directory, projectDirectory)) return false
|
|
41
|
+
const branches = (approved ??= approvedBranchesFrom(input.transcript_path, settings, label))
|
|
42
|
+
if (branches.size === 0) return false
|
|
43
|
+
const branch = headBranch(pullRequest)
|
|
44
|
+
return branch !== undefined && branches.has(branch)
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Reads the hook input and decides; throws on input it cannot read. */
|
|
50
|
+
export const decideInput = (text: string, label: string, projectDirectory: string): MergeGuardDecision => {
|
|
51
|
+
const input = JSON.parse(text) as HookInput
|
|
52
|
+
const command = input.tool_input?.command
|
|
53
|
+
if (typeof command !== 'string') return PASS
|
|
54
|
+
return decideBashCommand(command, policyFor(input, label, projectDirectory))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const failureDecision = (error: unknown): MergeGuardDecision => ({
|
|
58
|
+
kind: 'ask',
|
|
59
|
+
reason: `The merge guard hook failed (${error instanceof Error ? error.message : String(error)}), so it cannot tell whether this command is guarded. Confirm it only if you know it is harmless.`,
|
|
60
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export type GuardSettings = {
|
|
6
|
+
commands: string[]
|
|
7
|
+
selfMerge: { offer: boolean; approvalMinutes: number }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SETTINGS: GuardSettings = {
|
|
11
|
+
commands: ['gh pr merge', 'gh workflow run'],
|
|
12
|
+
selfMerge: { offer: true, approvalMinutes: 180 },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const CONFIG_PATH = join('.jackherd', 'config.json')
|
|
16
|
+
export const GUARDED_COMMAND = /^[a-z][a-z0-9-]*( [a-z][a-z0-9-]*){1,3}$/
|
|
17
|
+
export const MAX_MINUTES = 1440
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
20
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
21
|
+
|
|
22
|
+
const isCommandList = (value: unknown): value is string[] =>
|
|
23
|
+
Array.isArray(value) && value.every((entry) => typeof entry === 'string' && GUARDED_COMMAND.test(entry))
|
|
24
|
+
|
|
25
|
+
const isMinutes = (value: unknown): value is number =>
|
|
26
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= MAX_MINUTES
|
|
27
|
+
|
|
28
|
+
/** The `guard` section of the first config that exists; the hook has no schema library, `jh` validates the file. */
|
|
29
|
+
const readGuardSection = (directories: string[]): Record<string, unknown> => {
|
|
30
|
+
const path = directories.map((directory) => join(directory, CONFIG_PATH)).find((candidate) => existsSync(candidate))
|
|
31
|
+
if (!path) return {}
|
|
32
|
+
try {
|
|
33
|
+
const config: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
34
|
+
return isRecord(config) && isRecord(config.guard) ? config.guard : {}
|
|
35
|
+
} catch {
|
|
36
|
+
return {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The guard settings of the project's `.jackherd/config.json`, else of the home one. Whatever is missing or
|
|
42
|
+
* malformed keeps its default, so a broken config guards more, never less; only an empty list switches the guard off.
|
|
43
|
+
*/
|
|
44
|
+
export const readGuardSettings = (projectDirectory: string, home: string = homedir()): GuardSettings => {
|
|
45
|
+
const section = readGuardSection([projectDirectory, home])
|
|
46
|
+
const selfMerge = isRecord(section.selfMerge) ? section.selfMerge : {}
|
|
47
|
+
return {
|
|
48
|
+
commands: isCommandList(section.commands) ? section.commands : DEFAULT_SETTINGS.commands,
|
|
49
|
+
selfMerge: {
|
|
50
|
+
offer: typeof selfMerge.offer === 'boolean' ? selfMerge.offer : DEFAULT_SETTINGS.selfMerge.offer,
|
|
51
|
+
approvalMinutes: isMinutes(selfMerge.approvalMinutes)
|
|
52
|
+
? selfMerge.approvalMinutes
|
|
53
|
+
: DEFAULT_SETTINGS.selfMerge.approvalMinutes,
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Runs the decision off the main thread, so index.ts can end it when it takes too long.
|
|
2
|
+
import { decideInput, failureDecision } from './run'
|
|
3
|
+
|
|
4
|
+
type Job = { text: string; label: string; projectDirectory: string }
|
|
5
|
+
|
|
6
|
+
declare const self: Worker
|
|
7
|
+
|
|
8
|
+
self.onmessage = ({ data }: MessageEvent<Job>) => {
|
|
9
|
+
try {
|
|
10
|
+
self.postMessage(decideInput(data.text, data.label, data.projectDirectory))
|
|
11
|
+
} catch (error) {
|
|
12
|
+
self.postMessage(failureDecision(error))
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { posix } from 'node:path'
|
|
2
|
+
import type { SimpleCommand, Word } from './shell'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Words Claude Code strips before it matches a Bash rule, so they do not hide the command. Its
|
|
6
|
+
* permission matching also reaches into control-flow bodies, which the reserved words open.
|
|
7
|
+
*/
|
|
8
|
+
const RULE_TRANSPARENT_WRAPPERS = new Set(['time', 'nohup', 'nice', 'command', 'builtin', 'noglob'])
|
|
9
|
+
const RESERVED_WORDS = new Set(['!', '{', '}', 'if', 'then', 'elif', 'else', 'fi', 'do', 'done', 'while', 'until'])
|
|
10
|
+
export const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
|
|
11
|
+
const DURATION = /^\d+(\.\d+)?[smhd]?$/
|
|
12
|
+
const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish'])
|
|
13
|
+
const SHELL_COMMAND_FLAG = /^-[a-z]*c[a-z]*$/
|
|
14
|
+
|
|
15
|
+
/** Programs that run their arguments as a command, so a program right after them is the one that runs. */
|
|
16
|
+
const COMMAND_RUNNERS = new Set([
|
|
17
|
+
...RULE_TRANSPARENT_WRAPPERS,
|
|
18
|
+
'env',
|
|
19
|
+
'sudo',
|
|
20
|
+
'doas',
|
|
21
|
+
'exec',
|
|
22
|
+
'xargs',
|
|
23
|
+
'timeout',
|
|
24
|
+
'stdbuf',
|
|
25
|
+
'setsid',
|
|
26
|
+
'watch',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
const transparentWidth = (words: SimpleCommand, index: number): number => {
|
|
30
|
+
const { value, literal } = words[index]
|
|
31
|
+
if (ASSIGNMENT.test(value)) return 1
|
|
32
|
+
if (!literal) return 0
|
|
33
|
+
if (RESERVED_WORDS.has(value) || RULE_TRANSPARENT_WRAPPERS.has(value)) return 1
|
|
34
|
+
return value === 'timeout' && DURATION.test(words[index + 1]?.value ?? '') ? 2 : 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The index of the word a permission rule compares against: past assignments and stripped wrappers. */
|
|
38
|
+
export const ruleStart = (words: SimpleCommand): number => {
|
|
39
|
+
let index = 0
|
|
40
|
+
while (index < words.length) {
|
|
41
|
+
const width = transparentWidth(words, index)
|
|
42
|
+
if (width === 0) break
|
|
43
|
+
index += width
|
|
44
|
+
}
|
|
45
|
+
return index
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The program a word names, as a case-insensitive file system would find it. */
|
|
49
|
+
export const programName = (word: Word): string =>
|
|
50
|
+
posix
|
|
51
|
+
.basename(word.value.replace(/^=/, ''))
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/\.exe$/, '')
|
|
54
|
+
|
|
55
|
+
/** At the rule start, or anywhere after a runner such as `sudo -u me` or `env FOO=1`: the program runs there. */
|
|
56
|
+
export const isProgramPosition = (words: SimpleCommand, index: number): boolean =>
|
|
57
|
+
index === ruleStart(words) || words.slice(0, index).some((word) => COMMAND_RUNNERS.has(programName(word)))
|
|
58
|
+
|
|
59
|
+
export const isOption = (word: Word | undefined): boolean => word?.value.startsWith('-') ?? false
|
|
60
|
+
|
|
61
|
+
export const isShell = (word: Word): boolean => SHELLS.has(programName(word))
|
|
62
|
+
|
|
63
|
+
/** The script word after `-c`: the first one that is neither an option nor `--`. */
|
|
64
|
+
const shellCommandScript = (words: SimpleCommand, shellIndex: number): string | undefined => {
|
|
65
|
+
const flag = words.findIndex((word, position) => position > shellIndex && SHELL_COMMAND_FLAG.test(word.value))
|
|
66
|
+
if (flag === -1) return undefined
|
|
67
|
+
return words.slice(flag + 1).find((word) => !isOption(word))?.value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const inlineScript = (words: SimpleCommand, index: number): string | undefined => {
|
|
71
|
+
const word = words[index]
|
|
72
|
+
if (word.literal && word.value === 'eval') return words.slice(index + 1).map(({ value }) => value).join(' ')
|
|
73
|
+
return isShell(word) ? shellCommandScript(words, index) : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A shell at the rule start with no script argument reads its script from stdin: a pipe, a here-document. */
|
|
77
|
+
export const readsScriptFromInput = (words: SimpleCommand): boolean => {
|
|
78
|
+
const start = ruleStart(words)
|
|
79
|
+
const shell = words[start]
|
|
80
|
+
return shell !== undefined && isShell(shell) && words.slice(start + 1).every(isOption)
|
|
81
|
+
}
|
|
82
|
+
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parseShell } from '../
|
|
1
|
+
import { parseShell } from '../shared/shell'
|
|
2
2
|
|
|
3
3
|
/** Programs that run their `-c` argument as a command line; the names of shells among them also read stdin as a script. */
|
|
4
4
|
export const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
-
import { parseShell } from '../
|
|
2
|
+
import { parseShell } from '../shared/shell'
|
|
3
3
|
import { hasBackgroundJob } from './background'
|
|
4
4
|
import { SHELLS, shellHeredocBodies, shellHereStrings, withoutHeredocBodies } from './shell-input'
|
|
5
5
|
|
package/kit/hooks/hooks.json
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
+
"ask-hint": {
|
|
3
|
+
"event": "Stop",
|
|
4
|
+
"handler": {
|
|
5
|
+
"type": "command",
|
|
6
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/ask-hint/index.ts",
|
|
7
|
+
"timeout": 10
|
|
8
|
+
}
|
|
9
|
+
},
|
|
2
10
|
"format-on-edit": {
|
|
3
11
|
"event": "PostToolUse",
|
|
4
12
|
"matcher": "Edit|Write",
|
|
@@ -18,6 +26,16 @@
|
|
|
18
26
|
"onFailure": "block"
|
|
19
27
|
}
|
|
20
28
|
},
|
|
29
|
+
"merge-guard": {
|
|
30
|
+
"event": "PreToolUse",
|
|
31
|
+
"matcher": "Bash",
|
|
32
|
+
"handler": {
|
|
33
|
+
"type": "command",
|
|
34
|
+
"command": "cd \"$CLAUDE_PROJECT_DIR\" && bun .claude/hooks/merge-guard/index.ts '{{kit.guard.selfMergeLabel}}' || exit 2",
|
|
35
|
+
"timeout": 10,
|
|
36
|
+
"onFailure": "block"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
21
39
|
"watch-guard": {
|
|
22
40
|
"event": "PreToolUse",
|
|
23
41
|
"matcher": "Bash",
|