SLOPSHOPPER

secret-guard

Oscura i segreti (token, chiavi, password) nei risultati degli strumenti prima che Claude li legga

newguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · secret-guard
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ secret-guard │ ⏺ Read(src/auth.ts) │ 🔒 Oscurato 1 segreto (chiave Stripe │ ⎿ Read 6 lines │ sk_live_) nel risultato di Bash │ ⏺ 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 › /segreti ⎿ secret-guard: secret-guard ha oscurato in questa sessione: ⎿ secret-guard: - chiave Stripe sk_live_: 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

secret-guard

Oscura token, chiavi e password nei risultati degli strumenti prima che Claude li legga, così un segreto stampato da un comando non finisce nella conversazione.

Come si usa

Non serve fare nulla: lavora da sola. Il comando /segreti dice quanti segreti ha oscurato nella sessione, per tipo:

secret-guard ha oscurato in questa sessione:
- token Google ya29.: 2
- stringa casuale: 1

Al posto del valore Claude legge, tra parentesi quadre, «segreto oscurato:» seguito dal tipo, ad esempio «chiave AWS AKIA». Ogni volta compare un avviso (toast), con un lucchetto davanti: «Oscurato 1 segreto (token Google ya29.) nel risultato di Bash».

Cosa fa da sola

  • Su ogni chiamata a uno strumento, dopo che è stata eseguita: cerca i segreti in tutte le stringhe del risultato e li sostituisce. Se lo strumento finisce in errore, oscura anche il testo dell'errore.
  • Prima di Edit, Write, MultiEdit e NotebookEdit: se il testo da scrivere contiene un segreto oscurato, la scrittura viene rifiutata. Altrimenti Claude scriverebbe nel file il segnaposto al posto del valore vero. Claude riceve l'indicazione di fare una modifica più piccola che lasci com'è la parte con il segreto, oppure di chiedere a te di inserire il valore.
  • Sui tuoi messaggi: non cambia nulla, avvisa soltanto: «⚠ Il messaggio contiene 1 segreto (…): Claude lo leggerà.»

Cosa riconosce, in quest'ordine:

