Skip to content

Commit ba60440

Browse files
committed
fix: make hooks work across VS Code Copilot, Copilot CLI, and Claude Code
The safety hooks emitted only the VS Code / Claude output shape (hookSpecificOutput and systemMessage), which the GitHub Copilot CLI ignores. The CLI reads a top-level permissionDecision and additionalContext instead. Both scripts now emit both shapes, so a blocked statement is denied and a lint is surfaced regardless of which tool runs the hook. check-sql-files.py also accepts the file path under file_path, filePath, or path, and reads the toolArgs container the CLI uses in addition to tool_input. Adds scripts/test-hooks.sh (run in CI) covering both output shapes, the container and path-key variants, and the fail-open behavior for issues #20 and #23.
1 parent 479f064 commit ba60440

4 files changed

Lines changed: 158 additions & 50 deletions

File tree

.github/workflows/test-hooks.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Test hooks
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- '.github/hooks/**'
7+
- 'scripts/**'
8+
- '.github/workflows/test-hooks.yml'
9+
push:
10+
branches:
11+
- main
12+
paths:
13+
- '.github/hooks/**'
14+
- 'scripts/**'
15+
- '.github/workflows/test-hooks.yml'
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
test-hooks:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- name: Run hook regression tests
26+
run: bash scripts/test-hooks.sh

scripts/check-sql-files.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
#!/usr/bin/env python3
22
"""Post-edit check for CockroachDB anti-patterns in SQL and code files.
3-
Receives JSON on stdin from Claude Code PostToolUse hook.
3+
Receives JSON on stdin from a PostToolUse hook.
4+
5+
The advisory message is emitted under both ``systemMessage`` (VS Code Copilot /
6+
Claude Code, user-visible) and ``additionalContext`` (GitHub Copilot CLI,
7+
model-visible), so the lint surfaces regardless of which tool runs the hook.
48
"""
59

610
import json
@@ -18,12 +22,20 @@ def main():
1822
except (json.JSONDecodeError, EOFError):
1923
sys.exit(0)
2024

21-
# Claude Code uses snake_case (file_path); VS Code / Copilot tools use
22-
# camelCase (filePath). Accept either so the hook fires on both.
23-
tool_input = data.get("tool_input", {}) or data.get("toolInput", {})
25+
# tool_input/toolInput (VS Code / Claude) or toolArgs (Copilot CLI). File
26+
# path key varies by tool: file_path (Claude), filePath / path (others).
27+
tool_input = (
28+
data.get("tool_input")
29+
or data.get("toolInput")
30+
or data.get("toolArgs")
31+
or {}
32+
)
33+
if not isinstance(tool_input, dict):
34+
sys.exit(0)
2435
file_path = (
2536
tool_input.get("file_path")
2637
or tool_input.get("filePath")
38+
or tool_input.get("path")
2739
or ""
2840
)
2941
if not file_path:
@@ -46,13 +58,13 @@ def main():
4658

4759
if re.search(r"\b(SERIAL|BIGSERIAL)\b", content, re.IGNORECASE):
4860
warnings.append(
49-
"SERIAL/BIGSERIAL detected causes write hotspots in CockroachDB, "
61+
"SERIAL/BIGSERIAL detected: causes write hotspots in CockroachDB, "
5062
"use UUID with gen_random_uuid() instead."
5163
)
5264

5365
if re.search(r"SELECT\s+\*\s+FROM", content, re.IGNORECASE):
5466
warnings.append(
55-
"SELECT * detected enumerate columns explicitly for CockroachDB "
67+
"SELECT * detected: enumerate columns explicitly for CockroachDB "
5668
"to enable covering index optimizations."
5769
)
5870

