SLOPSHOPPER

public-repo-guard

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…

newguardcommandprocess
v0.1.0no licenseupdated 2026-10-07Szotasz/claude-mods/plugins/public-repo-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · public-repo-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pushor ⎿ public-repo-guard: Push guard off: pushes go out unchecked. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Saját Claude Code mod-ok (function hook pluginek) egy pluginpiacon.

Telepítés

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).

Pluginek

usage-band

A státuszsorba (a prompt alá) írja:

🤖 Opus 5.5  │  Ctx ███░░░░░ 42% 420k/1M  │  5h ███░░░░░ 34% → 16:20  │  Hét ███████░ 93% ⚠ → Cs 10:00
  • modell neve (/model váltáskor és turn végén frissül)
  • kontextusablak telítettsége
  • 5 órás és heti rate-limit, reset időponttal (csak Pro/Max előfizetéssel)
  • ⚠ 90% felett, és egy toast, amikor egy limit átlépi (ablakonként, resetenként egyszer)

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 angol
  • hu: 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.

usage-header

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
  • A kontextussáv minden kategóriája a /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.
  • A bontás helyi becslés ($.session.usage({ breakdown: 'summary' })), API-hívás nélkül; minden turn végén és limitmozduláskor frissül.
  • Limitek: zöld 70% alatt, sárga 70–90%, piros 90% fölött (⚠), reset időponttal. Csak Pro/Max előfizetéssel van adat.
  • /hasznalat kapcsolja, /hasznalat be|ki beállítja; az állapotot sessionök között megőrzi. Az eszköztárban (/eszkozok) is kapcsolható.
  • A sávot megosztja a fókusz mód listájával és az eszköztárral (azok alatt jelenik meg).

Beállítás (/config): usage-header.language (auto/hu/en).

project-pane

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
  • Szinkron gomb (s): csak fast-forward, soha nem merge-öl, rebase-el vagy töröl.
  • a branch lemaradt az upstreamtől → git pull --ff-only
  • másik branchen vagy, és az alapértelmezett (main) előrébb jár → git switch main + git merge --ff-only origin/main
  • commitolatlan módosítás esetén csak „Stash + szinkron” van: git stash push -u, vissza: git stash pop
  • ha elakad, a hibát Claude-nak szóló kérdésként a promptba teszi
  • Frissítés (r): 60 mp-enként és minden turn végén; git fetch 5 percenként.
  • Kérdezd Claude-ot (a): a legutóbbi elbukott CI-futásról kérdést tesz a promptba.
  • Toast, ha a session alatt elbukik egy Netlify deploy vagy CI-futás.

Adatforrások (a CLI-k saját bejelentkezésével, a plugin tokent nem olvas):

SzekcióForrásHa hiányzik
Gitgit—
PR, CIgh (gh auth login)tipp a panelen
Deploynetlify CLI + .netlify/state.jsonnpm i -g netlify-cli && netlify login
Supabasesupabase/.temp/project-ref, supabase/migrations, supabase projects listsupabase login az állapothoz

Beállítás (/config): project-pane.language (auto/hu/en), project-pane.autoOpen (alapból be).

focus-mode

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.
  • A listát a modell tölti a mod által regisztrált 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).
  • Kikapcsolva minden a megszokott módon látszik.

Beállítás (/config): focus-mode.language (auto/hu/en).

tool-hub

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
  • Modonként BE/KI állapot és kapcsoló gomb, ctrl+x tab után az 1–5 számbillentyűvel is.
  • Ami nincs telepítve, „nincs betöltve” felirattal jelenik meg.
  • Bekapcsolt fókusz módnál a lábléc is kiírja: „Fókusz mód”.
  • Parancsból is ugyanez: /fokusz, /limitsav, /hasznalat, /project, /jatek (mind be|ki argumentummal).
  • A sávot megosztja a fókusz mód listájával (a kapcsolók alatta jelennek meg).