TipoCome lo riconosce
Forme notechiave privata PEM, JWT, token Google (1//, ya29., AIza), ntok_/nins_/ngat_, Anthropic sk-ant-, OpenAI sk-, GitHub (ghp_, gho_, ghu_, ghs_, ghr_, github_pat_), GitLab glpat-, Slack xox…-, AWS AKIA, Stripe sk_live_, rk_test_…, npm npm_
Righe in stile .envNOME=valore dove il nome contiene PASSWORD, PASSWD, SECRET, TOKEN, API_KEY, APIKEY, PRIVATE_KEY o CREDENTIAL(S); oscura solo il valore
Stringhe casuali32 caratteri o più, con almeno tre classi di caratteri e un'entropia di almeno 4,3 bit per carattere

Cosa legge e cosa manda

  • Legge solo i risultati degli strumenti e i tuoi messaggi, in memoria. Non apre file, non lancia processi, non chiama modelli.
  • Nello stato della sessione tiene solo il conteggio per tipo (counts), mai un valore. Per le righe .env il tipo contiene il nome della variabile (ad esempio «valore di DB_PASSWORD»).
  • Nella conversazione resta il risultato oscurato al posto di quello vero.

Requisiti

Nessuno strumento esterno.

Limiti

  • Riconosce i segreti dalla forma del valore: una password corta e senza un nome che la indichi (PASSWORD=…) passa.
  • Una riga .env non viene oscurata se il valore ha meno di 8 caratteri, se comincia con $ o se è un segnaposto (changeme, xxx, <…>, your_…, example…, todo, none, true…).
  • Non sono considerate casuali le stringhe esadecimali, gli UUID, gli hash sha…- dei lockfile, i percorsi e gli identificatori lunghi in camelCase o snake_case.
  • Non guarda i campi base64, type, filePath, rawOutputPath, backgroundTaskId, mimeType e media_type dei risultati (immagini, percorsi, identificativi).
  • Non oscura il testo che Claude scrive né quello che passa agli strumenti: solo quello che riceve.
  • Blocca solo le scritture fatte con Edit, Write, MultiEdit e NotebookEdit; un segnaposto scritto in un file da Bash non viene fermato.
  • Un testo che contiene alla lettera il segnaposto (anche solo per documentarlo) non si può scrivere con quegli strumenti.
  • Se il controllo stesso va in errore o supera il tempo concesso, il risultato passa così com'è, senza oscuramento.

Installazione

claude plugin marketplace add Wowbagger1994/claude-mods
claude plugin install secret-guard@enrik-mods

Oppure, dal prompt di Claude Code: /plugin install secret-guard --marketplace Wowbagger1994/claude-mods.

Sviluppo

Dalla cartella della mod:

claude plugin validate .
claude plugin test .

Oggi i test sono 32.

Source 3 files
hooks/register.tsx 84 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Finding } from './detect'
5import { describe, hasMask, maskDeep, maskText } from './detect'
6
7const PLUGIN = 'secret-guard'
8/** Tools that write text into files: a blotted-out secret must never be written back. */
9const WRITING_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
10/** The fields of those tools that carry the text written; `old_string` only names what is replaced. */
11const WRITTEN_FIELDS = ['new_string', 'content', 'new_source', 'edits'] as const
12
13const counts = atom({ plugin: 'secret-guard', key: 'counts' } as const, {})
14
15export const WRITE_DENIED =
16  'Il testo contiene un segreto oscurato da secret-guard («[segreto oscurato: …]»): non si può scrivere nel file. ' +
17  'Riscrivi la modifica senza toccare la parte con il segreto, ad esempio con un Edit più piccolo che la lasci com’è, ' +
18  'oppure chiedi all’utente di inserire lui il valore.'
19
20/** The text a writing tool would put in a file, all its fields together. */
21const writtenText = (e: Record<string, unknown>): string =>
22  WRITTEN_FIELDS.map(field => e[field])
23    .filter(v => v !== undefined)
24    .map(v => (typeof v === 'string' ? v : JSON.stringify(v)))
25    .join('\n')
26
27const plural = (n: number) => (n === 1 ? '1 segreto' : `${n} segreti`)
28
29/** Counts what was blotted out, by label, and tells the user without ever showing a value. */
30const report = async ($: EngineInterface, found: readonly Finding[], where: string) => {
31  await update($, counts, c =>
32    found.reduce<Record<string, number>>((acc, f) => ({ ...acc, [f.label]: (acc[f.label] ?? 0) + 1 }), c),
33  )
34  $.ui.toast(`🔒 Oscurato ${plural(found.length)} (${describe(found)}) ${where}`)
35}
36
37export const register: Register = on => {
38  on('session.start', async ($, e, next) => {
39    await $.command.register({ name: 'segreti', description: 'Quanti segreti secret-guard ha oscurato in questa sessione, per tipo' })
40    return next(e)
41  })
42
43  on('command.run', { command: 'segreti' }, async $ => {
44    const c = await read($, counts)
45    const lines = Object.entries(c).map(([label, n]) => `- ${label}: ${n}`)
46    return {
47      text:
48        lines.length === 0
49          ? 'secret-guard: nessun segreto oscurato in questa sessione.'
50          : `secret-guard ha oscurato in questa sessione:\n${lines.join('\n')}`,
51    }
52  })
53
54  // Every tool: what a tool hands back is masked before Claude reads it.
55  on('tool.call', async ($, e, next) => {
56    const input = e as unknown as Record<string, unknown>
57    if (WRITING_TOOLS.has(e.tool) && hasMask(writtenText(input))) return { deny: WRITE_DENIED }
58
59    const ran = await next(e)
60    if (ran.deny !== undefined) return ran
61    if (ran.isError === true) {
62      const masked = maskText(ran.text ?? '')
63      if (masked.found.length === 0) return ran
64      await report($, masked.found, `nell’errore di ${e.tool}`)
65      return { deny: masked.text }
66    }
67    const masked = maskDeep(ran.result)
68    if (masked.found.length === 0) return ran
69    await report($, masked.found, `nel risultato di ${e.tool}`)
70    // A result of our own (no `ref`): core maps it for Claude and records it in place of the real one.
71    return ran.context === undefined ? { result: masked.value } : { result: masked.value, context: ran.context }
72  }).catch(($, e, next) => next(e))
73
74  // The user's own words are theirs: only a warning, never a change.
75  on('prompt.submit', async ($, e, next) => {
76    const { found } = maskText(e.text)
77    if (found.length > 0) $.ui.toast(`⚠ Il messaggio contiene ${plural(found.length)} (${describe(found)}): Claude lo leggerà.`)
78    return next(e)
79  }).catch(($, e, next) => {
80    $.ui.log(`${PLUGIN}: controllo del messaggio non riuscito`, { to: 'debug' })
81    return next(e)
82  })
83}
84
hooks/detect.ts 197 lines
1/**
2 * Finds secrets by their value, wherever they appear, and blots them out:
3 * known token shapes first, then .env-style assignments, then long random
4 * strings. Pure: nothing here touches the session.
5 */
6
7/** One secret found: its kind and the label shown in its place (a public prefix at most). */
8export type Finding = { kind: string; label: string }
9export type Masked = { text: string; found: Finding[] }
10
11export const MASK_OPEN = '[segreto oscurato: '
12const MASK_CLOSE = ']'
13
14type Rule = { kind: string; pattern: RegExp; label: (match: string) => string }
15
16const NOT_AFTER_WORD = '(?<![A-Za-z0-9])'
17
18/** Token shapes with a known prefix, most specific first. */
19const RULES: readonly Rule[] = [
20  {
21    kind: 'private-key',
22    pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
23    label: () => 'chiave privata',
24  },
25  {
26    kind: 'jwt',
27    pattern: /eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
28    label: () => 'token JWT',
29  },
30  { kind: 'google-refresh', pattern: new RegExp(`${NOT_AFTER_WORD}1//[A-Za-z0-9_-]{20,}`, 'g'), label: () => 'token di aggiornamento Google 1//' },
31  { kind: 'google-access', pattern: /ya29\.[A-Za-z0-9_-]{20,}/g, label: () => 'token Google ya29.' },
32  { kind: 'google-api', pattern: /AIza[0-9A-Za-z_-]{35}/g, label: () => 'chiave API Google AIza' },
33  {
34    kind: 'user-token',
35    pattern: new RegExp(`${NOT_AFTER_WORD}(?:ntok|nins|ngat)_[A-Za-z0-9_-]{8,}`, 'g'),
36    label: m => `token ${m.slice(0, 5)}`,
37  },
38  { kind: 'anthropic', pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, label: () => 'chiave Anthropic sk-ant-' },
39  { kind: 'openai', pattern: new RegExp(`${NOT_AFTER_WORD}sk-(?:proj-)?[A-Za-z0-9_-]{20,}`, 'g'), label: () => 'chiave OpenAI sk-' },
40  { kind: 'github', pattern: new RegExp(`${NOT_AFTER_WORD}gh[pousr]_[A-Za-z0-9]{30,}`, 'g'), label: m => `token GitHub ${m.slice(0, 4)}` },
41  { kind: 'github', pattern: /github_pat_[A-Za-z0-9_]{20,}/g, label: () => 'token GitHub github_pat_' },
42  { kind: 'gitlab', pattern: /glpat-[A-Za-z0-9_-]{20,}/g, label: () => 'token GitLab glpat-' },
43  { kind: 'slack', pattern: /xox[abposr]-[A-Za-z0-9-]{10,}/g, label: m => `token Slack ${m.slice(0, 5)}` },
44  { kind: 'aws', pattern: /(?<![A-Z0-9])AKIA[0-9A-Z]{16}(?![A-Z0-9])/g, label: () => 'chiave AWS AKIA' },
45  { kind: 'stripe', pattern: new RegExp(`${NOT_AFTER_WORD}[sr]k_(?:live|test)_[A-Za-z0-9]{10,}`, 'g'), label: m => `chiave Stripe ${m.slice(0, 8)}` },
46  { kind: 'npm', pattern: new RegExp(`${NOT_AFTER_WORD}npm_[A-Za-z0-9]{30,}`, 'g'), label: () => 'token npm npm_' },
47]
48
49const maskOf = (label: string) => `${MASK_OPEN}${label}${MASK_CLOSE}`
50
51/** True when the text holds a blotted-out secret: what a write must not put back in a file. */
52export const hasMask = (text: string): boolean => text.includes(MASK_OPEN)
53
54const applyRule = (text: string, rule: Rule): Masked => {
55  const found: Finding[] = []
56  const out = text.replace(rule.pattern, match => {
57    const label = rule.label(match)
58    found.push({ kind: rule.kind, label })
59    return maskOf(label)
60  })
61  return { text: out, found }
62}
63
64// .env-style: NAME=value where NAME says it is a secret; only the value is blotted out.
65const ENV_LINE =
66  /^(\s*(?:export\s+)?[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|APIKEY|PRIVATE_KEY|CREDENTIALS?)[A-Z0-9_]*\s*=\s*)(.+)$/gm
67const MIN_ENV_VALUE = 8
68const PLACEHOLDER = /^(?:changeme|change-me|xxx+|\*{3,}|<.*>|your[-_].*|example.*|placeholder.*|todo|none|null|undefined|true|false)$/i
69
70const isRealValue = (raw: string): boolean => {
71  const value = raw.trim().replace(/^['"]|['"]$/g, '')
72  if (value.length < MIN_ENV_VALUE || hasMask(value)) return false
73  if (value.startsWith('$') || PLACEHOLDER.test(value)) return false
74  return true
75}
76
77const maskEnv = (text: string): Masked => {
78  const found: Finding[] = []
79  const out = text.replace(ENV_LINE, (line, head: string, value: string) => {
80    if (!isRealValue(value)) return line
81    const name = head.replace(/^\s*(?:export\s+)?/, '').replace(/\s*=\s*$/, '')
82    const label = `valore di ${name}`
83    found.push({ kind: 'env', label })
84    return `${head}${maskOf(label)}`
85  })
86  return { text: out, found }
87}
88
89/** A candidate: base64-ish characters, '=' only as trailing padding, so `NAME=value` splits at the sign. */
90const RANDOM_TOKEN = /[A-Za-z0-9_\-+/.]{32,}={0,2}/g
91const MIN_ENTROPY = 4.3
92const MIN_CLASSES = 3
93// A lockfile's integrity hash: the prefix may sit just before the token or, since '-' is a token character, open it.
94const INTEGRITY_PREFIX = /sha(?:1|256|384|512)-$/
95const INTEGRITY_TOKEN = /^sha(?:1|256|384|512)-/
96const HEX = /^[0-9a-fA-F]+$/
97const UUID_LIKE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i
98const WORDY_SEGMENT = /^[A-Za-z][a-z0-9]*(?:[._-][A-Za-z0-9]+)*$/
99/** A file name's extension: base64 and random keys have no dots, a path's last segment often does. */
100const FILE_EXTENSION = /\.[a-z][a-z0-9]{0,4}$/
101
102/** Bits per character of the string's own symbol frequencies. */
103export const entropy = (s: string): number => {
104  const counts = new Map<string, number>()
105  for (const ch of s) counts.set(ch, (counts.get(ch) ?? 0) + 1)
106  return [...counts.values()].reduce((sum, n) => {
107    const p = n / s.length
108    return sum - p * Math.log2(p)
109  }, 0)
110}
111
112const classesOf = (s: string): number =>
113  [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(re => re.test(s)).length
114
115/** A path: rooted, or slash-separated with mostly word-like segments. */
116const isPath = (s: string): boolean => {
117  if (/^(?:\/|\.\.?\/|~\/)/.test(s)) return true
118  if (!s.includes('/')) return false
119  const segments = s.split('/').filter(Boolean)
120  if (segments.some(seg => FILE_EXTENSION.test(seg))) return true
121  return segments.filter(seg => WORDY_SEGMENT.test(seg)).length * 2 >= segments.length
122}
123
124/** A long identifier: camelCase or snake words, mostly of 3 letters or more. */
125const isIdentifier = (s: string): boolean => {
126  if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(s)) return false
127  const words = s.split(/_|(?=[A-Z][a-z])|(?<=[a-z])(?=[A-Z0-9])/).filter(Boolean)
128  return words.filter(w => w.length >= 3).length >= words.length * 0.7
129}
130
131/** A long string that looks random enough to be a credential. */
132export const isRandomSecret = (token: string, before: string): boolean =>
133  !HEX.test(token.replace(/-/g, '')) &&
134  !INTEGRITY_PREFIX.test(before) &&
135  !INTEGRITY_TOKEN.test(token) &&
136  !UUID_LIKE.test(token) &&
137  !isPath(token) &&
138  !isIdentifier(token) &&
139  classesOf(token) >= MIN_CLASSES &&
140  entropy(token) >= MIN_ENTROPY
141
142const maskRandom = (text: string): Masked => {
143  const found: Finding[] = []
144  const out = text.replace(RANDOM_TOKEN, (token: string, at: number) => {
145    if (!isRandomSecret(token, text.slice(Math.max(0, at - 8), at))) return token
146    found.push({ kind: 'random', label: 'stringa casuale' })
147    return maskOf('stringa casuale')
148  })
149  return { text: out, found }
150}
151
152/** The text with every secret blotted out, and what was found, in order of the passes. */
153export const maskText = (text: string): Masked => {
154  const steps = [...RULES.map(rule => (t: string) => applyRule(t, rule)), maskEnv, maskRandom]
155  return steps.reduce<Masked>(
156    (acc, step) => {
157      const next = step(acc.text)
158      return { text: next.text, found: [...acc.found, ...next.found] }
159    },
160    { text, found: [] },
161  )
162}
163
164/** Keys whose strings are data, not text a secret hides in: images, paths, ids, types. */
165const SKIPPED_KEYS = new Set(['base64', 'type', 'filePath', 'rawOutputPath', 'backgroundTaskId', 'mimeType', 'media_type'])
166
167export type MaskedValue = { value: unknown; found: Finding[] }
168
169/** A copy of any JSON-like value with every string masked; the value itself is never changed. */
170export const maskDeep = (value: unknown): MaskedValue => {
171  if (typeof value === 'string') {
172    const m = maskText(value)
173    return { value: m.text, found: m.found }
174  }
175  if (Array.isArray(value)) {
176    const parts = value.map(maskDeep)
177    return { value: parts.map(p => p.value), found: parts.flatMap(p => p.found) }
178  }
179  if (typeof value === 'object' && value !== null) {
180    const entries = Object.entries(value).map(([key, v]): [string, MaskedValue] =>
181      SKIPPED_KEYS.has(key) ? [key, { value: v, found: [] }] : [key, maskDeep(v)],
182    )
183    return {
184      value: Object.fromEntries(entries.map(([k, m]) => [k, m.value])),
185      found: entries.flatMap(([, m]) => m.found),
186    }
187  }
188  return { value, found: [] }
189}
190
191/** «token Google ya29., stringa casuale ×2»: what was found, each label once with its count. */
192export const describe = (found: readonly Finding[]): string => {
193  const counts = new Map<string, number>()
194  for (const f of found) counts.set(f.label, (counts.get(f.label) ?? 0) + 1)
195  return [...counts.entries()].map(([label, n]) => (n > 1 ? `${label} ×${n}` : label)).join(', ')
196}
197
types/index.d.ts 12 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'secret-guard': {
4      /** Secrets blotted out this session, by label (`token Google ya29.`): never a value. */
5      counts: Record<string, number>
6    }
7  }
8}
9
10/** A label of a kind of secret, as shown in place of its value. */
11export type SecretLabel = string
12