Skip to content

Commit c7df6ab

Browse files
alicodingclaude
andauthored
chore: session-infra hardening — command-guard hook (pkill -f/force-push denied) + orphan-spec server-leak fix (#522)
* test: the orphan-object spec stops its servers in finally — a mid-test failure no longer leaks the port into the retry Regression class: a dedicated-port server stopped only after its assertions leaks on test timeout, and the retry's bind failure then masks the real error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq * chore: PreToolUse command guard — pkill -f/killall and force-push/history-rewrite are hook-denied (goal 0191) The two never-granted command families with real incidents behind them (a broad pkill killed the production mill-server; force-push is CLAUDE.md's standing never) move from prose to a deterministic deny: scripts/hook-command-guard.sh, same fail-open/exit-2 contract as hook-build-guard.sh. Patterns anchor to command-segment starts so a commit message MENTIONING these words never false-positives; a 16-case matrix (8 deny / 8 pass) was run against the script and caught two regex false negatives before wiring. amend/plain-rebase stay judgement (the rule's own 'without being explicitly asked' makes a hard deny wrong, and the pr-shepherd's instructed rebases are a legal flow). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012im1JxQQV2ahnXzZDdVmZq --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0d3670e commit c7df6ab

4 files changed

Lines changed: 82 additions & 3 deletions

File tree

.claude/settings.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@
2929
"type": "command",
3030
"command": "${CLAUDE_PROJECT_DIR}/scripts/hook-build-guard.sh",
3131
"args": []
32+
},
33+
{
34+
"type": "command",
35+
"command": "${CLAUDE_PROJECT_DIR}/scripts/hook-command-guard.sh",
36+
"args": []
3237
}
3338
]
3439
}
@@ -44,4 +49,4 @@
4449
}
4550
]
4651
}
47-
}
52+
}

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,10 @@ never leave the working tree dirty or a completed, verified change sitting
192192
staged-but-uncommitted at the end of a turn. Write a real commit message
193193
(not a placeholder), double-check staged content doesn't include anything
194194
secret-shaped, and never force-push, amend a previous commit, or rewrite
195-
history without being explicitly asked.
195+
history without being explicitly asked. (Force-push and
196+
filter-branch/filter-repo are hook-denied outright —
197+
`scripts/hook-command-guard.sh`, which also denies `pkill -f`/`killall`;
198+
amend/rebase stay judgement since "explicitly asked" is legal.)
196199

197200
**Deliver through short-lived branches + a PR per goal; push at least once
198201
per session — never let unpushed work accumulate**

frontend/e2e/runtime-plugins.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,8 +252,14 @@ test('a plugin object placed before its plugin is removed stays visible, honest,
252252
backupDir: path.join(dir, 'backups'),
253253
}
254254
const browser = await chromium.launch()
255+
// Track every spawned server so a mid-test failure still stops them
256+
// in finally -- a server stopped only after its assertions leaks on
257+
// timeout, and the leaked port then fails the retry with a bind
258+
// error that masks the real failure.
259+
const servers: SpawnedServer[] = []
255260
try {
256261
const first = await spawnMillServer({ ...spawnOpts, extraEnv: { MILL_PLUGINS_DIR: pluginsDir } })
262+
servers.push(first)
257263
const page1 = await browser.newPage({ baseURL: first.baseURL })
258264
await page1.goto('/')
259265
await page1.getByRole('link', { name: 'Atlas' }).click()
@@ -269,6 +275,7 @@ test('a plugin object placed before its plugin is removed stays visible, honest,
269275
await first.stop()
270276

271277
const second = await spawnMillServer({ ...spawnOpts, extraEnv: { MILL_PLUGINS_DIR: emptyPluginsDir } })
278+
servers.push(second)
272279
const page2 = await browser.newPage({ baseURL: second.baseURL })
273280
await page2.goto('/')
274281
await page2.getByRole('link', { name: 'Atlas' }).click()
@@ -284,9 +291,9 @@ test('a plugin object placed before its plugin is removed stays visible, honest,
284291
await expect(menu).toBeVisible()
285292
await menu.getByText('Delete', { exact: true }).click()
286293
await expect(page2.getByTestId('atlas-unknown-kind-face')).toHaveCount(0)
287-
await second.stop()
288294
} finally {
289295
await browser.close()
296+
for (const s of servers) await s.stop().catch(() => {})
290297
rmSync(dir, { recursive: true, force: true })
291298
}
292299
})

