Skip to content

Commit 8630e92

Browse files
committed
chore(wheelhouse): cascade template@f34d0e85
Auto-applied by socket-wheelhouse sync-scaffolding into socket-cli. 11 file(s) touched: - .claude/hooks/fleet/_shared/payload.mts - .claude/hooks/fleet/no-premature-commit-kill-guard/README.md - .claude/hooks/fleet/no-premature-commit-kill-guard/index.mts - .claude/hooks/fleet/no-premature-commit-kill-guard/package.json - .claude/hooks/fleet/no-premature-commit-kill-guard/test/index.test.mts - .claude/hooks/fleet/no-premature-commit-kill-guard/tsconfig.json - .claude/settings.json - CLAUDE.md - docs/agents.md/fleet/hook-registry.md - pnpm-workspace.yaml - scripts/fleet/check/soak-excludes-have-dates.mts
1 parent 18731d6 commit 8630e92

11 files changed

Lines changed: 427 additions & 6 deletions

File tree

.claude/hooks/fleet/_shared/payload.mts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export interface ToolInput {
6161
readonly old_string?: unknown | undefined
6262
// Bash
6363
readonly command?: unknown | undefined
64+
// Bash: true when the call requested `run_in_background`. Hooks that gate
65+
// backgrounding (a backgrounded git commit hides its bounded pre-commit's
66+
// completion) read it. Optional + unknown so a shape surprise can't crash.
67+
readonly run_in_background?: unknown | undefined
6468
}
6569

