cc-safe-setup 2.10.0 → 3.0.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/README.md CHANGED
@@ -160,6 +160,17 @@ npx cc-safe-setup --scan # detect tech stack, recommend hooks
160
160
  npx cc-safe-setup --scan --apply # auto-create CLAUDE.md with project rules
161
161
  ```
162
162
 
163
+ ### Create Hooks from Plain English
164
+
165
+ ```bash
166
+ npx cc-safe-setup --create "block npm publish without tests"
167
+ npx cc-safe-setup --create "auto approve test commands"
168
+ npx cc-safe-setup --create "block curl pipe to bash"
169
+ npx cc-safe-setup --create "block DROP TABLE and TRUNCATE"
170
+ ```
171
+
172
+ 9 built-in templates + generic fallback. Creates the script, registers it, and runs a smoke test.
173
+
163
174
  ### Self-Learning Safety
164
175
 
165
176
  ```bash
package/action.yml CHANGED
@@ -10,21 +10,47 @@ inputs:
10
10
  required: false
11
11
  default: '70'
12
12
 
13
+ outputs:
14
+ score:
15
+ description: 'Safety score (0-100)'
16
+ value: ${{ steps.audit.outputs.score }}
17
+ grade:
18
+ description: 'Safety grade (A/B/C/F)'
19
+ value: ${{ steps.audit.outputs.grade }}
20
+ risks:
21
+ description: 'Number of risks found'
22
+ value: ${{ steps.audit.outputs.risks }}
23
+
13
24
  runs:
14
25
  using: 'composite'
15
26
  steps:
16
27
  - name: Run safety audit
28
+ id: audit
17
29
  shell: bash
18
30
  run: |
19
31
  echo "::group::Claude Code Safety Audit"
32
+ npx cc-safe-setup@latest --audit --json 2>/dev/null > /tmp/audit-json.txt || true
20
33
  npx cc-safe-setup@latest --audit 2>&1 | tee /tmp/audit-output.txt
21
34
  echo "::endgroup::"
22
35
 
23
- # Extract score
24
- SCORE=$(grep -oP 'Safety Score: \K\d+' /tmp/audit-output.txt || echo "0")
36
+ # Parse JSON output
37
+ if [ -s /tmp/audit-json.txt ]; then
38
+ SCORE=$(python3 -c "import json; print(json.load(open('/tmp/audit-json.txt'))['score'])" 2>/dev/null || echo "0")
39
+ GRADE=$(python3 -c "import json; print(json.load(open('/tmp/audit-json.txt'))['grade'])" 2>/dev/null || echo "?")
40
+ RISK_COUNT=$(python3 -c "import json; print(len(json.load(open('/tmp/audit-json.txt'))['risks']))" 2>/dev/null || echo "0")
41
+ else
42
+ SCORE=$(grep -oP 'Safety Score: \K\d+' /tmp/audit-output.txt || echo "0")
43
+ GRADE="?"
44
+ RISK_COUNT="?"
45
+ fi
46
+
25
47
  THRESHOLD="${{ inputs.threshold }}"
26
48
 
27
- echo "Safety Score: $SCORE / 100 (threshold: $THRESHOLD)"
49
+ echo "score=$SCORE" >> $GITHUB_OUTPUT
50
+ echo "grade=$GRADE" >> $GITHUB_OUTPUT
51
+ echo "risks=$RISK_COUNT" >> $GITHUB_OUTPUT
52
+
53
+ echo "Safety Score: $SCORE/100 (Grade: $GRADE, Risks: $RISK_COUNT, Threshold: $THRESHOLD)"
28
54
 
29
55
  if [ "$SCORE" -lt "$THRESHOLD" ]; then
30
56
  echo "::error::Safety score $SCORE is below threshold $THRESHOLD. Run 'npx cc-safe-setup --audit --fix' to improve."
package/index.mjs CHANGED
@@ -79,6 +79,8 @@ const IMPORT_IDX = process.argv.findIndex(a => a === '--import');
79
79
  const IMPORT_FILE = IMPORT_IDX !== -1 ? process.argv[IMPORT_IDX + 1] : null;
80
80
  const STATS = process.argv.includes('--stats');
81
81
  const JSON_OUTPUT = process.argv.includes('--json');
82
+ const CREATE_IDX = process.argv.findIndex(a => a === '--create');
83
+ const CREATE_DESC = CREATE_IDX !== -1 ? process.argv.slice(CREATE_IDX + 1).join(' ') : null;
82
84
 