scripts/hook-command-guard.sh

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env bash
2+
# PreToolUse hook (.claude/settings.json, matcher: Bash): denies the
3+
# two command families this repo's rules mark never-granted, after
4+
# each cost a real incident (docs/goals/0191's evidence bar -- no hook
5+
# for a problem that hasn't occurred):
6+
# - `pkill -f` / `killall`: a broad pkill once took down the
7+
# production mill-server LaunchAgent; kills must target own PIDs
8+
# or lsof-resolved ports.
9+
# - `git push --force[-with-lease]` / `-f` and history-rewrite
10+
# plumbing (filter-branch/filter-repo): never granted (CLAUDE.md).
11+
# `commit --amend` and plain `git rebase` stay JUDGEMENT, not
12+
# hooks: both are legitimate when explicitly asked (the rule's own
13+
# wording), and the pr-shepherd's instructed rebases are a
14+
# standing legal flow -- a hook cannot see "was asked".
15+
# Patterns are anchored to a command-segment start (after ^ ; & |) so
16+
# a commit message or echoed string MENTIONING these words is never a
17+
# false positive -- false negatives are acceptable here, a wrongly
18+
# blocked session is not. Fails OPEN (exit 0) on any internal error,
19+
# same contract as hook-build-guard.sh; exit 2 is the documented
20+
# unconditional PreToolUse deny.
21+
set -uo pipefail
22+
23+
input="$(cat)"
24+
25+
if ! command -v jq >/dev/null 2>&1; then
26+
exit 0
27+
fi
28+
if ! echo "$input" | jq -e . >/dev/null 2>&1; then
29+
exit 0
30+
fi
31+
32+
tool_name="$(echo "$input" | jq -r '.tool_name // empty')"
33+
if [ "$tool_name" != "Bash" ]; then
34+
exit 0
35+
fi
36+
37+
command_str="$(echo "$input" | jq -r '.tool_input.command // empty')"
38+
if [ -z "$command_str" ]; then
39+
exit 0
40+
fi
41+
42+
seg='(^|[;&|][[:space:]]*|&&[[:space:]]*|\|\|[[:space:]]*)(sudo[[:space:]]+)?'
43+
44+
if echo "$command_str" | grep -qE "${seg}pkill([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+-[a-zA-Z]*f"; then
45+
echo "Blocked: pkill -f is never allowed here (a broad pkill once killed the production mill-server). Kill only your own PIDs, or resolve the port with lsof -ti and kill that PID." >&2
46+
exit 2
47+
fi
48+
49+
if echo "$command_str" | grep -qE "${seg}killall([[:space:]]|$)"; then
50+
echo "Blocked: killall is never allowed here (same incident class as pkill -f). Kill only your own PIDs, or resolve the port with lsof -ti and kill that PID." >&2
51+
exit 2
52+
fi
53+
54+
if echo "$command_str" | grep -qE "${seg}git[[:space:]]+push([[:space:]]+[^;&|[:space:]]+)*[[:space:]]+(--force(-with-lease)?|-f)([[:space:]]|$)"; then
55+
echo "Blocked: force-push is never granted in this repo (CLAUDE.md). Fix forward with a new commit instead." >&2
56+
exit 2
57+
fi
58+
59+
if echo "$command_str" | grep -qE "${seg}git[[:space:]]+(filter-branch|filter-repo)([[:space:]]|$)"; then
60+
echo "Blocked: history rewrites are never granted in this repo (CLAUDE.md)." >&2
61+
exit 2
62+
fi
63+
64+
exit 0

0 commit comments

Comments
 (0)