@@ -61,7 +73,7 @@ def main():
6173
if re.search(r"\bBEGIN\b|sql\.Tx", content):
6274
if not re.search(r"crdb\.ExecuteTx|retry|40001", content, re.IGNORECASE):
6375
warnings.append(
64-
"Transaction without retry logic detected CockroachDB requires "
76+
"Transaction without retry logic detected: CockroachDB requires "
6577
"retry on SQLSTATE 40001 (serialization_failure). "
6678
"Use crdb.ExecuteTx from cockroach-go."
6779
)
@@ -71,14 +83,16 @@ def main():
7183
if re.search(r"\bBEGIN\b|connection\.setAutoCommit", content):
7284
if not re.search(r"retry|40001|RetryableExecutor", content, re.IGNORECASE):
7385
warnings.append(
74-
"Transaction without retry logic detected CockroachDB requires "
86+
"Transaction without retry logic detected: CockroachDB requires "
7587
"retry on SQLSTATE 40001. "
7688
"Use cockroachdb-jdbc-wrapper RetryableExecutor."
7789
)
7890

7991
if warnings:
92+
message = "CockroachDB lint: " + " ".join(warnings)
8093
json.dump({
81-
"systemMessage": "CockroachDB lint: " + " ".join(warnings)
94+
"systemMessage": message,
95+
"additionalContext": message,
8296
}, sys.stdout)
8397

8498
sys.exit(0)

scripts/test-hooks.sh

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bash
2+
# Regression tests for the CockroachDB safety hooks (Copilot plugin).
3+
#
4+
# Runs the real commands from .github/hooks/cockroachdb.json (with
5+
# ${CLAUDE_PLUGIN_ROOT} substituted) and asserts the output satisfies BOTH
6+
# hook output contracts, so the hooks work across every Copilot surface:
7+
# - VS Code Copilot / Claude Code: hookSpecificOutput + systemMessage
8+
# - GitHub Copilot CLI: top-level permissionDecision + additionalContext
9+
#
10+
# Also asserts fail-open (exit 0, no block) when the plugin root cannot be
11+
# resolved (issues #20 MAX_PATH and #23 unsubstituted token).
12+
set -uo pipefail
13+
14+
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
15+
HOOKS="$ROOT/.github/hooks/cockroachdb.json"
16+
TMP="$(mktemp -d)"
17+
trap 'rm -rf "$TMP"' EXIT
18+
fails=0
19+
20+
hook_cmd() { # $1=event $2=root
21+
python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["hooks"][sys.argv[2]][0]["hooks"][0]["command"].replace("${CLAUDE_PLUGIN_ROOT}", sys.argv[3]))' "$HOOKS" "$1" "$2"
22+
}
23+
24+
# check: desc, event, root, stdin, want_rc, mode(empty|all), space-separated tokens
25+
check() {
26+
local desc="$1" event="$2" root="$3" stdin="$4" want_rc="$5" mode="$6" tokens="${7:-}"
27+
local cmd out rc ok=1
28+
cmd="$(hook_cmd "$event" "$root")"
29+
out="$(printf '%s' "$stdin" | sh -c "$cmd" 2>/dev/null)"; rc=$?
30+
[ "$rc" = "$want_rc" ] || ok=0
31+
if [ "$mode" = "empty" ]; then
32+
[ -z "$out" ] || ok=0
33+
else
34+
for t in $tokens; do printf '%s' "$out" | grep -q "$t" || ok=0; done
35+
fi
36+
if [ "$ok" = 1 ]; then echo "ok - $desc"; else echo "FAIL - $desc (rc=$rc, out=${out:-<empty>})"; fails=$((fails + 1)); fi
37+
}
38+
39+
printf 'CREATE TABLE t (id SERIAL PRIMARY KEY);\n' > "$TMP/a.sql"
40+
printf '# markdown, not sql\n' > "$TMP/a.md"
41+
42+
# PreToolUse: deny must appear in BOTH shapes
43+
check "DROP DATABASE deny (both shapes)" PreToolUse "$ROOT" '{"tool_input":{"sql":"DROP DATABASE x"}}' 0 all "permissionDecision hookSpecificOutput"
44+
check "TRUNCATE deny (both shapes)" PreToolUse "$ROOT" '{"tool_input":{"sql":"TRUNCATE TABLE t"}}' 0 all "permissionDecision hookSpecificOutput"
45+
check "toolArgs container deny (CLI)" PreToolUse "$ROOT" '{"toolArgs":{"sql":"DROP DATABASE x"}}' 0 all "permissionDecision"
46+
check "SERIAL warn (both shapes)" PreToolUse "$ROOT" '{"tool_input":{"sql":"CREATE TABLE t (id SERIAL)"}}' 0 all "systemMessage additionalContext"
47+
check "safe SQL: no output" PreToolUse "$ROOT" '{"tool_input":{"sql":"SELECT 1"}}' 0 empty
48+
49+
# PostToolUse: lint in BOTH shapes; all path-key aliases; CLI container
50+
check "lint .sql via file_path (both)" PostToolUse "$ROOT" "{\"tool_input\":{\"file_path\":\"$TMP/a.sql\"}}" 0 all "systemMessage additionalContext"
51+
check "lint via filePath key" PostToolUse "$ROOT" "{\"tool_input\":{\"filePath\":\"$TMP/a.sql\"}}" 0 all "systemMessage"
52+
check "lint via path key" PostToolUse "$ROOT" "{\"tool_input\":{\"path\":\"$TMP/a.sql\"}}" 0 all "systemMessage"
53+
check "lint via toolArgs container (CLI)" PostToolUse "$ROOT" "{\"toolArgs\":{\"filePath\":\"$TMP/a.sql\"}}" 0 all "additionalContext"
54+
check "non-SQL edit: no output" PostToolUse "$ROOT" "{\"tool_input\":{\"file_path\":\"$TMP/a.md\"}}" 0 empty
55+
56+
# fail-open when plugin root is missing / unsubstituted (issues #20, #23)
57+
check "PreToolUse fail-open on bad root" PreToolUse '${CLAUDE_PLUGIN_ROOT}' '{"tool_input":{"sql":"DROP DATABASE x"}}' 0 empty
58+
check "PostToolUse fail-open on bad root" PostToolUse '${CLAUDE_PLUGIN_ROOT}' "{\"tool_input\":{\"file_path\":\"$TMP/a.sql\"}}" 0 empty
59+
60+
echo
61+
if [ "$fails" -eq 0 ]; then
62+
echo "All hook regression tests passed."
63+
else
64+
echo "$fails test(s) failed."
65+
exit 1
66+
fi

