cc-safe-setup 2.4.0 → 2.6.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 +8 -1
- package/SAFETY_CHECKLIST.md +53 -0
- package/audit-web/index.html +494 -38
- package/docs/index.html +603 -0
- package/examples/case-sensitive-guard.sh +145 -0
- package/index.mjs +176 -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
|
package/index.mjs
CHANGED
|
@@ -72,6 +72,7 @@ const AUDIT = process.argv.includes('--audit');
|
|
|
72
72
|
const LEARN = process.argv.includes('--learn');
|
|
73
73
|
const SCAN = process.argv.includes('--scan');
|
|
74
74
|
const FULL = process.argv.includes('--full');
|
|
75
|
+
const DOCTOR = process.argv.includes('--doctor');
|
|
75
76
|
|
|
76
77
|
if (HELP) {
|
|
77
78
|
console.log(`
|
|
@@ -85,7 +86,12 @@ if (HELP) {
|
|
|
85
86
|
npx cc-safe-setup --uninstall Remove all installed hooks
|
|
86
87
|
npx cc-safe-setup --examples List 25 example hooks (5 categories)
|
|
87
88
|
npx cc-safe-setup --install-example <name> Install a specific example
|
|
88
|
-
npx cc-safe-setup --
|
|
89
|
+
npx cc-safe-setup --full Complete setup: hooks + scan + audit + badge
|
|
90
|
+
npx cc-safe-setup --audit Safety score (0-100) with fixes
|
|
91
|
+
npx cc-safe-setup --audit --fix Auto-fix missing protections
|
|
92
|
+
npx cc-safe-setup --scan Detect tech stack, recommend hooks
|
|
93
|
+
npx cc-safe-setup --learn Learn from your block history
|
|
94
|
+
npx cc-safe-setup --doctor Diagnose why hooks aren't working
|
|
89
95
|
npx cc-safe-setup --help Show this help
|
|
90
96
|
|
|
91
97
|
Hooks installed:
|
|
@@ -734,6 +740,174 @@ async function fullSetup() {
|
|
|
734
740
|
console.log();
|
|
735
741
|
}
|
|
736
742
|
|
|
743
|
+
async function doctor() {
|
|
744
|
+
const { execSync, spawnSync } = await import('child_process');
|
|
745
|
+
const { statSync, readdirSync } = await import('fs');
|
|
746
|
+
|
|
747
|
+
console.log();
|
|
748
|
+
console.log(c.bold + ' cc-safe-setup --doctor' + c.reset);
|
|
749
|
+
console.log(c.dim + ' Diagnosing why hooks might not be working...' + c.reset);
|
|
750
|
+
console.log();
|
|
751
|
+
|
|
752
|
+
let issues = 0;
|
|
753
|
+
let warnings = 0;
|
|
754
|
+
|
|
755
|
+
const pass = (msg) => console.log(c.green + ' ✓ ' + c.reset + msg);
|
|
756
|
+
const fail = (msg) => { console.log(c.red + ' ✗ ' + c.reset + msg); issues++; };
|
|
757
|
+
const warn = (msg) => { console.log(c.yellow + ' ! ' + c.reset + msg); warnings++; };
|
|
758
|
+
|
|
759
|
+
// 1. Check jq
|
|
760
|
+
try {
|
|
761
|
+
execSync('which jq', { stdio: 'pipe' });
|
|
762
|
+
const ver = execSync('jq --version', { stdio: 'pipe' }).toString().trim();
|
|
763
|
+
pass('jq installed (' + ver + ')');
|
|
764
|
+
} catch {
|
|
765
|
+
fail('jq is not installed — hooks cannot parse JSON input');
|
|
766
|
+
console.log(c.dim + ' Fix: brew install jq (macOS) | apt install jq (Linux) | choco install jq (Windows)' + c.reset);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// 2. Check settings.json exists
|
|
770
|
+
if (!existsSync(SETTINGS_PATH)) {
|
|
771
|
+
fail('~/.claude/settings.json does not exist');
|
|
772
|
+
console.log(c.dim + ' Fix: npx cc-safe-setup' + c.reset);
|
|
773
|
+
} else {
|
|
774
|
+
pass('settings.json exists');
|
|
775
|
+
|
|
776
|
+
// 3. Parse settings.json
|
|
777
|
+
let settings;
|
|
778
|
+
try {
|
|
779
|
+
settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
780
|
+
pass('settings.json is valid JSON');
|
|
781
|
+
} catch (e) {
|
|
782
|
+
fail('settings.json has invalid JSON: ' + e.message);
|
|
783
|
+
console.log(c.dim + ' Fix: npx cc-safe-setup --uninstall && npx cc-safe-setup' + c.reset);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (settings) {
|
|
787
|
+
// 4. Check hooks section exists
|
|
788
|
+
const hooks = settings.hooks;
|
|
789
|
+
if (!hooks) {
|
|
790
|
+
fail('No "hooks" section in settings.json');
|
|
791
|
+
} else {
|
|
792
|
+
pass('"hooks" section exists in settings.json');
|
|
793
|
+
|
|
794
|
+
// 5. Check each hook trigger type
|
|
795
|
+
for (const trigger of ['PreToolUse', 'PostToolUse', 'Stop']) {
|
|
796
|
+
const entries = hooks[trigger] || [];
|
|
797
|
+
if (entries.length > 0) {
|
|
798
|
+
pass(trigger + ': ' + entries.length + ' hook(s) registered');
|
|
799
|
+
|
|
800
|
+
// 6. Check each hook command path
|
|
801
|
+
for (const entry of entries) {
|
|
802
|
+
const hookList = entry.hooks || [];
|
|
803
|
+
for (const h of hookList) {
|
|
804
|
+
if (h.type !== 'command') continue;
|
|
805
|
+
const cmd = h.command;
|
|
806
|
+
// Extract the script path from commands like "bash ~/.claude/hooks/x.sh" or "~/bin/x.sh arg1 arg2"
|
|
807
|
+
let scriptPath = cmd;
|
|
808
|
+
// Strip leading interpreter (bash, sh, node, python3, etc.)
|
|
809
|
+
scriptPath = scriptPath.replace(/^(bash|sh|node|python3?)\s+/, '');
|
|
810
|
+
// Take first token (before arguments)
|
|
811
|
+
scriptPath = scriptPath.split(/\s+/)[0];
|
|
812
|
+
// Resolve ~ to HOME
|
|
813
|
+
const resolved = scriptPath.replace(/^~/, HOME);
|
|
814
|
+
|
|
815
|
+
if (!existsSync(resolved)) {
|
|
816
|
+
fail('Hook script not found: ' + scriptPath + (scriptPath !== cmd ? ' (from: ' + cmd + ')' : ''));
|
|
817
|
+
console.log(c.dim + ' Fix: create the missing script or update settings.json' + c.reset);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// 7. Check executable permission
|
|
822
|
+
try {
|
|
823
|
+
const stat = statSync(resolved);
|
|
824
|
+
const isExec = (stat.mode & 0o111) !== 0;
|
|
825
|
+
if (!isExec) {
|
|
826
|
+
fail('Not executable: ' + cmd);
|
|
827
|
+
console.log(c.dim + ' Fix: chmod +x ' + resolved + c.reset);
|
|
828
|
+
}
|
|
829
|
+
} catch {}
|
|
830
|
+
|
|
831
|
+
// 8. Check shebang
|
|
832
|
+
try {
|
|
833
|
+
const content = readFileSync(resolved, 'utf-8');
|
|
834
|
+
if (!content.startsWith('#!/')) {
|
|
835
|
+
warn('Missing shebang (#!/bin/bash) in: ' + cmd);
|
|
836
|
+
console.log(c.dim + ' Add #!/bin/bash as the first line' + c.reset);
|
|
837
|
+
}
|
|
838
|
+
} catch {}
|
|
839
|
+
|
|
840
|
+
// 9. Test hook with empty input
|
|
841
|
+
try {
|
|
842
|
+
const result = spawnSync('bash', [resolved], {
|
|
843
|
+
input: '{}',
|
|
844
|
+
timeout: 5000,
|
|
845
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
846
|
+
});
|
|
847
|
+
if (result.status !== 0 && result.status !== 2) {
|
|
848
|
+
warn('Hook exits with code ' + result.status + ' on empty input: ' + cmd);
|
|
849
|
+
const stderr = (result.stderr || '').toString().trim();
|
|
850
|
+
if (stderr) console.log(c.dim + ' stderr: ' + stderr.slice(0, 200) + c.reset);
|
|
851
|
+
}
|
|
852
|
+
} catch {}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 10. Check for common misconfigurations
|
|
860
|
+
if (settings.defaultMode === 'bypassPermissions') {
|
|
861
|
+
warn('defaultMode is "bypassPermissions" — hooks may be skipped entirely');
|
|
862
|
+
console.log(c.dim + ' Consider using "dontAsk" instead (hooks still run)' + c.reset);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// 11. Check for dangerouslySkipPermissions in allow
|
|
866
|
+
const allows = settings.permissions?.allow || [];
|
|
867
|
+
if (allows.includes('Bash(*)')) {
|
|
868
|
+
warn('Bash(*) in allow list — commands auto-approved before hooks run');
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// 12. Check hooks directory
|
|
874
|
+
if (!existsSync(HOOKS_DIR)) {
|
|
875
|
+
fail('~/.claude/hooks/ directory does not exist');
|
|
876
|
+
console.log(c.dim + ' Fix: npx cc-safe-setup' + c.reset);
|
|
877
|
+
} else {
|
|
878
|
+
const files = readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh'));
|
|
879
|
+
pass('hooks directory exists (' + files.length + ' scripts)');
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 13. Check Claude Code version (needs hooks support)
|
|
883
|
+
try {
|
|
884
|
+
const ver = execSync('claude --version 2>/dev/null || echo "not found"', { stdio: 'pipe' }).toString().trim();
|
|
885
|
+
if (ver === 'not found') {
|
|
886
|
+
warn('Claude Code CLI not found in PATH');
|
|
887
|
+
} else {
|
|
888
|
+
pass('Claude Code: ' + ver);
|
|
889
|
+
}
|
|
890
|
+
} catch {
|
|
891
|
+
warn('Could not check Claude Code version');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Summary
|
|
895
|
+
console.log();
|
|
896
|
+
if (issues === 0 && warnings === 0) {
|
|
897
|
+
console.log(c.bold + c.green + ' All checks passed. Hooks should be working.' + c.reset);
|
|
898
|
+
console.log(c.dim + ' If hooks still don\'t fire, restart Claude Code (hooks load on startup).' + c.reset);
|
|
899
|
+
} else if (issues === 0) {
|
|
900
|
+
console.log(c.bold + c.yellow + ' ' + warnings + ' warning(s), but no blocking issues.' + c.reset);
|
|
901
|
+
console.log(c.dim + ' Hooks should work. Restart Claude Code if they don\'t fire.' + c.reset);
|
|
902
|
+
} else {
|
|
903
|
+
console.log(c.bold + c.red + ' ' + issues + ' issue(s) found that prevent hooks from working.' + c.reset);
|
|
904
|
+
console.log(c.dim + ' Fix the issues above, then restart Claude Code.' + c.reset);
|
|
905
|
+
}
|
|
906
|
+
console.log();
|
|
907
|
+
|
|
908
|
+
process.exit(issues > 0 ? 1 : 0);
|
|
909
|
+
}
|
|
910
|
+
|
|
737
911
|
function scan() {
|
|
738
912
|
console.log();
|
|
739
913
|
console.log(c.bold + ' cc-safe-setup --scan' + c.reset);
|
|
@@ -884,6 +1058,7 @@ async function main() {
|
|
|
884
1058
|
if (LEARN) return learn();
|
|
885
1059
|
if (SCAN) return scan();
|
|
886
1060
|
if (FULL) return fullSetup();
|
|
1061
|
+
if (DOCTOR) return doctor();
|
|
887
1062
|
|
|
888
1063
|
console.log();
|
|
889
1064
|
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.6.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"
|