cc-safe-setup 2.4.0 → 2.5.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 +8 -1
- package/SAFETY_CHECKLIST.md +53 -0
- package/audit-web/index.html +494 -38
- package/docs/index.html +603 -0
- package/examples/case-sensitive-guard.sh +145 -0
- package/index.mjs +5 -1
- package/package.json +2 -2
package/docs/index.html
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Claude Code Safety Audit & Setup Generator</title>
|
|
7
|
+
<meta name="description" content="Audit your Claude Code settings and generate safety hooks — 100% in your browser. No npm required.">
|
|
8
|
+
<style>
|
|
9
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; padding: 2rem; }
|
|
11
|
+
.container { max-width: 800px; margin: 0 auto; }
|
|
12
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #f0f6fc; }
|
|
13
|
+
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; color: #f0f6fc; }
|
|
14
|
+
h3 { font-size: 1rem; margin: 1rem 0 0.5rem; color: #c9d1d9; }
|
|
15
|
+
.subtitle { color: #8b949e; margin-bottom: 2rem; }
|
|
16
|
+
textarea { width: 100%; height: 180px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-family: monospace; font-size: 13px; padding: 1rem; resize: vertical; }
|
|
17
|
+
textarea::placeholder { color: #484f58; }
|
|
18
|
+
.btn { background: #238636; color: #fff; border: none; padding: 0.6rem 1.2rem; border-radius: 6px; font-size: 0.9rem; cursor: pointer; margin-top: 0.75rem; margin-right: 0.5rem; }
|
|
19
|
+
.btn:hover { background: #2ea043; }
|
|
20
|
+
.btn-secondary { background: #30363d; }
|
|
21
|
+
.btn-secondary:hover { background: #484f58; }
|
|
22
|
+
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.75rem; margin-top: 0; }
|
|
23
|
+
.results { margin-top: 1.5rem; }
|
|
24
|
+
.score { font-size: 2rem; font-weight: bold; margin: 1rem 0; }
|
|
25
|
+
.score.good { color: #3fb950; }
|
|
26
|
+
.score.mid { color: #d29922; }
|
|
27
|
+
.score.bad { color: #f85149; }
|
|
28
|
+
.risk { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 0.5rem 0; }
|
|
29
|
+
.risk .severity { font-weight: bold; margin-right: 0.5rem; }
|
|
30
|
+
.risk .severity.critical, .risk .severity.high { color: #f85149; }
|
|
31
|
+
.risk .severity.medium { color: #d29922; }
|
|
32
|
+
.risk .severity.low { color: #8b949e; }
|
|
33
|
+
.risk .fix { color: #8b949e; font-family: monospace; font-size: 12px; margin-top: 0.5rem; }
|
|
34
|
+
.good-item { color: #3fb950; margin: 0.25rem 0; }
|
|
35
|
+
.privacy { color: #484f58; font-size: 12px; margin-top: 2rem; text-align: center; }
|
|
36
|
+
a { color: #58a6ff; text-decoration: none; }
|
|
37
|
+
.tabs { display: flex; gap: 0; margin-top: 2rem; border-bottom: 1px solid #30363d; }
|
|
38
|
+
.tab { padding: 0.6rem 1.2rem; cursor: pointer; color: #8b949e; border-bottom: 2px solid transparent; font-size: 0.9rem; }
|
|
39
|
+
.tab.active { color: #f0f6fc; border-bottom-color: #f78166; }
|
|
40
|
+
.tab:hover { color: #c9d1d9; }
|
|
41
|
+
.tab-content { display: none; }
|
|
42
|
+
.tab-content.active { display: block; }
|
|
43
|
+
.code-block { position: relative; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 0.5rem 0; overflow-x: auto; }
|
|
44
|
+
.code-block pre { font-family: monospace; font-size: 12px; white-space: pre; color: #c9d1d9; margin: 0; }
|
|
45
|
+
.code-block .copy-btn { position: absolute; top: 0.5rem; right: 0.5rem; background: #30363d; color: #c9d1d9; border: 1px solid #484f58; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 11px; cursor: pointer; }
|
|
46
|
+
.code-block .copy-btn:hover { background: #484f58; }
|
|
47
|
+
.hook-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 1rem; margin: 0.5rem 0; }
|
|
48
|
+
.hook-card summary { cursor: pointer; font-weight: bold; color: #f0f6fc; }
|
|
49
|
+
.hook-card summary:hover { color: #58a6ff; }
|
|
50
|
+
.hook-card .desc { color: #8b949e; font-size: 0.85rem; margin-top: 0.25rem; }
|
|
51
|
+
.setup-steps { counter-reset: step; list-style: none; padding: 0; }
|
|
52
|
+
.setup-steps li { counter-increment: step; padding: 0.75rem 0 0.75rem 2.5rem; position: relative; border-left: 2px solid #30363d; margin-left: 1rem; }
|
|
53
|
+
.setup-steps li::before { content: counter(step); position: absolute; left: -1rem; top: 0.65rem; width: 2rem; height: 2rem; background: #238636; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: bold; }
|
|
54
|
+
.setup-steps li:last-child { border-left: 2px solid transparent; }
|
|
55
|
+
.badge-row { display: flex; gap: 0.5rem; margin: 1rem 0; flex-wrap: wrap; }
|
|
56
|
+
.badge { display: inline-block; padding: 0.2rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: bold; }
|
|
57
|
+
.badge-green { background: #238636; color: #fff; }
|
|
58
|
+
.badge-yellow { background: #9e6a03; color: #fff; }
|
|
59
|
+
.badge-red { background: #da3633; color: #fff; }
|
|
60
|
+
.empty-state { text-align: center; padding: 3rem 1rem; color: #484f58; }
|
|
61
|
+
.empty-state p { margin: 0.5rem 0; }
|
|
62
|
+
</style>
|
|
63
|
+
</head>
|
|
64
|
+
<body>
|
|
65
|
+
<div class="container">
|
|
66
|
+
<h1>Claude Code Safety Audit</h1>
|
|
67
|
+
<p class="subtitle">Audit your setup and generate safety hooks — 100% in your browser. No npm required.</p>
|
|
68
|
+
|
|
69
|
+
<textarea id="settings" placeholder='{
|
|
70
|
+
"permissions": { "allow": ["Bash(git:*)"] },
|
|
71
|
+
"hooks": {
|
|
72
|
+
"PreToolUse": [{ "matcher": "Bash", "hooks": [{"type":"command","command":"..."}] }]
|
|
73
|
+
}
|
|
74
|
+
}'></textarea>
|
|
75
|
+
<br>
|
|
76
|
+
<button class="btn" onclick="runAudit()">Run Audit</button>
|
|
77
|
+
<button class="btn btn-secondary" onclick="generateFresh()">Generate Fresh Setup</button>
|
|
78
|
+
|
|
79
|
+
<div class="results" id="results"></div>
|
|
80
|
+
|
|
81
|
+
<div class="tabs" id="tabs" style="display:none">
|
|
82
|
+
<div class="tab active" onclick="switchTab('audit')">Audit Results</div>
|
|
83
|
+
<div class="tab" onclick="switchTab('fix')">Fix & Generate</div>
|
|
84
|
+
<div class="tab" onclick="switchTab('manual')">Manual Install Guide</div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="tab-content active" id="tab-audit"></div>
|
|
88
|
+
<div class="tab-content" id="tab-fix"></div>
|
|
89
|
+
<div class="tab-content" id="tab-manual"></div>
|
|
90
|
+
|
|
91
|
+
<p class="privacy">100% client-side. Your settings never leave this page. <a href="https://github.com/yurukusa/cc-safe-setup">Source</a> · <a href="https://www.npmjs.com/package/cc-safe-setup">npm</a></p>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<script>
|
|
95
|
+
// Hook metadata for generation
|
|
96
|
+
const HOOKS = {
|
|
97
|
+
'destructive-guard': {
|
|
98
|
+
trigger: 'PreToolUse', matcher: 'Bash',
|
|
99
|
+
name: 'Destructive Command Blocker',
|
|
100
|
+
desc: 'Blocks rm -rf /, git reset --hard, git clean -fd, PowerShell Remove-Item'
|
|
101
|
+
},
|
|
102
|
+
'branch-guard': {
|
|
103
|
+
trigger: 'PreToolUse', matcher: 'Bash',
|
|
104
|
+
name: 'Branch Push Protector',
|
|
105
|
+
desc: 'Blocks push to main/master and force-push on all branches'
|
|
106
|
+
},
|
|
107
|
+
'secret-guard': {
|
|
108
|
+
trigger: 'PreToolUse', matcher: 'Bash',
|
|
109
|
+
name: 'Secret Leak Prevention',
|
|
110
|
+
desc: 'Blocks git add .env, credential files, git add . with .env present'
|
|
111
|
+
},
|
|
112
|
+
'comment-strip': {
|
|
113
|
+
trigger: 'PreToolUse', matcher: 'Bash',
|
|
114
|
+
name: 'Bash Comment Stripper',
|
|
115
|
+
desc: 'Strips comments that break permission allowlists'
|
|
116
|
+
},
|
|
117
|
+
'cd-git-allow': {
|
|
118
|
+
trigger: 'PreToolUse', matcher: 'Bash',
|
|
119
|
+
name: 'cd+git Auto-Approver',
|
|
120
|
+
desc: 'Auto-approves read-only cd + git compound commands'
|
|
121
|
+
},
|
|
122
|
+
'syntax-check': {
|
|
123
|
+
trigger: 'PostToolUse', matcher: 'Edit|Write',
|
|
124
|
+
name: 'Post-Edit Syntax Validator',
|
|
125
|
+
desc: 'Checks Python, Shell, JSON, YAML, JS syntax after edits'
|
|
126
|
+
},
|
|
127
|
+
'context-monitor': {
|
|
128
|
+
trigger: 'PostToolUse', matcher: '',
|
|
129
|
+
name: 'Context Window Monitor',
|
|
130
|
+
desc: 'Graduated warnings at 40%/25%/20%/15% context remaining'
|
|
131
|
+
},
|
|
132
|
+
'api-error-alert': {
|
|
133
|
+
trigger: 'Stop', matcher: '',
|
|
134
|
+
name: 'API Error Alert',
|
|
135
|
+
desc: 'Desktop notification + log when session stops from API errors'
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let lastAuditState = null;
|
|
140
|
+
|
|
141
|
+
function switchTab(name) {
|
|
142
|
+
document.querySelectorAll('.tab').forEach((t,i) => {
|
|
143
|
+
t.classList.toggle('active', ['audit','fix','manual'][i] === name);
|
|
144
|
+
});
|
|
145
|
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
|
146
|
+
document.getElementById('tab-' + name).classList.add('active');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function copyText(id) {
|
|
150
|
+
const el = document.getElementById(id);
|
|
151
|
+
const text = el.textContent || el.innerText;
|
|
152
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
153
|
+
const btn = el.closest('.code-block').querySelector('.copy-btn');
|
|
154
|
+
btn.textContent = 'Copied!';
|
|
155
|
+
setTimeout(() => btn.textContent = 'Copy', 1500);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function escHtml(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
160
|
+
|
|
161
|
+
function runAudit() {
|
|
162
|
+
const raw = document.getElementById('settings').value.trim();
|
|
163
|
+
const el = document.getElementById('results');
|
|
164
|
+
|
|
165
|
+
if (!raw) {
|
|
166
|
+
generateFresh();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let settings;
|
|
171
|
+
try {
|
|
172
|
+
settings = JSON.parse(raw);
|
|
173
|
+
} catch(e) {
|
|
174
|
+
el.innerHTML = '<p style="color:#f85149">Invalid JSON. Paste your settings.json content.</p>';
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const {risks, good, score, missing} = analyzeSettings(settings);
|
|
179
|
+
lastAuditState = {settings, risks, good, score, missing};
|
|
180
|
+
|
|
181
|
+
// Show tabs
|
|
182
|
+
document.getElementById('tabs').style.display = 'flex';
|
|
183
|
+
|
|
184
|
+
renderAuditTab(risks, good, score);
|
|
185
|
+
renderFixTab(settings, risks, missing);
|
|
186
|
+
renderManualTab();
|
|
187
|
+
switchTab('audit');
|
|
188
|
+
el.innerHTML = '';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function generateFresh() {
|
|
192
|
+
const emptySettings = {};
|
|
193
|
+
const {risks, good, score, missing} = analyzeSettings(emptySettings);
|
|
194
|
+
lastAuditState = {settings: emptySettings, risks, good, score, missing};
|
|
195
|
+
|
|
196
|
+
document.getElementById('tabs').style.display = 'flex';
|
|
197
|
+
document.getElementById('results').innerHTML = '';
|
|
198
|
+
|
|
199
|
+
renderAuditTab(risks, good, score);
|
|
200
|
+
renderFixTab(emptySettings, risks, Object.keys(HOOKS));
|
|
201
|
+
renderManualTab();
|
|
202
|
+
switchTab('fix');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function analyzeSettings(settings) {
|
|
206
|
+
const risks = [];
|
|
207
|
+
const good = [];
|
|
208
|
+
const missing = [];
|
|
209
|
+
|
|
210
|
+
const preHooks = settings.hooks?.PreToolUse || [];
|
|
211
|
+
const postHooks = settings.hooks?.PostToolUse || [];
|
|
212
|
+
const stopHooks = settings.hooks?.Stop || [];
|
|
213
|
+
const allCmds = JSON.stringify(preHooks).toLowerCase();
|
|
214
|
+
const postCmds = JSON.stringify(postHooks).toLowerCase();
|
|
215
|
+
const stopCmds = JSON.stringify(stopHooks).toLowerCase();
|
|
216
|
+
|
|
217
|
+
if (preHooks.length === 0) {
|
|
218
|
+
risks.push({ severity: 'CRITICAL', issue: 'No PreToolUse hooks — rm -rf, git reset --hard run unchecked', fix: 'npx cc-safe-setup' });
|
|
219
|
+
missing.push('destructive-guard', 'branch-guard', 'secret-guard', 'comment-strip', 'cd-git-allow');
|
|
220
|
+
} else {
|
|
221
|
+
good.push('PreToolUse hooks (' + preHooks.length + ')');
|
|
222
|
+
if (!allCmds.match(/destructive|guard|block|rm.*rf|reset.*hard/)) {
|
|
223
|
+
risks.push({ severity: 'HIGH', issue: 'No destructive command blocker', fix: 'npx cc-safe-setup' });
|
|
224
|
+
missing.push('destructive-guard');
|
|
225
|
+
} else good.push('Destructive command protection');
|
|
226
|
+
|
|
227
|
+
if (!allCmds.match(/branch|push|main|master/)) {
|
|
228
|
+
risks.push({ severity: 'HIGH', issue: 'No branch push protection', fix: 'npx cc-safe-setup' });
|
|
229
|
+
missing.push('branch-guard');
|
|
230
|
+
} else good.push('Branch push protection');
|
|
231
|
+
|
|
232
|
+
if (!allCmds.match(/secret|env|credential/)) {
|
|
233
|
+
risks.push({ severity: 'HIGH', issue: 'No secret leak protection (.env commits)', fix: 'npx cc-safe-setup' });
|
|
234
|
+
missing.push('secret-guard');
|
|
235
|
+
} else good.push('Secret leak protection');
|
|
236
|
+
|
|
237
|
+
if (!allCmds.match(/comment|strip/)) missing.push('comment-strip');
|
|
238
|
+
if (!allCmds.match(/cd.*git.*allow|auto.*approv/)) missing.push('cd-git-allow');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (!allCmds.match(/database|wipe|migrate|prisma/)) {
|
|
242
|
+
risks.push({ severity: 'MEDIUM', issue: 'No database wipe protection', fix: 'npx cc-safe-setup --install-example block-database-wipe' });
|
|
243
|
+
} else good.push('Database wipe protection');
|
|
244
|
+
|
|
245
|
+
if (postHooks.length === 0) {
|
|
246
|
+
risks.push({ severity: 'MEDIUM', issue: 'No PostToolUse hooks (no syntax checking)', fix: 'npx cc-safe-setup' });
|
|
247
|
+
missing.push('syntax-check', 'context-monitor');
|
|
248
|
+
} else {
|
|
249
|
+
good.push('PostToolUse hooks (' + postHooks.length + ')');
|
|
250
|
+
if (!postCmds.match(/syntax|check|py_compile|node.*check/)) missing.push('syntax-check');
|
|
251
|
+
if (!postCmds.match(/context|monitor|capacity/)) missing.push('context-monitor');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (stopHooks.length === 0) {
|
|
255
|
+
missing.push('api-error-alert');
|
|
256
|
+
} else {
|
|
257
|
+
good.push('Stop hooks (' + stopHooks.length + ')');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const allows = settings.permissions?.allow || [];
|
|
261
|
+
if (allows.includes('Bash(*)')) {
|
|
262
|
+
risks.push({ severity: 'MEDIUM', issue: 'Bash(*) in allow list — all commands auto-approved', fix: 'Use specific patterns like Bash(git:*) instead' });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const denies = settings.permissions?.deny || [];
|
|
266
|
+
if (denies.length > 0) good.push('Deny rules (' + denies.length + ')');
|
|
267
|
+
|
|
268
|
+
const mode = settings.defaultMode;
|
|
269
|
+
if (mode === 'bypassPermissions') {
|
|
270
|
+
risks.push({ severity: 'HIGH', issue: 'bypassPermissions — all permission checks disabled', fix: 'Use dontAsk with hooks instead' });
|
|
271
|
+
} else if (mode === 'dontAsk') {
|
|
272
|
+
good.push('dontAsk mode (auto-approve, hooks still run)');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (settings.sandbox?.enabled === false) {
|
|
276
|
+
risks.push({ severity: 'MEDIUM', issue: 'Sandbox disabled', fix: 'Re-enable or use excludedCommands' });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const score = Math.max(0, 100 - risks.reduce((s, r) => {
|
|
280
|
+
if (r.severity === 'CRITICAL') return s + 30;
|
|
281
|
+
if (r.severity === 'HIGH') return s + 20;
|
|
282
|
+
if (r.severity === 'MEDIUM') return s + 10;
|
|
283
|
+
return s + 5;
|
|
284
|
+
}, 0));
|
|
285
|
+
|
|
286
|
+
return {risks, good, score, missing: [...new Set(missing)]};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function renderAuditTab(risks, good, score) {
|
|
290
|
+
const scoreClass = score >= 80 ? 'good' : score >= 50 ? 'mid' : 'bad';
|
|
291
|
+
let html = '<div class="score ' + scoreClass + '">Safety Score: ' + score + '/100</div>';
|
|
292
|
+
|
|
293
|
+
if (good.length > 0) {
|
|
294
|
+
html += '<h3 style="color:#3fb950">Working</h3>';
|
|
295
|
+
html += good.map(g => '<div class="good-item">' + g + '</div>').join('');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (risks.length > 0) {
|
|
299
|
+
html += '<h3 style="margin-top:1rem">Risks (' + risks.length + ')</h3>';
|
|
300
|
+
html += risks.map(r =>
|
|
301
|
+
'<div class="risk"><span class="severity ' + r.severity.toLowerCase() + '">[' + r.severity + ']</span> ' + escHtml(r.issue) +
|
|
302
|
+
'<div class="fix">Fix: ' + escHtml(r.fix) + '</div></div>'
|
|
303
|
+
).join('');
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (risks.length === 0) {
|
|
307
|
+
html += '<p style="color:#3fb950;margin-top:1rem">No risks detected. Your setup looks solid.</p>';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
document.getElementById('tab-audit').innerHTML = html;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function renderFixTab(settings, risks, missing) {
|
|
314
|
+
if (missing.length === 0) {
|
|
315
|
+
document.getElementById('tab-fix').innerHTML = '<p style="color:#3fb950;margin-top:1rem">All 8 hooks detected. Nothing to add.</p>';
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Generate the settings.json patch
|
|
320
|
+
const patch = generateSettingsPatch(settings, missing);
|
|
321
|
+
const installScript = generateInstallScript(missing);
|
|
322
|
+
|
|
323
|
+
let html = '<h2>Quick Fix</h2>';
|
|
324
|
+
html += '<p style="color:#8b949e;margin-bottom:1rem">Missing ' + missing.length + ' hook(s). Choose your install method:</p>';
|
|
325
|
+
|
|
326
|
+
// Option 1: npx
|
|
327
|
+
html += '<h3>Option 1: One command (recommended)</h3>';
|
|
328
|
+
html += '<div class="code-block"><button class="copy-btn" onclick="copyText(\'npx-cmd\')">Copy</button><pre id="npx-cmd">npx cc-safe-setup</pre></div>';
|
|
329
|
+
|
|
330
|
+
// Option 2: Generated settings.json
|
|
331
|
+
html += '<h3>Option 2: Copy settings.json patch</h3>';
|
|
332
|
+
html += '<p style="color:#8b949e;font-size:0.85rem;margin:0.25rem 0">Merge this into your <code>~/.claude/settings.json</code>:</p>';
|
|
333
|
+
html += '<div class="code-block"><button class="copy-btn" onclick="copyText(\'settings-patch\')">Copy</button><pre id="settings-patch">' + escHtml(JSON.stringify(patch, null, 2)) + '</pre></div>';
|
|
334
|
+
|
|
335
|
+
// Option 3: Full install script
|
|
336
|
+
html += '<h3>Option 3: Shell script (no npm needed)</h3>';
|
|
337
|
+
html += '<p style="color:#8b949e;font-size:0.85rem;margin:0.25rem 0">Run this in your terminal. Creates hook scripts and updates settings.json:</p>';
|
|
338
|
+
html += '<div class="code-block" style="max-height:400px;overflow-y:auto"><button class="copy-btn" onclick="copyText(\'install-script\')">Copy</button><pre id="install-script">' + escHtml(installScript) + '</pre></div>';
|
|
339
|
+
|
|
340
|
+
// Hook details
|
|
341
|
+
html += '<h3 style="margin-top:1.5rem">Hooks being added</h3>';
|
|
342
|
+
for (const id of missing) {
|
|
343
|
+
const h = HOOKS[id];
|
|
344
|
+
if (!h) continue;
|
|
345
|
+
html += '<div class="hook-card"><summary>' + escHtml(h.name) + '</summary>';
|
|
346
|
+
html += '<div class="desc">' + escHtml(h.desc) + ' · Trigger: ' + h.trigger + (h.matcher ? ' · Matcher: ' + h.matcher : '') + '</div></div>';
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
document.getElementById('tab-fix').innerHTML = html;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function renderManualTab() {
|
|
353
|
+
let html = '<h2>Manual Installation Guide</h2>';
|
|
354
|
+
html += '<p style="color:#8b949e;margin-bottom:1rem">Step-by-step for setting up Claude Code safety hooks without any tools.</p>';
|
|
355
|
+
|
|
356
|
+
html += '<ol class="setup-steps">';
|
|
357
|
+
html += '<li><strong>Create the hooks directory</strong><div class="code-block"><pre>mkdir -p ~/.claude/hooks</pre></div></li>';
|
|
358
|
+
html += '<li><strong>Create hook scripts</strong><br><span style="color:#8b949e;font-size:0.85rem">Use the "Fix & Generate" tab to get the script content, or run <code>npx cc-safe-setup --dry-run</code> to preview.</span></li>';
|
|
359
|
+
html += '<li><strong>Make scripts executable</strong><div class="code-block"><pre>chmod +x ~/.claude/hooks/*.sh</pre></div></li>';
|
|
360
|
+
html += '<li><strong>Update settings.json</strong><br><span style="color:#8b949e;font-size:0.85rem">Add the hook entries from "Fix & Generate" tab to <code>~/.claude/settings.json</code>.</span></li>';
|
|
361
|
+
html += '<li><strong>Restart Claude Code</strong><br><span style="color:#8b949e;font-size:0.85rem">Close and reopen Claude Code. Hooks activate on restart.</span></li>';
|
|
362
|
+
html += '<li><strong>Verify</strong><div class="code-block"><pre>npx cc-safe-setup --verify</pre></div><span style="color:#8b949e;font-size:0.85rem">Or test manually: try <code>rm -rf /</code> in Claude Code — the hook should block it.</span></li>';
|
|
363
|
+
html += '</ol>';
|
|
364
|
+
|
|
365
|
+
html += '<h3 style="margin-top:1.5rem">Requirements</h3>';
|
|
366
|
+
html += '<ul style="margin-left:1.5rem;color:#8b949e;font-size:0.85rem">';
|
|
367
|
+
html += '<li><code>jq</code> — for JSON parsing in hooks (<code>brew install jq</code> / <code>apt install jq</code>)</li>';
|
|
368
|
+
html += '<li><code>bash</code> — hooks are bash scripts</li>';
|
|
369
|
+
html += '<li>Claude Code v2.1+ — for hooks support</li>';
|
|
370
|
+
html += '</ul>';
|
|
371
|
+
|
|
372
|
+
html += '<h3 style="margin-top:1.5rem">Hooks Reference</h3>';
|
|
373
|
+
for (const [id, h] of Object.entries(HOOKS)) {
|
|
374
|
+
html += '<div class="hook-card"><details><summary>' + escHtml(h.name) + ' <span style="color:#484f58;font-weight:normal;font-size:0.8rem">(' + id + '.sh)</span></summary>';
|
|
375
|
+
html += '<div class="desc" style="margin-top:0.5rem">' + escHtml(h.desc) + '</div>';
|
|
376
|
+
html += '<div style="margin-top:0.25rem;font-size:0.8rem;color:#484f58">Trigger: ' + h.trigger + (h.matcher ? ' | Matcher: ' + h.matcher : ' | Matcher: (all)') + '</div>';
|
|
377
|
+
html += '</details></div>';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
document.getElementById('tab-manual').innerHTML = html;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function generateSettingsPatch(existing, missing) {
|
|
384
|
+
const patch = JSON.parse(JSON.stringify(existing || {}));
|
|
385
|
+
if (!patch.hooks) patch.hooks = {};
|
|
386
|
+
if (!patch.hooks.PreToolUse) patch.hooks.PreToolUse = [];
|
|
387
|
+
if (!patch.hooks.PostToolUse) patch.hooks.PostToolUse = [];
|
|
388
|
+
if (!patch.hooks.Stop) patch.hooks.Stop = [];
|
|
389
|
+
|
|
390
|
+
for (const id of missing) {
|
|
391
|
+
const h = HOOKS[id];
|
|
392
|
+
if (!h) continue;
|
|
393
|
+
const entry = {
|
|
394
|
+
matcher: h.matcher,
|
|
395
|
+
hooks: [{ type: 'command', command: '~/.claude/hooks/' + id + '.sh' }]
|
|
396
|
+
};
|
|
397
|
+
if (h.trigger === 'PreToolUse') patch.hooks.PreToolUse.push(entry);
|
|
398
|
+
else if (h.trigger === 'PostToolUse') patch.hooks.PostToolUse.push(entry);
|
|
399
|
+
else if (h.trigger === 'Stop') patch.hooks.Stop.push(entry);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Clean empty arrays
|
|
403
|
+
if (patch.hooks.PreToolUse.length === 0) delete patch.hooks.PreToolUse;
|
|
404
|
+
if (patch.hooks.PostToolUse.length === 0) delete patch.hooks.PostToolUse;
|
|
405
|
+
if (patch.hooks.Stop.length === 0) delete patch.hooks.Stop;
|
|
406
|
+
|
|
407
|
+
return patch;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function generateInstallScript(missing) {
|
|
411
|
+
let script = '#!/bin/bash\n';
|
|
412
|
+
script += '# Claude Code Safety Hooks — Generated by https://yurukusa.github.io/cc-safe-setup/\n';
|
|
413
|
+
script += '# Run: bash install-safety-hooks.sh\n\n';
|
|
414
|
+
script += 'set -euo pipefail\n\n';
|
|
415
|
+
script += 'HOOK_DIR="$HOME/.claude/hooks"\n';
|
|
416
|
+
script += 'SETTINGS="$HOME/.claude/settings.json"\n\n';
|
|
417
|
+
script += '# Check jq\n';
|
|
418
|
+
script += 'if ! command -v jq &>/dev/null; then\n';
|
|
419
|
+
script += ' echo "Error: jq is required. Install with: brew install jq / apt install jq"\n';
|
|
420
|
+
script += ' exit 1\n';
|
|
421
|
+
script += 'fi\n\n';
|
|
422
|
+
script += 'mkdir -p "$HOOK_DIR"\n\n';
|
|
423
|
+
|
|
424
|
+
// Write each hook script
|
|
425
|
+
for (const id of missing) {
|
|
426
|
+
const h = HOOKS[id];
|
|
427
|
+
if (!h) continue;
|
|
428
|
+
script += '# --- ' + h.name + ' ---\n';
|
|
429
|
+
script += 'cat > "$HOOK_DIR/' + id + '.sh" << \'HOOKEOF\'\n';
|
|
430
|
+
script += getMinimalHookScript(id);
|
|
431
|
+
script += '\nHOOKEOF\n';
|
|
432
|
+
script += 'chmod +x "$HOOK_DIR/' + id + '.sh"\n';
|
|
433
|
+
script += 'echo "Installed: ' + id + '.sh"\n\n';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Update settings.json
|
|
437
|
+
script += '# --- Update settings.json ---\n';
|
|
438
|
+
script += 'if [ ! -f "$SETTINGS" ]; then\n';
|
|
439
|
+
script += ' echo "{}" > "$SETTINGS"\n';
|
|
440
|
+
script += 'fi\n\n';
|
|
441
|
+
|
|
442
|
+
// Build jq command to merge hooks
|
|
443
|
+
const pre = missing.filter(id => HOOKS[id]?.trigger === 'PreToolUse');
|
|
444
|
+
const post = missing.filter(id => HOOKS[id]?.trigger === 'PostToolUse');
|
|
445
|
+
const stop = missing.filter(id => HOOKS[id]?.trigger === 'Stop');
|
|
446
|
+
|
|
447
|
+
if (pre.length > 0) {
|
|
448
|
+
script += '# Add PreToolUse hooks\n';
|
|
449
|
+
for (const id of pre) {
|
|
450
|
+
const h = HOOKS[id];
|
|
451
|
+
script += 'jq \'.hooks.PreToolUse += [{"matcher":"' + h.matcher + '","hooks":[{"type":"command","command":"\'$HOOK_DIR\'/' + id + '.sh"}]}]\' "$SETTINGS" > /tmp/cc-settings-tmp.json && mv /tmp/cc-settings-tmp.json "$SETTINGS"\n';
|
|
452
|
+
}
|
|
453
|
+
script += '\n';
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (post.length > 0) {
|
|
457
|
+
script += '# Add PostToolUse hooks\n';
|
|
458
|
+
for (const id of post) {
|
|
459
|
+
const h = HOOKS[id];
|
|
460
|
+
script += 'jq \'.hooks.PostToolUse += [{"matcher":"' + h.matcher + '","hooks":[{"type":"command","command":"\'$HOOK_DIR\'/' + id + '.sh"}]}]\' "$SETTINGS" > /tmp/cc-settings-tmp.json && mv /tmp/cc-settings-tmp.json "$SETTINGS"\n';
|
|
461
|
+
}
|
|
462
|
+
script += '\n';
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (stop.length > 0) {
|
|
466
|
+
script += '# Add Stop hooks\n';
|
|
467
|
+
for (const id of stop) {
|
|
468
|
+
script += 'jq \'.hooks.Stop += [{"matcher":"","hooks":[{"type":"command","command":"\'$HOOK_DIR\'/' + id + '.sh"}]}]\' "$SETTINGS" > /tmp/cc-settings-tmp.json && mv /tmp/cc-settings-tmp.json "$SETTINGS"\n';
|
|
469
|
+
}
|
|
470
|
+
script += '\n';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
script += 'echo ""\n';
|
|
474
|
+
script += 'echo "Done. ' + missing.length + ' safety hooks installed."\n';
|
|
475
|
+
script += 'echo "Restart Claude Code to activate."\n';
|
|
476
|
+
|
|
477
|
+
return script;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function getMinimalHookScript(id) {
|
|
481
|
+
// Minimal but functional versions of each hook
|
|
482
|
+
const scripts = {
|
|
483
|
+
'destructive-guard': `#!/bin/bash
|
|
484
|
+
INPUT=$(cat)
|
|
485
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
486
|
+
[ -z "$COMMAND" ] && exit 0
|
|
487
|
+
[ "\${CC_ALLOW_DESTRUCTIVE:-0}" = "1" ] && exit 0
|
|
488
|
+
SAFE_DIRS="\${CC_SAFE_DELETE_DIRS:-node_modules:dist:build:.cache:__pycache__:coverage:.next}"
|
|
489
|
+
# rm -rf on sensitive paths
|
|
490
|
+
if echo "$COMMAND" | grep -qE 'rm\\s+(-[rf]+\\s+)*(\\/$|\\/\\s|\\/home|\\/etc|\\/usr|~\\/|~\\s*$|\\.\\.\\/|\\.\\s*$)'; then
|
|
491
|
+
SAFE=0
|
|
492
|
+
IFS=':' read -ra D <<< "$SAFE_DIRS"
|
|
493
|
+
for d in "\${D[@]}"; do echo "$COMMAND" | grep -qE "rm\\s+.*\${d}\\s*$" && SAFE=1 && break; done
|
|
494
|
+
[ "$SAFE" = 0 ] && echo "BLOCKED: rm on sensitive path. Command: $COMMAND" >&2 && exit 2
|
|
495
|
+
fi
|
|
496
|
+
# git reset --hard
|
|
497
|
+
echo "$COMMAND" | grep -qE '(^|;|&&)\\s*git\\s+reset\\s+--hard' && echo "BLOCKED: git reset --hard" >&2 && exit 2
|
|
498
|
+
# git clean
|
|
499
|
+
echo "$COMMAND" | grep -qE '(^|;|&&)\\s*git\\s+clean\\s+-[a-z]*[fd]' && echo "BLOCKED: git clean" >&2 && exit 2
|
|
500
|
+
# PowerShell destructive
|
|
501
|
+
echo "$COMMAND" | grep -qiE 'Remove-Item.*-Recurse.*-Force|rd\\s+/s\\s+/q' && echo "BLOCKED: Destructive PowerShell command" >&2 && exit 2
|
|
502
|
+
# git checkout --force
|
|
503
|
+
echo "$COMMAND" | grep -qE '(^|;|&&)\\s*git\\s+(checkout|switch)\\s+.*(--force|-f\\b)' && echo "BLOCKED: git checkout --force" >&2 && exit 2
|
|
504
|
+
exit 0`,
|
|
505
|
+
|
|
506
|
+
'branch-guard': `#!/bin/bash
|
|
507
|
+
INPUT=$(cat)
|
|
508
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
509
|
+
[ -z "$COMMAND" ] && exit 0
|
|
510
|
+
echo "$COMMAND" | grep -qE '^\\s*git\\s+push' || exit 0
|
|
511
|
+
# Force push
|
|
512
|
+
if [ "\${CC_ALLOW_FORCE_PUSH:-0}" != "1" ]; then
|
|
513
|
+
echo "$COMMAND" | grep -qE 'git\\s+push\\s+.*(-f\\b|--force\\b|--force-with-lease)' && echo "BLOCKED: Force push" >&2 && exit 2
|
|
514
|
+
fi
|
|
515
|
+
# Protected branches
|
|
516
|
+
PROTECTED="\${CC_PROTECT_BRANCHES:-main:master}"
|
|
517
|
+
IFS=':' read -ra B <<< "$PROTECTED"
|
|
518
|
+
for b in "\${B[@]}"; do
|
|
519
|
+
echo "$COMMAND" | grep -qwE "origin\\s+\${b}|\${b}\\s|\${b}$" && echo "BLOCKED: Push to protected branch $b" >&2 && exit 2
|
|
520
|
+
done
|
|
521
|
+
exit 0`,
|
|
522
|
+
|
|
523
|
+
'secret-guard': `#!/bin/bash
|
|
524
|
+
INPUT=$(cat)
|
|
525
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
526
|
+
[ -z "$COMMAND" ] && exit 0
|
|
527
|
+
echo "$COMMAND" | grep -qE '^\\s*git\\s+add' || exit 0
|
|
528
|
+
# Direct .env staging
|
|
529
|
+
echo "$COMMAND" | grep -qiE 'git\\s+add\\s+.*\\.env(\\s|$|\\.)' && echo "BLOCKED: .env file staging" >&2 && exit 2
|
|
530
|
+
# Credential files
|
|
531
|
+
echo "$COMMAND" | grep -qiE 'git\\s+add\\s+.*(credentials|\\.pem|\\.key|\\.p12|id_rsa|id_ed25519)' && echo "BLOCKED: Credential file staging" >&2 && exit 2
|
|
532
|
+
# git add . with .env present
|
|
533
|
+
if echo "$COMMAND" | grep -qE 'git\\s+add\\s+(-A|--all|\\.)(\\s|$)'; then
|
|
534
|
+
[ -f ".env" ] || [ -f ".env.local" ] && echo "BLOCKED: git add . with .env present" >&2 && exit 2
|
|
535
|
+
fi
|
|
536
|
+
exit 0`,
|
|
537
|
+
|
|
538
|
+
'comment-strip': `#!/bin/bash
|
|
539
|
+
INPUT=$(cat)
|
|
540
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
541
|
+
[ -z "$COMMAND" ] && exit 0
|
|
542
|
+
CLEAN=$(echo "$COMMAND" | sed '/^[[:space:]]*#/d; /^[[:space:]]*$/d')
|
|
543
|
+
[ "$CLEAN" = "$COMMAND" ] && exit 0
|
|
544
|
+
[ -z "$CLEAN" ] && exit 0
|
|
545
|
+
jq -n --arg cmd "$CLEAN" '{"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{"command":$cmd}}}'`,
|
|
546
|
+
|
|
547
|
+
'cd-git-allow': `#!/bin/bash
|
|
548
|
+
INPUT=$(cat)
|
|
549
|
+
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
550
|
+
[ -z "$COMMAND" ] && exit 0
|
|
551
|
+
echo "$COMMAND" | grep -qE '^\\s*cd\\s+.*&&\\s*git\\s' || exit 0
|
|
552
|
+
GIT_CMD=$(echo "$COMMAND" | grep -oP '&&\\s*git\\s+\\K\\S+')
|
|
553
|
+
for safe in log diff status branch show rev-parse tag remote; do
|
|
554
|
+
[ "$GIT_CMD" = "$safe" ] && jq -n '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"cd+git auto-approved"}}' && exit 0
|
|
555
|
+
done
|
|
556
|
+
exit 0`,
|
|
557
|
+
|
|
558
|
+
'syntax-check': `#!/bin/bash
|
|
559
|
+
INPUT=$(cat)
|
|
560
|
+
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty' 2>/dev/null)
|
|
561
|
+
[ -z "$FILE_PATH" ] || [ ! -f "$FILE_PATH" ] && exit 0
|
|
562
|
+
EXT="\${FILE_PATH##*.}"
|
|
563
|
+
case "$EXT" in
|
|
564
|
+
py) python3 -m py_compile "$FILE_PATH" 2>&1 || echo "SYNTAX ERROR (Python): $FILE_PATH" >&2 ;;
|
|
565
|
+
sh|bash) bash -n "$FILE_PATH" 2>&1 || echo "SYNTAX ERROR (Shell): $FILE_PATH" >&2 ;;
|
|
566
|
+
json) command -v jq &>/dev/null && { jq empty "$FILE_PATH" 2>&1 || echo "SYNTAX ERROR (JSON): $FILE_PATH" >&2; } ;;
|
|
567
|
+
js) command -v node &>/dev/null && { node --check "$FILE_PATH" 2>&1 || echo "SYNTAX ERROR (JS): $FILE_PATH" >&2; } ;;
|
|
568
|
+
esac
|
|
569
|
+
exit 0`,
|
|
570
|
+
|
|
571
|
+
'context-monitor': `#!/bin/bash
|
|
572
|
+
COUNTER_FILE="/tmp/cc-context-monitor-count"
|
|
573
|
+
COUNT=$(cat "$COUNTER_FILE" 2>/dev/null || echo 0)
|
|
574
|
+
COUNT=$((COUNT + 1))
|
|
575
|
+
echo "$COUNT" > "$COUNTER_FILE"
|
|
576
|
+
[ $((COUNT % 5)) -ne 0 ] && exit 0
|
|
577
|
+
# Estimate context usage from tool call count (~180 calls = full context)
|
|
578
|
+
PCT=$(( 100 - (COUNT * 100 / 180) ))
|
|
579
|
+
[ "$PCT" -lt 0 ] && PCT=0
|
|
580
|
+
if [ "$PCT" -le 15 ]; then
|
|
581
|
+
echo "EMERGENCY: Context ~\${PCT}% remaining. Run /compact NOW." >&2
|
|
582
|
+
elif [ "$PCT" -le 25 ]; then
|
|
583
|
+
echo "WARNING: Context ~\${PCT}% remaining. Finish current task." >&2
|
|
584
|
+
elif [ "$PCT" -le 40 ]; then
|
|
585
|
+
echo "CAUTION: Context ~\${PCT}% remaining." >&2
|
|
586
|
+
fi
|
|
587
|
+
exit 0`,
|
|
588
|
+
|
|
589
|
+
'api-error-alert': `#!/bin/bash
|
|
590
|
+
INPUT=$(cat)
|
|
591
|
+
REASON=$(echo "$INPUT" | jq -r '.stop_reason // "unknown"' 2>/dev/null)
|
|
592
|
+
[ "$REASON" = "user" ] || [ "$REASON" = "normal" ] || [ -z "$REASON" ] && exit 0
|
|
593
|
+
LOG="\${CC_ERROR_ALERT_LOG:-$HOME/.claude/session-errors.log}"
|
|
594
|
+
mkdir -p "$(dirname "$LOG")" 2>/dev/null
|
|
595
|
+
echo "[$(date -Iseconds)] Session stopped: reason=$REASON" >> "$LOG"
|
|
596
|
+
exit 0`
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
return scripts[id] || '#!/bin/bash\nexit 0';
|
|
600
|
+
}
|
|
601
|
+
</script>
|
|
602
|
+
</body>
|
|
603
|
+
</html>
|