scripts/validate-sql.py

Lines changed: 43 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,77 +1,79 @@
11
#!/usr/bin/env python3
22
"""Pre-execution SQL validation for CockroachDB.
33
Blocks dangerous patterns before they reach the database.
4-
Receives JSON on stdin from Claude Code PreToolUse hook.
5-
Exit 0 = allow, exit 2 = block.
4+
Receives JSON on stdin from a PreToolUse hook.
5+
6+
Output is written so both hook contracts understand it:
7+
- GitHub Copilot CLI reads a top-level ``permissionDecision`` (and
8+
``additionalContext`` for guidance).
9+
- VS Code Copilot and Claude Code read ``hookSpecificOutput`` (and
10+
``systemMessage``).
11+
Emitting both keeps this one script working across all three surfaces.
612
"""
713

814
import json
915
import re
1016
import sys
1117

1218

19+
def deny(reason):
20+
json.dump({
21+
"permissionDecision": "deny",
22+
"permissionDecisionReason": reason,
23+
"hookSpecificOutput": {
24+
"hookEventName": "PreToolUse",
25+
"permissionDecision": "deny",
26+
"permissionDecisionReason": reason,
27+
},
28+
}, sys.stdout)
29+
sys.exit(0)
30+
31+
32+
def warn(message):
33+
json.dump({
34+
"systemMessage": message,
35+
"additionalContext": message,
36+
}, sys.stdout)
37+
sys.exit(0)
38+
39+
1340
def main():
1441
try:
1542
data = json.load(sys.stdin)
1643
except (json.JSONDecodeError, EOFError):
1744
sys.exit(0)
1845

