cc-safe-setup 29.6.0 → 29.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COOKBOOK.md +70 -0
- package/README.md +43 -4
- package/TROUBLESHOOTING.md +30 -0
- package/examples/api-rate-limit-tracker.sh +51 -0
- package/examples/auto-answer-question.sh +67 -0
- package/examples/auto-approve-readonly-tools.sh +10 -0
- package/examples/aws-production-guard.sh +40 -0
- package/examples/banned-command-guard.sh +48 -0
- package/examples/bash-heuristic-approver.sh +59 -0
- package/examples/block-database-wipe.sh +1 -1
- package/examples/classifier-fallback-allow.sh +70 -0
- package/examples/commit-message-check.sh +8 -1
- package/examples/commit-message-quality.sh +35 -0
- package/examples/credential-exfil-guard.sh +12 -0
- package/examples/cwd-reminder.sh +37 -0
- package/examples/dependency-install-guard.sh +84 -0
- package/examples/deploy-guard.sh +1 -1
- package/examples/detect-mixed-indentation.sh +33 -0
- package/examples/disk-space-check.sh +42 -0
- package/examples/docker-dangerous-guard.sh +47 -0
- package/examples/dockerfile-lint.sh +58 -0
- package/examples/edit-always-allow.sh +53 -0
- package/examples/env-file-gitignore-check.sh +39 -0
- package/examples/env-source-guard.sh +1 -1
- package/examples/git-stash-before-danger.sh +58 -0
- package/examples/github-actions-guard.sh +49 -0
- package/examples/gitignore-auto-add.sh +30 -0
- package/examples/go-vet-after-edit.sh +33 -0
- package/examples/hook-tamper-guard.sh +67 -0
- package/examples/kubernetes-guard.sh +2 -1
- package/examples/large-file-write-guard.sh +40 -0
- package/examples/main-branch-warn.sh +40 -0
- package/examples/max-edit-size-guard.sh +9 -15
- package/examples/mcp-server-guard.sh +70 -0
- package/examples/multiline-command-approver.sh +89 -0
- package/examples/no-base64-exfil.sh +27 -0
- package/examples/no-debug-commit.sh +60 -0
- package/examples/no-exposed-port-in-dockerfile.sh +32 -0
- package/examples/no-fixme-ship.sh +41 -0
- package/examples/no-hardcoded-ip.sh +26 -0
- package/examples/no-http-in-code.sh +19 -0
- package/examples/no-push-without-tests.sh +33 -0
- package/examples/no-self-signed-cert.sh +19 -0
- package/examples/no-star-import-python.sh +28 -0
- package/examples/no-wget-piped-bash.sh +22 -0
- package/examples/node-version-check.sh +40 -0
- package/examples/npm-publish-guard.sh +5 -2
- package/examples/output-token-env-check.sh +44 -0
- package/examples/package-lock-frozen.sh +25 -0
- package/examples/pip-venv-required.sh +40 -0
- package/examples/port-conflict-check.sh +62 -0
- package/examples/prefer-builtin-tools.sh +33 -0
- package/examples/python-import-check.sh +52 -0
- package/examples/python-ruff-on-edit.sh +51 -0
- package/examples/quoted-flag-approver.sh +51 -0
- package/examples/react-key-warn.sh +32 -0
- package/examples/rm-safety-net.sh +9 -0
- package/examples/rust-clippy-after-edit.sh +37 -0
- package/examples/session-quota-tracker.sh +44 -0
- package/examples/session-start-safety-check.sh +60 -0
- package/examples/session-summary-stop.sh +49 -0
- package/examples/session-time-limit.sh +34 -0
- package/examples/temp-file-cleanup.sh +41 -0
- package/examples/test-before-push.sh +8 -1
- package/examples/test-coverage-reminder.sh +49 -0
- package/examples/test-exit-code-verify.sh +60 -0
- package/examples/tool-file-logger.sh +46 -0
- package/examples/typescript-lint-on-edit.sh +61 -0
- package/examples/typescript-strict-check.sh +35 -0
- package/examples/uncommitted-changes-stop.sh +16 -0
- package/examples/uncommitted-discard-guard.sh +72 -0
- package/examples/worktree-unmerged-guard.sh +13 -3
- package/examples/yaml-syntax-check.sh +50 -0
- package/index.mjs +3 -0
- package/package.json +2 -2
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# temp-file-cleanup.sh — Stop hook
|
|
3
|
+
# Trigger: Stop
|
|
4
|
+
# Matcher: (empty — runs on session end)
|
|
5
|
+
#
|
|
6
|
+
# Cleans up temporary files created by Claude Code sessions.
|
|
7
|
+
# Claude Code creates /tmp/claude-*-cwd files for directory tracking
|
|
8
|
+
# but never deletes them, accumulating 500+ files/day.
|
|
9
|
+
#
|
|
10
|
+
# See: https://github.com/anthropics/claude-code/issues/8856
|
|
11
|
+
#
|
|
12
|
+
# Usage: Add to settings.json as a Stop hook
|
|
13
|
+
#
|
|
14
|
+
# {
|
|
15
|
+
# "hooks": {
|
|
16
|
+
# "Stop": [{
|
|
17
|
+
# "matcher": "",
|
|
18
|
+
# "hooks": [{ "type": "command", "command": "bash /path/to/temp-file-cleanup.sh" }]
|
|
19
|
+
# }]
|
|
20
|
+
# }
|
|
21
|
+
# }
|
|
22
|
+
|
|
23
|
+
# Count before cleanup
|
|
24
|
+
COUNT=$(find /tmp -maxdepth 1 -name "claude-*" -type f 2>/dev/null | wc -l)
|
|
25
|
+
|
|
26
|
+
if [ "$COUNT" -eq 0 ]; then
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Clean up Claude Code temp files older than 1 hour
|
|
31
|
+
find /tmp -maxdepth 1 -name "claude-*-cwd" -type f -mmin +60 -delete 2>/dev/null
|
|
32
|
+
find /tmp -maxdepth 1 -name "claude-*" -type f -mmin +60 -delete 2>/dev/null
|
|
33
|
+
|
|
34
|
+
REMAINING=$(find /tmp -maxdepth 1 -name "claude-*" -type f 2>/dev/null | wc -l)
|
|
35
|
+
CLEANED=$((COUNT - REMAINING))
|
|
36
|
+
|
|
37
|
+
if [ "$CLEANED" -gt 0 ]; then
|
|
38
|
+
echo "Cleaned $CLEANED Claude temp files (${REMAINING} recent files kept)" >&2
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
exit 0
|
|
@@ -12,10 +12,17 @@
|
|
|
12
12
|
# "hooks": {
|
|
13
13
|
# "PreToolUse": [{
|
|
14
14
|
# "matcher": "Bash",
|
|
15
|
-
# "hooks": [{
|
|
15
|
+
# "hooks": [{
|
|
16
|
+
# "type": "command",
|
|
17
|
+
# "if": "Bash(git push *)",
|
|
18
|
+
# "command": "~/.claude/hooks/test-before-push.sh"
|
|
19
|
+
# }]
|
|
16
20
|
# }]
|
|
17
21
|
# }
|
|
18
22
|
# }
|
|
23
|
+
#
|
|
24
|
+
# The "if" field (v2.1.85+) eliminates process spawning for non-push commands.
|
|
25
|
+
# Without "if", the hook still works — it checks internally and exits early.
|
|
19
26
|
|
|
20
27
|
INPUT=$(cat)
|
|
21
28
|
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# test-coverage-reminder.sh — Remind to run tests after code changes
|
|
3
|
+
#
|
|
4
|
+
# Prevents: Pushing untested code. Claude often edits files
|
|
5
|
+
# without running the test suite afterward.
|
|
6
|
+
#
|
|
7
|
+
# Tracks: number of Edit/Write calls since last test run.
|
|
8
|
+
# Warns at: 5 edits without tests, blocks at 10.
|
|
9
|
+
#
|
|
10
|
+
# TRIGGER: PostToolUse
|
|
11
|
+
# MATCHER: "Write|Edit|Bash"
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# {
|
|
15
|
+
# "hooks": {
|
|
16
|
+
# "PostToolUse": [{
|
|
17
|
+
# "matcher": "Write|Edit|Bash",
|
|
18
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/test-coverage-reminder.sh" }]
|
|
19
|
+
# }]
|
|
20
|
+
# }
|
|
21
|
+
# }
|
|
22
|
+
|
|
23
|
+
COUNTER_FILE="/tmp/cc-edit-since-test-$$"
|
|
24
|
+
INPUT=$(cat)
|
|
25
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
case "$TOOL" in
|
|
28
|
+
Write|Edit)
|
|
29
|
+
# Increment edit counter
|
|
30
|
+
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
31
|
+
COUNT=$((COUNT + 1))
|
|
32
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
33
|
+
|
|
34
|
+
if [ "$COUNT" -eq 5 ]; then
|
|
35
|
+
echo "REMINDER: 5 files changed since last test run. Consider running tests." >&2
|
|
36
|
+
elif [ "$COUNT" -ge 10 ]; then
|
|
37
|
+
echo "WARNING: $COUNT files changed without running tests. Run tests now." >&2
|
|
38
|
+
fi
|
|
39
|
+
;;
|
|
40
|
+
Bash)
|
|
41
|
+
# Reset counter if a test command was run
|
|
42
|
+
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
43
|
+
if echo "$CMD" | grep -qiE '(npm\s+test|npx\s+jest|npx\s+vitest|pytest|go\s+test|cargo\s+test|make\s+test|bash\s+test)'; then
|
|
44
|
+
echo "0" > "$COUNTER_FILE"
|
|
45
|
+
fi
|
|
46
|
+
;;
|
|
47
|
+
esac
|
|
48
|
+
|
|
49
|
+
exit 0
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# test-exit-code-verify.sh — Verify test command exit codes match results
|
|
3
|
+
#
|
|
4
|
+
# Problem: Claude reports "tests passed" even when they didn't run or failed.
|
|
5
|
+
# This PostToolUse hook checks the actual exit code of test commands and
|
|
6
|
+
# emits a warning if the exit code indicates failure.
|
|
7
|
+
#
|
|
8
|
+
# GitHub Issue: #1501 (Claude reports false test results)
|
|
9
|
+
#
|
|
10
|
+
# Usage: Add to settings.json as a PostToolUse hook on "Bash"
|
|
11
|
+
#
|
|
12
|
+
# How it works:
|
|
13
|
+
# 1. Detects test-like commands (npm test, pytest, jest, go test, etc.)
|
|
14
|
+
# 2. Checks the actual exit code from tool output
|
|
15
|
+
# 3. If exit code != 0, warns Claude via stderr so it cannot claim success
|
|
16
|
+
# 4. If no output was captured, warns about phantom test runs
|
|
17
|
+
#
|
|
18
|
+
# Why stderr: PostToolUse hook stderr is shown to Claude as feedback.
|
|
19
|
+
# This forces Claude to acknowledge test failures instead of fabricating results.
|
|
20
|
+
|
|
21
|
+
INPUT=$(cat)
|
|
22
|
+
|
|
23
|
+
# Extract command and exit code
|
|
24
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
25
|
+
EXIT_CODE=$(echo "$INPUT" | jq -r '.tool_result.exitCode // .tool_result.exit_code // empty' 2>/dev/null)
|
|
26
|
+
STDOUT=$(echo "$INPUT" | jq -r '.tool_result.stdout // empty' 2>/dev/null)
|
|
27
|
+
|
|
28
|
+
[ -z "$COMMAND" ] && exit 0
|
|
29
|
+
|
|
30
|
+
# Detect test commands
|
|
31
|
+
is_test_command() {
|
|
32
|
+
local cmd="$1"
|
|
33
|
+
echo "$cmd" | grep -qiE '(npm\s+test|npx\s+jest|npx\s+vitest|pytest|python\s+-m\s+pytest|go\s+test|cargo\s+test|bundle\s+exec\s+rspec|mix\s+test|dotnet\s+test|mvn\s+test|gradle\s+test|make\s+test|bash\s+test\.sh)'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if ! is_test_command "$COMMAND"; then
|
|
37
|
+
exit 0
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
# Check exit code
|
|
41
|
+
if [ -n "$EXIT_CODE" ] && [ "$EXIT_CODE" != "0" ]; then
|
|
42
|
+
echo "⚠️ TEST FAILURE DETECTED" >&2
|
|
43
|
+
echo "Command: $(echo "$COMMAND" | head -c 100)" >&2
|
|
44
|
+
echo "Exit code: $EXIT_CODE" >&2
|
|
45
|
+
echo "Do NOT report these tests as passing. The exit code proves failure." >&2
|
|
46
|
+
echo "Re-read the output above and fix the failing tests." >&2
|
|
47
|
+
exit 0
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
# Check for empty output (phantom test run)
|
|
51
|
+
if [ -z "$STDOUT" ] || [ ${#STDOUT} -lt 10 ]; then
|
|
52
|
+
echo "⚠️ TEST OUTPUT SUSPICIOUSLY SHORT" >&2
|
|
53
|
+
echo "Command: $(echo "$COMMAND" | head -c 100)" >&2
|
|
54
|
+
echo "Output length: ${#STDOUT} chars" >&2
|
|
55
|
+
echo "Verify tests actually ran. Short output may indicate no tests executed." >&2
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# Tests appear to have run and passed
|
|
60
|
+
exit 0
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# tool-file-logger.sh — Log file paths from Read/Write/Edit to stderr
|
|
3
|
+
#
|
|
4
|
+
# Solves: "No indication of WHICH file for READ tool" (#21151 — 180 reactions)
|
|
5
|
+
# Users must expand every Read/Write/Edit to see the file path.
|
|
6
|
+
# This hook shows the file path in the collapsed view.
|
|
7
|
+
#
|
|
8
|
+
# Output format:
|
|
9
|
+
# [Read: src/components/App.tsx]
|
|
10
|
+
# [Write: package.json]
|
|
11
|
+
# [Edit: src/utils/helpers.ts]
|
|
12
|
+
#
|
|
13
|
+
# TRIGGER: PostToolUse
|
|
14
|
+
# MATCHER: "Read|Write|Edit"
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
# {
|
|
18
|
+
# "hooks": {
|
|
19
|
+
# "PostToolUse": [{
|
|
20
|
+
# "matcher": "Read|Write|Edit",
|
|
21
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/tool-file-logger.sh" }]
|
|
22
|
+
# }]
|
|
23
|
+
# }
|
|
24
|
+
# }
|
|
25
|
+
|
|
26
|
+
INPUT=$(cat)
|
|
27
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
28
|
+
[ -z "$TOOL" ] && exit 0
|
|
29
|
+
|
|
30
|
+
# Extract file path from tool input
|
|
31
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.filePath // empty' 2>/dev/null)
|
|
32
|
+
[ -z "$FILE" ] && exit 0
|
|
33
|
+
|
|
34
|
+
# Show just the filename for brevity, full path available in expanded view
|
|
35
|
+
BASENAME=$(basename "$FILE")
|
|
36
|
+
DIR=$(dirname "$FILE")
|
|
37
|
+
|
|
38
|
+
# For paths inside home directory, show relative path
|
|
39
|
+
if echo "$DIR" | grep -q "^$HOME"; then
|
|
40
|
+
RELDIR=$(echo "$DIR" | sed "s|^$HOME|~|")
|
|
41
|
+
echo "[$TOOL: $RELDIR/$BASENAME]" >&2
|
|
42
|
+
else
|
|
43
|
+
echo "[$TOOL: $FILE]" >&2
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
exit 0
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# typescript-lint-on-edit.sh — Run TypeScript type check after editing .ts/.tsx files
|
|
3
|
+
#
|
|
4
|
+
# TRIGGER: PostToolUse
|
|
5
|
+
# MATCHER: Edit
|
|
6
|
+
#
|
|
7
|
+
# Best with v2.1.85 "if" field:
|
|
8
|
+
# {
|
|
9
|
+
# "hooks": {
|
|
10
|
+
# "PostToolUse": [{
|
|
11
|
+
# "matcher": "Edit",
|
|
12
|
+
# "hooks": [{
|
|
13
|
+
# "type": "command",
|
|
14
|
+
# "if": "Edit(*.ts)",
|
|
15
|
+
# "command": "~/.claude/hooks/typescript-lint-on-edit.sh"
|
|
16
|
+
# }]
|
|
17
|
+
# }]
|
|
18
|
+
# }
|
|
19
|
+
# }
|
|
20
|
+
#
|
|
21
|
+
# Without "if", the hook checks file extension internally.
|
|
22
|
+
|
|
23
|
+
INPUT=$(cat)
|
|
24
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
25
|
+
|
|
26
|
+
# Skip non-TypeScript files
|
|
27
|
+
case "$FILE" in
|
|
28
|
+
*.ts|*.tsx) ;;
|
|
29
|
+
*) exit 0 ;;
|
|
30
|
+
esac
|
|
31
|
+
|
|
32
|
+
[ ! -f "$FILE" ] && exit 0
|
|
33
|
+
|
|
34
|
+
# Find tsconfig.json in parent directories
|
|
35
|
+
DIR=$(dirname "$FILE")
|
|
36
|
+
TSCONFIG=""
|
|
37
|
+
while [ "$DIR" != "/" ]; do
|
|
38
|
+
if [ -f "$DIR/tsconfig.json" ]; then
|
|
39
|
+
TSCONFIG="$DIR/tsconfig.json"
|
|
40
|
+
break
|
|
41
|
+
fi
|
|
42
|
+
DIR=$(dirname "$DIR")
|
|
43
|
+
done
|
|
44
|
+
|
|
45
|
+
# No tsconfig = no type checking possible
|
|
46
|
+
[ -z "$TSCONFIG" ] && exit 0
|
|
47
|
+
|
|
48
|
+
# Run tsc --noEmit on the specific file
|
|
49
|
+
PROJECT_DIR=$(dirname "$TSCONFIG")
|
|
50
|
+
ISSUES=$(cd "$PROJECT_DIR" && npx tsc --noEmit --pretty false 2>&1 | grep "$(basename "$FILE")" | head -10)
|
|
51
|
+
|
|
52
|
+
if [ -n "$ISSUES" ]; then
|
|
53
|
+
COUNT=$(echo "$ISSUES" | wc -l)
|
|
54
|
+
echo "⚠ TypeScript: $COUNT error(s) in $(basename "$FILE")" >&2
|
|
55
|
+
echo "$ISSUES" | head -5 >&2
|
|
56
|
+
if [ "$COUNT" -gt 5 ]; then
|
|
57
|
+
echo " ... and $((COUNT - 5)) more" >&2
|
|
58
|
+
fi
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
exit 0
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# typescript-strict-check.sh — Warn when TypeScript strict mode is disabled
|
|
3
|
+
#
|
|
4
|
+
# Prevents: Claude silently setting "strict": false in tsconfig.json
|
|
5
|
+
# to bypass type errors instead of fixing them.
|
|
6
|
+
#
|
|
7
|
+
# TRIGGER: PostToolUse
|
|
8
|
+
# MATCHER: "Write|Edit"
|
|
9
|
+
|
|
10
|
+
INPUT=$(cat)
|
|
11
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
12
|
+
[ -z "$FILE" ] && exit 0
|
|
13
|
+
|
|
14
|
+
BASENAME=$(basename "$FILE")
|
|
15
|
+
[ "$BASENAME" != "tsconfig.json" ] && exit 0
|
|
16
|
+
[ ! -f "$FILE" ] && exit 0
|
|
17
|
+
|
|
18
|
+
# Check if strict is explicitly set to false
|
|
19
|
+
if python3 -c "
|
|
20
|
+
import json
|
|
21
|
+
with open('$FILE') as f:
|
|
22
|
+
config = json.load(f)
|
|
23
|
+
opts = config.get('compilerOptions', {})
|
|
24
|
+
if opts.get('strict') == False:
|
|
25
|
+
exit(1)
|
|
26
|
+
if opts.get('noImplicitAny') == False:
|
|
27
|
+
exit(1)
|
|
28
|
+
" 2>/dev/null; then
|
|
29
|
+
: # OK
|
|
30
|
+
else
|
|
31
|
+
echo "WARNING: TypeScript strict mode is disabled in $FILE." >&2
|
|
32
|
+
echo " Consider enabling 'strict: true' for better type safety." >&2
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
exit 0
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
INPUT=$(cat)
|
|
2
|
+
if ! git rev-parse --is-inside-work-tree > /dev/null 2>&1; then
|
|
3
|
+
exit 0
|
|
4
|
+
fi
|
|
5
|
+
MODIFIED=$(git diff --name-only 2>/dev/null | wc -l)
|
|
6
|
+
STAGED=$(git diff --cached --name-only 2>/dev/null | wc -l)
|
|
7
|
+
UNTRACKED=$(git ls-files --others --exclude-standard 2>/dev/null | wc -l)
|
|
8
|
+
TOTAL=$((MODIFIED + STAGED + UNTRACKED))
|
|
9
|
+
if [ "$TOTAL" -gt 0 ]; then
|
|
10
|
+
echo "⚠ WARNING: $TOTAL uncommitted changes:" >&2
|
|
11
|
+
[ "$MODIFIED" -gt 0 ] && echo " Modified: $MODIFIED files" >&2
|
|
12
|
+
[ "$STAGED" -gt 0 ] && echo " Staged: $STAGED files" >&2
|
|
13
|
+
[ "$UNTRACKED" -gt 0 ] && echo " Untracked: $UNTRACKED files" >&2
|
|
14
|
+
echo " Consider: git add -A && git commit -m 'session checkpoint'" >&2
|
|
15
|
+
fi
|
|
16
|
+
exit 0
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# uncommitted-discard-guard.sh — Block commands that discard uncommitted changes
|
|
3
|
+
#
|
|
4
|
+
# Solves: Claude running "git checkout -- ." or "git restore ." to discard
|
|
5
|
+
# hours of uncommitted work. Real incident: #37888 — 30+ files of
|
|
6
|
+
# manual edits destroyed twice in one session.
|
|
7
|
+
#
|
|
8
|
+
# Detects:
|
|
9
|
+
# git checkout -- <files> (discards working tree changes)
|
|
10
|
+
# git checkout . (discards all changes)
|
|
11
|
+
# git restore <files> (same effect as checkout --)
|
|
12
|
+
# git restore . (discards all working tree changes)
|
|
13
|
+
# git stash drop (permanently deletes stashed changes)
|
|
14
|
+
#
|
|
15
|
+
# Does NOT block:
|
|
16
|
+
# git checkout <branch> (switching branches — safe)
|
|
17
|
+
# git checkout -b <branch> (creating branches — safe)
|
|
18
|
+
# git restore --staged (unstaging — non-destructive)
|
|
19
|
+
# git stash (saving changes — safe)
|
|
20
|
+
# git stash pop (restoring changes — safe)
|
|
21
|
+
#
|
|
22
|
+
# TRIGGER: PreToolUse MATCHER: "Bash"
|
|
23
|
+
|
|
24
|
+
INPUT=$(cat)
|
|
25
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
[ -z "$COMMAND" ] && exit 0
|
|
28
|
+
|
|
29
|
+
# Block: git checkout -- <files> (discard working tree changes)
|
|
30
|
+
# The "--" separator followed by paths means "discard changes to these files"
|
|
31
|
+
if echo "$COMMAND" | grep -qE 'git\s+checkout\s+--\s+\S'; then
|
|
32
|
+
echo "BLOCKED: git checkout -- <files> discards uncommitted changes permanently." >&2
|
|
33
|
+
echo "" >&2
|
|
34
|
+
echo "Command: $COMMAND" >&2
|
|
35
|
+
echo "" >&2
|
|
36
|
+
echo "If you need to discard changes, commit or stash first:" >&2
|
|
37
|
+
echo " git stash # save changes for later" >&2
|
|
38
|
+
echo " git stash pop # restore saved changes" >&2
|
|
39
|
+
exit 2
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Block: git checkout . (discard ALL changes)
|
|
43
|
+
if echo "$COMMAND" | grep -qE 'git\s+checkout\s+\.\s*$'; then
|
|
44
|
+
echo "BLOCKED: git checkout . discards ALL uncommitted changes." >&2
|
|
45
|
+
echo "" >&2
|
|
46
|
+
echo "This would destroy every uncommitted modification in the working tree." >&2
|
|
47
|
+
echo "Commit or stash your changes first." >&2
|
|
48
|
+
exit 2
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# Block: git restore <files> without --staged (discards working tree changes)
|
|
52
|
+
if echo "$COMMAND" | grep -qE 'git\s+restore\s+' && ! echo "$COMMAND" | grep -qE 'git\s+restore\s+--staged'; then
|
|
53
|
+
# Allow "git restore --staged" (just unstages, non-destructive)
|
|
54
|
+
# Block "git restore <files>" and "git restore ."
|
|
55
|
+
echo "BLOCKED: git restore discards uncommitted changes." >&2
|
|
56
|
+
echo "" >&2
|
|
57
|
+
echo "Command: $COMMAND" >&2
|
|
58
|
+
echo "" >&2
|
|
59
|
+
echo "Use 'git restore --staged <file>' to unstage without losing changes." >&2
|
|
60
|
+
echo "Use 'git stash' to save changes for later." >&2
|
|
61
|
+
exit 2
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Block: git stash drop (permanently deletes stashed changes)
|
|
65
|
+
if echo "$COMMAND" | grep -qE 'git\s+stash\s+drop'; then
|
|
66
|
+
echo "BLOCKED: git stash drop permanently deletes stashed changes." >&2
|
|
67
|
+
echo "" >&2
|
|
68
|
+
echo "If you're sure, use 'git stash pop' to apply and remove in one step." >&2
|
|
69
|
+
exit 2
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
exit 0
|
|
@@ -19,7 +19,12 @@
|
|
|
19
19
|
# }
|
|
20
20
|
|
|
21
21
|
INPUT=$(cat)
|
|
22
|
-
|
|
22
|
+
# jq with python3 fallback (macOS may not have jq)
|
|
23
|
+
if command -v jq &>/dev/null; then
|
|
24
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
25
|
+
else
|
|
26
|
+
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;d=json.load(sys.stdin);print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)
|
|
27
|
+
fi
|
|
23
28
|
|
|
24
29
|
[ -z "$COMMAND" ] && exit 0
|
|
25
30
|
|
|
@@ -48,9 +53,14 @@ if [ -z "$BRANCH" ] || [ "$BRANCH" = "HEAD" ]; then
|
|
|
48
53
|
exit 0
|
|
49
54
|
fi
|
|
50
55
|
|
|
51
|
-
# Find the default branch
|
|
56
|
+
# Find the default branch (portable: checks symbolic ref, then tries main/master)
|
|
52
57
|
DEFAULT_BRANCH=$(git -C "$WORKTREE_PATH" symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|refs/remotes/origin/||')
|
|
53
|
-
[ -z "$DEFAULT_BRANCH" ]
|
|
58
|
+
if [ -z "$DEFAULT_BRANCH" ]; then
|
|
59
|
+
for candidate in main master; do
|
|
60
|
+
git -C "$WORKTREE_PATH" rev-parse --verify "$candidate" &>/dev/null && DEFAULT_BRANCH="$candidate" && break
|
|
61
|
+
done
|
|
62
|
+
fi
|
|
63
|
+
[ -z "$DEFAULT_BRANCH" ] && exit 0
|
|
54
64
|
|
|
55
65
|
# Count unmerged commits
|
|
56
66
|
UNMERGED=$(git -C "$WORKTREE_PATH" log --oneline "$DEFAULT_BRANCH..$BRANCH" 2>/dev/null | wc -l)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# yaml-syntax-check.sh — Validate YAML after editing
|
|
3
|
+
#
|
|
4
|
+
# Prevents: Broken YAML configs (docker-compose, CI pipelines, k8s manifests).
|
|
5
|
+
# YAML indentation errors are invisible until deployment fails.
|
|
6
|
+
#
|
|
7
|
+
# TRIGGER: PostToolUse
|
|
8
|
+
# MATCHER: "Write|Edit"
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# {
|
|
12
|
+
# "hooks": {
|
|
13
|
+
# "PostToolUse": [{
|
|
14
|
+
# "matcher": "Write|Edit",
|
|
15
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/yaml-syntax-check.sh" }]
|
|
16
|
+
# }]
|
|
17
|
+
# }
|
|
18
|
+
# }
|
|
19
|
+
|
|
20
|
+
INPUT=$(cat)
|
|
21
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
22
|
+
[ -z "$FILE" ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Only check YAML files
|
|
25
|
+
case "$FILE" in
|
|
26
|
+
*.yml|*.yaml) ;;
|
|
27
|
+
*) exit 0 ;;
|
|
28
|
+
esac
|
|
29
|
+
|
|
30
|
+
[ ! -f "$FILE" ] && exit 0
|
|
31
|
+
|
|
32
|
+
# Try python yaml parser
|
|
33
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
34
|
+
ERROR=$(python3 -c "
|
|
35
|
+
import yaml, sys
|
|
36
|
+
try:
|
|
37
|
+
with open('$FILE') as f:
|
|
38
|
+
yaml.safe_load(f)
|
|
39
|
+
except yaml.YAMLError as e:
|
|
40
|
+
print(str(e)[:200])
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
" 2>&1)
|
|
43
|
+
if [ $? -ne 0 ]; then
|
|
44
|
+
echo "YAML SYNTAX ERROR in $FILE:" >&2
|
|
45
|
+
echo " $ERROR" >&2
|
|
46
|
+
exit 2
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
exit 0
|
package/index.mjs
CHANGED
|
@@ -456,6 +456,8 @@ function examples() {
|
|
|
456
456
|
'package-script-guard.sh': 'Warn when package.json scripts change',
|
|
457
457
|
'lockfile-guard.sh': 'Warn when lockfiles modified in commits',
|
|
458
458
|
'git-lfs-guard.sh': 'Suggest Git LFS for large files',
|
|
459
|
+
'python-ruff-on-edit.sh': 'PostToolUse: lint Python files after edit (ruff/flake8/pylint)',
|
|
460
|
+
'typescript-lint-on-edit.sh': 'PostToolUse: type check TypeScript files after edit (tsc --noEmit)',
|
|
459
461
|
},
|
|
460
462
|
'Recovery': {
|
|
461
463
|
'auto-checkpoint.sh': 'Auto-commit after edits for rollback protection',
|
|
@@ -465,6 +467,7 @@ function examples() {
|
|
|
465
467
|
'UX': {
|
|
466
468
|
'prompt-length-guard.sh': 'UserPromptSubmit: warn on long prompts (>5000 chars)',
|
|
467
469
|
'prompt-injection-detector.sh': 'UserPromptSubmit: detect prompt injection patterns',
|
|
470
|
+
'auto-answer-question.sh': 'PreToolUse: auto-answer AskUserQuestion for headless mode (v2.1.85)',
|
|
468
471
|
'notify-waiting.sh': 'Desktop notification when Claude waits for input',
|
|
469
472
|
'tmp-cleanup.sh': 'Clean up /tmp/claude-*-cwd files on session end',
|
|
470
473
|
'hook-debug-wrapper.sh': 'Wrap any hook to log input/output/exit/timing',
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-safe-setup",
|
|
3
|
-
"version": "29.6.
|
|
4
|
-
"description": "One command to make Claude Code safe.
|
|
3
|
+
"version": "29.6.1",
|
|
4
|
+
"description": "One command to make Claude Code safe. 406 example hooks + 8 built-in. 52 CLI commands. 5564 tests. Works with Auto Mode.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-safe-setup": "index.mjs"
|