cc-safe-setup 2.9.0 → 3.0.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.
Files changed (3) hide show
  1. package/action.yml +29 -3
  2. package/index.mjs +262 -0
  3. package/package.json +2 -2
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
@@ -78,6 +78,9 @@ const EXPORT = process.argv.includes('--export');
78
78
  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
+ 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;
81
84
 
82
85
  if (HELP) {
83
86
  console.log(`
@@ -94,10 +97,12 @@ if (HELP) {
94
97
  npx cc-safe-setup --full Complete setup: hooks + scan + audit + badge
95
98
  npx cc-safe-setup --audit Safety score (0-100) with fixes
96
99
  npx cc-safe-setup --audit --fix Auto-fix missing protections
100
+ npx cc-safe-setup --audit --json Machine-readable output for CI/CD
97
101
  npx cc-safe-setup --scan Detect tech stack, recommend hooks
98
102
  npx cc-safe-setup --learn Learn from your block history
99
103
  npx cc-safe-setup --doctor Diagnose why hooks aren't working
100
104
  npx cc-safe-setup --watch Live dashboard of blocked commands
105
+ npx cc-safe-setup --create "<desc>" Generate a custom hook from description
101
106
  npx cc-safe-setup --stats Block statistics and patterns report
102
107
  npx cc-safe-setup --export Export hooks config for team sharing
103
108
  npx cc-safe-setup --import <file> Import hooks from exported config
@@ -596,7 +601,20 @@ async function audit() {
596
601
  console.log(c.dim + ' Paste this into your README.md' + c.reset);
597
602
  }
598
603
 
604
+ // JSON output (for CI/CD integration)
605
+ if (JSON_OUTPUT) {
606
+ const output = {
607
+ score,
608
+ grade: score >= 80 ? 'A' : score >= 60 ? 'B' : score >= 40 ? 'C' : 'F',
609
+ risks: risks.map(r => ({ severity: r.severity, issue: r.issue, fix: r.fix })),
610
+ passing: good,
611
+ timestamp: new Date().toISOString(),
612
+ };
613
+ console.log(JSON.stringify(output, null, 2));
614
+ }
615
+
599
616
  console.log();
617
+ process.exit(score < (parseInt(process.env.CC_AUDIT_THRESHOLD) || 0) ? 1 : 0);
600
618
  }
601
619
 
602
620
  function learn() {
@@ -749,6 +767,249 @@ async function fullSetup() {
749
767
  console.log();
750
768
  }
751
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
+
752
1013
  async function stats() {
753
1014
  const LOG_PATH = join(HOME, '.claude', 'blocked-commands.log');
754
1015
 
@@ -1427,6 +1688,7 @@ async function main() {
1427
1688
  if (FULL) return fullSetup();
1428
1689
  if (DOCTOR) return doctor();
1429
1690
  if (WATCH) return watch();
1691
+ if (CREATE_DESC) return createHook(CREATE_DESC);
1430
1692
  if (STATS) return stats();
1431
1693
  if (EXPORT) return exportConfig();
1432
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.9.0",
3
+ "version": "3.0.0",
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
+ }