cc-safe-setup 2.3.0 → 2.5.0
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/README.md +31 -1
- package/SAFETY_CHECKLIST.md +53 -0
- package/action.yml +34 -0
- package/audit-web/index.html +494 -38
- package/docs/index.html +603 -0
- package/examples/case-sensitive-guard.sh +145 -0
- package/examples/session-checkpoint.sh +54 -0
- package/index.mjs +57 -1
- package/package.json +2 -2
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# case-sensitive-guard.sh — Case-Insensitive Filesystem Safety Guard
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Detects case-insensitive filesystems (exFAT, NTFS, HFS+, APFS
|
|
7
|
+
# case-insensitive) and warns before mkdir/rm that would collide
|
|
8
|
+
# due to case folding.
|
|
9
|
+
#
|
|
10
|
+
# Real incident: GitHub #37875 — Claude created "Content" dir on
|
|
11
|
+
# exFAT drive where "content" already existed. Both resolved to
|
|
12
|
+
# the same path. Claude then ran rm -rf on "content", destroying
|
|
13
|
+
# all user data.
|
|
14
|
+
#
|
|
15
|
+
# TRIGGER: PreToolUse
|
|
16
|
+
# MATCHER: "Bash"
|
|
17
|
+
#
|
|
18
|
+
# WHAT IT BLOCKS (exit 2):
|
|
19
|
+
# - rm -rf on case-insensitive FS when a case-variant directory
|
|
20
|
+
# exists that resolves to the same inode
|
|
21
|
+
# - mkdir that would silently collide with existing dir on
|
|
22
|
+
# case-insensitive FS
|
|
23
|
+
#
|
|
24
|
+
# WHAT IT ALLOWS (exit 0):
|
|
25
|
+
# - All commands on case-sensitive filesystems
|
|
26
|
+
# - rm/mkdir where no case collision exists
|
|
27
|
+
# - Commands that don't involve mkdir or rm
|
|
28
|
+
#
|
|
29
|
+
# HOW IT WORKS:
|
|
30
|
+
# 1. Extract target path from mkdir/rm commands
|
|
31
|
+
# 2. Check if filesystem is case-insensitive (create temp file,
|
|
32
|
+
# check if uppercase variant exists)
|
|
33
|
+
# 3. If case-insensitive, check for case-variant collisions
|
|
34
|
+
# 4. Block if collision detected
|
|
35
|
+
# ================================================================
|
|
36
|
+
|
|
37
|
+
INPUT=$(cat)
|
|
38
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
39
|
+
|
|
40
|
+
if [[ -z "$COMMAND" ]]; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
# Only check mkdir and rm commands
|
|
45
|
+
if ! echo "$COMMAND" | grep -qE '^\s*(mkdir|rm)\s'; then
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
# Extract the target path
|
|
50
|
+
TARGET=""
|
|
51
|
+
if echo "$COMMAND" | grep -qE '^\s*mkdir'; then
|
|
52
|
+
TARGET=$(echo "$COMMAND" | grep -oP 'mkdir\s+(-p\s+)?\K\S+' | tail -1)
|
|
53
|
+
elif echo "$COMMAND" | grep -qE '^\s*rm\s'; then
|
|
54
|
+
TARGET=$(echo "$COMMAND" | grep -oP 'rm\s+(-[rf]+\s+)*\K\S+' | tail -1)
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
if [[ -z "$TARGET" ]]; then
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# Resolve the parent directory
|
|
62
|
+
PARENT_DIR=$(dirname "$TARGET" 2>/dev/null)
|
|
63
|
+
BASE_NAME=$(basename "$TARGET" 2>/dev/null)
|
|
64
|
+
|
|
65
|
+
if [[ -z "$PARENT_DIR" ]] || [[ ! -d "$PARENT_DIR" ]]; then
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# --- Check if filesystem is case-insensitive ---
|
|
70
|
+
is_case_insensitive() {
|
|
71
|
+
local dir="$1"
|
|
72
|
+
local test_file="${dir}/.cc_case_test_$$"
|
|
73
|
+
local test_upper="${dir}/.CC_CASE_TEST_$$"
|
|
74
|
+
|
|
75
|
+
# Create lowercase test file
|
|
76
|
+
if ! touch "$test_file" 2>/dev/null; then
|
|
77
|
+
return 1 # Can't test, assume case-sensitive (safe default)
|
|
78
|
+
fi
|
|
79
|
+
|
|
80
|
+
# Check if uppercase variant resolves to the same file
|
|
81
|
+
if [[ -f "$test_upper" ]]; then
|
|
82
|
+
rm -f "$test_file" 2>/dev/null
|
|
83
|
+
return 0 # Case-insensitive
|
|
84
|
+
else
|
|
85
|
+
rm -f "$test_file" 2>/dev/null
|
|
86
|
+
return 1 # Case-sensitive
|
|
87
|
+
fi
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# --- Check for case-variant collisions ---
|
|
91
|
+
find_case_collision() {
|
|
92
|
+
local dir="$1"
|
|
93
|
+
local name="$2"
|
|
94
|
+
local name_lower
|
|
95
|
+
name_lower=$(echo "$name" | tr '[:upper:]' '[:lower:]')
|
|
96
|
+
|
|
97
|
+
# List directory entries and check for case variants
|
|
98
|
+
while IFS= read -r entry; do
|
|
99
|
+
local entry_lower
|
|
100
|
+
entry_lower=$(echo "$entry" | tr '[:upper:]' '[:lower:]')
|
|
101
|
+
if [[ "$entry_lower" == "$name_lower" ]] && [[ "$entry" != "$name" ]]; then
|
|
102
|
+
echo "$entry"
|
|
103
|
+
return 0
|
|
104
|
+
fi
|
|
105
|
+
done < <(ls -1 "$dir" 2>/dev/null)
|
|
106
|
+
|
|
107
|
+
return 1
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Only proceed if filesystem is case-insensitive
|
|
111
|
+
if ! is_case_insensitive "$PARENT_DIR"; then
|
|
112
|
+
exit 0
|
|
113
|
+
fi
|
|
114
|
+
|
|
115
|
+
# Check for collision
|
|
116
|
+
COLLISION=$(find_case_collision "$PARENT_DIR" "$BASE_NAME")
|
|
117
|
+
|
|
118
|
+
if [[ -n "$COLLISION" ]]; then
|
|
119
|
+
if echo "$COMMAND" | grep -qE '^\s*rm\s'; then
|
|
120
|
+
echo "BLOCKED: Case-insensitive filesystem collision detected." >&2
|
|
121
|
+
echo "" >&2
|
|
122
|
+
echo "Command: $COMMAND" >&2
|
|
123
|
+
echo "" >&2
|
|
124
|
+
echo "Target: $TARGET" >&2
|
|
125
|
+
echo "Collides with: $PARENT_DIR/$COLLISION" >&2
|
|
126
|
+
echo "" >&2
|
|
127
|
+
echo "This filesystem is case-insensitive (exFAT, NTFS, HFS+, etc.)." >&2
|
|
128
|
+
echo "'$BASE_NAME' and '$COLLISION' resolve to the SAME path." >&2
|
|
129
|
+
echo "rm -rf would destroy the data you think you're keeping." >&2
|
|
130
|
+
echo "" >&2
|
|
131
|
+
echo "Verify with: ls -la \"$PARENT_DIR\" | grep -i \"$BASE_NAME\"" >&2
|
|
132
|
+
exit 2
|
|
133
|
+
elif echo "$COMMAND" | grep -qE '^\s*mkdir'; then
|
|
134
|
+
echo "WARNING: Case-insensitive filesystem — directory already exists." >&2
|
|
135
|
+
echo "" >&2
|
|
136
|
+
echo "Command: $COMMAND" >&2
|
|
137
|
+
echo "Existing: $PARENT_DIR/$COLLISION" >&2
|
|
138
|
+
echo "" >&2
|
|
139
|
+
echo "On this filesystem, '$BASE_NAME' and '$COLLISION' are the same path." >&2
|
|
140
|
+
echo "mkdir will either fail or silently use the existing directory." >&2
|
|
141
|
+
exit 2
|
|
142
|
+
fi
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
exit 0
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# session-checkpoint.sh — Auto-save session state on every stop
|
|
3
|
+
#
|
|
4
|
+
# Solves: Session crashes/disconnects causing expensive re-analysis
|
|
5
|
+
# of the entire codebase on next start (#37866)
|
|
6
|
+
#
|
|
7
|
+
# Saves: git state, recent commits, modified files, working directory.
|
|
8
|
+
# Next session reads the checkpoint instead of re-analyzing everything.
|
|
9
|
+
#
|
|
10
|
+
# Usage: Add to settings.json as a Stop hook
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "hooks": {
|
|
14
|
+
# "Stop": [{
|
|
15
|
+
# "hooks": [{ "type": "command", "command": "~/.claude/hooks/session-checkpoint.sh" }]
|
|
16
|
+
# }]
|
|
17
|
+
# }
|
|
18
|
+
# }
|
|
19
|
+
#
|
|
20
|
+
# Recovery: Add to CLAUDE.md:
|
|
21
|
+
# "If ~/.claude/checkpoints/ has a file for this project, read it first."
|
|
22
|
+
|
|
23
|
+
INPUT=$(cat)
|
|
24
|
+
REASON=$(echo "$INPUT" | jq -r '.stop_reason // empty' 2>/dev/null)
|
|
25
|
+
|
|
26
|
+
CHECKPOINT_DIR="$HOME/.claude/checkpoints"
|
|
27
|
+
mkdir -p "$CHECKPOINT_DIR"
|
|
28
|
+
|
|
29
|
+
PROJECT_NAME=$(basename "$(pwd)")
|
|
30
|
+
CHECKPOINT="$CHECKPOINT_DIR/${PROJECT_NAME}-latest.md"
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
echo "# Session Checkpoint"
|
|
34
|
+
echo "Saved: $(date -Iseconds)"
|
|
35
|
+
echo "Directory: $(pwd)"
|
|
36
|
+
echo "Stop reason: ${REASON:-unknown}"
|
|
37
|
+
echo ""
|
|
38
|
+
echo "## Recent Commits"
|
|
39
|
+
git log --oneline -10 2>/dev/null || echo "(not a git repo)"
|
|
40
|
+
echo ""
|
|
41
|
+
echo "## Uncommitted Changes"
|
|
42
|
+
git diff --stat 2>/dev/null || echo "(none)"
|
|
43
|
+
echo ""
|
|
44
|
+
echo "## Staged Files"
|
|
45
|
+
git diff --cached --name-only 2>/dev/null || echo "(none)"
|
|
46
|
+
echo ""
|
|
47
|
+
echo "## Current Branch"
|
|
48
|
+
git branch --show-current 2>/dev/null || echo "(unknown)"
|
|
49
|
+
} > "$CHECKPOINT" 2>/dev/null
|
|
50
|
+
|
|
51
|
+
# Cleanup: keep only last 10 checkpoints
|
|
52
|
+
ls -t "$CHECKPOINT_DIR"/*.md 2>/dev/null | tail -n +11 | xargs rm -f 2>/dev/null
|
|
53
|
+
|
|
54
|
+
exit 0
|
package/index.mjs
CHANGED
|
@@ -71,6 +71,7 @@ const INSTALL_EXAMPLE = INSTALL_EXAMPLE_IDX !== -1 ? process.argv[INSTALL_EXAMPL
|
|
|
71
71
|
const AUDIT = process.argv.includes('--audit');
|
|
72
72
|
const LEARN = process.argv.includes('--learn');
|
|
73
73
|
const SCAN = process.argv.includes('--scan');
|
|
74
|
+
const FULL = process.argv.includes('--full');
|
|
74
75
|
|
|
75
76
|
if (HELP) {
|
|
76
77
|
console.log(`
|
|
@@ -84,7 +85,11 @@ if (HELP) {
|
|
|
84
85
|
npx cc-safe-setup --uninstall Remove all installed hooks
|
|
85
86
|
npx cc-safe-setup --examples List 25 example hooks (5 categories)
|
|
86
87
|
npx cc-safe-setup --install-example <name> Install a specific example
|
|
87
|
-
npx cc-safe-setup --
|
|
88
|
+
npx cc-safe-setup --full Complete setup: hooks + scan + audit + badge
|
|
89
|
+
npx cc-safe-setup --audit Safety score (0-100) with fixes
|
|
90
|
+
npx cc-safe-setup --audit --fix Auto-fix missing protections
|
|
91
|
+
npx cc-safe-setup --scan Detect tech stack, recommend hooks
|
|
92
|
+
npx cc-safe-setup --learn Learn from your block history
|
|
88
93
|
npx cc-safe-setup --help Show this help
|
|
89
94
|
|
|
90
95
|
Hooks installed:
|
|
@@ -568,6 +573,18 @@ async function audit() {
|
|
|
568
573
|
console.log();
|
|
569
574
|
console.log(c.dim + ' Run with --fix to auto-apply: npx cc-safe-setup --audit --fix' + c.reset);
|
|
570
575
|
}
|
|
576
|
+
|
|
577
|
+
// Badge output
|
|
578
|
+
if (process.argv.includes('--badge')) {
|
|
579
|
+
const color = score >= 80 ? 'brightgreen' : score >= 50 ? 'yellow' : 'red';
|
|
580
|
+
const badge = ``;
|
|
581
|
+
console.log();
|
|
582
|
+
console.log(c.bold + ' README Badge:' + c.reset);
|
|
583
|
+
console.log(' ' + badge);
|
|
584
|
+
console.log();
|
|
585
|
+
console.log(c.dim + ' Paste this into your README.md' + c.reset);
|
|
586
|
+
}
|
|
587
|
+
|
|
571
588
|
console.log();
|
|
572
589
|
}
|
|
573
590
|
|
|
@@ -683,6 +700,44 @@ exit 0`;
|
|
|
683
700
|
console.log();
|
|
684
701
|
}
|
|
685
702
|
|
|
703
|
+
async function fullSetup() {
|
|
704
|
+
console.log();
|
|
705
|
+
console.log(c.bold + c.green + ' cc-safe-setup --full' + c.reset);
|
|
706
|
+
console.log(c.dim + ' Complete safety setup in one command' + c.reset);
|
|
707
|
+
console.log();
|
|
708
|
+
|
|
709
|
+
const { execSync } = await import('child_process');
|
|
710
|
+
const self = process.argv[1];
|
|
711
|
+
|
|
712
|
+
// Step 1: Install 8 built-in hooks
|
|
713
|
+
console.log(c.bold + ' Step 1: Installing 8 built-in safety hooks...' + c.reset);
|
|
714
|
+
try {
|
|
715
|
+
execSync('node ' + self + ' --yes', { stdio: 'inherit' });
|
|
716
|
+
} catch(e) {
|
|
717
|
+
// --yes doesn't exist, run normal install
|
|
718
|
+
execSync('node ' + self, { stdio: 'inherit', input: 'y\n' });
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Step 2: Scan project and create CLAUDE.md
|
|
722
|
+
console.log();
|
|
723
|
+
console.log(c.bold + ' Step 2: Scanning project and creating CLAUDE.md...' + c.reset);
|
|
724
|
+
scan();
|
|
725
|
+
|
|
726
|
+
// Step 3: Audit and show results
|
|
727
|
+
console.log();
|
|
728
|
+
console.log(c.bold + ' Step 3: Running safety audit...' + c.reset);
|
|
729
|
+
// Inject --badge into argv temporarily
|
|
730
|
+
process.argv.push('--badge');
|
|
731
|
+
await audit();
|
|
732
|
+
|
|
733
|
+
console.log(c.bold + c.green + ' ✓ Full setup complete!' + c.reset);
|
|
734
|
+
console.log(c.dim + ' Your project now has:' + c.reset);
|
|
735
|
+
console.log(c.dim + ' • 8 built-in safety hooks' + c.reset);
|
|
736
|
+
console.log(c.dim + ' • Project-specific hook recommendations' + c.reset);
|
|
737
|
+
console.log(c.dim + ' • Safety score and README badge' + c.reset);
|
|
738
|
+
console.log();
|
|
739
|
+
}
|
|
740
|
+
|
|
686
741
|
function scan() {
|
|
687
742
|
console.log();
|
|
688
743
|
console.log(c.bold + ' cc-safe-setup --scan' + c.reset);
|
|
@@ -832,6 +887,7 @@ async function main() {
|
|
|
832
887
|
if (AUDIT) return audit();
|
|
833
888
|
if (LEARN) return learn();
|
|
834
889
|
if (SCAN) return scan();
|
|
890
|
+
if (FULL) return fullSetup();
|
|
835
891
|
|
|
836
892
|
console.log();
|
|
837
893
|
console.log(c.bold + ' cc-safe-setup' + c.reset);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-safe-setup",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "One command to make Claude Code safe for autonomous operation. 8 built-in hooks +
|
|
3
|
+
"version": "2.5.0",
|
|
4
|
+
"description": "One command to make Claude Code safe for autonomous operation. 8 built-in hooks + 26 installable examples. Destructive blocker, branch guard, database wipe protection, case-insensitive FS guard, and more.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-safe-setup": "index.mjs"
|