cc-safe-setup 2.10.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.
- package/action.yml +29 -3
- package/index.mjs +247 -0
- 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
|
-
#
|
|
24
|
-
|
|
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 "
|
|
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": "
|
|
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
|
+
}
|