19-
tool_input = data.get("tool_input", {})
46+
# tool_input (VS Code / Claude) or toolArgs (Copilot CLI camelCase)
47+
tool_input = data.get("tool_input") or data.get("toolArgs") or {}
48+
if not isinstance(tool_input, dict):
49+
sys.exit(0)
2050
sql = tool_input.get("sql", "") or tool_input.get("statement", "")
2151
if not sql:
2252
sys.exit(0)
2353

2454
sql_upper = sql.upper()
2555

26-
# Block DROP DATABASE
2756
if re.search(r"DROP\s+DATABASE", sql_upper):
28-
json.dump({
29-
"hookSpecificOutput": {
30-
"hookEventName": "PreToolUse",
31-
"permissionDecision": "deny",
32-
"permissionDecisionReason":
33-
"DROP DATABASE is blocked by CockroachDB plugin safety hook. "
34-
"Use DROP TABLE for individual tables instead."
35-
}
36-
}, sys.stdout)
37-
sys.exit(0)
57+
deny("DROP DATABASE is blocked by CockroachDB plugin safety hook. "
58+
"Use DROP TABLE for individual tables instead.")
3859

39-
# Block TRUNCATE (data loss risk)
4060
if re.search(r"^\s*TRUNCATE\s", sql_upper, re.MULTILINE):
41-
json.dump({
42-
"hookSpecificOutput": {
43-
"hookEventName": "PreToolUse",
44-
"permissionDecision": "deny",
45-
"permissionDecisionReason":
46-
"TRUNCATE is blocked by CockroachDB plugin safety hook. "
47-
"Use DELETE with a WHERE clause for targeted row removal."
48-
}
49-
}, sys.stdout)
50-
sys.exit(0)
61+
deny("TRUNCATE is blocked by CockroachDB plugin safety hook. "
62+
"Use DELETE with a WHERE clause for targeted row removal.")
5163

52-
# Warn about SERIAL (anti-pattern — causes hotspots)
5364
if re.search(r"\b(SERIAL|BIGSERIAL)\b", sql_upper):
54-
json.dump({
55-
"systemMessage":
56-
"WARNING: SERIAL/BIGSERIAL creates sequential IDs that cause write "
57-
"hotspots in CockroachDB. Use UUID with gen_random_uuid() instead: "
58-
"id UUID PRIMARY KEY DEFAULT gen_random_uuid()"
59-
}, sys.stdout)
60-
sys.exit(0)
65+
warn("WARNING: SERIAL/BIGSERIAL creates sequential IDs that cause write "
66+
"hotspots in CockroachDB. Use UUID with gen_random_uuid() instead: "
67+
"id UUID PRIMARY KEY DEFAULT gen_random_uuid()")
6168

62-
# Warn about multiple DDL in one transaction
6369
ddl_count = len(re.findall(
6470
r"(CREATE|ALTER|DROP)\s+(TABLE|INDEX|VIEW|SEQUENCE|TYPE|SCHEMA)",
6571
sql_upper
6672
))
6773
if ddl_count > 1:
68-
json.dump({
69-
"systemMessage":
70-
"WARNING: Multiple DDL statements detected. CockroachDB supports "
71-
"only one DDL per transaction. Split into separate statements or "
72-
"use SET autocommit_before_ddl = true."
73-
}, sys.stdout)
74-
sys.exit(0)
74+
warn("WARNING: Multiple DDL statements detected. CockroachDB supports "
75+
"only one DDL per transaction. Split into separate statements or "
76+
"use SET autocommit_before_ddl = true.")
7577

7678
sys.exit(0)
7779

0 commit comments

Comments
 (0)