Add dotclaude configuration files
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
# Hooks
|
||||
|
||||
Hook scripts are deterministic enforcement — unlike rules (advisory), hooks **guarantee** behavior by blocking or modifying tool calls before/after they execute.
|
||||
|
||||
Hooks are wired in `settings.json` under the `"hooks"` key. Each hook specifies an event, a matcher, and a command to run.
|
||||
|
||||
## Available Hooks
|
||||
|
||||
### protect-files.sh
|
||||
**Event**: PreToolUse (Edit|Write)
|
||||
|
||||
Blocks edits to sensitive and generated files. Fails closed (blocks if `jq` is missing).
|
||||
- `.env`, `.env.*` — secrets (by basename and path)
|
||||
- `*.pem`, `*.key`, `*.crt`, `*.p12`, `*.pfx` — certificates and keys
|
||||
- `id_rsa`, `id_ed25519`, `credentials.json`, `.npmrc`, `.pypirc` — credentials
|
||||
- `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` — lock files
|
||||
- `*.gen.ts`, `*.generated.*` — generated code
|
||||
- `*.min.js`, `*.min.css` — minified bundles
|
||||
- Anything inside `.git/`, `secrets/`, or `.claude/hooks/`
|
||||
- Self-protecting: blocks edits to hook scripts and `settings.json`
|
||||
|
||||
### warn-large-files.sh
|
||||
**Event**: PreToolUse (Edit|Write)
|
||||
|
||||
Blocks writes to build artifacts, dependency directories, and binary files. Fails closed.
|
||||
- `node_modules/`, `vendor/`, `dist/`, `build/`, `.next/`, `__pycache__/`, `.venv/`
|
||||
- `*.wasm`, `*.so`, `*.dylib`, `*.dll`, `*.exe`, `*.zip`, `*.tar.*`
|
||||
- `*.mp4`, `*.mov`, `*.mp3`, `*.pyc`, `*.class`
|
||||
|
||||
### block-dangerous-commands.sh
|
||||
**Event**: PreToolUse (Bash)
|
||||
|
||||
Blocks dangerous shell commands. Detects patterns even in chained commands (`&&`, `;`). Fails closed.
|
||||
- **Git**: `git push origin main/master`, `git push --force` (allows `--force-with-lease`), bare `git push` on main
|
||||
- **Filesystem**: `rm -rf /`, `rm -rf ~`, recursive delete on root/home paths
|
||||
- **Database**: `DROP TABLE/DATABASE`, `DELETE FROM` without WHERE, `TRUNCATE TABLE`
|
||||
- **System**: `chmod 777`, piping `curl`/`wget` to `bash`/`sh`, `mkfs`, `dd if=`, writes to `/dev/`
|
||||
|
||||
### format-on-save.sh
|
||||
**Event**: PostToolUse (Edit|Write)
|
||||
|
||||
Auto-formats files after Claude edits them. Auto-detects formatters by checking for both the binary and a config file:
|
||||
- Biome: `biome.json` + `node_modules/.bin/biome`
|
||||
- Prettier: `.prettierrc*` or `package.json` prettier key + `node_modules/.bin/prettier`
|
||||
- Ruff: `ruff.toml` or `pyproject.toml [tool.ruff]` + `ruff` binary
|
||||
- Black: `pyproject.toml [tool.black]` + `black` binary
|
||||
- rustfmt: standard for Rust (no config needed)
|
||||
- gofmt: standard for Go (no config needed)
|
||||
|
||||
### session-start.sh
|
||||
**Event**: SessionStart
|
||||
|
||||
Injects dynamic project context at session start: current branch (or detached HEAD warning), last commit, uncommitted changes count, staged changes indicator, and stash count.
|
||||
|
||||
## Adding Your Own
|
||||
|
||||
1. Create a `.sh` script in this directory
|
||||
2. Make it executable: `chmod +x your-hook.sh`
|
||||
3. Wire it in `settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/your-hook.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Exit 0 = allow, Exit 2 = block
|
||||
- Scripts receive JSON on stdin with `tool_input`
|
||||
- Requires `jq` for JSON parsing
|
||||
|
||||
See [Claude Code docs](https://code.claude.com/docs/en/hooks) for all hook events.
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
# Blocks dangerous shell commands: push to main, force push, destructive operations.
|
||||
# Used as a PreToolUse hook for Bash operations.
|
||||
# Exit 2 = block the action. Exit 0 = allow.
|
||||
|
||||
# Requires jq for JSON parsing — fail closed if missing
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"jq is required for command protection hooks but is not installed.\"}}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
|
||||
|
||||
if [ -z "$COMMAND" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
deny() {
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$1\"}}"
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Git protections
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Check if the command contains git push (handles chaining with &&, ;, |, subshells)
|
||||
if echo "$COMMAND" | grep -qE '(^|[;&|()]+[[:space:]]*)git[[:space:]]+push'; then
|
||||
|
||||
# Block push to main or master
|
||||
if echo "$COMMAND" | grep -qE 'git[[:space:]]+push.*(origin[[:space:]]+|:)(main|master)\b'; then
|
||||
deny "Blocked: cannot push directly to main/master. Use a feature branch and create a PR."
|
||||
fi
|
||||
|
||||
# Block bare "git push" when on main/master
|
||||
if echo "$COMMAND" | grep -qE 'git[[:space:]]+push[[:space:]]*($|[;&|])'; then
|
||||
CURRENT_BRANCH=$(git branch --show-current 2>/dev/null)
|
||||
if [ "$CURRENT_BRANCH" = "main" ] || [ "$CURRENT_BRANCH" = "master" ]; then
|
||||
deny "Blocked: you are on $CURRENT_BRANCH. Use a feature branch and create a PR."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Block force push (allow --force-with-lease)
|
||||
if echo "$COMMAND" | grep -qE 'git[[:space:]]+push.*(-[a-zA-Z]*f|--force)([[:space:]]|$)' && ! echo "$COMMAND" | grep -q '\-\-force-with-lease'; then
|
||||
deny "Blocked: force push is not allowed. Use --force-with-lease if you need to overwrite remote."
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Destructive filesystem operations
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Block rm -rf on root, home, or broad paths
|
||||
if echo "$COMMAND" | grep -qE 'rm[[:space:]]+-[a-zA-Z]*r[a-zA-Z]*f[[:space:]]+(\/|~|\$HOME|\.\.\/\.\.)'; then
|
||||
deny "Blocked: recursive force-delete on root/home/parent paths. Specify a safe target directory."
|
||||
fi
|
||||
|
||||
# Block rm -rf / or rm -rf /* or rm -rf ~
|
||||
if echo "$COMMAND" | grep -qE 'rm[[:space:]]+-[a-zA-Z]*r.*[[:space:]]+(\/[[:space:]]|\/\*|\/$|~\/?\*?[[:space:]]|~\/?\*?$)'; then
|
||||
deny "Blocked: recursive delete targeting root or home directory."
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Dangerous database operations
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Block DROP TABLE/DATABASE without safeguards
|
||||
if echo "$COMMAND" | grep -qiE 'DROP[[:space:]]+(TABLE|DATABASE|SCHEMA)[[:space:]]'; then
|
||||
deny "Blocked: DROP TABLE/DATABASE/SCHEMA detected. This is destructive and irreversible. Run manually if intended."
|
||||
fi
|
||||
|
||||
# Block DELETE FROM without WHERE
|
||||
if echo "$COMMAND" | grep -qiE 'DELETE[[:space:]]+FROM[[:space:]]+[a-zA-Z_]+[[:space:]]*($|;)' && ! echo "$COMMAND" | grep -qiE 'WHERE'; then
|
||||
deny "Blocked: DELETE FROM without WHERE clause would delete all rows. Add a WHERE clause."
|
||||
fi
|
||||
|
||||
# Block TRUNCATE TABLE
|
||||
if echo "$COMMAND" | grep -qiE 'TRUNCATE[[:space:]]+TABLE'; then
|
||||
deny "Blocked: TRUNCATE TABLE detected. This is destructive and irreversible. Run manually if intended."
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Dangerous system commands
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Block chmod 777
|
||||
if echo "$COMMAND" | grep -qE 'chmod[[:space:]]+777'; then
|
||||
deny "Blocked: chmod 777 gives everyone read/write/execute. Use more restrictive permissions (e.g., 755 or 644)."
|
||||
fi
|
||||
|
||||
# Block piping curl/wget to shell execution
|
||||
if echo "$COMMAND" | grep -qE '(curl|wget)[[:space:]].*\|[[:space:]]*(bash|sh|zsh|sudo)'; then
|
||||
deny "Blocked: piping downloaded content directly to a shell is dangerous. Download first, inspect, then execute."
|
||||
fi
|
||||
|
||||
# Block disk/partition destructive commands
|
||||
if echo "$COMMAND" | grep -qE '(mkfs|dd[[:space:]]+if=|>[[:space:]]*/dev/)'; then
|
||||
deny "Blocked: destructive disk operation detected. This can cause irreversible data loss."
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Destructive git operations
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
# Block git reset --hard (loses uncommitted work permanently)
|
||||
if echo "$COMMAND" | grep -qE 'git[[:space:]]+reset[[:space:]]+--hard'; then
|
||||
deny "Blocked: git reset --hard discards uncommitted changes permanently. Use git stash or git reset --soft instead."
|
||||
fi
|
||||
|
||||
# Block git clean -f (permanently deletes untracked files)
|
||||
if echo "$COMMAND" | grep -qE 'git[[:space:]]+clean[[:space:]]+-[a-zA-Z]*f'; then
|
||||
deny "Blocked: git clean -f permanently deletes untracked files. Review with git clean -n first, then run manually if intended."
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Accidental package publishing
|
||||
# ──────────────────────────────────────────────
|
||||
|
||||
if echo "$COMMAND" | grep -qE '(npm|yarn|pnpm|bun)[[:space:]]+publish'; then
|
||||
deny "Blocked: publishing npm packages should be done manually or via CI, not through Claude Code."
|
||||
fi
|
||||
|
||||
if echo "$COMMAND" | grep -qE 'cargo[[:space:]]+publish'; then
|
||||
deny "Blocked: publishing crates should be done manually or via CI, not through Claude Code."
|
||||
fi
|
||||
|
||||
if echo "$COMMAND" | grep -qE 'gem[[:space:]]+push'; then
|
||||
deny "Blocked: publishing gems should be done manually or via CI, not through Claude Code."
|
||||
fi
|
||||
|
||||
if echo "$COMMAND" | grep -qE 'twine[[:space:]]+upload'; then
|
||||
deny "Blocked: publishing Python packages should be done manually or via CI, not through Claude Code."
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
# Auto-formats files after Claude edits them.
|
||||
# Used as a PostToolUse hook for Edit|Write operations.
|
||||
# Auto-detects formatters — requires both the binary AND a config file to activate.
|
||||
|
||||
# Requires jq for JSON parsing
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
if [ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
EXTENSION="${FILE_PATH##*.}"
|
||||
FORMATTED=false
|
||||
|
||||
# Find the project root (nearest directory with package.json, pyproject.toml, Cargo.toml, go.mod, or .git)
|
||||
find_project_root() {
|
||||
local dir="$PWD"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -f "$dir/package.json" ] || [ -f "$dir/pyproject.toml" ] || [ -f "$dir/Cargo.toml" ] || [ -f "$dir/go.mod" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return
|
||||
fi
|
||||
dir=$(dirname "$dir")
|
||||
done
|
||||
echo "$PWD"
|
||||
}
|
||||
|
||||
ROOT=$(find_project_root)
|
||||
|
||||
# --- Biome (JS/TS all-in-one — check first, it's faster than Prettier) ---
|
||||
if [ "$FORMATTED" = false ] && [ -f "$ROOT/node_modules/.bin/biome" ] && [ -f "$ROOT/biome.json" -o -f "$ROOT/biome.jsonc" ]; then
|
||||
case "$EXTENSION" in
|
||||
js|jsx|ts|tsx|json|css)
|
||||
npx biome format --write "$FILE_PATH" 2>/dev/null && FORMATTED=true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- Prettier (Node.js / TypeScript) ---
|
||||
if [ "$FORMATTED" = false ] && [ -f "$ROOT/node_modules/.bin/prettier" ]; then
|
||||
# Check for Prettier config (any common format)
|
||||
HAS_PRETTIER_CONFIG=false
|
||||
for cfg in .prettierrc .prettierrc.json .prettierrc.yml .prettierrc.yaml .prettierrc.js .prettierrc.cjs .prettierrc.mjs .prettierrc.toml prettier.config.js prettier.config.cjs prettier.config.mjs; do
|
||||
if [ -f "$ROOT/$cfg" ]; then
|
||||
HAS_PRETTIER_CONFIG=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
# Also check package.json for "prettier" key
|
||||
if [ "$HAS_PRETTIER_CONFIG" = false ] && [ -f "$ROOT/package.json" ] && grep -q '"prettier"' "$ROOT/package.json" 2>/dev/null; then
|
||||
HAS_PRETTIER_CONFIG=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_PRETTIER_CONFIG" = true ]; then
|
||||
case "$EXTENSION" in
|
||||
js|jsx|ts|tsx|json|css|scss|md|yaml|yml|html)
|
||||
npx prettier --write "$FILE_PATH" 2>/dev/null && FORMATTED=true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Ruff (Python — modern replacement for Black + isort) ---
|
||||
if [ "$FORMATTED" = false ] && command -v ruff >/dev/null 2>&1; then
|
||||
HAS_RUFF_CONFIG=false
|
||||
if [ -f "$ROOT/ruff.toml" ] || [ -f "$ROOT/.ruff.toml" ]; then
|
||||
HAS_RUFF_CONFIG=true
|
||||
elif [ -f "$ROOT/pyproject.toml" ] && grep -q '\[tool\.ruff\]' "$ROOT/pyproject.toml" 2>/dev/null; then
|
||||
HAS_RUFF_CONFIG=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_RUFF_CONFIG" = true ]; then
|
||||
case "$EXTENSION" in
|
||||
py)
|
||||
ruff format "$FILE_PATH" 2>/dev/null
|
||||
ruff check --fix "$FILE_PATH" 2>/dev/null
|
||||
FORMATTED=true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Black + isort (Python — fallback if Ruff not configured) ---
|
||||
if [ "$FORMATTED" = false ] && command -v black >/dev/null 2>&1; then
|
||||
HAS_BLACK_CONFIG=false
|
||||
if [ -f "$ROOT/pyproject.toml" ] && grep -q '\[tool\.black\]' "$ROOT/pyproject.toml" 2>/dev/null; then
|
||||
HAS_BLACK_CONFIG=true
|
||||
fi
|
||||
|
||||
if [ "$HAS_BLACK_CONFIG" = true ]; then
|
||||
case "$EXTENSION" in
|
||||
py)
|
||||
black --quiet "$FILE_PATH" 2>/dev/null
|
||||
command -v isort >/dev/null 2>&1 && isort --quiet "$FILE_PATH" 2>/dev/null
|
||||
FORMATTED=true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Rust (rustfmt is standard — no config check needed) ---
|
||||
if [ "$FORMATTED" = false ] && command -v rustfmt >/dev/null 2>&1; then
|
||||
case "$EXTENSION" in
|
||||
rs)
|
||||
rustfmt "$FILE_PATH" 2>/dev/null && FORMATTED=true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- Go (gofmt is standard — no config check needed) ---
|
||||
if [ "$FORMATTED" = false ] && command -v gofmt >/dev/null 2>&1; then
|
||||
case "$EXTENSION" in
|
||||
go)
|
||||
gofmt -w "$FILE_PATH" 2>/dev/null && FORMATTED=true
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/bash
|
||||
# Blocks edits to sensitive or generated files.
|
||||
# Used as a PreToolUse hook for Edit|Write operations.
|
||||
# Exit 2 = block the action. Exit 0 = allow.
|
||||
|
||||
# Requires jq for JSON parsing — fail closed if missing
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"jq is required for file protection hooks but is not installed.\"}}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Protected patterns — add your own
|
||||
PROTECTED_PATTERNS=(
|
||||
".env"
|
||||
".env.*"
|
||||
"*.pem"
|
||||
"*.key"
|
||||
"*.crt"
|
||||
"*.p12"
|
||||
"*.pfx"
|
||||
"id_rsa"
|
||||
"id_ed25519"
|
||||
"credentials.json"
|
||||
".npmrc"
|
||||
".pypirc"
|
||||
"package-lock.json"
|
||||
"yarn.lock"
|
||||
"pnpm-lock.yaml"
|
||||
"*.gen.ts"
|
||||
"*.generated.*"
|
||||
"*.min.js"
|
||||
"*.min.css"
|
||||
)
|
||||
|
||||
BASENAME=$(basename "$FILE_PATH")
|
||||
|
||||
for pattern in "${PROTECTED_PATTERNS[@]}"; do
|
||||
case "$BASENAME" in
|
||||
$pattern)
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Protected file: $BASENAME matches pattern '$pattern'\"}}"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Block anything in common sensitive directories (handles both relative and absolute paths)
|
||||
case "$FILE_PATH" in
|
||||
.git/*|*/.git/*)
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Cannot edit files inside .git/\"}}"
|
||||
exit 2
|
||||
;;
|
||||
secrets/*|*/secrets/*)
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Cannot edit files inside secrets/\"}}"
|
||||
exit 2
|
||||
;;
|
||||
.env|.env.*|*/.env|*/.env.*)
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Cannot edit .env files\"}}"
|
||||
exit 2
|
||||
;;
|
||||
.claude/hooks/*|*/.claude/hooks/*)
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Cannot edit hook scripts — these enforce security boundaries.\"}}"
|
||||
exit 2
|
||||
;;
|
||||
.claude/settings.json|*/.claude/settings.json)
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"Editing settings.json — this controls permissions and hooks. Confirm this change.\"}}"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
# Scans file content for accidental secrets before writing.
|
||||
# Used as a PreToolUse hook for Edit|Write operations.
|
||||
# Exit 2 = block. Exit 0 = allow.
|
||||
|
||||
# Requires jq for JSON parsing — allow if missing (don't block the user)
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
|
||||
|
||||
# Extract the content being written
|
||||
if [ "$TOOL_NAME" = "Write" ]; then
|
||||
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
|
||||
elif [ "$TOOL_NAME" = "Edit" ]; then
|
||||
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -z "$CONTENT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- High-confidence secret patterns ---
|
||||
|
||||
MATCHES=""
|
||||
|
||||
# AWS Access Key IDs
|
||||
if echo "$CONTENT" | grep -qE 'AKIA[0-9A-Z]{16}'; then
|
||||
MATCHES="$MATCHES AWS access key (AKIA...);"
|
||||
fi
|
||||
|
||||
# AWS Secret Access Keys (40 chars base64 after a key assignment)
|
||||
if echo "$CONTENT" | grep -qiE '(aws_secret_access_key|secret_key)[[:space:]]*[=:][[:space:]]*["\x27]?[A-Za-z0-9/+=]{40}'; then
|
||||
MATCHES="$MATCHES AWS secret key;"
|
||||
fi
|
||||
|
||||
# GitHub tokens (PAT, OAuth, App)
|
||||
if echo "$CONTENT" | grep -qE '(ghp_|gho_|ghs_|ghr_|github_pat_)[a-zA-Z0-9_]{20,}'; then
|
||||
MATCHES="$MATCHES GitHub token;"
|
||||
fi
|
||||
|
||||
# OpenAI / Stripe / Anthropic style keys (sk-...)
|
||||
if echo "$CONTENT" | grep -qE 'sk-[a-zA-Z0-9]{20,}'; then
|
||||
MATCHES="$MATCHES API key (sk-...);"
|
||||
fi
|
||||
|
||||
# Slack tokens
|
||||
if echo "$CONTENT" | grep -qE 'xox[bpras]-[0-9a-zA-Z-]{10,}'; then
|
||||
MATCHES="$MATCHES Slack token;"
|
||||
fi
|
||||
|
||||
# Private key blocks
|
||||
if echo "$CONTENT" | grep -qE -- '-----BEGIN[[:space:]]+(RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----'; then
|
||||
MATCHES="$MATCHES private key block;"
|
||||
fi
|
||||
|
||||
# Connection strings with embedded credentials
|
||||
if echo "$CONTENT" | grep -qE '(mongodb|postgres|mysql|redis|amqp|smtp)(\+[a-z]+)?://[^:[:space:]]+:[^@[:space:]]+@'; then
|
||||
MATCHES="$MATCHES connection string with credentials;"
|
||||
fi
|
||||
|
||||
# Generic password/secret/token assignments with literal string values
|
||||
# Matches: password = "actual_value", SECRET_KEY: 'actual_value', api_token="actual_value"
|
||||
# Excludes: env var references like process.env.*, os.environ.*, ${...}, getenv(...)
|
||||
if echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27][^"\x27]{8,}["\x27]' && \
|
||||
! echo "$CONTENT" | grep -qiE '(password|secret|token|api_key|apikey|api_secret)[[:space:]]*[=:][[:space:]]*["\x27]?(process\.env|os\.environ|getenv|\$\{|ENV\[|env\()'; then
|
||||
MATCHES="$MATCHES hardcoded credential;"
|
||||
fi
|
||||
|
||||
if [ -n "$MATCHES" ]; then
|
||||
# Use "ask" not "deny" — warn the user but let them override (could be test fixtures)
|
||||
REASON="Possible secret detected in content:$MATCHES Review carefully before allowing."
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"ask\",\"permissionDecisionReason\":\"$REASON\"}}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Injects dynamic project context at session start.
|
||||
# Used as a SessionStart hook.
|
||||
|
||||
CONTEXT=""
|
||||
|
||||
# Current branch (or detached HEAD)
|
||||
BRANCH=$(git branch --show-current 2>/dev/null)
|
||||
if [ -n "$BRANCH" ]; then
|
||||
CONTEXT="Branch: $BRANCH"
|
||||
elif git rev-parse --git-dir >/dev/null 2>&1; then
|
||||
SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null)
|
||||
CONTEXT="HEAD: detached at $SHORT_SHA"
|
||||
fi
|
||||
|
||||
# Last commit
|
||||
LAST_COMMIT=$(git log --oneline -1 2>/dev/null)
|
||||
if [ -n "$LAST_COMMIT" ]; then
|
||||
CONTEXT="$CONTEXT | Last commit: $LAST_COMMIT"
|
||||
fi
|
||||
|
||||
# Uncommitted changes count
|
||||
CHANGES=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$CHANGES" -gt 0 ] 2>/dev/null; then
|
||||
CONTEXT="$CONTEXT | Uncommitted changes: $CHANGES files"
|
||||
fi
|
||||
|
||||
# Staged changes indicator
|
||||
if ! git diff --cached --quiet 2>/dev/null; then
|
||||
CONTEXT="$CONTEXT | Staged: yes"
|
||||
fi
|
||||
|
||||
# Stash count
|
||||
STASH_COUNT=$(git stash list 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$STASH_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
CONTEXT="$CONTEXT | Stashes: $STASH_COUNT"
|
||||
fi
|
||||
|
||||
# Active PR on current branch (if gh CLI is available)
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
PR_INFO=$(gh pr view --json number,title,state --jq '"PR #\(.number): \(.title) (\(.state))"' 2>/dev/null)
|
||||
if [ -n "$PR_INFO" ]; then
|
||||
CONTEXT="$CONTEXT | $PR_INFO"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$CONTEXT" ]; then
|
||||
echo "$CONTEXT"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
# Blocks writes to build artifacts, binary files, and dependency directories.
|
||||
# Used as a PreToolUse hook for Edit|Write operations.
|
||||
# Exit 2 = block the action. Exit 0 = allow.
|
||||
|
||||
# Requires jq for JSON parsing — fail closed if missing
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"jq is required for file protection hooks but is not installed.\"}}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
INPUT=$(cat)
|
||||
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
|
||||
|
||||
if [ -z "$FILE_PATH" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Block dependency and build directories
|
||||
case "$FILE_PATH" in
|
||||
node_modules/*|*/node_modules/*)
|
||||
REASON="Cannot write into node_modules/ — install dependencies via package manager instead." ;;
|
||||
vendor/*|*/vendor/*)
|
||||
REASON="Cannot write into vendor/ — use dependency manager instead." ;;
|
||||
dist/*|*/dist/*|build/*|*/build/*|.next/*|*/.next/*)
|
||||
REASON="Cannot write into build output directories — these are generated by the build process." ;;
|
||||
__pycache__/*|*/__pycache__/*)
|
||||
REASON="Cannot write into __pycache__/ — these are generated by Python." ;;
|
||||
.venv/*|*/.venv/*|venv/*|*/venv/*)
|
||||
REASON="Cannot write into virtual environment directories." ;;
|
||||
*)
|
||||
REASON="" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$REASON" ]; then
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Block binary and archive file extensions
|
||||
BASENAME=$(basename "$FILE_PATH")
|
||||
case "$BASENAME" in
|
||||
*.wasm|*.so|*.dylib|*.dll|*.exe|*.o|*.a)
|
||||
REASON="Cannot write binary files — these should be compiled, not hand-written." ;;
|
||||
*.zip|*.tar|*.tar.gz|*.tar.bz2|*.tgz|*.rar|*.7z)
|
||||
REASON="Cannot write archive files." ;;
|
||||
*.mp4|*.mov|*.avi|*.mkv|*.mp3|*.wav|*.flac)
|
||||
REASON="Cannot write media files — add these manually outside Claude Code." ;;
|
||||
*.pyc|*.pyo|*.class)
|
||||
REASON="Cannot write compiled bytecode files." ;;
|
||||
esac
|
||||
|
||||
if [ -n "$REASON" ]; then
|
||||
echo "{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"$REASON\"}}"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user