cc-safe-setup 5.1.0 → 5.3.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 +3 -0
- package/examples/binary-file-guard.sh +32 -0
- package/examples/stale-branch-guard.sh +44 -0
- package/examples/symlink-guard.sh +78 -0
- package/index.mjs +88 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -224,6 +224,9 @@ Or browse all available examples in [`examples/`](examples/):
|
|
|
224
224
|
- **session-handoff.sh** — Auto-save git state and session info to `~/.claude/session-handoff.md` on session end
|
|
225
225
|
- **diff-size-guard.sh** — Warn/block when committing too many files at once (default: warn at 10, block at 50)
|
|
226
226
|
- **dependency-audit.sh** — Warn when installing packages not in manifest (npm/pip/cargo supply chain awareness)
|
|
227
|
+
- **symlink-guard.sh** — Detect symlink/junction traversal in rm targets ([#36339](https://github.com/anthropics/claude-code/issues/36339) [#764](https://github.com/anthropics/claude-code/issues/764))
|
|
228
|
+
- **binary-file-guard.sh** — Warn when Write targets binary file types (images, archives)
|
|
229
|
+
- **stale-branch-guard.sh** — Warn when working branch is far behind default
|
|
227
230
|
- **cost-tracker.sh** — Estimate session token cost and warn at thresholds ($1, $5)
|
|
228
231
|
- **read-before-edit.sh** — Warn when editing files not recently read (prevents old_string mismatches)
|
|
229
232
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# binary-file-guard.sh — Warn when Write creates binary/large files
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Claude Code sometimes tries to Write binary content (images,
|
|
7
|
+
# archives, compiled files) which produces corrupted output.
|
|
8
|
+
# This hook detects binary patterns in Write content.
|
|
9
|
+
#
|
|
10
|
+
# TRIGGER: PreToolUse
|
|
11
|
+
# MATCHER: "Write"
|
|
12
|
+
# ================================================================
|
|
13
|
+
|
|
14
|
+
INPUT=$(cat)
|
|
15
|
+
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
16
|
+
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty' 2>/dev/null)
|
|
17
|
+
|
|
18
|
+
if [[ -z "$FILE" ]]; then
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
# Check file extension for binary types
|
|
23
|
+
EXT="${FILE##*.}"
|
|
24
|
+
BINARY_EXTS="png|jpg|jpeg|gif|bmp|ico|webp|svg|mp3|mp4|wav|zip|tar|gz|rar|7z|exe|dll|so|dylib|class|pyc|wasm|pdf|doc|docx|xls|xlsx|ppt|pptx"
|
|
25
|
+
|
|
26
|
+
if echo "$EXT" | grep -qiE "^($BINARY_EXTS)$"; then
|
|
27
|
+
echo "WARNING: Writing to binary file type: $FILE" >&2
|
|
28
|
+
echo "Claude Code cannot reliably create binary files." >&2
|
|
29
|
+
echo "Use a proper tool (ImageMagick, ffmpeg, etc.) instead." >&2
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
exit 0
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# stale-branch-guard.sh — Warn when working on a stale branch
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# Claude Code can work on a branch that's far behind main,
|
|
7
|
+
# creating merge conflicts. This hook warns when the current
|
|
8
|
+
# branch is 50+ commits behind the default branch.
|
|
9
|
+
#
|
|
10
|
+
# TRIGGER: PostToolUse
|
|
11
|
+
# MATCHER: ""
|
|
12
|
+
#
|
|
13
|
+
# Only checks every 20 tool calls to avoid overhead.
|
|
14
|
+
# ================================================================
|
|
15
|
+
|
|
16
|
+
COUNTER_FILE="/tmp/cc-stale-branch-check"
|
|
17
|
+
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
18
|
+
COUNT=$((COUNT + 1))
|
|
19
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
20
|
+
|
|
21
|
+
# Only check every 20 tool calls
|
|
22
|
+
[ $((COUNT % 20)) -ne 0 ] && exit 0
|
|
23
|
+
|
|
24
|
+
# Only check in git repos
|
|
25
|
+
[ -d .git ] || exit 0
|
|
26
|
+
|
|
27
|
+
# Get default branch
|
|
28
|
+
DEFAULT=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's@^refs/remotes/origin/@@')
|
|
29
|
+
[ -z "$DEFAULT" ] && DEFAULT="main"
|
|
30
|
+
|
|
31
|
+
CURRENT=$(git branch --show-current 2>/dev/null)
|
|
32
|
+
[ -z "$CURRENT" ] || [ "$CURRENT" = "$DEFAULT" ] && exit 0
|
|
33
|
+
|
|
34
|
+
# Count commits behind
|
|
35
|
+
BEHIND=$(git rev-list --count HEAD..origin/"$DEFAULT" 2>/dev/null || echo 0)
|
|
36
|
+
|
|
37
|
+
if [ "$BEHIND" -ge 50 ]; then
|
|
38
|
+
echo "WARNING: Branch '$CURRENT' is $BEHIND commits behind $DEFAULT." >&2
|
|
39
|
+
echo "Consider rebasing: git rebase origin/$DEFAULT" >&2
|
|
40
|
+
elif [ "$BEHIND" -ge 20 ]; then
|
|
41
|
+
echo "NOTE: Branch '$CURRENT' is $BEHIND commits behind $DEFAULT." >&2
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
exit 0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# ================================================================
|
|
3
|
+
# symlink-guard.sh — Detect symlink/junction traversal before rm
|
|
4
|
+
# ================================================================
|
|
5
|
+
# PURPOSE:
|
|
6
|
+
# rm -rf on a directory containing symlinks can follow them and
|
|
7
|
+
# delete data outside the target. NTFS junctions on WSL2 are
|
|
8
|
+
# especially dangerous (#36339: entire C:\Users deleted).
|
|
9
|
+
#
|
|
10
|
+
# This hook checks if the rm target contains symlinks pointing
|
|
11
|
+
# outside the current project.
|
|
12
|
+
#
|
|
13
|
+
# TRIGGER: PreToolUse
|
|
14
|
+
# MATCHER: "Bash"
|
|
15
|
+
#
|
|
16
|
+
# WHAT IT BLOCKS:
|
|
17
|
+
# - rm -rf on directories containing symlinks to outside paths
|
|
18
|
+
# - rm on targets that are themselves symlinks to sensitive dirs
|
|
19
|
+
#
|
|
20
|
+
# GitHub Issues: #36339 (93r), #764 (63r), #24964 (135r)
|
|
21
|
+
# ================================================================
|
|
22
|
+
|
|
23
|
+
INPUT=$(cat)
|
|
24
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
25
|
+
|
|
26
|
+
if [[ -z "$COMMAND" ]]; then
|
|
27
|
+
exit 0
|
|
28
|
+
fi
|
|
29
|
+
|
|
30
|
+
# Only check rm commands with recursive flags
|
|
31
|
+
if ! echo "$COMMAND" | grep -qE '^\s*rm\s+.*-[rf]'; then
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
# Extract target path
|
|
36
|
+
TARGET=$(echo "$COMMAND" | grep -oP 'rm\s+(-[rf]+\s+)*\K\S+' | tail -1)
|
|
37
|
+
|
|
38
|
+
if [[ -z "$TARGET" ]] || [[ ! -e "$TARGET" ]]; then
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
# Check 1: Is the target itself a symlink?
|
|
43
|
+
if [ -L "$TARGET" ]; then
|
|
44
|
+
REAL=$(readlink -f "$TARGET" 2>/dev/null)
|
|
45
|
+
echo "WARNING: rm target is a symlink." >&2
|
|
46
|
+
echo " Target: $TARGET" >&2
|
|
47
|
+
echo " Points to: $REAL" >&2
|
|
48
|
+
echo " rm -rf will follow and delete the real path." >&2
|
|
49
|
+
# Don't block, just warn — user may intend to delete the link
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# Check 2: Does the target directory contain symlinks to outside?
|
|
53
|
+
if [ -d "$TARGET" ]; then
|
|
54
|
+
PROJECT_DIR=$(pwd)
|
|
55
|
+
DANGEROUS_LINKS=$(find "$TARGET" -maxdepth 3 -type l 2>/dev/null | while read link; do
|
|
56
|
+
REAL=$(readlink -f "$link" 2>/dev/null)
|
|
57
|
+
# Check if symlink points outside the project
|
|
58
|
+
if [[ -n "$REAL" ]] && [[ "$REAL" != "$PROJECT_DIR"* ]]; then
|
|
59
|
+
echo "$link -> $REAL"
|
|
60
|
+
fi
|
|
61
|
+
done | head -3)
|
|
62
|
+
|
|
63
|
+
if [[ -n "$DANGEROUS_LINKS" ]]; then
|
|
64
|
+
echo "BLOCKED: rm target contains symlinks pointing outside project." >&2
|
|
65
|
+
echo "" >&2
|
|
66
|
+
echo "Command: $COMMAND" >&2
|
|
67
|
+
echo "Dangerous links:" >&2
|
|
68
|
+
echo "$DANGEROUS_LINKS" | while read line; do
|
|
69
|
+
echo " $line" >&2
|
|
70
|
+
done
|
|
71
|
+
echo "" >&2
|
|
72
|
+
echo "rm -rf would follow these links and delete external data." >&2
|
|
73
|
+
echo "Remove the symlinks first, then delete the directory." >&2
|
|
74
|
+
exit 2
|
|
75
|
+
fi
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
exit 0
|
package/index.mjs
CHANGED
|
@@ -86,6 +86,8 @@ const SHARE = process.argv.includes('--share');
|
|
|
86
86
|
const BENCHMARK = process.argv.includes('--benchmark');
|
|
87
87
|
const DASHBOARD = process.argv.includes('--dashboard');
|
|
88
88
|
const ISSUES = process.argv.includes('--issues');
|
|
89
|
+
const COMPARE_IDX = process.argv.findIndex(a => a === '--compare');
|
|
90
|
+
const COMPARE = COMPARE_IDX !== -1 ? { a: process.argv[COMPARE_IDX + 1], b: process.argv[COMPARE_IDX + 2] } : null;
|
|
89
91
|
const CREATE_IDX = process.argv.findIndex(a => a === '--create');
|
|
90
92
|
const CREATE_DESC = CREATE_IDX !== -1 ? process.argv.slice(CREATE_IDX + 1).join(' ') : null;
|
|
91
93
|
|
|
@@ -107,6 +109,7 @@ if (HELP) {
|
|
|
107
109
|
npx cc-safe-setup --audit --json Machine-readable output for CI/CD
|
|
108
110
|
npx cc-safe-setup --scan Detect tech stack, recommend hooks
|
|
109
111
|
npx cc-safe-setup --learn Learn from your block history
|
|
112
|
+
npx cc-safe-setup --compare <a> <b> Compare two hooks side-by-side
|
|
110
113
|
npx cc-safe-setup --issues Show GitHub Issues each hook addresses
|
|
111
114
|
npx cc-safe-setup --dashboard Real-time status dashboard
|
|
112
115
|
npx cc-safe-setup --benchmark Measure hook execution time
|
|
@@ -807,6 +810,90 @@ async function fullSetup() {
|
|
|
807
810
|
console.log();
|
|
808
811
|
}
|
|
809
812
|
|
|
813
|
+
async function compare(hookA, hookB) {
|
|
814
|
+
const { spawnSync } = await import('child_process');
|
|
815
|
+
const { statSync } = await import('fs');
|
|
816
|
+
|
|
817
|
+
console.log();
|
|
818
|
+
console.log(c.bold + ' cc-safe-setup --compare' + c.reset);
|
|
819
|
+
console.log();
|
|
820
|
+
|
|
821
|
+
if (!hookA || !hookB) {
|
|
822
|
+
console.log(c.red + ' Usage: npx cc-safe-setup --compare <hook-a.sh> <hook-b.sh>' + c.reset);
|
|
823
|
+
process.exit(1);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Resolve paths
|
|
827
|
+
const resolveHook = (h) => {
|
|
828
|
+
if (existsSync(h)) return h;
|
|
829
|
+
const inHooks = join(HOOKS_DIR, h);
|
|
830
|
+
if (existsSync(inHooks)) return inHooks;
|
|
831
|
+
const inExamples = join(__dirname, 'examples', h);
|
|
832
|
+
if (existsSync(inExamples)) return inExamples;
|
|
833
|
+
return null;
|
|
834
|
+
};
|
|
835
|
+
|
|
836
|
+
const pathA = resolveHook(hookA);
|
|
837
|
+
const pathB = resolveHook(hookB);
|
|
838
|
+
|
|
839
|
+
if (!pathA) { console.log(c.red + ' Hook A not found: ' + hookA + c.reset); process.exit(1); }
|
|
840
|
+
if (!pathB) { console.log(c.red + ' Hook B not found: ' + hookB + c.reset); process.exit(1); }
|
|
841
|
+
|
|
842
|
+
const nameA = hookA.split('/').pop();
|
|
843
|
+
const nameB = hookB.split('/').pop();
|
|
844
|
+
|
|
845
|
+
// Test cases
|
|
846
|
+
const tests = [
|
|
847
|
+
{ name: 'empty input', input: '{}' },
|
|
848
|
+
{ name: 'safe command', input: '{"tool_input":{"command":"echo hello"}}' },
|
|
849
|
+
{ name: 'rm -rf /', input: '{"tool_input":{"command":"rm -rf /"}}' },
|
|
850
|
+
{ name: 'rm -rf ~', input: '{"tool_input":{"command":"rm -rf ~"}}' },
|
|
851
|
+
{ name: 'git push main', input: '{"tool_input":{"command":"git push origin main"}}' },
|
|
852
|
+
{ name: 'git push --force', input: '{"tool_input":{"command":"git push --force"}}' },
|
|
853
|
+
{ name: 'git add .env', input: '{"tool_input":{"command":"git add .env"}}' },
|
|
854
|
+
{ name: 'git reset --hard', input: '{"tool_input":{"command":"git reset --hard"}}' },
|
|
855
|
+
{ name: 'npm test', input: '{"tool_input":{"command":"npm test"}}' },
|
|
856
|
+
{ name: 'cd && git log', input: '{"tool_input":{"command":"cd /tmp && git log"}}' },
|
|
857
|
+
];
|
|
858
|
+
|
|
859
|
+
function runHook(path, input) {
|
|
860
|
+
const start = process.hrtime.bigint();
|
|
861
|
+
const result = spawnSync('bash', [path], { input, timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
862
|
+
const ms = Number(process.hrtime.bigint() - start) / 1_000_000;
|
|
863
|
+
return { exit: result.status ?? -1, ms, stderr: (result.stderr || Buffer.alloc(0)).toString().slice(0, 80) };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Header
|
|
867
|
+
console.log(' ' + c.bold + 'Test'.padEnd(20) + nameA.padEnd(25) + nameB + c.reset);
|
|
868
|
+
console.log(' ' + '-'.repeat(65));
|
|
869
|
+
|
|
870
|
+
let sameCount = 0;
|
|
871
|
+
let diffCount = 0;
|
|
872
|
+
|
|
873
|
+
for (const test of tests) {
|
|
874
|
+
const a = runHook(pathA, test.input);
|
|
875
|
+
const b = runHook(pathB, test.input);
|
|
876
|
+
const same = a.exit === b.exit;
|
|
877
|
+
if (same) sameCount++; else diffCount++;
|
|
878
|
+
|
|
879
|
+
const exitA = a.exit === 0 ? c.green + 'allow' + c.reset : a.exit === 2 ? c.red + 'BLOCK' + c.reset : c.yellow + 'err' + a.exit + c.reset;
|
|
880
|
+
const exitB = b.exit === 0 ? c.green + 'allow' + c.reset : b.exit === 2 ? c.red + 'BLOCK' + c.reset : c.yellow + 'err' + b.exit + c.reset;
|
|
881
|
+
const marker = same ? ' ' : c.yellow + '≠' + c.reset;
|
|
882
|
+
|
|
883
|
+
console.log(' ' + marker + ' ' + test.name.padEnd(18) + (exitA + ' ' + a.ms.toFixed(0) + 'ms').padEnd(30) + exitB + ' ' + b.ms.toFixed(0) + 'ms');
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// Size comparison
|
|
887
|
+
const sizeA = statSync(pathA).size;
|
|
888
|
+
const sizeB = statSync(pathB).size;
|
|
889
|
+
|
|
890
|
+
console.log(' ' + '-'.repeat(65));
|
|
891
|
+
console.log(' Same decisions: ' + sameCount + '/' + tests.length);
|
|
892
|
+
if (diffCount > 0) console.log(' ' + c.yellow + 'Different: ' + diffCount + c.reset);
|
|
893
|
+
console.log(' Size: ' + nameA + ' ' + sizeA + 'B vs ' + nameB + ' ' + sizeB + 'B');
|
|
894
|
+
console.log();
|
|
895
|
+
}
|
|
896
|
+
|
|
810
897
|
function issues() {
|
|
811
898
|
// Map hooks to the GitHub Issues they address
|
|
812
899
|
const ISSUE_MAP = [
|
|
@@ -2327,6 +2414,7 @@ async function main() {
|
|
|
2327
2414
|
if (FULL) return fullSetup();
|
|
2328
2415
|
if (DOCTOR) return doctor();
|
|
2329
2416
|
if (WATCH) return watch();
|
|
2417
|
+
if (COMPARE) return compare(COMPARE.a, COMPARE.b);
|
|
2330
2418
|
if (ISSUES) return issues();
|
|
2331
2419
|
if (DASHBOARD) return dashboard();
|
|
2332
2420
|
if (BENCHMARK) return benchmark();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-safe-setup",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.3.0",
|
|
4
4
|
"description": "One command to make Claude Code safe for autonomous operation. 8 built-in + 39 examples. 23 commands including dashboard, issues, create, audit, lint, diff. 260 tests. 2,500+ daily npm downloads.",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|