6670
/**
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# no-premature-commit-kill-guard
2+
3+
PreToolUse Bash hook. Blocks two anti-patterns that share one root cause:
4+
5+
1. **Backgrounding a `git commit`** (or `rebase` / `merge` / `cherry-pick`) via `run_in_background: true`.
6+
2. **`pkill` / `kill` / `killall` of a `git commit` or `vitest`** process.
7+
8+
## Why
9+
10+
A `git commit` (and the other three, which also fire the pre-commit chain) runs the staged-test reminder. That reminder is **bounded to ~60s** (`STAGED_TEST_TIMEOUT_MS` in `.git-hooks/_shared/helpers.mts`) but still takes real time on a non-trivial staged set. A commit that is "still running" before that bound elapses is **not a hang**.
11+
12+
The failure loop this guard breaks:
13+
14+
- Backgrounding the commit hides the bounded run's completion. The operator checks too early, sees it "still going", and concludes it hung.
15+
- Then a `pkill` / `kill` of the git-commit (or the vitest it spawned) tears down a mid-pre-commit run. That leaves a stale `.git/index.lock` (index corruption — the next git op fails with "Another git process seems to be running") and leaks vitest worker processes that pile up across attempts.
16+
17+
Running the commit in the **foreground** and waiting for the bounded pre-commit avoids the whole loop. CI / the merge gate run the full suite regardless, so nothing is lost by letting the local bounded reminder finish.
18+
19+
## Detection
20+
21+
AST-parsed via `_shared/shell-command.mts` (`findInvocation` / `commandsFor`), never a raw regex on the line:
22+
23+
- `run_in_background === true` **and** the command invokes `git commit` / `git rebase` / `git merge` / `git cherry-pick`.
24+
- a `pkill` / `kill` / `killall` whose args reference a `git commit` or `vitest` target. A `kill <pid>` of an unrelated process is not matched (no git/vitest token).
25+
26+
## Bypass
27+
28+
`Allow background-git bypass` typed verbatim in a recent user turn — for the rare genuinely-long migration commit you will babysit out of band, or to reap a confirmed-dead leaked vitest after the commit has already exited.
29+
30+
## Failing open
31+
32+
Parse / payload errors exit 0. A guard bug must not block unrelated Bash.
33+
34+
## Related
35+
36+
- `.git-hooks/_shared/helpers.mts``runStagedTestsReminder` + the `STAGED_TEST_TIMEOUT_MS` bound this guard relies on.
37+
- `stale-process-sweeper/` — reaps genuine orphan workers at turn end.
38+
- CLAUDE.md → "Background Bash".
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse hook — no-premature-commit-kill-guard.
3+
//
4+
// Two Bash anti-patterns, one root cause: a `git commit` (and rebase/merge/
5+
// cherry-pick, which also fire the pre-commit chain) runs the staged-test
6+
// reminder, which is BOUNDED to ~60s (STAGED_TEST_TIMEOUT_MS) but still takes
7+
// real time. A commit that is "still running" before that elapses is NOT a
8+
// hang.
9+
//
10+
// 1. Backgrounding it (`run_in_background: true`) hides the bounded run's
11+
// completion, so the operator checks too early, sees it "still going",
12+
// and concludes it hung.
13+
// 2. Then `pkill`/`kill` of the git-commit (or the vitest it spawned) tears
14+
// down a mid-pre-commit run — which corrupts the index (a half-written
15+
// `.git/index.lock`) and leaks vitest worker processes.
16+
//
17+
// Both are blocked here so the loop can't start: run commits in the FOREGROUND
18+
// and WAIT for the bounded pre-commit; never kill one mid-flight.
19+
//
20+
// Detection (AST-parsed via _shared/shell-command.mts, never raw regex on the
21+
// line):
22+
// - run_in_background === true AND the command invokes
23+
// `git <commit|rebase|merge|cherry-pick>`.
24+
// - a `pkill`/`kill`/`killall` whose args reference a `git commit` or
25+
// `vitest` target.
26+
//
27+
// Bypass: `Allow background-git bypass` typed verbatim in a recent user turn
28+
// (e.g. a genuinely long migration commit you'll babysit out-of-band, or
29+
// reaping a confirmed-dead leaked vitest).
30+
//
31+
// Fails open on parse / payload errors.
32+
33+
import process from 'node:process'
34+
35+
import { commandsFor, findInvocation } from '../_shared/shell-command.mts'
36+
import { bypassPhrasePresent, readStdin } from '../_shared/transcript.mts'
37+
38+
const BYPASS_PHRASE = 'Allow background-git bypass'
39+
40+
const GIT_PRE_COMMIT_SUBCOMMANDS = [
41+
'commit',
42+
'rebase',
43+
'merge',
44+
'cherry-pick',
45+
]
46+
47+
interface Payload {
48+
tool_name?: unknown | undefined
49+
tool_input?:
50+
| { command?: unknown | undefined; run_in_background?: unknown | undefined }
51+
| undefined
52+
transcript_path?: unknown | undefined
53+
}
54+
55+
// True when the command invokes a git subcommand that triggers the pre-commit
56+
// chain (and thus the bounded staged-test reminder).
57+
export function invokesPreCommitGit(command: string): string | undefined {
58+
for (let i = 0, { length } = GIT_PRE_COMMIT_SUBCOMMANDS; i < length; i += 1) {
59+
const sub = GIT_PRE_COMMIT_SUBCOMMANDS[i]!
60+
if (findInvocation(command, { binary: 'git', subcommand: sub })) {
61+
return `git ${sub}`
62+
}
63+
}
64+
return undefined
65+
}
66+
67+
// True when the command is a process-kill (`pkill`/`kill`/`killall`) whose
68+
// args target a `git commit` or a `vitest` run — the premature-teardown shape.
69+
// `kill <pid>` of an unrelated process is NOT matched (no git/vitest token).
70+
export function killsCommitOrVitest(command: string): string | undefined {
71+
for (const bin of ['pkill', 'killall', 'kill']) {
72+
const cmds = commandsFor(command, bin)
73+
for (let i = 0, { length } = cmds; i < length; i += 1) {
74+
const joined = cmds[i]!.args.join(' ')
75+
if (/\bvitest\b/.test(joined)) {
76+
return `${bin} … vitest`
77+
}
78+
if (/git\s+commit\b/.test(joined)) {
79+
return `${bin} … git commit`
80+
}
81+
}
82+
}
83+
return undefined
84+
}
85+
86+
function emitBackgroundBlock(label: string): void {
87+
process.stderr.write(
88+
[
89+
`[no-premature-commit-kill-guard] Blocked: backgrounding \`${label}\`.`,
90+
'',
91+
` A ${label} fires the pre-commit chain, whose staged-test reminder is`,
92+
' BOUNDED to ~60s (STAGED_TEST_TIMEOUT_MS) but still takes real time. Run',
93+
' in the FOREGROUND and wait — a still-running commit is not a hang.',
94+
' Backgrounding hides its completion and invites a premature kill that',
95+
' corrupts the index + leaks vitest workers.',
96+
'',
97+
` Bypass (rare; you'll babysit it): type "${BYPASS_PHRASE}".`,
98+
].join('\n') + '\n',
99+
)
100+
}
101+
102+
function emitKillBlock(label: string): void {
103+
process.stderr.write(
104+
[
105+
`[no-premature-commit-kill-guard] Blocked: \`${label}\`.`,
106+
'',
107+
' Killing a git-commit or its vitest mid-pre-commit corrupts the index',
108+
' (stale .git/index.lock) and leaks vitest worker processes. The',
109+
' pre-commit staged-test reminder is bounded to ~60s — WAIT for it.',
110+
'',
111+
' If a run is genuinely dead (confirmed, not just slow), reap the orphan',
112+
' with `pkill -f "vitest/dist/workers"` after the commit has exited, or',
113+
` type "${BYPASS_PHRASE}" to allow this kill.`,
114+
].join('\n') + '\n',
115+
)
116+
}
117+
118+
async function main(): Promise<void> {
119+
const raw = await readStdin()
120+
let payload: Payload
121+
try {
122+
payload = JSON.parse(raw) as Payload
123+
} catch {
124+
process.exit(0)
125+
}
126+
127+
if (payload.tool_name !== 'Bash') {
128+
process.exit(0)
129+
}
130+
131+
const command =
132+
typeof payload.tool_input?.command === 'string'
133+
? payload.tool_input.command
134+
: ''
135+
if (!command.trim()) {
136+
process.exit(0)
137+
}
138+
139+
const backgrounded = payload.tool_input?.run_in_background === true
140+
const bgGit = backgrounded ? invokesPreCommitGit(command) : undefined
141+
const killTarget = killsCommitOrVitest(command)
142+
143+
if (!bgGit && !killTarget) {
144+
process.exit(0)
145+
}
146+
147+
const transcriptPath =
148+
typeof payload.transcript_path === 'string'
149+
? payload.transcript_path
150+
: undefined
151+
if (transcriptPath && bypassPhrasePresent(transcriptPath, [BYPASS_PHRASE], 3)) {
152+
process.exit(0)
153+
}
154+
155+
if (bgGit) {
156+
emitBackgroundBlock(bgGit)
157+
} else if (killTarget) {
158+
emitKillBlock(killTarget)
159+
}
160+
process.exit(2)
161+
}
162+
163+
void main()
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "hook-no-premature-commit-kill-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"devDependencies": {
13+
"@socketsecurity/lib-stable": "catalog:",
14+
"@types/node": "catalog:"
15+
}
16+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// prefer-async-spawn: sync-semantics-required — a node:test spec drives the
2+
// hook subprocess and asserts on its exit + stderr inline; spawnSync (from the
3+
// lib, not node:child_process) is the right fit. encoding is set at runtime so
4+
// stdout/stderr come back as strings.
5+
import { spawnSync } from '@socketsecurity/lib-stable/process/spawn/child'
6+
import { mkdtempSync, writeFileSync } from 'node:fs'
7+
import os from 'node:os'
8+
import path from 'node:path'
9+
import { fileURLToPath } from 'node:url'
10+
import { test } from 'node:test'
11+
import assert from 'node:assert/strict'
12+
13+
import { invokesPreCommitGit, killsCommitOrVitest } from '../index.mts'
14+
15+
const HOOK = path.join(
16+
path.dirname(fileURLToPath(import.meta.url)),
17+
'..',
18+
'index.mts',
19+
)
20+
21+
function writeTranscript(userText: string): string {
22+
const dir = mkdtempSync(path.join(os.tmpdir(), 'no-premature-kill-tx-'))
23+
const file = path.join(dir, 'transcript.jsonl')
24+
writeFileSync(
25+
file,
26+
JSON.stringify({
27+
type: 'user',
28+
message: { role: 'user', content: userText },
29+
}) + '\n',
30+
)
31+
return file
32+
}
33+
34+
function run(
35+
command: string,
36+
opts?: { background?: boolean; transcriptPath?: string },
37+
): { code: number; stderr: string } {
38+
const r = spawnSync('node', [HOOK], {
39+
encoding: 'utf8',
40+
input: JSON.stringify({
41+
tool_name: 'Bash',
42+
tool_input: { command, run_in_background: opts?.background ?? false },
43+
transcript_path: opts?.transcriptPath,
44+
}),
45+
})
46+
return { code: typeof r.status === 'number' ? r.status : -1, stderr: String(r.stderr ?? '') }
47+
}
48+
49+
// --- pure helpers ---
50+
51+
test('invokesPreCommitGit: git commit / rebase / merge / cherry-pick', () => {
52+
assert.equal(invokesPreCommitGit('git commit -m "x"'), 'git commit')
53+
assert.equal(invokesPreCommitGit('git rebase origin/main'), 'git rebase')
54+
assert.equal(invokesPreCommitGit('git merge feat'), 'git merge')
55+
assert.equal(invokesPreCommitGit('git cherry-pick abc123'), 'git cherry-pick')
56+
})
57+
58+
test('invokesPreCommitGit: non-pre-commit git is undefined', () => {
59+
assert.equal(invokesPreCommitGit('git status'), undefined)
60+
assert.equal(invokesPreCommitGit('git push origin main'), undefined)
61+
assert.equal(invokesPreCommitGit('node build.mts'), undefined)
62+
})
63+
64+
test('killsCommitOrVitest: pkill/kill of vitest or git commit', () => {
65+
assert.ok(killsCommitOrVitest('pkill -f vitest'))
66+
assert.ok(killsCommitOrVitest('pkill -9 -f "vitest/dist/workers"'))
67+
assert.ok(killsCommitOrVitest("pkill -f 'git commit'"))
68+
assert.ok(killsCommitOrVitest('killall vitest'))
69+
})
70+
71+
test('killsCommitOrVitest: unrelated kill is undefined', () => {
72+
assert.equal(killsCommitOrVitest('kill 12345'), undefined)
73+
assert.equal(killsCommitOrVitest('pkill -f my-dev-server'), undefined)
74+
assert.equal(killsCommitOrVitest('git status'), undefined)
75+
})
76+
77+
// --- end-to-end (spawned hook) ---
78+
79+
test('blocks backgrounding a git commit', () => {
80+
const { code, stderr } = run('git commit -m "wip"', { background: true })
81+
assert.equal(code, 2)
82+
assert.match(stderr, /no-premature-commit-kill-guard/)
83+
assert.match(stderr, /FOREGROUND/)
84+
})
85+
86+
test('blocks backgrounding a git rebase', () => {
87+
const { code } = run('git rebase origin/main', { background: true })
88+
assert.equal(code, 2)
89+
})
90+
91+
test('allows a FOREGROUND git commit', () => {
92+
const { code } = run('git commit -m "wip"', { background: false })
93+
assert.equal(code, 0)
94+
})
95+
96+
test('allows backgrounding a non-git command (dev server)', () => {
97+
const { code } = run('node dev-server.mts', { background: true })
98+
assert.equal(code, 0)
99+
})
100+
101+
test('blocks pkill of vitest', () => {
102+
const { code, stderr } = run('pkill -f vitest')
103+
assert.equal(code, 2)
104+
assert.match(stderr, /corrupts the index/)
105+
})
106+
107+
test('blocks pkill of a git commit', () => {
108+
const { code } = run("pkill -f 'git commit'")
109+
assert.equal(code, 2)
110+
})
111+
112+
test('allows kill of an unrelated pid', () => {
113+
const { code } = run('kill 4242')
114+
assert.equal(code, 0)
115+
})
116+
117+
test('bypass phrase allows backgrounding the git commit', () => {
118+
const { code } = run('git commit -m "long migration"', {
119+
background: true,
120+
transcriptPath: writeTranscript('Allow background-git bypass'),
121+
})
122+
assert.equal(code, 0)
123+
})
124+
125+
test('non-Bash tool passes through', () => {
126+
const r = spawnSync('node', [HOOK], {
127+
encoding: 'utf8',
128+
input: JSON.stringify({
129+
tool_name: 'Read',
130+
tool_input: { file_path: 'foo.ts' },
131+
}),
132+
})
133+
assert.equal(r.status, 0)
134+
})
135+
136+
test('malformed payload fails open', () => {
137+
const r = spawnSync('node', [HOOK], { encoding: 'utf8', input: 'not-json' })
138+
assert.equal(r.status, 0)
139+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"declarationMap": false,
4+
"erasableSyntaxOnly": true,
5+
"module": "nodenext",
6+
"moduleResolution": "nodenext",
7+
"noEmit": true,
8+
"rewriteRelativeImportExtensions": true,
9+
"skipLibCheck": true,
10+
"sourceMap": false,
11+
"strict": true,
12+
"target": "esnext",
13+
"types": ["node"],
14+
"verbatimModuleSyntax": true
15+
}
16+
}

.claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,10 @@
347347
"type": "command",
348348
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-pm-exec-guard/index.mts"
349349
},
350+
{
351+
"type": "command",
352+
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-premature-commit-kill-guard/index.mts"
353+
},
350354
{
351355
"type": "command",
352356
"command": "node \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/fleet/no-tsx-guard/index.mts"

0 commit comments

Comments
 (0)