Blocca commit e push su main/master e i segreti in chiaro nei file scritti da Claude

I mods di Ale per Claude Code (terminale, scheda Code del desktop, VS Code).
Una volta sola, nel terminale:
claude plugin marketplace add StraordinaryAS/claude-mods
claude plugin install guardia-regole@ale-mods
claude plugin install scelta-modello@ale-mods
claude plugin install tipografia-it@ale-mods
Dentro una sessione Claude Code: /plugin install guardia-regole --marketplace StraordinaryAS/claude-mods
App desktop senza terminale: + accanto al prompt › Plugins › Add marketplace › StraordinaryAS/claude-mods › Add plugin.
Aggiornare dopo nuove versioni: claude plugin marketplace update ale-mods, poi /reload-plugins nella sessione.
| Mod | Cosa fa |
|---|---|
guardia-regole | Blocca commit e push su main/master e i segreti in chiaro (token, chiavi, password) nei file scritti da Claude. I file .env sono ammessi. |
scelta-modello | Per ogni messaggio sceglie Haiku (traduzioni, riassunti, formattazione), Opus (piani, architettura, bug, revisioni) o Sonnet (tutto il resto). Le conferme ("vai", "ok", "2") tengono il modello di prima. @haiku, @sonnet, @opus a inizio messaggio forzano la scelta. A conversazione lunga non scende di livello, per non perdere la cache. /modello-auto off lo spegne. Regole in mods/scelta-modello/hooks/rules.ts. |
tipografia-it | Skill con le regole di tipografia italiana per documenti, PDF e web: righe isolate, spazi, trattini, caporali, apostrofo ’, accenti, font e interlinea. |
I plugin non arrivano nella chat di claude.ai. Per avere tipografia-it anche lì, comprimi la cartella mods/tipografia-it/skills/tipografia-it in uno zip e caricalo da Impostazioni › Funzionalità › Skill › Carica skill.
Nuova cartella mods/<nome>/ con .claude-plugin/plugin.json, hooks/hooks.json, hooks/register.ts e test in tests/. Poi una voce in .claude-plugin/marketplace.json. Controllo: claude plugin validate mods/<nome> e claude plugin test mods/<nome>.
hooks/register.ts 38 lines1import type { Register } from 'claude-code'
2import { findSecret, isEnvFile } from './secrets'
3import { cdBefore, gitActions, isProtected, pushHasRefspec, pushNamesProtected } from './git'
4
5const NO_MAIN =
6 'Regola di Ale: mai lavorare su main. Crea un ramo (git switch -c <nome>) e fai lì commit e push; il merge su main solo dopo il suo via.'
7
8export const register: Register = on => {
9 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
10 const command = e.command
11 for (const { verb, dir, args } of gitActions(command)) {
12 if (verb === 'push' && pushNamesProtected(args))
13 return { deny: `${$.plugin.name}: push verso main/master bloccato. ${NO_MAIN}` }
14 if (verb === 'push' && pushHasRefspec(args)) continue
15
16 const cwd = dir ?? cdBefore(command, command.indexOf('git'))
17 const head = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], cwd ? { cwd } : undefined)
18 if (head.exitCode === 0 && isProtected(head.stdout))
19 return { deny: `${$.plugin.name}: ${verb} sul ramo ${head.stdout.trim()} bloccato. ${NO_MAIN}` }
20 }
21 return next(e)
22 }).catch(($, e, next) => next(e)) // se la guardia si rompe, non blocca il lavoro
23
24 on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, ($, e, next) => {
25 const path = 'file_path' in e ? e.file_path : 'notebook_path' in e ? e.notebook_path : ''
26 if (isEnvFile(String(path))) return next(e)
27 const text = 'content' in e ? e.content : 'new_string' in e ? e.new_string : 'new_source' in e ? e.new_source : ''
28 const kind = findSecret(String(text ?? ''))
29 return kind
30 ? {
31 deny:
32 `${$.plugin.name}: ${kind} in chiaro in ${path}. Regola di Ale: segreti mai in chiaro nei file. ` +
33 'Mettilo in .env (non versionato) o in una variabile d\'ambiente e leggilo da lì.',
34 }
35 : next(e)
36 }).catch(($, e, next) => next(e))
37}
38hooks/secrets.ts 28 lines1// Pattern dei segreti più comuni. Un match blocca la scrittura del file.
2const PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
3 ['chiave privata', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
4 ['token GitHub', /\b(gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})\b/],
5 ['chiave Anthropic', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
6 ['chiave OpenAI', /\bsk-(proj-)?[A-Za-z0-9_-]{32,}/],
7 ['token Supabase', /\b(sbp_[a-f0-9]{40}|sb_secret_[A-Za-z0-9_-]{20,})\b/],
8 ['JWT (es. chiave Supabase)', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/],
9 ['chiave AWS', /\bAKIA[0-9A-Z]{16}\b/],
10 ['chiave Stripe live', /\b(sk|rk)_live_[A-Za-z0-9]{20,}/],
11 ['token Slack', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
12 ['chiave Google API', /\bAIza[0-9A-Za-z_-]{35}\b/],
13 [
14 'password o token assegnato in chiaro',
15 /\b(password|passwd|secret|token|api[_-]?key|access[_-]?key)\b["']?\s*[:=]\s*["'](?![^"']*(\$\{|process\.env|<|your|xxx|example))[^"'\s]{12,}["']/i,
16 ],
17]
18
19/** Primo tipo di segreto trovato nel testo, o undefined. */
20export const findSecret = (text: string): string | undefined =>
21 PATTERNS.find(([, re]) => re.test(text))?.[0]
22
23/** I file .env locali sono il posto giusto per i segreti (non vanno su git). */
24export const isEnvFile = (path: string): boolean => {
25 const name = path.split(/[\\/]/).pop() ?? ''
26 return /^\.env(\..+)?$/.test(name) && !/\.(example|sample|template)$/.test(name)
27}
28hooks/git.ts 41 lines1const PROTECTED = new Set(['main', 'master'])
2
3/** I comandi git del testo che fanno commit o push, uno per segmento. */
4export const gitActions = (command: string) =>
5 command
6 .split(/&&|\|\||;|\n|\|/)
7 .map(s => s.trim())
8 .flatMap(segment => {
9 const m = segment.match(/^(?:\S+=\S+\s+)*git\b(.*)$/)
10 if (!m) return []
11 const tokens = (m[1] ?? '').trim().split(/\s+/).filter(Boolean)
12 let dir: string | undefined
13 const rest: string[] = []
14 for (let i = 0; i < tokens.length; i++) {
15 if (tokens[i] === '-C') dir = tokens[++i]
16 else rest.push(tokens[i]!)
17 }
18 const at = rest.findIndex(t => !t.startsWith('-'))
19 const verb = rest[at]
20 if (verb !== 'commit' && verb !== 'push') return []
21 const args = rest.slice(at + 1)
22 return [{ verb, dir, args } as const]
23 })
24
25/** La cartella dell'ultimo `cd` prima del comando git, se c'è. */
26export const cdBefore = (command: string, git: number): string | undefined => {
27 const before = command.slice(0, git)
28 const all = [...before.matchAll(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|\S+)/g)]
29 return all.at(-1)?.[1]?.replace(/^["']|["']$/g, '')
30}
31
32/** Il push nomina esplicitamente main/master (es. `origin main`, `HEAD:main`). */
33export const pushNamesProtected = (args: readonly string[]) =>
34 args.some(a => !a.startsWith('-') && PROTECTED.has(a.split(':').pop()!.replace(/^refs\/heads\//, '')))
35
36/** Il push indica un ramo esplicito (remote + refspec). */
37export const pushHasRefspec = (args: readonly string[]) =>
38 args.filter(a => !a.startsWith('-')).length >= 2
39
40export const isProtected = (branch: string) => PROTECTED.has(branch.trim())
41