Files
datastore/.claude/hooks/allow_investigate_cmds.py
zooqueen 9b59b8e388 datastore: rename all remaining clickhouse-named files → datastore (tests/ci/utils/docs/packages)
Complete the filename-level debrand the 26.6 merge surfaced: 238 files (test .sql/.reference pairs,
ci/jobs + workflows, utils, docs, package specs) renamed clickhouse->datastore (all case variants incl
clickHouse), with references updated. Build-critical product files were already done. Excludes contrib (third-party).
2026-06-23 13:10:28 -07:00

91 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""PreToolUse hook for the investigate-ci reduced-prompt profile.
Auto-approves ONLY the exact safe command shapes the skill runs every time, so
they need no allow-list entry (whose wildcard tail cannot be constrained) and no
prompt. Two families:
1. The play.clickhouse.com read-only SELECT history query.
2. node .claude/tools/fetch_ci_report.js against known CI-report hosts, with
only read/report flags (NO file-writing forms).
The hook never auto-approves a write: --download-logs and stdout redirection are
deliberately excluded, because a string-pinned path cannot be made symlink-safe
(a planted tmp/investigate symlink would redirect the write outside). Those write
forms, and everything else, fall through to the normal permission prompt -- this
is an allowlist (default-deny). Both families also reject shell composition/
substitution, so the dangerous variants (curl --data-binary @<file>, $(...), a
second piped command, an arbitrary URL/host) prompt instead of running.
Output contract: print a PreToolUse "allow" decision only on a match; otherwise
print nothing and exit 0 so normal handling (the prompt) runs. Never emits "deny".
"""
import json
import re
import sys
# --- Family 1: the play.clickhouse.com read-only SELECT query -----------------
# Quote-delimited body that must start with SELECT and contain no " $ ` -- so
# @file, a second --data-binary, and $(...)/backtick substitution all fail.
PLAY = re.compile(
r"""curl -sS 'https://play\.clickhouse\.com/\?user=play' --data-binary "\s*SELECT[^"$`]*\""""
)
# --- Family 2: fetch_ci_report.js against known CI-report hosts ---------------
# Full-match allowlist. The URL is quote-delimited and excludes " $ ` so it
# cannot break out or inject substitution, even though it may contain & ? = (a
# query string). Flags are an allowlist; --download-logs and stdout redirect are
# pinned to tmp/investigate. Anything outside this exact structure -- chaining,
# pipes, extra redirects, other hosts/flags -- fails to match and prompts.
FETCH = re.compile(
r"node \.claude/tools/fetch_ci_report\.js "
r'"(?:https://s3\.amazonaws\.com/datastore-test-reports/'
r"|https://d1k2gkhrlfqv31\.cloudfront\.net/datastore-test-reports-private/"
r'|https://github\.com/ClickHouse/ClickHouse/(?:pull|issues)/)[^"$`]*"'
r"(?: (?:--failed|--cidb|--all|--links"
r"|--report [0-9]+"
r"|--credentials [^\s\"$`;|&<>]+))*"
r"(?: 2>&1| 2>/dev/null)?"
)
def play_ok(command: str) -> bool:
return bool(PLAY.fullmatch(command))
def fetch_ok(command: str) -> bool:
# Reject path traversal: the tmp/investigate path class permits '.', so '..'
# would otherwise let --download-logs / > escape the scratch dir. Legit fetch
# commands (URL + flags + tmp paths) never contain '..'.
if ".." in command:
return False
return bool(FETCH.fullmatch(command))
def main() -> None:
try:
data = json.load(sys.stdin)
except Exception:
return # malformed input -> no decision -> prompt
if data.get("tool_name") != "Bash":
return
command = data.get("tool_input", {}).get("command", "").strip()
if play_ok(command) or fetch_ok(command):
print(
json.dumps(
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "exact read-only investigate-ci command shape",
}
}
)
)
if __name__ == "__main__":
main()