cc-safe-setup 2.4.0 → 2.6.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.
@@ -3,18 +3,24 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Claude Code Safety Audit</title>
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.">
7
8
  <style>
8
9
  * { box-sizing: border-box; margin: 0; padding: 0; }
9
10
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0d1117; color: #c9d1d9; min-height: 100vh; padding: 2rem; }
10
- .container { max-width: 720px; margin: 0 auto; }
11
+ .container { max-width: 800px; margin: 0 auto; }
11
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; }
12
15
  .subtitle { color: #8b949e; margin-bottom: 2rem; }
13
- textarea { width: 100%; height: 200px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; color: #c9d1d9; font-family: monospace; font-size: 13px; padding: 1rem; resize: vertical; }
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; }
14
17
  textarea::placeholder { color: #484f58; }
15
- button { background: #238636; color: #fff; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; font-size: 1rem; cursor: pointer; margin-top: 1rem; }
16
- button:hover { background: #2ea043; }
17
- .results { margin-top: 2rem; }
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; }
18
24
  .score { font-size: 2rem; font-weight: bold; margin: 1rem 0; }
19
25
  .score.good { color: #3fb950; }
20
26
  .score.mid { color: #d29922; }
@@ -28,12 +34,37 @@ button:hover { background: #2ea043; }
28
34
  .good-item { color: #3fb950; margin: 0.25rem 0; }
29
35
  .privacy { color: #484f58; font-size: 12px; margin-top: 2rem; text-align: center; }
30
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; }
31
62
  </style>
32
63
  </head>
33
64
  <body>
34
65
  <div class="container">
35
66
  <h1>Claude Code Safety Audit</h1>
36
- <p class="subtitle">Paste your <code>~/.claude/settings.json</code> below. Nothing leaves your browser.</p>
67
+ <p class="subtitle">Audit your setup and generate safety hooks — 100% in your browser. No npm required.</p>
37
68
 
38
69
  <textarea id="settings" placeholder='{
39
70
  "permissions": { "allow": ["Bash(git:*)"] },
@@ -42,18 +73,100 @@ a { color: #58a6ff; text-decoration: none; }
42
73
  }
43
74
  }'></textarea>
44
75
  <br>
45
- <button onclick="runAudit()">Run Audit</button>
76
+ <button class="btn" onclick="runAudit()">Run Audit</button>
77
+ <button class="btn btn-secondary" onclick="generateFresh()">Generate Fresh Setup</button>
46
78
 
47
79
  <div class="results" id="results"></div>
48
80
 
49
- <p class="privacy">🔒 100% client-side. Your settings never leave this page. <a href="https://github.com/yurukusa/cc-safe-setup">Source</a></p>
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>
50
92
  </div>
51
93
 
52
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
160
+
53
161
  function runAudit() {
54
162
  const raw = document.getElementById('settings').value.trim();
55
163
  const el = document.getElementById('results');
56
164
 
165
+ if (!raw) {
166
+ generateFresh();
167
+ return;
168
+ }
169
+
57
170
  let settings;
58
171
  try {
59
172
  settings = JSON.parse(raw);
@@ -62,56 +175,107 @@ function runAudit() {
62
175
  return;
63
176
  }
64
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) {
65
206
  const risks = [];
66
207
  const good = [];
208
+ const missing = [];
67
209
 
68
210
  const preHooks = settings.hooks?.PreToolUse || [];
69
211
  const postHooks = settings.hooks?.PostToolUse || [];
212
+ const stopHooks = settings.hooks?.Stop || [];
70
213
  const allCmds = JSON.stringify(preHooks).toLowerCase();
214
+ const postCmds = JSON.stringify(postHooks).toLowerCase();
215
+ const stopCmds = JSON.stringify(stopHooks).toLowerCase();
71
216
 
72
- // 1. PreToolUse hooks
73
217
  if (preHooks.length === 0) {
74
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');
75
220
  } else {
76
221
  good.push('PreToolUse hooks (' + preHooks.length + ')');
77
- }
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');
78
226
 
79
- // 2. Destructive guard
80
- if (!allCmds.match(/destructive|guard|block|rm.*rf|reset.*hard/)) {
81
- risks.push({ severity: 'HIGH', issue: 'No destructive command blocker', fix: 'npx cc-safe-setup' });
82
- } else good.push('Destructive command protection');
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');
83
231
 
84
- // 3. Branch guard
85
- if (!allCmds.match(/branch|push|main|master/)) {
86
- risks.push({ severity: 'HIGH', issue: 'No branch push protection', fix: 'npx cc-safe-setup' });
87
- } else good.push('Branch push protection');
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');
88
236
 
89
- // 4. Secret guard
90
- if (!allCmds.match(/secret|env|credential/)) {
91
- risks.push({ severity: 'HIGH', issue: 'No secret leak protection (.env commits)', fix: 'npx cc-safe-setup' });
92
- } else good.push('Secret leak protection');
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
+ }
93
240
 
94
- // 5. Database wipe
95
241
  if (!allCmds.match(/database|wipe|migrate|prisma/)) {
96
242
  risks.push({ severity: 'MEDIUM', issue: 'No database wipe protection', fix: 'npx cc-safe-setup --install-example block-database-wipe' });
97
243
  } else good.push('Database wipe protection');
98
244
 
99
- // 6. PostToolUse
100
245
  if (postHooks.length === 0) {
101
246
  risks.push({ severity: 'MEDIUM', issue: 'No PostToolUse hooks (no syntax checking)', fix: 'npx cc-safe-setup' });
102
- } else good.push('PostToolUse hooks (' + postHooks.length + ')');
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
+ }
103
259
 
104
- // 7. Allow rules check
105
260
  const allows = settings.permissions?.allow || [];
106
261
  if (allows.includes('Bash(*)')) {
107
- risks.push({ severity: 'MEDIUM', issue: 'Bash(*) in allow list — all commands auto-approved without checks', fix: 'Use specific patterns like Bash(git:*) instead' });
262
+ risks.push({ severity: 'MEDIUM', issue: 'Bash(*) in allow list — all commands auto-approved', fix: 'Use specific patterns like Bash(git:*) instead' });
108
263
  }
109
264
 
110
- // 8. Deny rules
111
265
  const denies = settings.permissions?.deny || [];
112
- if (denies.length > 0) good.push('Deny rules configured (' + denies.length + ')');
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
+ }
113
278
 
114
- // Score
115
279
  const score = Math.max(0, 100 - risks.reduce((s, r) => {
116
280
  if (r.severity === 'CRITICAL') return s + 30;
117
281
  if (r.severity === 'HIGH') return s + 20;
@@ -119,20 +283,23 @@ function runAudit() {
119
283
  return s + 5;
120
284
  }, 0));
121
285
 
122
- const scoreClass = score >= 80 ? 'good' : score >= 50 ? 'mid' : 'bad';
286
+ return {risks, good, score, missing: [...new Set(missing)]};
287
+ }
123
288
 
289
+ function renderAuditTab(risks, good, score) {
290
+ const scoreClass = score >= 80 ? 'good' : score >= 50 ? 'mid' : 'bad';
124
291
  let html = '<div class="score ' + scoreClass + '">Safety Score: ' + score + '/100</div>';
125
292
 
126
293
  if (good.length > 0) {
127
- html += '<h3 style="color:#3fb950;margin:1rem 0 0.5rem">✓ Working</h3>';
128
- html += good.map(g => '<div class="good-item">✓ ' + g + '</div>').join('');
294
+ html += '<h3 style="color:#3fb950">Working</h3>';
295
+ html += good.map(g => '<div class="good-item">' + g + '</div>').join('');
129
296
  }
130
297
 
131
298
  if (risks.length > 0) {
132
- html += '<h3 style="margin:1rem 0 0.5rem">⚠ Risks (' + risks.length + ')</h3>';
299
+ html += '<h3 style="margin-top:1rem">Risks (' + risks.length + ')</h3>';
133
300
  html += risks.map(r =>
134
- '<div class="risk"><span class="severity ' + r.severity.toLowerCase() + '">[' + r.severity + ']</span>' + r.issue +
135
- '<div class="fix">Fix: ' + r.fix + '</div></div>'
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>'
136
303
  ).join('');
137
304
  }
138
305
 
@@ -140,7 +307,296 @@ function runAudit() {
140
307
  html += '<p style="color:#3fb950;margin-top:1rem">No risks detected. Your setup looks solid.</p>';
141
308
  }
142
309
 
143
- el.innerHTML = html;
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) + ' &middot; Trigger: ' + h.trigger + (h.matcher ? ' &middot; 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';
144
600
  }
145
601
  </script>
146
602
  </body>