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.
@@ -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 --audit Analyze your setup and recommend missing protections
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.0",
4
- "description": "One command to make Claude Code safe for autonomous operation. 8 built-in hooks + 25 installable examples. Destructive blocker, branch guard, database wipe protection, dotfile guard, and more.",
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"