Push guard: before a git push to a public GitHub repo (or making one public) it scans what would go out for Supabase project IDs, keys, tokens, emails, local…

Saját Claude Code mod-ok (function hook pluginek) egy pluginpiacon.
claude plugin marketplace add Szotasz/claude-mods
claude plugin install usage-band@claude-mods
claude plugin install usage-header@claude-mods
claude plugin install project-pane@claude-mods
claude plugin install focus-mode@claude-mods
claude plugin install tool-hub@claude-mods
claude plugin install snake-pane@claude-mods
claude plugin install supabase-guard@claude-mods
claude plugin install public-repo-guard@claude-mods
claude plugin install decision-popup@claude-mods
Frissítés: claude plugin update usage-band@claude-mods, majd /reload-plugins (vagy új session).
A státuszsorba (a prompt alá) írja:
🤖 Opus 5.5 │ Ctx ███░░░░░ 42% 420k/1M │ 5h ███░░░░░ 34% → 16:20 │ Hét ███████░ 93% ⚠ → Cs 10:00
/model váltáskor és turn végén frissül)Nyelv / Language: /config → usage-band.language, vagy a settings.json-ban:
"pluginConfigs": { "usage-band": { "language": "en" } }
auto (alapértelmezett): magyar, ha a rendszer nyelve (LANG) magyar, különben angolhu: magyar (Hét, Cs 10:00)en: English (Week, Thu 10:00)Követelmény: Claude Code 2.1.289 körüli verzió. A mod-API early access, kiadásról kiadásra változhat.
Sáv a prompt fölött: a kontextusablak kategóriánként színezve, alatta az 5 órás és a heti limit.
Kontextus ██▓▓▓▓▓▓████████████▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ 42% 84k/200k
■ Rendszerprompt 3k ■ Eszközök 12k ■ Memória 400 ■ Üzenetek 69k ▒ Tömörítési tartalék 33k ░ Szabad 83k
Limitek 5 óra ███████░░░░░░░░░░░░░ 34% → 16:20 Hét ███████████████████░ 93% ⚠ → Cs 10:00
/context saját színével látszik (rendszerprompt, eszközök, MCP, memóriafájlok, skillek, ágensek, üzenetek), utána a tömörítési tartalék és a szabad hely halványan; alatta jelmagyarázat tokenszámmal. Az igény szerint betöltött (halasztott) eszközsémák kimaradnak, mert nincsenek az ablakban.$.session.usage({ breakdown: 'summary' })), API-hívás nélkül; minden turn végén és limitmozduláskor frissül./hasznalat kapcsolja, /hasznalat be|ki beállítja; az állapotot sessionök között megőrzi. Az eszköztárban (/eszkozok) is kapcsolható.Beállítás (/config): usage-header.language (auto/hu/en).
Oldalpanel az aktuális git repóhoz. Magától megnyílik, ha a session git repóban indul és a terminál legalább 144 oszlop széles (miután egyszer /project-tel megnyitottad, 110 is elég); keskenyebb ablaknál egy toast szól, hogy vár. Bármikor: /project.
acme/shop ↻ most [ Frissítés ]
GIT
🌿 develop ↑0 ↓0 · 7 módosított fájl
utolsó commit: 57704fe · 7 hónapja — Fix checkout flow
⚠ a main 13 committal előrébb jár, te a develop branch-en vagy
Előbb commitold a módosításokat, vagy tedd félre őket:
[ Stash + szinkron ]
DEPLOY (Netlify)
✔ ready main 3 perce
PULL REQUESTEK
✔ #142 Stripe webhook retry · approved
CI (GitHub Actions)
✘ netlify-deploy-verify main · 5 napja [ Kérdezd Claude-ot ]
SUPABASE
abcdefghijklmnopqrst · Acme Shop · eu-west-3
18 migráció · utolsó: 018_add_orders_rls.sql
GitHub Netlify Supabase
s): csak fast-forward, soha nem merge-öl, rebase-el vagy töröl.git pull --ff-onlygit switch main + git merge --ff-only origin/maingit stash push -u, vissza: git stash popr): 60 mp-enként és minden turn végén; git fetch 5 percenként.a): a legutóbbi elbukott CI-futásról kérdést tesz a promptba.Adatforrások (a CLI-k saját bejelentkezésével, a plugin tokent nem olvas):
| Szekció | Forrás | Ha hiányzik |
|---|---|---|
| Git | git | — |
| PR, CI | gh (gh auth login) | tipp a panelen |
| Deploy | netlify CLI + .netlify/state.json | npm i -g netlify-cli && netlify login |
| Supabase | supabase/.temp/project-ref, supabase/migrations, supabase projects list | supabase login az állapothoz |
Beállítás (/config): project-pane.language (auto/hu/en), project-pane.autoOpen (alapból be).
Fókusz mód: a transcriptből eltűnnek az eszközhívások és eredményeik, a köztes szövegek, a saját promptjaid és a parancsok kimenete. Csak két dolog marad: a prompt fölötti sávban a részfeladatok listája állapotsávval és pipával, alatta a turn végső válasza.
✔ Meglévő mod-repo áttekintése
◐ Játék-mod megírása ██████░░░░ 60%
○ Felvétel az eszközök közé
/fokusz kapcsolja, /fokusz be / /fokusz ki beállítja; az állapotot sessionök között megőrzi.mcp__focus-mode__plan eszközzel; a rendszerprompt bekapcsolt állapotban erre utasítja (minden több lépéses kérésnél előbb a részfeladatok, aztán frissítés indításkor, haladáskor és befejezéskor).Beállítás (/config): focus-mode.language (auto/hu/en).
Eszköztár: egy helyről kapcsolhatók a fenti modok. A prompt alatti lábléc jobb oldalán lévő ⚙ Eszközök gomb (vagy /eszkozok) a prompt fölötti sávban nyitja meg:
⚙ Szabolcs eszközei kattintás, vagy ctrl+x tab után szám [ Bezár ]
1. Fókusz mód ● BE [ Kikapcsol ] · Csak a részfeladatok listája és a végső válasz látszik
2. Limitsáv ○ KI [ Bekapcsol ] · Alsó sáv: 5 órás és heti limit, kontextusablak
3. Használati sáv ● BE [ Kikapcsol ] · Prompt fölött: kontextus kategóriánként színezve, 5 órás és heti limit
4. Projektpanel ● BE [ Kikapcsol ] · GitHub, CI, Netlify és Supabase állapot oldalt
5. Kígyó játék ● BE [ Kikapcsol ] · Oldalt nyílik promptküldéskor, amíg Claude dolgozik
ctrl+x tab után az 1–5 számbillentyűvel is./fokusz, /limitsav, /hasznalat, /project, /jatek (mind be|ki argumentummal).Új mod felvétele az eszköztárba: a mod írja a saját isOn állapotát, adjon /<parancs> be|ki-t, és hookolja a state.set-et { plugin: 'tool-hub', key: 'request' }-re; a hubban egy sor a TOOLS listába, egy ág a stateOf-ba és a kulcs a types/index.d.ts-be.
Beállítás (/config): tool-hub.language (auto/hu/en).
Kígyó játék az oldalpanelben, amíg Claude dolgozik.
⏳ Claude dolgozik…
Pont: 4 Rekord: 17
╭──────────────────────────────────────╮
│ │
│ ████████ │
│ ██ ● │
╰──────────────────────────────────────╯
p: szünet · r: újra
w: ↑ a: ← s: ↓ d: → p: szünet r: új
/jatek mindig nyitja). /jatek ki kikapcsolja, az eszköztárban (/eszkozok) is kapcsolható.wasd / hjkl, szóköz vagy p szünet, r új játék. Vagy ctrl+x tab a panelre, és a w a s d p r gombok.Client surface modul), nem terheli a sessiont.Supabase-őr: megállítja a Supabase-hívást, ha nem az aktuális mappa projektjére menne, és egygombos felugróban rákérdez.
supabase/.temp/project-ref, a .env* fájlok https://<ref>.supabase.co címei és a CLAUDE.md (ha egyetlen projekt-ID-t említ) adják, plusz amit a felugróban „Ez a mappa projektje” gombbal megerősítettél.execute_sql, apply_migration, deploy_edge_function, branch-műveletek, illetve supabase db push --project-ref …): kérdez — Megtiltom / Engedélyezem / Ez a mappa projektje.DROP, TRUNCATE, DELETE FROM, WHERE nélküli UPDATE, REVOKE, RLS kikapcsolása, reset_branch, delete_branch, pause_project, supabase db reset --linked./supaor be|ki, vagy az eszköztárban.Push-őr: nyilvános GitHub-repóba push előtt (és gh repo edit --visibility public / gh repo create --public előtt) átnézi, mi menne ki.
git push origin forrás:cél alakot is.project_id, táblázatsor), JWT / Supabase-kulcs, Stripe/Anthropic/OpenAI/GitHub/AWS/Google/Slack/Resend/Telegram/ElevenLabs kulcsok, privát kulcs, e-mail-cím (a saját git e-mail és a noreply/példa címek kivételével), helyi /Users/<te>/ útvonal, privát repóid nevei (gh repo list --visibility private), .env fájlok, és amit /pushor figyel <szöveg>-gel felvettél.gh CLI (bejelentkezve). /pushor be|ki.Döntési felugró: a döntések egygombos kérdésként jönnek.
/dontes be|ki, vagy az eszköztárban.project-pane bővítése:
vercel ls)Státuszsor bővítése (usage-band):
/compact$.session.usage().cost)userConfig)Új mod-ok:
.env, kulcsfájlok, supabase/.temp szerkesztésének tiltása (tool.call → deny)turn.complete, $.audio.play)/quote — a kijelölt szöveget idézetként a promptba teszi ($.ui.selection)prompt.compose)Egy plugin mappája közvetlenül is betölthető:
claude --plugin-dir ./plugins/usage-band
Ellenőrzés:
claude plugin validate plugins/usage-band
claude plugin test plugins/usage-bandhooks/register.tsx 265 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { PREFILTER, addedLines, envFiles, githubRepo, goesPublic, grepLines, parsePush, scanLines } from './scan'
5import type { Finding, Line } from './scan'
6
7const isOn = atom({ plugin: 'public-repo-guard', key: 'isOn' } as const, true)
8
9type Lang = 'hu' | 'en'
10
11const KINDS = {
12 hu: {
13 supabaseRef: 'Supabase projekt-ID',
14 jwt: 'JWT / Supabase-kulcs',
15 privateKey: 'privát kulcs',
16 apiKey: 'API-kulcs',
17 email: 'e-mail-cím',
18 localPath: 'helyi útvonal',
19 privateRepo: 'privát repó neve',
20 watched: 'figyelt szöveg',
21 envFile: '.env fájl',
22 },
23 en: {
24 supabaseRef: 'Supabase project ID',
25 jwt: 'JWT / Supabase key',
26 privateKey: 'private key',
27 apiKey: 'API key',
28 email: 'email address',
29 localPath: 'local path',
30 privateRepo: 'private repo name',
31 watched: 'watched text',
32 envFile: '.env file',
33 },
34} as const
35
36const STRINGS = {
37 hu: {
38 header: 'Push-őr',
39 command: 'Push-őr: nyilvános repóba push előtt titkok, ID-k, e-mailek keresése',
40 hint: '[be|ki|figyel <szöveg>]',
41 on: 'Push-őr bekapcsolva.',
42 off: 'Push-őr kikapcsolva: a pushok ellenőrzés nélkül mennek.',
43 watching: (w: string) => `Figyelem mostantól: „${w}”.`,
44 usage: 'Használat: /pushor [be|ki|figyel <szöveg>]',
45 deny: 'Megtiltom',
46 push: 'Pushold így is',
47 remember: 'Pushold, jegyezd meg',
48 question: (repo: string, n: number, list: string) => `Nyilvános repóba menne (${repo}), és ${n} gyanús dolgot találtam:\n${list}\nMehet?`,
49 more: (n: number) => ` … és még ${n}`,
50 denied: (repo: string, list: string) =>
51 `A public-repo-guard megállította a pusht (${repo} nyilvános), mert ezek kerülnének ki:\n${list}\nTávolítsd el vagy anonimizáld őket, és írd át az érintett commitokat is (a historyban is benne vannak), aztán kérdezd meg a felhasználót, mielőtt újra pusholsz.`,
52 dismissed: 'A public-repo-guard megállította a pusht: a felhasználó bezárta a kérdést. Ne pushold újra kérdés nélkül.',
53 other: (answer: string) => `A public-repo-guard megállította a pusht; a felhasználó válasza: „${answer}”`,
54 failed: 'public-repo-guard: az ellenőrzés nem sikerült, ezért a push nem ment ki. /pushor ki után kézzel engedhető.',
55 },
56 en: {
57 header: 'Push guard',
58 command: 'Push guard: look for secrets, IDs and emails before pushing to a public repo',
59 hint: '[on|off|figyel <text>]',
60 on: 'Push guard on.',
61 off: 'Push guard off: pushes go out unchecked.',
62 watching: (w: string) => `Now watching for "${w}".`,
63 usage: 'Usage: /pushor [on|off|figyel <text>]',
64 deny: 'Deny',
65 push: 'Push anyway',
66 remember: 'Push, remember these',
67 question: (repo: string, n: number, list: string) => `This goes to a public repo (${repo}), and I found ${n} suspicious items:\n${list}\nGo ahead?`,
68 more: (n: number) => ` … and ${n} more`,
69 denied: (repo: string, list: string) =>
70 `public-repo-guard stopped the push (${repo} is public) because these would be published:\n${list}\nRemove or anonymise them, rewrite the commits that carry them (they are in the history too), then ask the user before pushing again.`,
71 dismissed: 'public-repo-guard stopped the push: the user closed the question. Do not push again without asking.',
72 other: (answer: string) => `public-repo-guard stopped the push; the user answered: "${answer}"`,
73 failed: 'public-repo-guard: the check failed, so the push did not go out. /pushor off lets it through by hand.',
74 },
75}
76
77let lang: Lang = 'en'
78const t = () => STRINGS[lang]
79
80async function pickLanguage($: EngineInterface, setting: unknown): Promise<Lang> {
81 if (setting === 'hu' || setting === 'en') return setting
82 const locale = (await $.env.get('LC_ALL')) || (await $.env.get('LANG')) || ''
83 return locale.toLowerCase().startsWith('hu') ? 'hu' : 'en'
84}
85
86async function strings($: EngineInterface, key: string): Promise<string[]> {
87 const value = await $.store.get(key)
88 return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
89}
90
91// Láthatóság repónként, 10 percig.
92const visibility = new Map<string, { at: number; value: string }>()
93const VISIBILITY_MS = 10 * 60_000
94const PRIVATE_LIST_MS = 24 * 60 * 60_000
95
96async function run($: EngineInterface, argv: string[], cwd: string) {
97 return $.process.run(argv, { cwd, timeoutMs: 20_000 })
98}
99
100async function visibilityOf($: EngineInterface, repo: string, cwd: string): Promise<string> {
101 const hit = visibility.get(repo)
102 if (hit !== undefined && Date.now() - hit.at < VISIBILITY_MS) return hit.value
103 const r = await run($, ['gh', 'repo', 'view', repo, '--json', 'visibility', '-q', '.visibility'], cwd)
104 const value = r.exitCode === 0 ? r.stdout.trim().toUpperCase() : 'UNKNOWN'
105 visibility.set(repo, { at: Date.now(), value })
106 return value
107}
108
109// A felhasználó privát repóinak nevei (owner/name és a jellegzetes rövid név), naponta frissítve.
110async function privateRepos($: EngineInterface, owner: string, cwd: string): Promise<string[]> {
111 const saved = (await $.store.get('privateRepos')) as { at?: number; owner?: string; list?: string[] } | undefined
112 let list = saved?.owner === owner && Date.now() - (saved.at ?? 0) < PRIVATE_LIST_MS ? (saved.list ?? []) : null
113 if (list === null) {
114 const r = await run($, ['gh', 'repo', 'list', owner, '--visibility', 'private', '--limit', '300', '--json', 'nameWithOwner', '-q', '.[].nameWithOwner'], cwd)
115 list = r.exitCode === 0 ? r.stdout.split('\n').map(s => s.trim()).filter(s => s !== '') : (saved?.list ?? [])
116 if (r.exitCode === 0) await $.store.set('privateRepos', { at: Date.now(), owner, list })
117 }
118 const bare = list.map(full => full.split('/')[1] ?? '').filter(name => /[-_]/.test(name) || name.length >= 10)
119 return [...list, ...bare]
120}
121
122function resolveDir(cwd: string, dir: string | null, home: string | null): string {
123 if (dir === null) return cwd
124 const expanded = home !== null ? dir.replace(/^~(?=\/|$)/, home) : dir
125 return expanded.startsWith('/') ? expanded : `${cwd}/${expanded}`
126}
127
128type Scan = { repo: string; findings: Finding[] }
129
130// Mi menne ki: a távoli ágon még nem lévő commitok (üzenet + hozzáadott sorok), vagy ha nincs mihez mérni, az egész fa.
131async function scan($: EngineInterface, command: string): Promise<Scan | null> {
132 const push = parsePush(command)
133 const flip = goesPublic(command)
134 if (push === null && !flip) return null
135
136 const home = (await $.env.get('HOME')) ?? null
137 const dir = resolveDir(await $.session.cwd(), push?.dir ?? null, home)
138 const remote = push?.remote ?? 'origin'
139 const url = /[:/]/.test(remote) ? remote : (await run($, ['git', 'remote', 'get-url', '--push', remote], dir)).stdout.trim()
140 const repo = githubRepo(url)
141 if (repo === null && url === '') return null
142 if (!flip && repo !== null && ['PRIVATE', 'INTERNAL'].includes(await visibilityOf($, repo, dir))) return null
143
144 let lines: Line[]
145 let envs: string[]
146 const tip = push?.src ?? 'HEAD'
147 let base: string | null = null
148 if (push !== null && !flip) {
149 let dst = push.dst ?? (tip === 'HEAD' ? null : tip.replace(/^refs\/heads\//, ''))
150 if (dst === null) dst = (await run($, ['git', 'rev-parse', '--abbrev-ref', 'HEAD'], dir)).stdout.trim()
151 for (const candidate of [`refs/remotes/${remote}/${dst}`, `refs/remotes/${remote}/HEAD`]) {
152 if ((await run($, ['git', 'rev-parse', '--verify', '-q', candidate], dir)).exitCode === 0) {
153 base = candidate
154 break
155 }
156 }
157 }
158 if (base !== null) {
159 const log = (await run($, ['git', 'log', '-p', '--no-color', '--no-ext-diff', '--format=%x1e%h%n%B%x1f', `${base}..${tip}`], dir)).stdout
160 if (log.trim() === '') return null
161 lines = addedLines(log)
162 envs = envFiles(log)
163 } else {
164 const grep = await run($, ['git', 'grep', '-I', '-n', '-i', '-E', PREFILTER, tip], dir)
165 lines = grepLines(grep.stdout, tip)
166 const tree = (await run($, ['git', 'ls-tree', '-r', '--name-only', tip], dir)).stdout
167 envs = envFiles(tree.split('\n').map(f => `+++ b/${f}`).join('\n'))
168 }
169
170 const owner = repo?.split('/')[0] ?? null
171 const findings = scanLines(lines, {
172 home,
173 privateRepos: owner === null ? [] : (await privateRepos($, owner, dir)).filter(r => r.toLowerCase() !== repo?.toLowerCase() && r.toLowerCase() !== repo?.split('/')[1]?.toLowerCase()),
174 watch: await strings($, 'watch'),
175 allow: await strings($, 'allow'),
176 ownEmail: (await run($, ['git', 'config', 'user.email'], dir)).stdout.trim() || null,
177 })
178 const allow = new Set((await strings($, 'allow')).map(a => a.toLowerCase()))
179 for (const file of envs) {
180 if (!allow.has(file.toLowerCase())) findings.unshift({ kind: 'envFile', file, line: 0, value: file, shown: file })
181 }
182 return findings.length === 0 ? null : { repo: repo ?? url, findings }
183}
184
185function listOf(findings: readonly Finding[], max: number): string {
186 const kinds = KINDS[lang]
187 const rows = findings.slice(0, max).map(f => {
188 const where = f.line > 0 ? `${f.file}:${f.line}` : f.file
189 return `• ${kinds[f.kind as keyof typeof kinds] ?? f.kind}: ${f.shown} (${where})`
190 })
191 if (findings.length > max) rows.push(t().more(findings.length - max))
192 return rows.join('\n')
193}
194
195async function setOn($: EngineInterface, value: boolean): Promise<void> {
196 await update($, isOn, () => value)
197 await $.store.set('isOn', value)
198}
199
200export const register: Register = (on, options) => {
201 if (options.language === 'hu' || options.language === 'en') lang = options.language
202
203 on('session.start', async ($, e, next) => {
204 lang = await pickLanguage($, options.language)
205 const saved = await $.store.get('isOn')
206 await update($, isOn, () => saved !== false)
207 await $.command.register({ name: 'pushor', description: t().command, argumentHint: t().hint, immediate: true })
208 return next(e)
209 })
210
211 on('command.run', { command: 'pushor' }, async ($, e) => {
212 const args = e.args.trim()
213 const [verb, ...rest] = args.split(/\s+/)
214 const arg = (verb ?? '').toLowerCase()
215 if (arg === 'figyel' || arg === 'watch') {
216 const text = rest.join(' ').trim()
217 if (text === '') return { text: t().usage }
218 await $.store.set('watch', [...new Set([...(await strings($, 'watch')), text])])
219 return { text: t().watching(text) }
220 }
221 const wanted = ['be', 'on'].includes(arg) ? true : ['ki', 'off'].includes(arg) ? false : arg === '' ? !(await read($, isOn)) : null
222 if (wanted === null) return { text: t().usage }
223 await setOn($, wanted)
224 return { text: wanted ? t().on : t().off }
225 })
226
227 on('state.set', { plugin: 'tool-hub', key: 'request' }, async ($, e, next) => {
228 const done = await next(e)
229 if (e.value?.tool === 'pushguard') await setOn($, e.value.on)
230 return done
231 })
232
233 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
234 if (!(await read($, isOn))) return next(e)
235 const found = await scan($, e.command)
236 if (found === null) return next(e)
237
238 let answer: string
239 try {
240 answer = await $.ui.ask(t().question(found.repo, found.findings.length, listOf(found.findings, 8)), {
241 header: t().header,
242 options: [t().deny, t().push, t().remember],
243 })
244 } catch {
245 return { deny: t().dismissed }
246 }
247 if (answer === t().push) return next(e)
248 if (answer === t().remember) {
249 await $.store.set('allow', [...new Set([...(await strings($, 'allow')), ...found.findings.map(f => f.value)])])
250 return next(e)
251 }
252 if (answer === t().deny) return { deny: t().denied(found.repo, listOf(found.findings, 40)) }
253 return { deny: t().other(answer) }
254 }).catch(($, e, next) => {
255 if (next.called) return next(e)
256 // Hibánál csak a push és a láthatóság-váltás áll meg; minden más parancs fut tovább.
257 try {
258 if (parsePush(e.command) === null && !goesPublic(e.command)) return next(e)
259 } catch {
260 if (!/\bgit\b[^;&|]*\bpush\b|\bgh\s+repo\b/.test(e.command)) return next(e)
261 }
262 return { deny: t().failed }
263 })
264}
265hooks/scan.ts 205 lines1// Tiszta függvények: a push parancs értelmezése és a kiszivárgó adatok keresése a pusholt változásokban.
2
3export type Push = { dir: string | null; remote: string; src: string; dst: string | null }
4
5const FLAGS_WITH_VALUE = new Set(['--repo', '--receive-pack', '--exec', '-o', '--push-option', '--signed', '--recurse-submodules'])
6
7function words(segment: string): string[] {
8 return [...segment.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '')
9}
10
11// `cd x && git push origin a:b`, `git -C x push -u origin main` → mit pusholunk, hova.
12export function parsePush(command: string): Push | null {
13 let dir: string | null = null
14 for (const segment of command.split(/&&|\|\||;|\n/)) {
15 const w = words(segment.trim())
16 if (w[0] === 'cd' && w[1] !== undefined) dir = w[1]
17 const g = w.indexOf('git')
18 if (g === -1) continue
19 let i = g + 1
20 let gitDir = dir
21 while (i < w.length && (w[i] ?? '').startsWith('-')) {
22 if (w[i] === '-C') {
23 gitDir = w[i + 1] ?? null
24 i += 2
25 } else if (w[i] === '-c') i += 2
26 else i += 1
27 }
28 if (w[i] !== 'push') continue
29 const positional: string[] = []
30 let dryRun = false
31 let deleting = false
32 for (let j = i + 1; j < w.length; j++) {
33 const a = w[j] ?? ''
34 if (a === '--dry-run' || a === '-n') dryRun = true
35 else if (a === '--delete' || a === '-d') deleting = true
36 else if (FLAGS_WITH_VALUE.has(a)) j++
37 else if (!a.startsWith('-')) positional.push(a)
38 }
39 if (dryRun || deleting) return null
40 const remote = positional[0] ?? 'origin'
41 const spec = (positional[1] ?? '').replace(/^\+/, '')
42 if (spec.startsWith(':')) return null
43 const colon = spec.indexOf(':')
44 const src = colon === -1 ? spec : spec.slice(0, colon)
45 const dst = colon === -1 ? '' : spec.slice(colon + 1)
46 return { dir: gitDir, remote, src: src === '' ? 'HEAD' : src, dst: dst === '' ? null : dst.replace(/^refs\/heads\//, '') }
47 }
48 return null
49}
50
51// `gh repo edit --visibility public` és `gh repo create --public`: az egész fát nézzük át.
52export function goesPublic(command: string): boolean {
53 return /\bgh\s+repo\s+edit\b[^;&|]*--visibility[=\s]+public\b/.test(command) || /\bgh\s+repo\s+create\b[^;&|]*--public\b/.test(command)
54}
55
56// git@github.com:owner/name.git, https://github.com/owner/name → owner/name
57export function githubRepo(url: string): string | null {
58 const m = /github\.com[:/]+([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/.exec(url.trim())
59 return m === null ? null : `${m[1]}/${m[2]}`
60}
61
62export type Line = { file: string; line: number; text: string }
63
64// `git log -p` kimenetéből a hozzáadott sorok és a commit üzenetek (file = "commit abc123").
65export function addedLines(log: string): Line[] {
66 const out: Line[] = []
67 let file = ''
68 let line = 0
69 let inMessage = false
70 let commit = ''
71 for (const raw of log.split('\n')) {
72 if (raw.startsWith('\u001e')) {
73 commit = raw.slice(1).trim()
74 inMessage = true
75 line = 0
76 continue
77 }
78 if (inMessage) {
79 if (raw.startsWith('\u001f')) {
80 inMessage = false
81 continue
82 }
83 line++
84 out.push({ file: `commit ${commit}`, line, text: raw })
85 continue
86 }
87 if (raw.startsWith('diff --git ')) {
88 file = /^diff --git a\/.* b\/(.*)$/.exec(raw)?.[1] ?? file
89 continue
90 }
91 if (raw.startsWith('+++ ')) {
92 if (raw !== '+++ /dev/null') file = raw.slice(4).replace(/^b\//, '')
93 continue
94 }
95 const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(raw)
96 if (hunk !== null) {
97 line = Number(hunk[1])
98 continue
99 }
100 if (raw.startsWith('+')) {
101 out.push({ file, line, text: raw.slice(1) })
102 line++
103 } else if (raw.startsWith(' ')) line++
104 }
105 return out
106}
107
108// `git grep -n` kimenete: rev:file:line:text vagy file:line:text
109export function grepLines(output: string, rev: string | null): Line[] {
110 const prefix = rev === null ? '' : `${rev}:`
111 return output.split('\n').flatMap(raw => {
112 const rest = raw.startsWith(prefix) ? raw.slice(prefix.length) : raw
113 const m = /^(.*?):(\d+):(.*)$/.exec(rest)
114 return m === null ? [] : [{ file: m[1] ?? '', line: Number(m[2]), text: m[3] ?? '' }]
115 })
116}
117
118// Az előszűrő a `git grep`-hez: csak a gyanús sorokat kérjük le.
119export const PREFILTER = 'supabase|project[_ -]?(id|ref)|eyJ|sk_|sk-|rk_live|whsec_|wsec_|gh[pousr]_|AKIA|AIza|PRIVATE KEY|xox[baprs]-|re_[A-Za-z0-9]|@|/Users/|/home/'
120
121type Rule = { kind: string; re: RegExp; mask: boolean }
122
123const RULES: Rule[] = [
124 { kind: 'supabaseRef', re: /\b([a-z]{20})\.supabase\.(?:co|in)\b/g, mask: true },
125 { kind: 'supabaseRef', re: /project[_ -]?(?:id|ref)\W{1,6}([a-z]{20})\b/gi, mask: true },
126 { kind: 'supabaseRef', re: /--project-ref[=\s]+([a-z]{20})\b/g, mask: true },
127 { kind: 'jwt', re: /\b(eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/g, mask: true },
128 { kind: 'privateKey', re: /(-----BEGIN [A-Z ]*PRIVATE KEY-----)/g, mask: false },
129 { kind: 'apiKey', re: /\b((?:sk|rk)_live_[A-Za-z0-9]{10,}|sk_test_[A-Za-z0-9]{10,}|whsec_[A-Za-z0-9]{10,}|wsec_[A-Za-z0-9]{20,})/g, mask: true },
130 { kind: 'apiKey', re: /\b(sk-ant-[A-Za-z0-9_-]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{32,})/g, mask: true },
131 { kind: 'apiKey', re: /\b(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/g, mask: true },
132 { kind: 'apiKey', re: /\b(AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|xox[baprs]-[A-Za-z0-9-]{10,}|re_[A-Za-z0-9]{8,}_[A-Za-z0-9]{16,})/g, mask: true },
133 { kind: 'apiKey', re: /\b(\d{8,10}:AA[A-Za-z0-9_-]{30,})/g, mask: true },
134 { kind: 'apiKey', re: /\b(sk_[0-9a-f]{40,})/g, mask: true },
135 { kind: 'email', re: /\b([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g, mask: false },
136]
137
138const HARMLESS_EMAIL = /noreply|no-reply|@users\.noreply\.github\.com$|^(git|you|user|name|someone|test|info|email|valaki|pelda)@|@(example|pelda|email|test|domain|ceg|company|something|your-?domain|acme)\.[a-z.]+$/i
139// Zajos, generált fájlok: ezekben nem keresünk.
140const SKIP_FILE = /(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb?|Cargo\.lock|poetry\.lock|composer\.lock)$/
141
142export type Finding = { kind: string; file: string; line: number; value: string; shown: string }
143
144export function maskValue(value: string): string {
145 if (value.length <= 10) return `${value.slice(0, 2)}…`
146 return `${value.slice(0, 6)}…${value.slice(-2)}`
147}
148
149export type Context = {
150 home: string | null
151 privateRepos: readonly string[]
152 watch: readonly string[]
153 allow: readonly string[]
154 ownEmail: string | null
155}
156
157export function scanLines(lines: readonly Line[], ctx: Context): Finding[] {
158 const found: Finding[] = []
159 const seen = new Set<string>()
160 const allow = new Set(ctx.allow.map(a => a.toLowerCase()))
161 const add = (kind: string, l: Line, value: string, mask: boolean) => {
162 if (allow.has(value.toLowerCase())) return
163 const key = `${kind}|${value}`
164 if (seen.has(key)) return
165 seen.add(key)
166 found.push({ kind, file: l.file, line: l.line, value, shown: mask ? maskValue(value) : value })
167 }
168
169 for (const l of lines) {
170 if (SKIP_FILE.test(l.file)) continue
171 for (const rule of RULES) {
172 for (const m of l.text.matchAll(rule.re)) {
173 const value = m[1] ?? ''
174 if (rule.kind === 'email' && (HARMLESS_EMAIL.test(value) || value.toLowerCase() === ctx.ownEmail?.toLowerCase())) continue
175 add(rule.kind, l, value, rule.mask)
176 }
177 }
178 // Egy sor, ami a Supabase-ről szól, és benne egy 20 kisbetűs azonosító (pl. táblázatban).
179 if (/supabase/i.test(l.text)) {
180 for (const m of l.text.matchAll(/(?<![A-Za-z0-9.])([a-z]{20})(?![A-Za-z0-9])/g)) {
181 add('supabaseRef', l, m[1] ?? '', true)
182 }
183 }
184 if (ctx.home !== null && l.text.includes(`${ctx.home}/`)) add('localPath', l, ctx.home, false)
185 const lower = l.text.toLowerCase()
186 for (const repo of ctx.privateRepos) {
187 if (lower.includes(repo.toLowerCase())) add('privateRepo', l, repo, false)
188 }
189 for (const w of ctx.watch) {
190 if (w !== '' && lower.includes(w.toLowerCase())) add('watched', l, w, true)
191 }
192 }
193 return found
194}
195
196// Új vagy módosított .env fájl (a minták kivételével).
197export function envFiles(log: string): string[] {
198 const files = new Set<string>()
199 for (const m of log.matchAll(/^\+\+\+ b\/(.+)$/gm)) {
200 const file = m[1] ?? ''
201 if (/(^|\/)\.env(\.[\w-]+)?$/.test(file) && !/\.(example|sample|template|dist)$/.test(file)) files.add(file)
202 }
203 return [...files]
204}
205types/index.d.ts 10 lines1// Az eszköztár (tool-hub) kérése: a kapcsoló gomb ezt írja, a mod a state.set hookjában veszi át.
2export type HubRequest = { tool: string; on: boolean; n: number }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'tool-hub': { request: HubRequest | null }
7 'public-repo-guard': { isOn: boolean }
8 }
9}
10