cc-safe-setup 4.0.3 → 5.1.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 CHANGED
@@ -249,6 +249,7 @@ Or browse all available examples in [`examples/`](examples/):
249
249
  - [Hooks Cookbook](https://github.com/yurukusa/claude-code-hooks/blob/main/COOKBOOK.md) — 25 recipes from real GitHub Issues ([interactive version](https://yurukusa.github.io/claude-code-hooks/))
250
250
  - [Japanese guide (Qiita)](https://qiita.com/yurukusa/items/a9714b33f5d974e8f1e8) — この記事の日本語解説
251
251
  - [Hook Test Runner](https://github.com/yurukusa/cc-hook-test) — `npx cc-hook-test <hook.sh>` to auto-test any hook
252
+ - [Hook Registry](https://github.com/yurukusa/cc-hook-registry) — `npx cc-hook-registry search database` to find community hooks
252
253
  - [Hooks Cheat Sheet](https://yurukusa.github.io/cc-safe-setup/cheatsheet.html) — printable A4 quick reference
253
254
  - [Ecosystem Comparison](https://yurukusa.github.io/cc-safe-setup/ecosystem.html) — all Claude Code hook projects compared
254
255
  - [The incident that inspired this tool](https://github.com/anthropics/claude-code/issues/36339) — NTFS junction rm -rf
package/docs/app.html ADDED
@@ -0,0 +1,271 @@
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>cc-safe-setup — Claude Code Safety Suite</title>
7
+ <meta name="description" content="Audit, build, browse, and learn Claude Code hooks. All in one page, 100% client-side.">
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}
11
+ .nav{display:flex;background:#161b22;border-bottom:1px solid #30363d;overflow-x:auto}
12
+ .nav a{padding:.75rem 1.2rem;color:#8b949e;text-decoration:none;font-size:.85rem;white-space:nowrap;border-bottom:2px solid transparent;cursor:pointer}
13
+ .nav a.active{color:#f0f6fc;border-bottom-color:#f78166}
14
+ .nav a:hover{color:#c9d1d9}
15
+ .page{display:none;max-width:800px;margin:0 auto;padding:1.5rem}
16
+ .page.active{display:block}
17
+ h1{font-size:1.3rem;color:#f0f6fc;margin-bottom:.5rem}
18
+ h2{font-size:1.1rem;color:#f0f6fc;margin:1.5rem 0 .5rem}
19
+ h3{font-size:.95rem;color:#c9d1d9;margin:1rem 0 .3rem}
20
+ p,.sub{color:#8b949e;font-size:.85rem;margin-bottom:.75rem}
21
+ a{color:#58a6ff;text-decoration:none}
22
+ textarea,input,select{width:100%;padding:.5rem;background:#161b22;border:1px solid #30363d;border-radius:4px;color:#c9d1d9;font-family:monospace;font-size:.8rem;margin:.25rem 0}
23
+ textarea{height:150px;resize:vertical}
24
+ button,.btn{background:#238636;color:#fff;border:none;padding:.5rem 1rem;border-radius:4px;cursor:pointer;font-size:.85rem;margin:.25rem .25rem .25rem 0}
25
+ button:hover,.btn:hover{background:#2ea043}
26
+ .btn-sm{padding:.25rem .5rem;font-size:.75rem}
27
+ .btn-secondary{background:#30363d}
28
+ pre{background:#161b22;border:1px solid #30363d;border-radius:4px;padding:.6rem;font-size:.75rem;overflow-x:auto;position:relative;margin:.3rem 0}
29
+ .copy{position:absolute;top:.3rem;right:.3rem;background:#30363d;color:#c9d1d9;border:none;padding:.15rem .4rem;border-radius:3px;font-size:.65rem;cursor:pointer}
30
+ .score{font-size:1.8rem;font-weight:bold;margin:.5rem 0}
31
+ .score.good{color:#3fb950} .score.mid{color:#d29922} .score.bad{color:#f85149}
32
+ .risk{background:#161b22;border:1px solid #30363d;border-radius:4px;padding:.75rem;margin:.3rem 0;font-size:.8rem}
33
+ .good-item{color:#3fb950;margin:.2rem 0;font-size:.8rem}
34
+ table{width:100%;border-collapse:collapse;font-size:.8rem;margin:.5rem 0}
35
+ th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid #21262d}
36
+ th{font-weight:600;color:#f0f6fc}
37
+ .recipe{background:#161b22;border:1px solid #30363d;border-radius:4px;margin:.3rem 0;overflow:hidden}
38
+ .recipe summary{padding:.5rem .75rem;cursor:pointer;font-weight:600;color:#f0f6fc;font-size:.85rem}
39
+ .recipe-body{padding:0 .75rem .5rem}
40
+ .filter{padding:.2rem .5rem;border-radius:3px;border:1px solid #30363d;background:transparent;color:#8b949e;cursor:pointer;font-size:.7rem;margin:.15rem}
41
+ .filter.active{background:#238636;border-color:#238636;color:#fff}
42
+ .badge{display:inline-block;padding:.1rem .3rem;border-radius:3px;font-size:.65rem;font-weight:bold}
43
+ .badge-bash{background:#1f6feb22;color:#58a6ff;border:1px solid #1f6feb44}
44
+ .badge-js{background:#f0e68c22;color:#f0e68c;border:1px solid #f0e68c44}
45
+ .badge-py{background:#3fb95022;color:#3fb950;border:1px solid #3fb95044}
46
+ .badge-ts{background:#388bfd22;color:#388bfd;border:1px solid #388bfd44}
47
+ .check{color:#3fb950} .cross{color:#484f58}
48
+ .footer{text-align:center;color:#484f58;font-size:.7rem;padding:2rem 1rem}
49
+ @media print{.nav{display:none} .page{display:block!important}}
50
+ </style>
51
+ </head>
52
+ <body>
53
+
54
+ <nav class="nav">
55
+ <a onclick="go('audit')" id="nav-audit" class="active">Audit</a>
56
+ <a onclick="go('builder')" id="nav-builder">Hook Builder</a>
57
+ <a onclick="go('cookbook')" id="nav-cookbook">Cookbook</a>
58
+ <a onclick="go('ecosystem')" id="nav-ecosystem">Ecosystem</a>
59
+ <a onclick="go('cheatsheet')" id="nav-cheatsheet">Cheat Sheet</a>
60
+ </nav>
61
+
62
+ <!-- PAGE: AUDIT -->
63
+ <div class="page active" id="page-audit">
64
+ <h1>Safety Audit</h1>
65
+ <p>Paste your <code>~/.claude/settings.json</code>. Nothing leaves your browser.</p>
66
+ <textarea id="settings" placeholder='{"permissions":{"allow":["Bash(git:*)"]},"hooks":{"PreToolUse":[...]}}'></textarea>
67
+ <button onclick="runAudit()">Run Audit</button>
68
+ <button class="btn-secondary" onclick="generateFresh()">Generate Fresh Setup</button>
69
+ <div id="audit-results"></div>
70
+ </div>
71
+
72
+ <!-- PAGE: HOOK BUILDER -->
73
+ <div class="page" id="page-builder">
74
+ <h1>Hook Builder</h1>
75
+ <p>Build a custom hook without writing code.</p>
76
+ <div style="display:flex;gap:.75rem;flex-wrap:wrap">
77
+ <div style="flex:1;min-width:180px">
78
+ <label style="font-size:.75rem;color:#8b949e">Action</label>
79
+ <select id="hb-action"><option value="block">Block</option><option value="warn">Warn</option><option value="approve">Auto-approve</option></select>
80
+ </div>
81
+ <div style="flex:2;min-width:250px">
82
+ <label style="font-size:.75rem;color:#8b949e">Pattern (regex)</label>
83
+ <input id="hb-pattern" placeholder="e.g. rm\s+-rf, git push --force">
84
+ </div>
85
+ </div>
86
+ <label style="font-size:.75rem;color:#8b949e">Message</label>
87
+ <input id="hb-message" placeholder="e.g. Run tests before pushing">
88
+ <button onclick="buildHook()">Generate</button>
89
+ <div id="hb-result"></div>
90
+ </div>
91
+
92
+ <!-- PAGE: COOKBOOK -->
93
+ <div class="page" id="page-cookbook">
94
+ <h1>Hooks Cookbook</h1>
95
+ <p>Copy-paste recipes from real GitHub Issues.</p>
96
+ <input id="cb-search" placeholder="Search... (database, git, deploy)" oninput="filterRecipes()">
97
+ <div style="margin:.3rem 0" id="cb-filters"></div>
98
+ <div id="cb-count" style="color:#8b949e;font-size:.8rem"></div>
99
+ <div id="cb-list"></div>
100
+ </div>
101
+
102
+ <!-- PAGE: ECOSYSTEM -->
103
+ <div class="page" id="page-ecosystem">
104
+ <h1>Ecosystem Comparison</h1>
105
+ <p>All major Claude Code hook projects compared.</p>
106
+ <table>
107
+ <tr><th>Project</th><th>Lang</th><th>Hooks</th><th>Install</th></tr>
108
+ <tr><td><a href="https://github.com/kenryu42/claude-code-safety-net">safety-net</a></td><td><span class="badge badge-ts">TS</span></td><td>5</td><td>npx</td></tr>
109
+ <tr><td><a href="https://github.com/yurukusa/cc-safe-setup">cc-safe-setup</a></td><td><span class="badge badge-bash">Bash</span></td><td>8+39</td><td>npx</td></tr>
110
+ <tr><td><a href="https://github.com/karanb192/claude-code-hooks">karanb192</a></td><td><span class="badge badge-js">JS</span></td><td>5+</td><td>copy</td></tr>
111
+ <tr><td><a href="https://github.com/disler/claude-code-hooks-mastery">mastery</a></td><td><span class="badge badge-py">Python</span></td><td>12</td><td>copy</td></tr>
112
+ <tr><td><a href="https://github.com/lasso-security/claude-hooks">lasso</a></td><td><span class="badge badge-py">Python</span></td><td>1</td><td>install.sh</td></tr>
113
+ </table>
114
+ <h2>Feature Matrix</h2>
115
+ <table>
116
+ <tr><th>Feature</th><th>safety-net</th><th>cc-safe-setup</th><th>karanb192</th><th>mastery</th></tr>
117
+ <tr><td>rm -rf blocker</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td><td class="check">✓</td></tr>
118
+ <tr><td>Branch guard</td><td class="check">✓</td><td class="check">✓</td><td class="cross">-</td><td class="cross">-</td></tr>
119
+ <tr><td>Secret guard</td><td class="cross">-</td><td class="check">✓</td><td class="check">✓</td><td class="cross">-</td></tr>
120
+ <tr><td>Syntax check</td><td class="cross">-</td><td class="check">✓</td><td class="cross">-</td><td class="cross">-</td></tr>
121
+ <tr><td>Context monitor</td><td class="cross">-</td><td class="check">✓</td><td class="cross">-</td><td class="cross">-</td></tr>
122
+ <tr><td>Hook generator</td><td class="cross">-</td><td class="check">✓</td><td class="cross">-</td><td class="cross">-</td></tr>
123
+ <tr><td>Dashboard</td><td class="cross">-</td><td class="check">✓</td><td class="cross">-</td><td class="cross">-</td></tr>
124
+ <tr><td>GitHub Action</td><td class="cross">-</td><td class="check">✓</td><td class="cross">-</td><td class="cross">-</td></tr>
125
+ </table>
126
+ </div>
127
+
128
+ <!-- PAGE: CHEAT SHEET -->
129
+ <div class="page" id="page-cheatsheet">
130
+ <h1>Hooks Cheat Sheet</h1>
131
+ <p>Print this page (Ctrl+P) for a quick reference.</p>
132
+
133
+ <h3>Lifecycle</h3>
134
+ <pre>Prompt → PreToolUse → Tool → PostToolUse → Stop</pre>
135
+
136
+ <h3>Exit Codes</h3>
137
+ <table><tr><th>Code</th><th>Meaning</th></tr>
138
+ <tr><td><code>0</code></td><td>Allow</td></tr>
139
+ <tr><td><code>2</code></td><td><strong>Block</strong></td></tr></table>
140
+
141
+ <h3>Minimal Block Hook</h3>
142
+ <pre>#!/bin/bash
143
+ CMD=$(cat | jq -r '.tool_input.command // empty')
144
+ [ -z "$CMD" ] && exit 0
145
+ echo "$CMD" | grep -qE 'PATTERN' && echo "BLOCKED" >&2 && exit 2
146
+ exit 0</pre>
147
+
148
+ <h3>Auto-Approve Hook</h3>
149
+ <pre>#!/bin/bash
150
+ CMD=$(cat | jq -r '.tool_input.command // empty')
151
+ [ -z "$CMD" ] && exit 0
152
+ echo "$CMD" | grep -qE '^git\s+(status|log|diff)' && \
153
+ jq -n '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
154
+ exit 0</pre>
155
+
156
+ <h3>Quick Commands</h3>
157
+ <table>
158
+ <tr><td><code>npx cc-safe-setup</code></td><td>Install 8 hooks</td></tr>
159
+ <tr><td><code>--create "desc"</code></td><td>Generate hook</td></tr>
160
+ <tr><td><code>--audit</code></td><td>Score 0-100</td></tr>
161
+ <tr><td><code>--dashboard</code></td><td>Live status</td></tr>
162
+ <tr><td><code>--doctor</code></td><td>Diagnose</td></tr>
163
+ <tr><td><code>--benchmark</code></td><td>Speed test</td></tr>
164
+ </table>
165
+ </div>
166
+
167
+ <div class="footer">
168
+ cc-safe-setup · 100% client-side · <a href="https://github.com/yurukusa/cc-safe-setup">GitHub</a> · <a href="https://www.npmjs.com/package/cc-safe-setup">npm</a>
169
+ </div>
170
+
171
+ <script>
172
+ // Navigation
173
+ function go(page) {
174
+ document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
175
+ document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active'));
176
+ document.getElementById('page-' + page).classList.add('active');
177
+ document.getElementById('nav-' + page).classList.add('active');
178
+ }
179
+
180
+ // AUDIT
181
+ function runAudit() {
182
+ const raw = document.getElementById('settings').value.trim();
183
+ const el = document.getElementById('audit-results');
184
+ if (!raw) { generateFresh(); return; }
185
+ let s; try { s = JSON.parse(raw); } catch { el.innerHTML='<p style="color:#f85149">Invalid JSON</p>'; return; }
186
+ const {risks,good,score} = analyze(s);
187
+ renderAudit(risks, good, score, el);
188
+ }
189
+ function generateFresh() {
190
+ const {risks,good,score} = analyze({});
191
+ renderAudit(risks, good, score, document.getElementById('audit-results'));
192
+ }
193
+ function analyze(s) {
194
+ const risks=[], good=[];
195
+ const pre=s.hooks?.PreToolUse||[], post=s.hooks?.PostToolUse||[];
196
+ const all=JSON.stringify(pre).toLowerCase();
197
+ if(!pre.length) risks.push({s:'CRITICAL',i:'No PreToolUse hooks',f:'npx cc-safe-setup'});
198
+ else { good.push('PreToolUse ('+pre.length+')');
199
+ if(!all.match(/destructive|guard|rm.*rf/)) risks.push({s:'HIGH',i:'No destructive guard',f:'npx cc-safe-setup'});
200
+ else good.push('Destructive protection');
201
+ if(!all.match(/branch|push|main/)) risks.push({s:'HIGH',i:'No branch guard',f:'npx cc-safe-setup'});
202
+ else good.push('Branch protection');
203
+ if(!all.match(/secret|env|credential/)) risks.push({s:'HIGH',i:'No secret guard',f:'npx cc-safe-setup'});
204
+ else good.push('Secret protection');
205
+ }
206
+ if(!post.length) risks.push({s:'MEDIUM',i:'No PostToolUse hooks',f:'npx cc-safe-setup'});
207
+ else good.push('PostToolUse ('+post.length+')');
208
+ const score=Math.max(0,100-risks.reduce((n,r)=>n+(r.s==='CRITICAL'?30:r.s==='HIGH'?20:10),0));
209
+ return {risks,good,score};
210
+ }
211
+ function renderAudit(risks,good,score,el) {
212
+ const cls=score>=80?'good':score>=50?'mid':'bad';
213
+ let h='<div class="score '+cls+'">'+score+'/100</div>';
214
+ if(good.length) { h+='<h3 style="color:#3fb950">Working</h3>'; good.forEach(g=>h+='<div class="good-item">✓ '+g+'</div>'); }
215
+ if(risks.length) { h+='<h3>Risks ('+risks.length+')</h3>'; risks.forEach(r=>h+='<div class="risk"><strong>['+r.s+']</strong> '+r.i+'<br><code>'+r.f+'</code></div>'); }
216
+ if(!risks.length) h+='<p style="color:#3fb950">No risks detected.</p>';
217
+ el.innerHTML=h;
218
+ }
219
+
220
+ // HOOK BUILDER
221
+ function buildHook() {
222
+ const action=document.getElementById('hb-action').value;
223
+ const pattern=document.getElementById('hb-pattern').value.trim();
224
+ const message=document.getElementById('hb-message').value.trim()||'Blocked';
225
+ const el=document.getElementById('hb-result');
226
+ if(!pattern){el.innerHTML='<p style="color:#f85149">Enter a pattern</p>';return;}
227
+ let s='#!/bin/bash\nCMD=$(cat | jq -r \'.tool_input.command // empty\' 2>/dev/null)\n[ -z "$CMD" ] && exit 0\n';
228
+ if(action==='block') s+='echo "$CMD" | grep -qE \''+pattern+'\' && echo "BLOCKED: '+message+'" >&2 && exit 2\n';
229
+ else if(action==='warn') s+='echo "$CMD" | grep -qE \''+pattern+'\' && echo "WARNING: '+message+'" >&2\n';
230
+ else s+='echo "$CMD" | grep -qE \''+pattern+'\' && jq -n \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\'\n';
231
+ s+='exit 0';
232
+ el.innerHTML='<h3>Script</h3><pre>'+esc(s)+'</pre><p style="font-size:.75rem;color:#8b949e">Save as ~/.claude/hooks/custom.sh, chmod +x, add to settings.json</p>';
233
+ }
234
+
235
+ // COOKBOOK
236
+ const RECIPES=[
237
+ {cat:'block',t:'Block rm -rf',d:'Destructive commands (#36339)',code:'CMD=$(cat|jq -r \'.tool_input.command // empty\')\n[ -z "$CMD" ] && exit 0\necho "$CMD"|grep -qE \'rm\\s+(-[rf]+\\s+)*/\' && echo "BLOCKED" >&2 && exit 2\nexit 0',trigger:'PreToolUse'},
238
+ {cat:'block',t:'Block force push',d:'Branch protection',code:'CMD=$(cat|jq -r \'.tool_input.command // empty\')\n[ -z "$CMD" ] && exit 0\necho "$CMD"|grep -qE \'git\\s+push.*--force\' && echo "BLOCKED" >&2 && exit 2\nexit 0',trigger:'PreToolUse'},
239
+ {cat:'block',t:'Block .env staging',d:'Secret leak prevention (#6527)',code:'CMD=$(cat|jq -r \'.tool_input.command // empty\')\n[ -z "$CMD" ] && exit 0\necho "$CMD"|grep -qiE \'git\\s+add.*\\.env\' && echo "BLOCKED" >&2 && exit 2\nexit 0',trigger:'PreToolUse'},
240
+ {cat:'block',t:'Block database wipe',d:'migrate:fresh, DROP DATABASE (#37405)',code:'CMD=$(cat|jq -r \'.tool_input.command // empty\')\n[ -z "$CMD" ] && exit 0\necho "$CMD"|grep -qiE \'migrate:fresh|DROP\\s+DATABASE|prisma\\s+migrate\\s+reset\' && echo "BLOCKED" >&2 && exit 2\nexit 0',trigger:'PreToolUse'},
241
+ {cat:'approve',t:'Auto-approve git read',d:'git status/log/diff with -C flag (#36900)',code:'CMD=$(cat|jq -r \'.tool_input.command // empty\')\n[ -z "$CMD" ] && exit 0\necho "$CMD"|grep -qE \'^\\s*git\\s+(-C\\s+\\S+\\s+)?(status|log|diff|branch|show)\' && jq -n \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}\'\nexit 0',trigger:'PreToolUse'},
242
+ {cat:'approve',t:'Compound commands',d:'cd && git log auto-approve (#30519)',code:'# Full: npx cc-safe-setup --install-example compound-command-approver',trigger:'PreToolUse'},
243
+ {cat:'detect',t:'Loop detector',d:'Break command repetition (5+ times)',code:'CMD=$(cat|jq -r \'.tool_input.command // empty\')\n[ -z "$CMD" ] && exit 0\nSTATE=/tmp/cc-loop\necho "$CMD">>$STATE\ntail -n 10 $STATE>${STATE}.tmp&&mv ${STATE}.tmp $STATE\nCOUNT=$(grep -cF "$CMD" $STATE||echo 0)\n[ "$COUNT" -ge 5 ]&&echo "BLOCKED: repeated $COUNT times" >&2&&exit 2\nexit 0',trigger:'PreToolUse'},
244
+ {cat:'utility',t:'Session handoff',d:'Save state for next session',code:'# npx cc-safe-setup --install-example session-handoff',trigger:'Stop'},
245
+ {cat:'utility',t:'Cost tracker',d:'Estimate session token cost',code:'# npx cc-safe-setup --install-example cost-tracker',trigger:'PostToolUse'},
246
+ {cat:'utility',t:'tmp cleanup',d:'/tmp/claude-*-cwd files (#8856)',code:'find /tmp -maxdepth 1 -name \'claude-*-cwd\' -type f -mmin +60 -delete 2>/dev/null\nexit 0',trigger:'Stop'},
247
+ ];
248
+
249
+ function initCookbook() {
250
+ const cats=['all','block','approve','detect','utility'];
251
+ document.getElementById('cb-filters').innerHTML=cats.map(c=>'<button class="filter'+(c==='all'?' active':'')+'" onclick="setCbFilter(\''+c+'\')">'+c+'</button>').join('');
252
+ filterRecipes();
253
+ }
254
+ let cbFilter='all';
255
+ function setCbFilter(f){cbFilter=f;document.querySelectorAll('#cb-filters .filter').forEach(b=>b.classList.toggle('active',b.textContent===f));filterRecipes();}
256
+ function filterRecipes(){
257
+ const q=(document.getElementById('cb-search')?.value||'').toLowerCase();
258
+ const filtered=RECIPES.filter(r=>(cbFilter==='all'||r.cat===cbFilter)&&(!q||r.t.toLowerCase().includes(q)||r.d.toLowerCase().includes(q)||(r.code||'').toLowerCase().includes(q)));
259
+ document.getElementById('cb-count').textContent=filtered.length+' recipe(s)';
260
+ document.getElementById('cb-list').innerHTML=filtered.map(r=>'<details class="recipe"><summary>'+esc(r.t)+' <span style="color:#484f58;font-size:.7rem">['+r.trigger+']</span></summary><div class="recipe-body"><p style="color:#8b949e;font-size:.8rem">'+esc(r.d)+'</p>'+(r.code?'<pre>'+esc(r.code)+'</pre>':'')+'</div></details>').join('');
261
+ }
262
+
263
+ function esc(s){return(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
264
+
265
+ // URL param
266
+ (function(){const p=new URLSearchParams(location.search);const c=p.get('config');if(c){try{document.getElementById('settings').value=atob(c);runAudit();}catch{}}})();
267
+
268
+ initCookbook();
269
+ </script>
270
+ </body>
271
+ </html>
@@ -0,0 +1,51 @@
1
+ #!/bin/bash
2
+ # ================================================================
3
+ # env-source-guard.sh — Block sourcing .env into shell environment
4
+ # ================================================================
5
+ # PURPOSE:
6
+ # Claude Code sometimes sources .env files directly into bash,
7
+ # causing environment variables to leak across commands.
8
+ # This caused a Laravel test suite to use development database
9
+ # instead of test database, wiping real data.
10
+ #
11
+ # GitHub #401 (54 reactions)
12
+ #
13
+ # TRIGGER: PreToolUse
14
+ # MATCHER: "Bash"
15
+ #
16
+ # WHAT IT BLOCKS:
17
+ # - source .env
18
+ # - . .env
19
+ # - source .env.local
20
+ # - export $(cat .env)
21
+ #
22
+ # WHAT IT ALLOWS:
23
+ # - Reading .env with cat (no sourcing)
24
+ # - Framework commands that load env properly
25
+ # ================================================================
26
+
27
+ INPUT=$(cat)
28
+ COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
29
+
30
+ if [[ -z "$COMMAND" ]]; then
31
+ exit 0
32
+ fi
33
+
34
+ # Block direct sourcing of .env files
35
+ if echo "$COMMAND" | grep -qE '(source|\.\s)\s+\.env'; then
36
+ echo "BLOCKED: Sourcing .env into shell environment." >&2
37
+ echo "Command: $COMMAND" >&2
38
+ echo "" >&2
39
+ echo "This loads all variables into the shell, affecting subsequent commands." >&2
40
+ echo "Use your framework's env loader (dotenv, etc.) instead." >&2
41
+ exit 2
42
+ fi
43
+
44
+ # Block export $(cat .env) pattern
45
+ if echo "$COMMAND" | grep -qE 'export\s+\$\(cat\s+\.env'; then
46
+ echo "BLOCKED: Exporting .env contents into shell." >&2
47
+ echo "Command: $COMMAND" >&2
48
+ exit 2
49
+ fi
50
+
51
+ exit 0
package/index.mjs CHANGED
@@ -85,6 +85,7 @@ const DIFF_FILE = DIFF_IDX !== -1 ? process.argv[DIFF_IDX + 1] : null;
85
85
  const SHARE = process.argv.includes('--share');
86
86
  const BENCHMARK = process.argv.includes('--benchmark');
87
87
  const DASHBOARD = process.argv.includes('--dashboard');
88
+ const ISSUES = process.argv.includes('--issues');
88
89
  const CREATE_IDX = process.argv.findIndex(a => a === '--create');
89
90
  const CREATE_DESC = CREATE_IDX !== -1 ? process.argv.slice(CREATE_IDX + 1).join(' ') : null;
90
91
 
@@ -106,6 +107,7 @@ if (HELP) {
106
107
  npx cc-safe-setup --audit --json Machine-readable output for CI/CD
107
108
  npx cc-safe-setup --scan Detect tech stack, recommend hooks
108
109
  npx cc-safe-setup --learn Learn from your block history
110
+ npx cc-safe-setup --issues Show GitHub Issues each hook addresses
109
111
  npx cc-safe-setup --dashboard Real-time status dashboard
110
112
  npx cc-safe-setup --benchmark Measure hook execution time
111
113
  npx cc-safe-setup --share Generate shareable URL for your setup
@@ -374,9 +376,18 @@ function examples() {
374
376
  console.log();
375
377
 
376
378
  for (const [cat, hooks] of Object.entries(CATEGORIES)) {
377
- if (filter && !cat.toLowerCase().includes(filter)) continue;
379
+ // Filter by category name OR hook name/description
380
+ const filteredHooks = filter
381
+ ? Object.entries(hooks).filter(([file, desc]) =>
382
+ cat.toLowerCase().includes(filter) ||
383
+ file.toLowerCase().includes(filter) ||
384
+ desc.toLowerCase().includes(filter))
385
+ : Object.entries(hooks);
386
+
387
+ if (filteredHooks.length === 0) continue;
388
+
378
389
  console.log(' ' + c.bold + c.blue + cat + c.reset);
379
- for (const [file, desc] of Object.entries(hooks)) {
390
+ for (const [file, desc] of filteredHooks) {
380
391
  console.log(' ' + c.green + '*' + c.reset + ' ' + c.bold + file + c.reset);
381
392
  console.log(' ' + c.dim + desc + c.reset);
382
393
  }
@@ -796,71 +807,236 @@ async function fullSetup() {
796
807
  console.log();
797
808
  }
798
809
 
810
+ function issues() {
811
+ // Map hooks to the GitHub Issues they address
812
+ const ISSUE_MAP = [
813
+ { hook: 'destructive-guard', issues: ['#36339 rm -rf NTFS junction (93r)', '#36640 NFS mount deletion', '#37331 PowerShell Remove-Item (13r)', '#36233 Mac filesystem deleted (67r)'] },
814
+ { hook: 'branch-guard', issues: ['Untested code pushed to main at 3am'] },
815
+ { hook: 'secret-guard', issues: ['#6527 .env committed to public repo (94r)'] },
816
+ { hook: 'syntax-check', issues: ['Syntax errors cascading through 30+ files'] },
817
+ { hook: 'context-monitor', issues: ['Sessions dying at 3% context with no warning'] },
818
+ { hook: 'comment-strip', issues: ['#29582 Bash comments break permissions (18r)'] },
819
+ { hook: 'cd-git-allow', issues: ['#32985 cd+git permission spam (9r)', '#16561 Compound commands (101r)'] },
820
+ { hook: 'api-error-alert', issues: ['Sessions silently dying from rate limits'] },
821
+ { hook: 'block-database-wipe', issues: ['#37405 Database destroyed (0r)', '#34729 Prisma migrate reset data loss'] },
822
+ { hook: 'compound-command-approver', issues: ['#30519 Permission matching broken (53r)', '#16561 Parse compound commands (101r)'] },
823
+ { hook: 'case-sensitive-guard', issues: ['#37875 exFAT case collision (0r)'] },
824
+ { hook: 'tmp-cleanup', issues: ['#8856 /tmp/claude-*-cwd leak (67r)', '#17609 tmp files not cleaned (29r)'] },
825
+ { hook: 'loop-detector', issues: ['Command repetition loops wasting context'] },
826
+ { hook: 'session-handoff', issues: ['#17428 Enhanced /compact (104r)', '#6354 CLAUDE.md lost after compact (27r)'] },
827
+ { hook: 'cost-tracker', issues: ['No visibility into session token costs'] },
828
+ { hook: 'deploy-guard', issues: ['#37314 Deploy without commit'] },
829
+ { hook: 'protect-dotfiles', issues: ['#37478 .bashrc destroyed (3r)'] },
830
+ { hook: 'scope-guard', issues: ['#36233 Files deleted outside project (67r)'] },
831
+ { hook: 'env-source-guard', issues: ['#401 .env loaded into bash environment (54r)'] },
832
+ { hook: 'diff-size-guard', issues: ['Unreviable mega-commits'] },
833
+ { hook: 'dependency-audit', issues: ['Supply chain risk from unknown packages'] },
834
+ { hook: 'read-before-edit', issues: ['old_string mismatch from editing unread files'] },
835
+ ];
836
+
837
+ console.log();
838
+ console.log(c.bold + ' cc-safe-setup --issues' + c.reset);
839
+ console.log(c.dim + ' Which GitHub Issues each hook addresses' + c.reset);
840
+ console.log();
841
+
842
+ let totalIssues = 0;
843
+ for (const entry of ISSUE_MAP) {
844
+ console.log(' ' + c.green + entry.hook + c.reset);
845
+ for (const issue of entry.issues) {
846
+ const isLink = issue.startsWith('#');
847
+ if (isLink) {
848
+ const num = issue.match(/#(\d+)/)?.[1];
849
+ console.log(' ' + c.dim + 'https://github.com/anthropics/claude-code/issues/' + num + c.reset);
850
+ console.log(' ' + issue.replace(/#\d+\s*/, ''));
851
+ } else {
852
+ console.log(' ' + c.dim + issue + c.reset);
853
+ }
854
+ totalIssues++;
855
+ }
856
+ console.log();
857
+ }
858
+
859
+ console.log(c.bold + ' ' + ISSUE_MAP.length + ' hooks addressing ' + totalIssues + ' issues/problems' + c.reset);
860
+ console.log();
861
+ }
862
+
799
863
  async function dashboard() {
800
- const { createReadStream, watchFile } = await import('fs');
801
- const { createInterface: createRL } = await import('readline');
864
+ const fsModule = await import('fs');
802
865
 
803
866
  const BLOCK_LOG = join(HOME, '.claude', 'blocked-commands.log');
867
+ const ERROR_LOG = join(HOME, '.claude', 'session-errors.log');
804
868
  const COST_FILE = '/tmp/cc-cost-tracker-calls';
805
869
  const CONTEXT_FILE = '/tmp/cc-context-pct';
870
+ const HANDOFF_FILE = join(HOME, '.claude', 'session-handoff.md');
871
+ const W = 60; // dashboard width
806
872
 
807
873
  const clear = () => process.stdout.write('\x1b[2J\x1b[H');
808
874
 
809
- // Count hooks
810
- let hookCount = 0;
811
- let exampleCount = 0;
875
+ // ANSI box drawing helpers
876
+ const box = {
877
+ tl: '┌', tr: '┐', bl: '└', br: '┘',
878
+ h: '─', v: '│', lt: '├', rt: '┤',
879
+ };
880
+
881
+ function hline(left, right, w) { return left + box.h.repeat(w - 2) + right; }
882
+ function pad(text, w) {
883
+ const stripped = text.replace(/\x1b\[[0-9;]*m/g, '');
884
+ const padding = Math.max(0, w - 2 - stripped.length);
885
+ return box.v + ' ' + text + ' '.repeat(padding) + box.v;
886
+ }
887
+ function progressBar(pct, w, filledColor, emptyColor) {
888
+ const barW = w - 2;
889
+ const filled = Math.round(pct / 100 * barW);
890
+ return filledColor + '█'.repeat(filled) + emptyColor + '░'.repeat(barW - filled) + c.reset;
891
+ }
892
+ function sparkline(values, w) {
893
+ const chars = ' ▁▂▃▄▅▆▇';
894
+ const max = Math.max(...values, 1);
895
+ return values.slice(-w).map(v => chars[Math.min(7, Math.round(v / max * 7))]).join('');
896
+ }
897
+
898
+ // Collect hook info
899
+ let hooksByTrigger = {};
900
+ let totalHooks = 0;
901
+ let scriptCount = 0;
812
902
  if (existsSync(SETTINGS_PATH)) {
813
903
  try {
814
904
  const s = JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
815
- for (const entries of Object.values(s.hooks || {})) {
816
- hookCount += entries.reduce((n, e) => n + (e.hooks || []).length, 0);
905
+ for (const [trigger, entries] of Object.entries(s.hooks || {})) {
906
+ const count = entries.reduce((n, e) => n + (e.hooks || []).length, 0);
907
+ hooksByTrigger[trigger] = count;
908
+ totalHooks += count;
817
909
  }
818
910
  } catch {}
819
911
  }
820
- exampleCount = existsSync(join(HOOKS_DIR)) ?
821
- (await import('fs')).readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh')).length : 0;
912
+ if (existsSync(HOOKS_DIR)) {
913
+ scriptCount = fsModule.readdirSync(HOOKS_DIR).filter(f => f.endsWith('.sh')).length;
914
+ }
915
+
916
+ // Audit score (cached)
917
+ let auditScore = '?';
918
+ try {
919
+ // Quick inline audit
920
+ let risks = 0;
921
+ const s = existsSync(SETTINGS_PATH) ? JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8')) : {};
922
+ const pre = s.hooks?.PreToolUse || [];
923
+ const post = s.hooks?.PostToolUse || [];
924
+ if (pre.length === 0) risks += 30;
925
+ const allCmds = JSON.stringify(pre).toLowerCase();
926
+ if (!allCmds.match(/destructive|guard|rm.*rf/)) risks += 20;
927
+ if (!allCmds.match(/branch|push|main/)) risks += 20;
928
+ if (!allCmds.match(/secret|env|credential/)) risks += 20;
929
+ if (post.length === 0) risks += 10;
930
+ auditScore = Math.max(0, 100 - risks);
931
+ } catch {}
822
932
 
823
933
  function render() {
824
934
  clear();
825
935
 
826
- // Header
827
- console.log(c.bold + ' cc-safe-setup --dashboard' + c.reset + ' ' + c.dim + new Date().toLocaleTimeString() + c.reset);
828
- console.log(' ' + '─'.repeat(50));
829
-
830
- // Status row
831
- const context = existsSync(CONTEXT_FILE) ? readFileSync(CONTEXT_FILE, 'utf-8').trim() + '%' : '?';
832
- const calls = existsSync(COST_FILE) ? readFileSync(COST_FILE, 'utf-8').trim() : '0';
833
- const cost = (parseInt(calls) * 0.105).toFixed(2);
936
+ const now = new Date();
937
+ const timeStr = now.toLocaleTimeString();
938
+ const dateStr = now.toLocaleDateString();
834
939
 
835
- console.log(' Hooks: ' + c.green + hookCount + c.reset + ' registered | Scripts: ' + exampleCount);
836
- console.log(' Context: ' + c.yellow + context + c.reset + ' | Cost: ~$' + cost + ' (' + calls + ' calls)');
837
- console.log(' ' + ''.repeat(50));
940
+ // Read live data
941
+ const contextPct = existsSync(CONTEXT_FILE) ? parseInt(readFileSync(CONTEXT_FILE, 'utf-8').trim()) || 0 : -1;
942
+ const toolCalls = existsSync(COST_FILE) ? parseInt(readFileSync(COST_FILE, 'utf-8').trim()) || 0 : 0;
943
+ const costEst = (toolCalls * 0.105).toFixed(2);
838
944
 
839
- // Recent blocks
840
- console.log(c.bold + ' Recent Blocks' + c.reset);
945
+ // Parse block log
946
+ let blocks = [];
947
+ let blocksByHour = new Array(24).fill(0);
948
+ let blockReasons = {};
841
949
  if (existsSync(BLOCK_LOG)) {
842
950
  const lines = readFileSync(BLOCK_LOG, 'utf-8').split('\n').filter(l => l.trim());
843
- const recent = lines.slice(-5);
844
- for (const line of recent) {
845
- const m = line.match(/^\[([^\]]+)\]\s*BLOCKED:\s*(.+?)\s*\|/);
951
+ for (const line of lines) {
952
+ const m = line.match(/^\[([^\]]+)\]\s*BLOCKED:\s*(.+?)\s*\|\s*cmd:\s*(.+)$/);
846
953
  if (m) {
847
- const time = m[1].replace(/T/, ' ').replace(/\+.*/, '').slice(11, 16);
848
- console.log(' ' + c.dim + time + c.reset + ' ' + c.red + m[2].trim() + c.reset);
954
+ const hour = new Date(m[1]).getHours();
955
+ if (!isNaN(hour)) blocksByHour[hour]++;
956
+ const reason = m[2].trim();
957
+ blockReasons[reason] = (blockReasons[reason] || 0) + 1;
958
+ blocks.push({ time: m[1], reason, cmd: m[3].trim() });
849
959
  }
850
960
  }
851
- if (recent.length === 0) console.log(c.dim + ' (none)' + c.reset);
961
+ }
962
+
963
+ const totalBlocks = blocks.length;
964
+ const todayBlocks = blocks.filter(b => b.time.startsWith(dateStr.split('/').reverse().join('-'))).length;
965
+
966
+ // === RENDER ===
967
+ console.log(hline(box.tl, box.tr, W));
968
+ console.log(pad(c.bold + 'cc-safe-setup dashboard' + c.reset + ' ' + c.dim + timeStr + c.reset, W));
969
+ console.log(hline(box.lt, box.rt, W));
970
+
971
+ // Status panel
972
+ const scoreColor = auditScore >= 80 ? c.green : auditScore >= 50 ? c.yellow : c.red;
973
+ const grade = auditScore >= 80 ? 'A' : auditScore >= 60 ? 'B' : auditScore >= 40 ? 'C' : 'F';
974
+ console.log(pad('Score: ' + scoreColor + auditScore + '/100' + c.reset + ' (Grade ' + grade + ') Hooks: ' + c.green + totalHooks + c.reset + ' Scripts: ' + scriptCount, W));
975
+
976
+ // Context bar
977
+ if (contextPct >= 0) {
978
+ const ctxColor = contextPct > 40 ? c.green : contextPct > 20 ? c.yellow : c.red;
979
+ console.log(pad('Context: ' + ctxColor + contextPct + '%' + c.reset + ' ' + progressBar(contextPct, 30, ctxColor, c.dim), W));
852
980
  } else {
853
- console.log(c.dim + ' (no log yet)' + c.reset);
981
+ console.log(pad('Context: ' + c.dim + 'unknown' + c.reset, W));
982
+ }
983
+
984
+ // Cost
985
+ console.log(pad('Cost: ~$' + costEst + ' (' + toolCalls + ' tool calls, Opus)', W));
986
+ console.log(pad('Blocks: ' + c.red + totalBlocks + c.reset + ' total | Today: ' + todayBlocks, W));
987
+
988
+ // Hooks by trigger
989
+ console.log(hline(box.lt, box.rt, W));
990
+ console.log(pad(c.bold + 'Hooks by Trigger' + c.reset, W));
991
+ for (const [trigger, count] of Object.entries(hooksByTrigger)) {
992
+ const bar = '█'.repeat(Math.min(count, 20));
993
+ console.log(pad(c.dim + trigger.padEnd(18) + c.reset + c.blue + bar + c.reset + ' ' + count, W));
994
+ }
995
+
996
+ // Hourly activity sparkline
997
+ console.log(hline(box.lt, box.rt, W));
998
+ console.log(pad(c.bold + 'Block Activity (24h)' + c.reset, W));
999
+ console.log(pad(c.yellow + sparkline(blocksByHour, 24) + c.reset + ' ' + c.dim + '0h' + ' '.repeat(20) + '23h' + c.reset, W));
1000
+
1001
+ // Top block reasons
1002
+ console.log(hline(box.lt, box.rt, W));
1003
+ console.log(pad(c.bold + 'Top Block Reasons' + c.reset, W));
1004
+ const sortedReasons = Object.entries(blockReasons).sort((a, b) => b[1] - a[1]).slice(0, 5);
1005
+ const maxR = sortedReasons[0]?.[1] || 1;
1006
+ for (const [reason, count] of sortedReasons) {
1007
+ const bar = '▓'.repeat(Math.ceil(count / maxR * 15));
1008
+ console.log(pad(c.red + bar + c.reset + ' ' + count + ' ' + c.dim + reason.slice(0, 25) + c.reset, W));
1009
+ }
1010
+ if (sortedReasons.length === 0) {
1011
+ console.log(pad(c.dim + '(no blocks recorded)' + c.reset, W));
1012
+ }
1013
+
1014
+ // Recent blocks
1015
+ console.log(hline(box.lt, box.rt, W));
1016
+ console.log(pad(c.bold + 'Recent Blocks' + c.reset, W));
1017
+ const recent = blocks.slice(-5);
1018
+ for (const b of recent) {
1019
+ const time = b.time.replace(/T/, ' ').replace(/\+.*/, '').slice(11, 16);
1020
+ console.log(pad(c.dim + time + c.reset + ' ' + c.red + b.reason.slice(0, 35) + c.reset, W));
1021
+ }
1022
+ if (recent.length === 0) console.log(pad(c.dim + '(none)' + c.reset, W));
1023
+
1024
+ // Session errors
1025
+ let errorCount = 0;
1026
+ if (existsSync(ERROR_LOG)) {
1027
+ errorCount = readFileSync(ERROR_LOG, 'utf-8').split('\n').filter(l => l.trim()).length;
1028
+ }
1029
+ if (errorCount > 0) {
1030
+ console.log(hline(box.lt, box.rt, W));
1031
+ console.log(pad(c.yellow + 'Session errors: ' + errorCount + c.reset, W));
854
1032
  }
855
1033
 
856
- console.log(' ' + '─'.repeat(50));
1034
+ console.log(hline(box.bl, box.br, W));
857
1035
  console.log(c.dim + ' Refreshing every 3s. Ctrl+C to exit.' + c.reset);
858
1036
  }
859
1037
 
860
1038
  render();
861
1039
  setInterval(render, 3000);
862
-
863
- // Keep alive
864
1040
  await new Promise(() => {});
865
1041
  }
866
1042
 
@@ -2151,6 +2327,7 @@ async function main() {
2151
2327
  if (FULL) return fullSetup();
2152
2328
  if (DOCTOR) return doctor();
2153
2329
  if (WATCH) return watch();
2330
+ if (ISSUES) return issues();
2154
2331
  if (DASHBOARD) return dashboard();
2155
2332
  if (BENCHMARK) return benchmark();
2156
2333
  if (SHARE) return share();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cc-safe-setup",
3
- "version": "4.0.3",
4
- "description": "One command to make Claude Code safe for autonomous operation. 8 built-in + 38 examples. 22 commands including dashboard, create, audit, lint, diff, benchmark. 2,500+ daily npm downloads.",
3
+ "version": "5.1.0",
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": {
7
7
  "cc-safe-setup": "index.mjs"