83
85
  if (HELP) {
84
86
  console.log(`
@@ -100,6 +102,7 @@ if (HELP) {
100
102
  npx cc-safe-setup --learn Learn from your block history
101
103
  npx cc-safe-setup --doctor Diagnose why hooks aren't working
102
104
  npx cc-safe-setup --watch Live dashboard of blocked commands
105
+ npx cc-safe-setup --create "<desc>" Generate a custom hook from description
103
106
  npx cc-safe-setup --stats Block statistics and patterns report
104
107
  npx cc-safe-setup --export Export hooks config for team sharing
105
108
  npx cc-safe-setup --import <file> Import hooks from exported config
@@ -764,6 +767,249 @@ async function fullSetup() {
764
767
  console.log();
765
768
  }
766
769
 
770
+ async function createHook(description) {
771
+ console.log();
772
+ console.log(c.bold + ' cc-safe-setup --create' + c.reset);
773
+ console.log(c.dim + ' Generating hook from: "' + description + '"' + c.reset);
774
+ console.log();
775
+
776
+ const desc = description.toLowerCase();
777
+
778
+ // Pattern matching engine — matches description to hook templates
779
+ const patterns = [
780
+ {
781
+ match: /block.*(npm\s+publish|yarn\s+publish|pnpm\s+publish)/,
782
+ name: 'block-publish-without-tests',
783
+ trigger: 'PreToolUse', matcher: 'Bash',
784
+ script: `#!/bin/bash
785
+ INPUT=$(cat)
786
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
787
+ [ -z "$COMMAND" ] && exit 0
788
+ if echo "$COMMAND" | grep -qE '(npm|yarn|pnpm)\\s+publish'; then
789
+ # Check if tests were run recently
790
+ if [ -f "package.json" ]; then
791
+ TEST_CMD=$(python3 -c "import json; print(json.load(open('package.json')).get('scripts',{}).get('test',''))" 2>/dev/null)
792
+ if [ -n "$TEST_CMD" ]; then
793
+ echo "BLOCKED: Run tests before publishing." >&2
794
+ echo "Command: $COMMAND" >&2
795
+ echo "Run: npm test && npm publish" >&2
796
+ exit 2
797
+ fi
798
+ fi
799
+ fi
800
+ exit 0`,
801
+ },
802
+ {
803
+ match: /block.*(docker\s+rm|docker\s+system\s+prune|docker.*(?:remove|delete|prune))/,
804
+ name: 'block-docker-destructive',
805
+ trigger: 'PreToolUse', matcher: 'Bash',
806
+ script: `#!/bin/bash
807
+ INPUT=$(cat)
808
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
809
+ [ -z "$COMMAND" ] && exit 0
810
+ if echo "$COMMAND" | grep -qiE 'docker\\s+(system\\s+prune|rm\\s+-f|rmi\\s+-f|volume\\s+rm|network\\s+rm)'; then
811
+ echo "BLOCKED: Destructive docker command." >&2
812
+ echo "Command: $COMMAND" >&2
813
+ exit 2
814
+ fi
815
+ exit 0`,
816
+ },
817
+ {
818
+ match: /block.*(curl.*pipe|curl.*\|.*sh|wget.*pipe|wget.*\|)/,
819
+ name: 'block-curl-pipe-sh',
820
+ trigger: 'PreToolUse', matcher: 'Bash',
821
+ script: `#!/bin/bash
822
+ INPUT=$(cat)
823
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
824
+ [ -z "$COMMAND" ] && exit 0
825
+ if echo "$COMMAND" | grep -qE '(curl|wget)\\s.*\\|\\s*(bash|sh|zsh|python)'; then
826
+ echo "BLOCKED: Piping remote content to shell." >&2
827
+ echo "Command: $COMMAND" >&2
828
+ echo "Download first, review, then execute." >&2
829
+ exit 2
830
+ fi
831
+ exit 0`,
832
+ },
833
+ {
834
+ match: /block.*(pip\s+install|pip3\s+install).*(?:sudo|system|global)/,
835
+ name: 'block-global-pip',
836
+ trigger: 'PreToolUse', matcher: 'Bash',
837
+ script: `#!/bin/bash
838
+ INPUT=$(cat)
839
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
840
+ [ -z "$COMMAND" ] && exit 0
841
+ if echo "$COMMAND" | grep -qE '(sudo\\s+)?(pip3?|python3?\\s+-m\\s+pip)\\s+install' && ! echo "$COMMAND" | grep -qE '(--user|venv|virtualenv|-e\\s+\\.)'; then
842
+ echo "BLOCKED: pip install without --user or virtual environment." >&2
843
+ echo "Command: $COMMAND" >&2
844
+ echo "Use: pip install --user, or activate a virtualenv first." >&2
845
+ exit 2
846
+ fi
847
+ exit 0`,
848
+ },
849
+ {
850
+ match: /block.*(large\s+file|big\s+file|file\s+size|over\s+\d+)/,
851
+ name: 'block-large-writes',
852
+ trigger: 'PreToolUse', matcher: 'Write',
853
+ script: `#!/bin/bash
854
+ INPUT=$(cat)
855
+ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
856
+ CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null)
857
+ [ -z "$FILE" ] && exit 0
858
+ SIZE=\${#CONTENT}
859
+ LIMIT=\${CC_MAX_WRITE_SIZE:-500000}
860
+ if [ "$SIZE" -gt "$LIMIT" ]; then
861
+ echo "WARNING: Writing $SIZE bytes to $FILE (limit: $LIMIT)." >&2
862
+ echo "Set CC_MAX_WRITE_SIZE to adjust the limit." >&2
863
+ exit 2
864
+ fi
865
+ exit 0`,
866
+ },
867
+ {
868
+ match: /block.*(drop\s+table|truncate|delete\s+from|alter\s+table)/,
869
+ name: 'block-raw-sql-destructive',
870
+ trigger: 'PreToolUse', matcher: 'Bash',
871
+ script: `#!/bin/bash
872
+ INPUT=$(cat)
873
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
874
+ [ -z "$COMMAND" ] && exit 0
875
+ if echo "$COMMAND" | grep -qiE '(DROP\\s+TABLE|TRUNCATE\\s+TABLE|DELETE\\s+FROM\\s+[a-z]|ALTER\\s+TABLE.*DROP)'; then
876
+ echo "BLOCKED: Destructive SQL command detected." >&2
877
+ echo "Command: $COMMAND" >&2
878
+ exit 2
879
+ fi
880
+ exit 0`,
881
+ },
882
+ {
883
+ match: /auto.?approve.*(test|jest|pytest|mocha|vitest)/,
884
+ name: 'auto-approve-tests',
885
+ trigger: 'PreToolUse', matcher: 'Bash',
886
+ script: `#!/bin/bash
887
+ INPUT=$(cat)
888
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
889
+ [ -z "$COMMAND" ] && exit 0
890
+ if echo "$COMMAND" | grep -qE '^\\s*(npm\\s+test|npx\\s+(jest|vitest|mocha)|pytest|python3?\\s+-m\\s+pytest|cargo\\s+test|go\\s+test)'; then
891
+ jq -n '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"test command auto-approved"}}'
892
+ fi
893
+ exit 0`,
894
+ },
895
+ {
896
+ match: /warn.*(todo|fixme|hack|xxx)/i,
897
+ name: 'warn-todo-markers',
898
+ trigger: 'PostToolUse', matcher: 'Edit|Write',
899
+ script: `#!/bin/bash
900
+ INPUT=$(cat)
901
+ FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
902
+ [ -z "$FILE" ] || [ ! -f "$FILE" ] && exit 0
903
+ COUNT=$(grep -ciE '(TODO|FIXME|HACK|XXX)' "$FILE" 2>/dev/null || echo 0)
904
+ [ "$COUNT" -gt 0 ] && echo "NOTE: $FILE has $COUNT TODO/FIXME markers." >&2
905
+ exit 0`,
906
+ },
907
+ {
908
+ match: /block.*(commit|push).*without.*(test|lint|check)/,
909
+ name: 'block-commit-without-checks',
910
+ trigger: 'PreToolUse', matcher: 'Bash',
911
+ script: `#!/bin/bash
912
+ INPUT=$(cat)
913
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
914
+ [ -z "$COMMAND" ] && exit 0
915
+ if echo "$COMMAND" | grep -qE 'git\\s+(commit|push)' && ! echo "$COMMAND" | grep -qE '(--no-verify|--allow-empty)'; then
916
+ if [ -f "package.json" ] && command -v npm &>/dev/null; then
917
+ npm test --silent 2>/dev/null
918
+ if [ $? -ne 0 ]; then
919
+ echo "BLOCKED: Tests failing. Fix tests before commit/push." >&2
920
+ exit 2
921
+ fi
922
+ fi
923
+ fi
924
+ exit 0`,
925
+ },
926
+ ];
927
+
928
+ // Find matching pattern
929
+ let matched = null;
930
+ for (const p of patterns) {
931
+ if (p.match.test(desc)) {
932
+ matched = p;
933
+ break;
934
+ }
935
+ }
936
+
937
+ if (!matched) {
938
+ // Generate a generic blocking hook from the description
939
+ const keywords = desc.match(/block\s+(.+)/i)?.[1] || desc;
940
+ const sanitized = keywords.replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').slice(0, 30);
941
+ matched = {
942
+ name: 'custom-' + sanitized,
943
+ trigger: 'PreToolUse', matcher: 'Bash',
944
+ script: `#!/bin/bash
945
+ # Custom hook: ${description}
946
+ # Generated by cc-safe-setup --create
947
+ # Edit the grep pattern to match your specific commands
948
+ INPUT=$(cat)
949
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
950
+ [ -z "$COMMAND" ] && exit 0
951
+ # TODO: Replace this pattern with the commands you want to block
952
+ if echo "$COMMAND" | grep -qiE '${keywords.replace(/'/g, '').replace(/\s+/g, '.*')}'; then
953
+ echo "BLOCKED: ${description}" >&2
954
+ echo "Command: $COMMAND" >&2
955
+ exit 2
956
+ fi
957
+ exit 0`,
958
+ };
959
+ console.log(c.yellow + ' No exact template match. Generated a generic hook.' + c.reset);
960
+ console.log(c.dim + ' Edit the grep pattern in the generated script.' + c.reset);
961
+ console.log();
962
+ }
963
+
964
+ // Write the hook script
965
+ mkdirSync(HOOKS_DIR, { recursive: true });
966
+ const hookPath = join(HOOKS_DIR, matched.name + '.sh');
967
+ writeFileSync(hookPath, matched.script);
968
+ chmodSync(hookPath, 0o755);
969
+
970
+ console.log(c.green + ' ✓ Created: ' + hookPath + c.reset);
971
+ console.log(c.dim + ' Trigger: ' + matched.trigger + ', Matcher: ' + matched.matcher + c.reset);
972
+
973
+ // Register in settings.json
974
+ let settings = {};
975
+ if (existsSync(SETTINGS_PATH)) {
976
+ try { settings = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
977
+ }
978
+ if (!settings.hooks) settings.hooks = {};
979
+ if (!settings.hooks[matched.trigger]) settings.hooks[matched.trigger] = [];
980
+
981
+ // Check if already registered
982
+ const existing = settings.hooks[matched.trigger].flatMap(e => (e.hooks || []).map(h => h.command));
983
+ if (!existing.some(cmd => cmd.includes(matched.name))) {
984
+ settings.hooks[matched.trigger].push({
985
+ matcher: matched.matcher,
986
+ hooks: [{ type: 'command', command: hookPath }],
987
+ });
988
+ writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
989
+ console.log(c.green + ' ✓ Registered in settings.json' + c.reset);
990
+ } else {
991
+ console.log(c.dim + ' Already registered in settings.json' + c.reset);
992
+ }
993
+
994
+ // Quick test
995
+ const { spawnSync } = await import('child_process');
996
+ const testResult = spawnSync('bash', [hookPath], {
997
+ input: '{}',
998
+ timeout: 5000,
999
+ stdio: ['pipe', 'pipe', 'pipe'],
1000
+ });
1001
+ if (testResult.status === 0) {
1002
+ console.log(c.green + ' ✓ Hook passes empty input test' + c.reset);
1003
+ } else {
1004
+ console.log(c.yellow + ' ! Hook exits ' + testResult.status + ' on empty input (may need adjustment)' + c.reset);
1005
+ }
1006
+
1007
+ console.log();
1008
+ console.log(c.dim + ' Test it: npx cc-hook-test ' + hookPath + c.reset);
1009
+ console.log(c.dim + ' Restart Claude Code to activate.' + c.reset);
1010
+ console.log();
1011
+ }
1012
+
767
1013
  async function stats() {
768
1014
  const LOG_PATH = join(HOME, '.claude', 'blocked-commands.log');
769
1015
 
@@ -1442,6 +1688,7 @@ async function main() {
1442
1688
  if (FULL) return fullSetup();
1443
1689
  if (DOCTOR) return doctor();
1444
1690
  if (WATCH) return watch();
1691
+ if (CREATE_DESC) return createHook(CREATE_DESC);
1445
1692
  if (STATS) return stats();
1446
1693
  if (EXPORT) return exportConfig();
1447
1694
  if (IMPORT_FILE) return importConfig(IMPORT_FILE);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "2.10.0",
3
+ "version": "3.0.1",
4
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": {
@@ -36,4 +36,4 @@
36
36
  "url": "https://github.com/yurukusa/cc-safe-setup"
37
37
  },
38
38
  "homepage": "https://github.com/yurukusa/cc-safe-setup"
39
- }
39
+ }