Ú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).

snake-pane

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
  • Promptküldéskor magától megnyílik (ha ebben a sessionben bezártad, már nem; /jatek mindig nyitja). /jatek ki kikapcsolja, az eszköztárban (/eszkozok) is kapcsolható.
  • Irányítás: kattints a táblára, utána nyilak / 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.
  • A turn végén a futó játék szünetel („Claude végzett”), hogy visszatérj a munkához.
  • A pontszámmal gyorsul; a rekordot sessionök között megőrzi.
  • A játék a rajzoló szálon fut (Client surface modul), nem terheli a sessiont.

supabase-guard

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.

  • A mappa projektjét a 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.
  • Más projektre menő írás (MCP 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.
  • Romboló művelet a saját projekten is kérdez: DROP, TRUNCATE, DELETE FROM, WHERE nélküli UPDATE, REVOKE, RLS kikapcsolása, reset_branch, delete_branch, pause_project, supabase db reset --linked.
  • Ha a mappához nem talál projektet, írás előtt kérdez (van „Engedem a session végéig” gomb is).
  • Más projekt olvasása fut, de a modell kap egy figyelmeztetést, hogy ne a rossz projektről vonjon le következtetést.
  • Hiba esetén zárva marad: ha az ellenőrzés elhasal, a Supabase-hívás nem fut le. /supaor be|ki, vagy az eszköztárban.

public-repo-guard

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.

  • A távoli ágon még nem lévő commitokat nézi (üzenet + hozzáadott sorok, a history is); ha nincs mihez mérni, az egész fát. Kezeli a git push origin forrás:cél alakot is.
  • Keres: Supabase projekt-ID (URL, 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.
  • Találatnál felugró, maszkolt listával — Megtiltom / Pushold így is / Pushold, jegyezd meg (az utóbbi kivételként megjegyzi a találatokat). Tiltáskor a modell megkapja a teljes listát a javításhoz.
  • Privát repóba menő pushnál nem csinál semmit. Kell hozzá a gh CLI (bejelentkezve). /pushor be|ki.

decision-popup

Döntési felugró: a döntések egygombos kérdésként jönnek.

  • A rendszerpromptban arra kéri Claude-ot, hogy minden döntést (engedélyezés, A vagy B, visszafordíthatatlan vagy kifelé ható lépés) az AskUserQuestion dialógussal kérdezzen meg, 2–4 rövid opcióval, a javasoltat vagy a biztonságosabbat elöl — egy számgombbal válaszolsz.
  • A dialógus keretet és címet kap („Döntés kell”); az őr-modok (supabase-guard, public-repo-guard) kérdése piros keretben, „Az őr megállított egy műveletet” címmel.
  • /dontes be|ki, vagy az eszköztárban.

Ötletek

project-pane bővítése:

  • Vercel deploy-ok (vercel ls)
  • remote Supabase migrációk összevetése a lokálissal
  • több repó egy panelen

Státuszsor bővítése (usage-band):

  • figyelmeztetés kontextusablakra (pl. 85%), opcionálisan automatikus /compact
  • session költsége ($.session.usage().cost)
  • fast mód jelzése, git branch, aktuális projekt
  • beállítható küszöb és megjelenő elemek (userConfig)

Új mod-ok:

  • env-guard: .env, kulcsfájlok, supabase/.temp szerkesztésének tiltása (tool.call → deny)
  • turn-timer: hosszú turn végén hang/toast, hogy vissza lehet nézni (turn.complete, $.audio.play)
  • quote: /quote — a kijelölt szöveget idézetként a promptba teszi ($.ui.selection)
  • hu-prompt: a rendszerpromptba magyar válasz- és stílusszabály (prompt.compose)

Fejlesztés

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-band
Source 3 files
hooks/register.tsx 265 lines
1import { 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}
265
hooks/scan.ts 205 lines
1// 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}
205
types/index.d.ts 10 lines
1// 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