i18n(uk): add missing files, translate P4 root docs
- Copy code/image/config files across all modules - Translate brand-voice and code-review templates - Translate CONTRIBUTING, CODE_OF_CONDUCT, SECURITY, STYLE_GUIDE - Copy CHANGELOG as-is (technical log) Ref: luongnv89/claude-howto#63
This commit is contained in:
149
uk/06-hooks/context-tracker-tiktoken.py
Normal file
149
uk/06-hooks/context-tracker-tiktoken.py
Normal file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Context Usage Tracker (tiktoken version) - Tracks token consumption per request.
|
||||
|
||||
Uses UserPromptSubmit as "pre-message" hook and Stop as "post-response" hook
|
||||
to calculate the delta in token usage for each request.
|
||||
|
||||
This version uses tiktoken with p50k_base encoding for ~90-95% accuracy.
|
||||
Requires: pip install tiktoken
|
||||
|
||||
For a zero-dependency version, see context-tracker.py.
|
||||
|
||||
Usage:
|
||||
Configure both hooks to use the same script:
|
||||
- UserPromptSubmit: saves current token count
|
||||
- Stop: calculates delta and reports usage
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
import tiktoken
|
||||
|
||||
TIKTOKEN_AVAILABLE = True
|
||||
except ImportError:
|
||||
TIKTOKEN_AVAILABLE = False
|
||||
print(
|
||||
"Warning: tiktoken not installed. Install with: pip install tiktoken",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Configuration
|
||||
CONTEXT_LIMIT = 128000 # Claude's context window (adjust for your model)
|
||||
|
||||
|
||||
def get_state_file(session_id: str) -> str:
|
||||
"""Get temp file path for storing pre-message token count, isolated by session."""
|
||||
return os.path.join(tempfile.gettempdir(), f"claude-context-{session_id}.json")
|
||||
|
||||
|
||||
def count_tokens(text: str) -> int:
|
||||
"""
|
||||
Count tokens using tiktoken with p50k_base encoding.
|
||||
|
||||
This provides ~90-95% accuracy compared to Claude's actual tokenizer.
|
||||
Falls back to character estimation if tiktoken is not available.
|
||||
|
||||
Note: Anthropic hasn't released an official offline tokenizer.
|
||||
tiktoken with p50k_base is a reasonable approximation since both
|
||||
Claude and GPT models use BPE (byte-pair encoding).
|
||||
"""
|
||||
if TIKTOKEN_AVAILABLE:
|
||||
enc = tiktoken.get_encoding("p50k_base")
|
||||
return len(enc.encode(text))
|
||||
else:
|
||||
# Fallback to character estimation (~4 chars per token)
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def read_transcript(transcript_path: str) -> str:
|
||||
"""Read and concatenate all content from transcript file."""
|
||||
if not transcript_path or not os.path.exists(transcript_path):
|
||||
return ""
|
||||
|
||||
content = []
|
||||
with open(transcript_path, "r") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line.strip())
|
||||
# Extract text content from various message formats
|
||||
if "message" in entry:
|
||||
msg = entry["message"]
|
||||
if isinstance(msg.get("content"), str):
|
||||
content.append(msg["content"])
|
||||
elif isinstance(msg.get("content"), list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
content.append(block.get("text", ""))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return "\n".join(content)
|
||||
|
||||
|
||||
def handle_user_prompt_submit(data: dict) -> None:
|
||||
"""Pre-message hook: Save current token count before request."""
|
||||
session_id = data.get("session_id", "unknown")
|
||||
transcript_path = data.get("transcript_path", "")
|
||||
|
||||
transcript_content = read_transcript(transcript_path)
|
||||
current_tokens = count_tokens(transcript_content)
|
||||
|
||||
# Save to temp file for later comparison
|
||||
state_file = get_state_file(session_id)
|
||||
with open(state_file, "w") as f:
|
||||
json.dump({"pre_tokens": current_tokens}, f)
|
||||
|
||||
|
||||
def handle_stop(data: dict) -> None:
|
||||
"""Post-response hook: Calculate and report token delta."""
|
||||
session_id = data.get("session_id", "unknown")
|
||||
transcript_path = data.get("transcript_path", "")
|
||||
|
||||
transcript_content = read_transcript(transcript_path)
|
||||
current_tokens = count_tokens(transcript_content)
|
||||
|
||||
# Load pre-message count
|
||||
state_file = get_state_file(session_id)
|
||||
pre_tokens = 0
|
||||
if os.path.exists(state_file):
|
||||
try:
|
||||
with open(state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
pre_tokens = state.get("pre_tokens", 0)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
# Calculate delta
|
||||
delta_tokens = current_tokens - pre_tokens
|
||||
remaining = CONTEXT_LIMIT - current_tokens
|
||||
percentage = (current_tokens / CONTEXT_LIMIT) * 100
|
||||
|
||||
# Report usage (stderr so it doesn't interfere with hook output)
|
||||
method = "tiktoken" if TIKTOKEN_AVAILABLE else "estimated"
|
||||
print(
|
||||
f"Context ({method}): ~{current_tokens:,} tokens "
|
||||
f"({percentage:.1f}% used, ~{remaining:,} remaining)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if delta_tokens > 0:
|
||||
print(f"This request: ~{delta_tokens:,} tokens", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
data = json.load(sys.stdin)
|
||||
event = data.get("hook_event_name", "")
|
||||
|
||||
if event == "UserPromptSubmit":
|
||||
handle_user_prompt_submit(data)
|
||||
elif event == "Stop":
|
||||
handle_stop(data)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
126
uk/06-hooks/context-tracker.py
Normal file
126
uk/06-hooks/context-tracker.py
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Context Usage Tracker - Tracks token consumption per request.
|
||||
|
||||
Uses UserPromptSubmit as "pre-message" hook and Stop as "post-response" hook
|
||||
to calculate the delta in token usage for each request.
|
||||
|
||||
This version uses character-based estimation (no dependencies).
|
||||
For better accuracy, see context-tracker-tiktoken.py.
|
||||
|
||||
Usage:
|
||||
Configure both hooks to use the same script:
|
||||
- UserPromptSubmit: saves current token count
|
||||
- Stop: calculates delta and reports usage
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
# Configuration
|
||||
CONTEXT_LIMIT = 128000 # Claude's context window (adjust for your model)
|
||||
|
||||
|
||||
def get_state_file(session_id: str) -> str:
|
||||
"""Get temp file path for storing pre-message token count, isolated by session."""
|
||||
return os.path.join(tempfile.gettempdir(), f"claude-context-{session_id}.json")
|
||||
|
||||
|
||||
def count_tokens_estimate(text: str) -> int:
|
||||
"""
|
||||
Estimate token count using character-based approximation.
|
||||
|
||||
Uses ~4 characters per token ratio, which provides ~80-90% accuracy
|
||||
for English text. Less accurate for code and non-English text.
|
||||
"""
|
||||
return len(text) // 4
|
||||
|
||||
|
||||
def read_transcript(transcript_path: str) -> str:
|
||||
"""Read and concatenate all content from transcript file."""
|
||||
if not transcript_path or not os.path.exists(transcript_path):
|
||||
return ""
|
||||
|
||||
content = []
|
||||
with open(transcript_path, "r") as f:
|
||||
for line in f:
|
||||
try:
|
||||
entry = json.loads(line.strip())
|
||||
# Extract text content from various message formats
|
||||
if "message" in entry:
|
||||
msg = entry["message"]
|
||||
if isinstance(msg.get("content"), str):
|
||||
content.append(msg["content"])
|
||||
elif isinstance(msg.get("content"), list):
|
||||
for block in msg["content"]:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
content.append(block.get("text", ""))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return "\n".join(content)
|
||||
|
||||
|
||||
def handle_user_prompt_submit(data: dict) -> None:
|
||||
"""Pre-message hook: Save current token count before request."""
|
||||
session_id = data.get("session_id", "unknown")
|
||||
transcript_path = data.get("transcript_path", "")
|
||||
|
||||
transcript_content = read_transcript(transcript_path)
|
||||
current_tokens = count_tokens_estimate(transcript_content)
|
||||
|
||||
# Save to temp file for later comparison
|
||||
state_file = get_state_file(session_id)
|
||||
with open(state_file, "w") as f:
|
||||
json.dump({"pre_tokens": current_tokens}, f)
|
||||
|
||||
|
||||
def handle_stop(data: dict) -> None:
|
||||
"""Post-response hook: Calculate and report token delta."""
|
||||
session_id = data.get("session_id", "unknown")
|
||||
transcript_path = data.get("transcript_path", "")
|
||||
|
||||
transcript_content = read_transcript(transcript_path)
|
||||
current_tokens = count_tokens_estimate(transcript_content)
|
||||
|
||||
# Load pre-message count
|
||||
state_file = get_state_file(session_id)
|
||||
pre_tokens = 0
|
||||
if os.path.exists(state_file):
|
||||
try:
|
||||
with open(state_file, "r") as f:
|
||||
state = json.load(f)
|
||||
pre_tokens = state.get("pre_tokens", 0)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
|
||||
# Calculate delta
|
||||
delta_tokens = current_tokens - pre_tokens
|
||||
remaining = CONTEXT_LIMIT - current_tokens
|
||||
percentage = (current_tokens / CONTEXT_LIMIT) * 100
|
||||
|
||||
# Report usage (stderr so it doesn't interfere with hook output)
|
||||
print(
|
||||
f"Context (estimated): ~{current_tokens:,} tokens "
|
||||
f"({percentage:.1f}% used, ~{remaining:,} remaining)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if delta_tokens > 0:
|
||||
print(f"This request: ~{delta_tokens:,} tokens", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
data = json.load(sys.stdin)
|
||||
event = data.get("hook_event_name", "")
|
||||
|
||||
if event == "UserPromptSubmit":
|
||||
handle_user_prompt_submit(data)
|
||||
elif event == "Stop":
|
||||
handle_stop(data)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
uk/06-hooks/dependency-check.sh
Normal file
156
uk/06-hooks/dependency-check.sh
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/bin/bash
|
||||
# Check for known vulnerabilities in dependencies after manifest files are modified.
|
||||
# Hook: PostToolUse:Write
|
||||
|
||||
FILE=$1
|
||||
|
||||
if [ -z "$FILE" ]; then
|
||||
echo "Usage: $0 <file_path>"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use basename for matching — $1 may be an absolute path
|
||||
BASENAME=$(basename "$FILE")
|
||||
|
||||
# Only run when a dependency manifest is written
|
||||
case "$BASENAME" in
|
||||
package.json|package-lock.json|yarn.lock|pnpm-lock.yaml| \
|
||||
requirements.txt|Pipfile|Pipfile.lock|pyproject.toml| \
|
||||
go.mod|go.sum| \
|
||||
Cargo.toml|Cargo.lock| \
|
||||
Gemfile|Gemfile.lock| \
|
||||
composer.json|composer.lock| \
|
||||
pom.xml|build.gradle|build.gradle.kts)
|
||||
echo "📦 Dependency manifest updated: $FILE — scanning for vulnerabilities..."
|
||||
;;
|
||||
*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
ISSUES_FOUND=0
|
||||
|
||||
# ── npm / yarn / pnpm ────────────────────────────────────────────────────────
|
||||
if [[ "$BASENAME" == package*.json || "$BASENAME" == yarn.lock || "$BASENAME" == pnpm-lock.yaml ]]; then
|
||||
if command -v npm &>/dev/null; then
|
||||
echo "🔍 Running npm audit..."
|
||||
if ! npm audit --audit-level=high --json 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
vulns = data.get('metadata', {}).get('vulnerabilities', {})
|
||||
high = vulns.get('high', 0) + vulns.get('critical', 0)
|
||||
if high:
|
||||
print(f' ⚠️ {high} high/critical npm vulnerabilities found. Run: npm audit fix')
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
ISSUES_FOUND=1
|
||||
else
|
||||
echo " ✅ No high/critical npm vulnerabilities"
|
||||
fi
|
||||
fi
|
||||
|
||||
if command -v yarn &>/dev/null && [[ "$BASENAME" == yarn.lock ]]; then
|
||||
echo "🔍 Running yarn audit..."
|
||||
if ! yarn audit --level high --json 2>/dev/null | \
|
||||
grep -q '"type":"auditAdvisory"' 2>/dev/null; then
|
||||
echo " ✅ No high yarn vulnerabilities"
|
||||
else
|
||||
echo " ⚠️ yarn audit found vulnerabilities. Run: yarn audit --level high"
|
||||
ISSUES_FOUND=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Python ───────────────────────────────────────────────────────────────────
|
||||
if [[ "$BASENAME" == requirements.txt || "$BASENAME" == Pipfile* || "$BASENAME" == pyproject.toml ]]; then
|
||||
if command -v pip-audit &>/dev/null; then
|
||||
echo "🔍 Running pip-audit..."
|
||||
if pip-audit --format=json 2>/dev/null | \
|
||||
python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
vulns = [d for d in data.get('dependencies', []) if d.get('vulns')]
|
||||
if vulns:
|
||||
for dep in vulns:
|
||||
for v in dep['vulns']:
|
||||
print(f' ⚠️ {dep[\"name\"]} {dep[\"version\"]}: {v[\"id\"]} — {v[\"fix_versions\"]}')
|
||||
sys.exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo " ✅ No Python vulnerabilities found"
|
||||
else
|
||||
ISSUES_FOUND=1
|
||||
echo " Run: pip-audit for details"
|
||||
fi
|
||||
elif command -v safety &>/dev/null; then
|
||||
echo "🔍 Running safety check..."
|
||||
OUTPUT=$(safety check --short-report 2>&1)
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo " ✅ No Python vulnerabilities found"
|
||||
elif echo "$OUTPUT" | grep -qiE "vulnerability|CVE|insecure"; then
|
||||
echo "$OUTPUT"
|
||||
ISSUES_FOUND=1
|
||||
else
|
||||
echo " ⚠️ safety check could not complete (network or config error)" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Go ───────────────────────────────────────────────────────────────────────
|
||||
if [[ "$BASENAME" == go.mod || "$BASENAME" == go.sum ]]; then
|
||||
if command -v govulncheck &>/dev/null; then
|
||||
echo "🔍 Running govulncheck..."
|
||||
OUTPUT=$(govulncheck ./... 2>&1)
|
||||
EXIT_CODE=$?
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo " ✅ No Go vulnerabilities found"
|
||||
elif echo "$OUTPUT" | grep -q "Vulnerability #"; then
|
||||
echo "$OUTPUT"
|
||||
ISSUES_FOUND=1
|
||||
else
|
||||
echo " ⚠️ govulncheck could not complete: $OUTPUT" >&2
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Rust ─────────────────────────────────────────────────────────────────────
|
||||
if [[ "$BASENAME" == Cargo.toml || "$BASENAME" == Cargo.lock ]]; then
|
||||
if command -v cargo-audit &>/dev/null; then
|
||||
echo "🔍 Running cargo audit..."
|
||||
if ! cargo audit 2>/dev/null; then
|
||||
ISSUES_FOUND=1
|
||||
else
|
||||
echo " ✅ No Rust vulnerabilities found"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Ruby ─────────────────────────────────────────────────────────────────────
|
||||
if [[ "$BASENAME" == Gemfile || "$BASENAME" == Gemfile.lock ]]; then
|
||||
if command -v bundler-audit &>/dev/null; then
|
||||
echo "🔍 Running bundler-audit..."
|
||||
bundler-audit check --update 2>/dev/null || ISSUES_FOUND=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Generic fallback: trivy ──────────────────────────────────────────────────
|
||||
if command -v trivy &>/dev/null; then
|
||||
echo "🔍 Running trivy fs scan..."
|
||||
if ! trivy fs --exit-code 1 --severity HIGH,CRITICAL --quiet . 2>/dev/null; then
|
||||
ISSUES_FOUND=1
|
||||
else
|
||||
echo " ✅ trivy found no HIGH/CRITICAL issues"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$ISSUES_FOUND" -eq 0 ]; then
|
||||
echo "✅ Dependency check passed — no vulnerabilities detected"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Vulnerabilities detected. Review and update dependencies before committing."
|
||||
echo " This hook is advisory only and will not block your workflow."
|
||||
fi
|
||||
|
||||
# Always exit 0 — this hook warns but does not block
|
||||
exit 0
|
||||
49
uk/06-hooks/format-code.sh
Normal file
49
uk/06-hooks/format-code.sh
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Auto-format code after writing
|
||||
# Hook: PostToolUse:Write
|
||||
#
|
||||
# Reads the target file path from stdin JSON and runs the appropriate formatter
|
||||
# in-place on the file after Claude writes it.
|
||||
#
|
||||
# Compatible with: macOS, Linux, Windows (Git Bash)
|
||||
|
||||
# Read JSON input from stdin (Claude Code hook protocol)
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract file_path using sed (compatible with all platforms)
|
||||
FILE_PATH=$(echo "$INPUT" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
|
||||
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Detect file type and format accordingly
|
||||
case "$FILE_PATH" in
|
||||
*.js|*.jsx|*.ts|*.tsx)
|
||||
if command -v prettier &> /dev/null; then
|
||||
prettier --write "$FILE_PATH" 2>/dev/null
|
||||
fi
|
||||
;;
|
||||
*.py)
|
||||
if command -v black &> /dev/null; then
|
||||
black "$FILE_PATH" 2>/dev/null
|
||||
fi
|
||||
;;
|
||||
*.go)
|
||||
if command -v gofmt &> /dev/null; then
|
||||
gofmt -w "$FILE_PATH" 2>/dev/null
|
||||
fi
|
||||
;;
|
||||
*.rs)
|
||||
if command -v rustfmt &> /dev/null; then
|
||||
rustfmt "$FILE_PATH" 2>/dev/null
|
||||
fi
|
||||
;;
|
||||
*.java)
|
||||
if command -v google-java-format &> /dev/null; then
|
||||
google-java-format -i "$FILE_PATH" 2>/dev/null
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
31
uk/06-hooks/log-bash.sh
Normal file
31
uk/06-hooks/log-bash.sh
Normal file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
# Log all bash commands
|
||||
# Hook: PostToolUse:Bash
|
||||
#
|
||||
# Reads the executed command from stdin JSON and logs it to a file.
|
||||
#
|
||||
# Compatible with: macOS, Linux, Windows (Git Bash)
|
||||
|
||||
# Read JSON input from stdin (Claude Code hook protocol)
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract the bash command from tool_input
|
||||
# Note: sed [^"]* stops at escaped quotes in JSON; for commands with double-quoted
|
||||
# strings, only the portion up to the first \" will be captured — this is a known
|
||||
# limitation of sed-based JSON parsing and is acceptable for logging purposes.
|
||||
COMMAND=$(echo "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TIMESTAMP=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
LOGFILE="$HOME/.claude/bash-commands.log"
|
||||
|
||||
# Create log directory if it doesn't exist
|
||||
mkdir -p "$(dirname "$LOGFILE")"
|
||||
|
||||
# Log the command
|
||||
echo "[$TIMESTAMP] $COMMAND" >> "$LOGFILE"
|
||||
|
||||
exit 0
|
||||
68
uk/06-hooks/notify-team.sh
Normal file
68
uk/06-hooks/notify-team.sh
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
# Send notifications on events
|
||||
# Hook: PostToolUse (matcher: Bash) — run after bash commands; filter for git push in script logic
|
||||
# Note: Claude Code has no native PostPush event. To trigger on git push, check the bash command
|
||||
# string for "git push" using a matcher or conditional logic within this script.
|
||||
|
||||
REPO_NAME=$(basename $(git rev-parse --show-toplevel 2>/dev/null) 2>/dev/null)
|
||||
COMMIT_MSG=$(git log -1 --pretty=%B 2>/dev/null)
|
||||
AUTHOR=$(git log -1 --pretty=%an 2>/dev/null)
|
||||
BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
|
||||
echo "📢 Sending notification to team..."
|
||||
|
||||
# Slack webhook example (replace with your webhook URL)
|
||||
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL:-}"
|
||||
|
||||
if [ -n "$SLACK_WEBHOOK" ]; then
|
||||
curl -X POST "$SLACK_WEBHOOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{
|
||||
\"text\": \"New push to *$REPO_NAME*\",
|
||||
\"attachments\": [{
|
||||
\"color\": \"good\",
|
||||
\"fields\": [
|
||||
{\"title\": \"Branch\", \"value\": \"$BRANCH\", \"short\": true},
|
||||
{\"title\": \"Author\", \"value\": \"$AUTHOR\", \"short\": true},
|
||||
{\"title\": \"Commit\", \"value\": \"$COMMIT_MSG\"}
|
||||
]
|
||||
}]
|
||||
}" \
|
||||
--silent --output /dev/null
|
||||
|
||||
echo "✅ Slack notification sent"
|
||||
fi
|
||||
|
||||
# Discord webhook example (replace with your webhook URL)
|
||||
DISCORD_WEBHOOK="${DISCORD_WEBHOOK_URL:-}"
|
||||
|
||||
if [ -n "$DISCORD_WEBHOOK" ]; then
|
||||
curl -X POST "$DISCORD_WEBHOOK" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{
|
||||
\"content\": \"**New push to $REPO_NAME**\",
|
||||
\"embeds\": [{
|
||||
\"title\": \"$COMMIT_MSG\",
|
||||
\"color\": 3066993,
|
||||
\"fields\": [
|
||||
{\"name\": \"Branch\", \"value\": \"$BRANCH\", \"inline\": true},
|
||||
{\"name\": \"Author\", \"value\": \"$AUTHOR\", \"inline\": true}
|
||||
]
|
||||
}]
|
||||
}" \
|
||||
--silent --output /dev/null
|
||||
|
||||
echo "✅ Discord notification sent"
|
||||
fi
|
||||
|
||||
# Email notification example
|
||||
EMAIL_TO="${TEAM_EMAIL:-}"
|
||||
|
||||
if [ -n "$EMAIL_TO" ]; then
|
||||
echo "New push to $REPO_NAME by $AUTHOR" | \
|
||||
mail -s "Git Push: $BRANCH" "$EMAIL_TO"
|
||||
|
||||
echo "✅ Email notification sent"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
50
uk/06-hooks/pre-commit.sh
Normal file
50
uk/06-hooks/pre-commit.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Run tests before commit
|
||||
# Hook: PreToolUse (matcher: Bash) - checks if the command is a git commit
|
||||
# Note: There is no "PreCommit" hook event. Use PreToolUse with a Bash matcher
|
||||
# and inspect the command to detect git commit operations.
|
||||
|
||||
echo "🧪 Running tests before commit..."
|
||||
|
||||
# Check if package.json exists (Node.js project)
|
||||
if [ -f "package.json" ]; then
|
||||
if grep -q "\"test\":" package.json; then
|
||||
npm test
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Tests failed! Commit blocked."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if pytest is available (Python project)
|
||||
if [ -f "pytest.ini" ] || [ -f "setup.py" ]; then
|
||||
if command -v pytest &> /dev/null; then
|
||||
pytest
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Tests failed! Commit blocked."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if go.mod exists (Go project)
|
||||
if [ -f "go.mod" ]; then
|
||||
go test ./...
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Tests failed! Commit blocked."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if Cargo.toml exists (Rust project)
|
||||
if [ -f "Cargo.toml" ]; then
|
||||
cargo test
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Tests failed! Commit blocked."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ All tests passed! Proceeding with commit."
|
||||
exit 0
|
||||
96
uk/06-hooks/pre-tool-check.sh
Normal file
96
uk/06-hooks/pre-tool-check.sh
Normal file
@@ -0,0 +1,96 @@
|
||||
#!/bin/bash
|
||||
# Pre-tool safety check for Bash commands
|
||||
# Hook: PreToolUse (matcher: Bash)
|
||||
#
|
||||
# This hook runs before every Bash tool execution and blocks or warns on
|
||||
# potentially destructive or high-risk shell commands.
|
||||
#
|
||||
# Setup:
|
||||
# cp 06-hooks/pre-tool-check.sh ~/.claude/hooks/
|
||||
# chmod +x ~/.claude/hooks/pre-tool-check.sh
|
||||
#
|
||||
# Configure in ~/.claude/settings.json:
|
||||
# {
|
||||
# "hooks": {
|
||||
# "PreToolUse": [
|
||||
# {
|
||||
# "matcher": "Bash",
|
||||
# "hooks": [
|
||||
# {
|
||||
# "type": "command",
|
||||
# "command": "~/.claude/hooks/pre-tool-check.sh"
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# ]
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Input: JSON via stdin with the shape:
|
||||
# { "tool_name": "Bash", "tool_input": { "command": "..." } }
|
||||
#
|
||||
# Output: Exit 0 to allow, exit 2 to block, or print JSON to modify behavior.
|
||||
|
||||
# Read the full JSON input from stdin
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract the command using portable sed (compatible with macOS and Linux)
|
||||
COMMAND=$(echo "$INPUT" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
|
||||
# Fall back to the raw input if extraction fails
|
||||
if [ -z "$COMMAND" ]; then
|
||||
COMMAND="$INPUT"
|
||||
fi
|
||||
|
||||
# ── Blocked patterns ──────────────────────────────────────────────────────────
|
||||
# These commands are blocked unconditionally because they are almost always
|
||||
# destructive and rarely intentional in an automated context.
|
||||
|
||||
BLOCKED_PATTERNS=(
|
||||
"rm -rf /"
|
||||
"rm -rf \*"
|
||||
"dd if=/dev/zero"
|
||||
"dd if=/dev/random"
|
||||
":(){:|:&};:" # Fork bomb
|
||||
"mkfs\." # Filesystem format
|
||||
"format c:" # Windows disk format
|
||||
)
|
||||
|
||||
for pattern in "${BLOCKED_PATTERNS[@]}"; do
|
||||
if echo "$COMMAND" | grep -qE "$pattern"; then
|
||||
echo "❌ Blocked: Potentially destructive command detected: $pattern"
|
||||
echo " Command: $COMMAND"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Warning patterns ──────────────────────────────────────────────────────────
|
||||
# These patterns are risky but may be intentional. Log a warning and allow.
|
||||
|
||||
WARNING_PATTERNS=(
|
||||
"rm -rf"
|
||||
"git push --force"
|
||||
"git reset --hard"
|
||||
"git clean -f"
|
||||
"chmod -R 777"
|
||||
"sudo rm"
|
||||
"DROP TABLE"
|
||||
"DROP DATABASE"
|
||||
"truncate"
|
||||
)
|
||||
|
||||
WARNINGS=0
|
||||
for pattern in "${WARNING_PATTERNS[@]}"; do
|
||||
if echo "$COMMAND" | grep -qi "$pattern"; then
|
||||
echo "⚠️ Warning: High-risk operation detected: $pattern"
|
||||
WARNINGS=$((WARNINGS + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$WARNINGS" -gt 0 ]; then
|
||||
echo " Command: $COMMAND"
|
||||
echo " Proceeding — review the above warnings before continuing."
|
||||
fi
|
||||
|
||||
# ── Allow ─────────────────────────────────────────────────────────────────────
|
||||
exit 0
|
||||
78
uk/06-hooks/security-scan.sh
Normal file
78
uk/06-hooks/security-scan.sh
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# Security scan on file write
|
||||
# Hook: PostToolUse:Write
|
||||
#
|
||||
# Scans files for hardcoded secrets, API keys, and credentials.
|
||||
# Outputs a non-blocking warning via additionalContext when issues are found.
|
||||
#
|
||||
# Compatible with: macOS, Linux, Windows (Git Bash)
|
||||
|
||||
# Read JSON input from stdin (Claude Code hook protocol)
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract file_path using sed (compatible with all platforms including Windows Git Bash)
|
||||
# Avoids grep -P (not available on Windows Git Bash) and python3 dependency
|
||||
FILE_PATH=$(echo "$INPUT" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
|
||||
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip binary files, vendor dirs, and build artifacts
|
||||
case "$FILE_PATH" in
|
||||
*.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.eot) exit 0 ;;
|
||||
*/node_modules/*|*/.git/*|*/dist/*|*/build/*) exit 0 ;;
|
||||
esac
|
||||
|
||||
ISSUES=""
|
||||
|
||||
# Check for hardcoded passwords
|
||||
# Handles both JSON format ("password": "value") and code format (password = 'value')
|
||||
# Use \\n as separator — it is a valid JSON newline escape and passes through printf safely
|
||||
if grep -qiE '"password"[[:space:]]*:[[:space:]]*"[^"]+"' "$FILE_PATH" 2>/dev/null; then
|
||||
ISSUES="${ISSUES}- WARNING: Potential hardcoded password detected\\n"
|
||||
elif grep -qiE '(password|passwd|pwd)[[:space:]]*=[[:space:]]*'"'"'[^'"'"']+'"'"'' "$FILE_PATH" 2>/dev/null; then
|
||||
ISSUES="${ISSUES}- WARNING: Potential hardcoded password detected\\n"
|
||||
fi
|
||||
|
||||
# Check for hardcoded API keys
|
||||
if grep -qiE '"(api[_-]?key|apikey|access[_-]?token)"[[:space:]]*:[[:space:]]*"[^"]+"' "$FILE_PATH" 2>/dev/null; then
|
||||
ISSUES="${ISSUES}- WARNING: Potential hardcoded API key detected\\n"
|
||||
fi
|
||||
|
||||
# Check for hardcoded secrets and tokens
|
||||
if grep -qiE '(secret|token)[[:space:]]*=[[:space:]]*['"'"'"][^'"'"'"]+['"'"'"]' "$FILE_PATH" 2>/dev/null; then
|
||||
ISSUES="${ISSUES}- WARNING: Potential hardcoded secret or token detected\\n"
|
||||
fi
|
||||
|
||||
# Check for private keys
|
||||
if grep -q "BEGIN.*PRIVATE KEY" "$FILE_PATH" 2>/dev/null; then
|
||||
ISSUES="${ISSUES}- WARNING: Private key detected\\n"
|
||||
fi
|
||||
|
||||
# Check for AWS keys
|
||||
if grep -qE "AKIA[0-9A-Z]{16}" "$FILE_PATH" 2>/dev/null; then
|
||||
ISSUES="${ISSUES}- WARNING: AWS access key detected\\n"
|
||||
fi
|
||||
|
||||
# Scan with semgrep if available (stdout suppressed to avoid mixing with JSON output)
|
||||
if command -v semgrep &> /dev/null; then
|
||||
semgrep --config=auto "$FILE_PATH" --quiet >/dev/null 2>/dev/null
|
||||
fi
|
||||
|
||||
# Scan with trufflehog if available (stdout suppressed to avoid mixing with JSON output)
|
||||
if command -v trufflehog &> /dev/null; then
|
||||
trufflehog filesystem "$FILE_PATH" --only-verified --quiet >/dev/null 2>/dev/null
|
||||
fi
|
||||
|
||||
# If issues found, output as additionalContext (non-blocking warning)
|
||||
# Use hookSpecificOutput format required by Claude Code PostToolUse protocol
|
||||
if [ -n "$ISSUES" ]; then
|
||||
# Escape file path for JSON (backslash and double-quote)
|
||||
# ISSUES already uses \\n as separator (valid JSON escape) — only escape double-quotes
|
||||
SAFE_PATH=$(printf '%s' "$FILE_PATH" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
SAFE_ISSUES=$(printf '%s' "$ISSUES" | sed 's/"/\\"/g')
|
||||
printf '{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": "Security scan found issues in %s:\\n%sPlease review and use environment variables instead."}}' "$SAFE_PATH" "$SAFE_ISSUES"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
54
uk/06-hooks/validate-prompt.sh
Normal file
54
uk/06-hooks/validate-prompt.sh
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/bin/bash
|
||||
# Validate user prompts
|
||||
# Hook: UserPromptSubmit
|
||||
#
|
||||
# Reads the user prompt from stdin JSON and blocks dangerous operations.
|
||||
#
|
||||
# Compatible with: macOS, Linux, Windows (Git Bash)
|
||||
|
||||
# Read JSON input from stdin (Claude Code hook protocol)
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract the prompt text from JSON input
|
||||
# Claude Code sends UserPromptSubmit with field "user_prompt" (falls back to "prompt")
|
||||
PROMPT=$(echo "$INPUT" | sed -n 's/.*"user_prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
if [ -z "$PROMPT" ]; then
|
||||
PROMPT=$(echo "$INPUT" | sed -n 's/.*"prompt"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$PROMPT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for dangerous operations
|
||||
DANGEROUS_PATTERNS=(
|
||||
"rm -rf /"
|
||||
"delete database"
|
||||
"drop database"
|
||||
"format disk"
|
||||
"dd if="
|
||||
)
|
||||
|
||||
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
|
||||
if echo "$PROMPT" | grep -qi "$pattern"; then
|
||||
printf '{"decision": "block", "reason": "Dangerous operation detected: %s"}' "$pattern"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
# Check for production deployments
|
||||
if echo "$PROMPT" | grep -qiE "(deploy|push).*production"; then
|
||||
if [ ! -f ".deployment-approved" ]; then
|
||||
echo '{"decision": "block", "reason": "Production deployment requires approval. Create .deployment-approved file to proceed."}'
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check for required context in certain operations
|
||||
if echo "$PROMPT" | grep -qi "refactor"; then
|
||||
if [ ! -d "tests" ] && [ ! -d "test" ]; then
|
||||
printf '{"additionalContext": "Warning: Refactoring without tests may be risky. Consider writing tests first."}'
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user