/rec przed nagrywaniem: maskuje sekrety, dane osobowe i kwoty na ekranie i zamyka prywatne pliki (/rec on|strict|off|config)

Mod do Claude Code: /rec on przed nagrywaniem ekranu. Maskuje sekrety, dane osobowe i kwoty w tym, co widać w terminalu, i nie pozwala Claude'owi otwierać prywatnych plików, dopóki nagrywasz.
English summary below.
| Komenda | Co robi |
|---|---|
/rec | Pokazuje bieżący tryb i pomoc |
/rec on | Włącza tryb nagrywania |
/rec strict | Jak on, plus maskowanie procentów i wszystkich większych liczb |
/rec off | Wyłącza tryb; pokazuje, ile prób dostępu do prywatnych plików zablokowano |
/rec config | Tworzy / wczytuje plik konfiguracji (patrz niżej) |
Tryb jest wspólny dla wszystkich sesji Claude Code i przetrwa restart, więc wyłącz go po nagraniu.
Przy włączonym trybie (/rec on):
.env (czytane do pamięci, nigdzie nie zapisywane);.env, ~/.ssh, ~/.aws, klucze, credentials.json, pliki i katalogi typu faktury/, payroll.csv, budżet.xlsx. Claude dostaje odmowę, zanim narzędzie ruszy (także przez dowiązania symboliczne i globy typu cat .en*).● REC w stopce przy polu wpisywania i (opcjonalnie) w statusline.Maskowanie dotyczy tylko tego, co widać na ekranie. Oryginał zostaje w historii sesji i trafia do modelu bez zmian.
Wymagania: Claude Code w wersji obsługującej mody (pluginy z hookami w TypeScript), testowane na 2.1.288 i 2.1.295; sprawdzisz przez claude --version.
Najprościej, wpisane w Claude Code (od 2.1.275):
/plugin install recording-mode --marketplace hugo88/claude-code-recording-mode
potwierdź dodanie marketplace (y) i wybierz zakres (Enter = użytkownik). Mod działa od razu. Aktualizacja: claude plugin update recording-mode.
Linux, macOS albo WSL (install.sh to skrypt bash; na samym Windowsie skopiuj ręcznie foldery hooks, types i .claude-plugin).
git clone https://github.com/hugo88/claude-code-recording-mode
cd claude-code-recording-mode
./install.sh # testy, potem kopia do ~/.claude/mods/recording-mode
Aktualizacja: git pull && ./install.sh. Inny folder docelowy: ./install.sh <folder>; bez testów: SKIP_TESTS=1 ./install.sh.
Przy pierwszej instalacji dopisz folder do CLAUDE_CODE_PLUGIN_DIRS w ~/.claude/settings.json (kilka folderów oddzielasz :):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/recording-mode"
}
}
i uruchom Claude Code ponownie. Na próbę bez zmian w ustawieniach: claude --plugin-dir ~/.claude/mods/recording-mode.
Sprawdzenie: wpisz /rec: powinien pokazać tryb off i pomoc. Po /rec on w stopce przy polu wpisywania pojawi się ● REC ON.
Zainstalowany przez /plugin install: claude plugin uninstall recording-mode, potem punkt 2 (tylko mods-data). Z klonu:
CLAUDE_CODE_PLUGIN_DIRS w ~/.claude/settings.json.rm -rf ~/.claude/mods/recording-mode ~/.claude/mods-data/recording-modeMod zapisuje stan w ~/.claude/mods-data/recording-mode/state.json, więc własny skrypt statusline może go pokazać:
case "$(cat ~/.claude/mods-data/recording-mode/state.json 2>/dev/null)" in
*'"mode":"strict"'*) printf '\033[1;31m● REC STRICT\033[0m ' ;;
*'"mode":"on"'*) printf '\033[1;31m● REC ON\033[0m ' ;;
esac
Żeby wskaźnik zmieniał się od razu, ustaw "statusLine": { ..., "refreshInterval": 1 }.
/rec config tworzy ~/.claude/mods-data/recording-mode/config.json z opisem pól w kluczu _opis. Zmiany wczytują się same.
| Pole | Do czego |
|---|---|
keepNames | Twoje imię i nazwisko / marka: widoczne także podczas nagrywania |
names | Osoby zawsze maskowane (z odmianą) |
hide | Dowolne teksty do ukrycia; "/wzorzec/flagi" to wyrażenie regularne |
allow | Teksty, których nigdy nie maskować |
privatePaths | Fragmenty ścieżek, których Claude nie otworzy (dopasowanie w całym poleceniu) |
allowPaths | Ścieżki zwolnione z wbudowanej blokady (np. contracts/ w projekcie Solidity) |
businessTools | Narzędzia / komendy, których wyniki są ukrywane w całości |
closedTools | Narzędzia, których Claude nie może wywołać podczas nagrywania |
noteToClaude | true: notka w każdym prompcie, żeby Claude sam unikał danych wrażliwych |
Edytuj ten plik przy wyłączonym nagrywaniu: jego treść nie jest maskowana w edytorze.
To pomoc przy nagrywaniu, nie zabezpieczenie. Przed publikacją nagrania i tak je obejrzyj.
! (bash wpisany przez użytkownika) nie jest maskowany.grep -r po katalogu, twarde dowiązania czy skrypty ją obejdą. Twarda ochrona to permissions.deny w ustawieniach Claude Code./, \ albo rozszerzenie); katalog wpisany bez ukośnika dopisz do privatePaths./rec on, mogą zostać na ekranie bez maskowania (poza trybem fullscreen terminal ich nie przerysowuje). Włącz tryb przed sesją albo zrób /clear..env patrzy na całe polecenie, więc odmówi też np. git commit -m "fix .env loading".~/.claude/projects/*/memory).| Owner: Jan |), nie po nagłówku kolumny.names.claude plugin validate .
claude plugin test .
tsc -p . # typy generuje Claude Code przy pierwszym załadowaniu moda
Wszystkie dane w testach są fikcyjne.
A Claude Code mod for screen recording. /rec on masks secrets, personal data (with Polish identifiers: PESEL, NIP, IBAN/NRB, name declension) and money amounts in what the terminal shows, and denies Claude access to private files (.env, ~/.ssh, keys, invoices, payroll…) until you run /rec off. Masking is display-only; the session history keeps the original. Messages and docs are in Polish.
Install: /plugin install recording-mode --marketplace hugo88/claude-code-recording-mode in Claude Code, or clone the repo and run ./install.sh (runs the tests, then copies the mod to ~/.claude/mods/recording-mode), add that folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json (env), restart Claude Code. Configure with /rec config.
It is a recording aid, not a security boundary: review your recording before publishing.
MIT
hooks/register.tsx 365 lines1// Recording Mode: /rec przed włączeniem nagrywania ekranu.
2// Gdy jest włączony (we wszystkich sesjach Claude Code na tym komputerze):
3// - klucze, wartości z .env, e-maile, nazwiska, telefony, adresy, PESEL, NIP, IBAN,
4// karty i kwoty są maskowane wszędzie, gdzie transkrypt rysuje tekst,
5// - wyniki narzędzi biznesowych (poczta, czat, zadania, kalendarz, finanse) są ukryte,
6// - Claude nie może otwierać prywatnych plików ani zamkniętych narzędzi,
7// - stopka przy polu wpisywania i statusline pokazują ● REC ON / ● REC STRICT / ○ REC OFF.
8// Maskowanie dotyczy tylko wyświetlania: kontekst modelu i historia sesji zostają nietknięte.
9// Mod nie wysyła niczego poza komputer: używa tylko $.fs, $.env, $.clock i $.ui.
10
11import { atom, read, update } from 'claude-code'
12import type { EngineInterface, Register } from 'claude-code'
13
14import type { RecMode } from '../types'
15import {
16 EMPTY_CONFIG,
17 businessSource,
18 closedTool,
19 deepMask,
20 hideText,
21 makeMasker,
22 privatePathHit,
23 secretValuesFromEnv,
24 toolCallPaths,
25 toolCallTargets,
26} from './privacy'
27import type { Masker, RecConfig } from './privacy'
28
29const modeAtom = atom({ plugin: 'recording-mode', key: 'mode' } as const, 'off' as RecMode)
30const configVersion = atom({ plugin: 'recording-mode', key: 'configVersion' } as const, 0)
31
32const DATA_DIR = '/.claude/mods-data/recording-mode'
33const STATE_FILE = DATA_DIR + '/state.json' // czyta go też ~/.claude/statusline.sh
34const CONFIG_FILE = DATA_DIR + '/config.json'
35
36const REC_RED = '#e5484d'
37const LABEL: Record<RecMode, string> = { on: '● REC ON', strict: '● REC STRICT', off: '○ REC OFF' }
38
39const CONFIG_TEMPLATE = {
40 _opis: [
41 'keepNames: Twoje imię i nazwisko / marka: widoczne także podczas nagrywania.',
42 'names: osoby zawsze maskowane (odmiana: Kowalski → Kowalskiego, Anna → Annie). Nietypową odmianę (Marek → Marka) dopisz osobno.',
43 'hide: dowolne teksty do ukrycia; "/wzorzec/flagi" to wyrażenie regularne.',
44 'allow: wyjątki: teksty, których nigdy nie maskować (np. publiczny adres firmy).',
45 'privatePaths: fragmenty ścieżek, których Claude nie otworzy podczas nagrywania (bez rozróżniania wielkości liter).',
46 'allowPaths: fragmenty ścieżek zwolnione z wbudowanej blokady (np. "contracts/" w projekcie Solidity).',
47 'businessTools: fragmenty nazw narzędzi / komend, których wyniki są ukrywane w całości.',
48 'closedTools: fragmenty nazw narzędzi, których Claude nie może wywołać podczas nagrywania.',
49 'noteToClaude: true dodaje do każdego promptu notkę, by Claude sam unikał danych wrażliwych w odpowiedziach.',
50 'Po zapisaniu pliku zmiany wczytują się same w ciągu kilku sekund (albo /rec config).',
51 ],
52 keepNames: [],
53 names: [],
54 hide: [],
55 allow: [],
56 privatePaths: [],
57 allowPaths: [],
58 businessTools: [],
59 closedTools: [],
60 noteToClaude: false,
61}
62
63function strings(v: unknown): string[] {
64 return Array.isArray(v)
65 ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map((x) => x.trim()).slice(0, 500)
66 : []
67}
68
69function parentDirs(path: string, levels: number): string[] {
70 const parts = String(path || '').split('/')
71 const out: string[] = []
72 for (let i = parts.length; i > 0 && out.length <= levels; i--) out.push(parts.slice(0, i).join('/'))
73 return out.filter(Boolean)
74}
75
76function noteOn(config: RecConfig): string {
77 const keep = config.keepNames.length ? ` (poza: ${config.keepNames.join(', ')})` : ''
78 return (
79 `Recording mode is on: the screen is being recorded for a public video. In replies and tool commands, leave out people's names${keep}, emails, phone numbers, addresses, PESEL/NIP/account numbers, amounts of money and business figures. ` +
80 'Use placeholders like [name] or [amount]. Private files stay closed until recording stops.'
81 )
82}
83const NOTE_OFF = 'Recording mode is off now. The earlier recording-mode note no longer applies.'
84
85// Stan modułu: przeładowanie zaczyna go od nowa; tryb trzyma atom i plik stanu.
86let home = ''
87let cwd = ''
88let mode: RecMode = 'off'
89let config: RecConfig = { ...EMPTY_CONFIG }
90let configRaw = ''
91let envValues: string[] = []
92let mask: Masker = (s) => s // makeMasker sam trzyma cache wyników
93let blocked = 0
94let noteSent = false
95const businessCalls = new Set<string>()
96
97const masked = (s: string): string => mask(s)
98
99function rebuild() {
100 mask =
101 mode === 'off'
102 ? (s) => s
103 : makeMasker({
104 strict: mode === 'strict',
105 values: envValues,
106 names: config.names,
107 keepNames: config.keepNames,
108 hide: config.hide,
109 allow: config.allow,
110 })
111}
112
113async function loadConfig($: EngineInterface): Promise<boolean> {
114 try {
115 const path = home + CONFIG_FILE
116 const raw = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
117 if (raw === configRaw) return false
118 configRaw = raw
119 const j = raw ? JSON.parse(raw) : {}
120 config = {
121 keepNames: strings(j.keepNames),
122 names: strings(j.names),
123 hide: strings(j.hide),
124 allow: strings(j.allow),
125 privatePaths: strings(j.privatePaths),
126 allowPaths: strings(j.allowPaths),
127 businessTools: strings(j.businessTools),
128 closedTools: strings(j.closedTools),
129 noteToClaude: j.noteToClaude === true,
130 }
131 return true
132 } catch {
133 return false // uszkodzony plik: zostaje poprzednia konfiguracja, reguły wbudowane działają
134 }
135}
136
137async function loadEnvValues($: EngineInterface) {
138 const found: string[] = []
139 for (const dir of parentDirs(cwd, 3)) {
140 for (const name of ['.env', '.env.local', '.env.development', '.env.production']) {
141 const p = dir + '/' + name
142 try {
143 if (await $.fs.exists(p)) found.push(...secretValuesFromEnv(await $.fs.read(p)))
144 } catch {
145 // nieczytelny: wzorce i tak łapią typowe formaty kluczy
146 }
147 }
148 }
149 envValues = [...new Set(found)] // tylko w pamięci, nigdzie nie zapisywane
150}
151
152async function readStateFile($: EngineInterface): Promise<RecMode> {
153 try {
154 const path = home + STATE_FILE
155 if (!(await $.fs.exists(path))) return 'off'
156 const j = JSON.parse(await $.fs.read(path))
157 return j.mode === 'on' || j.mode === 'strict' ? j.mode : 'off'
158 } catch {
159 return 'off'
160 }
161}
162
163async function applyMode($: EngineInterface, next: RecMode) {
164 const was = mode
165 mode = next
166 if (mode !== 'off' && was === 'off') await loadEnvValues($)
167 if (mode === 'off') {
168 envValues = []
169 businessCalls.clear()
170 }
171 rebuild()
172 await update($, modeAtom, () => mode) // przerysowuje wiersze i wskaźnik od razu
173}
174
175// Inna sesja mogła zmienić tryb albo ktoś zapisał config.json
176async function sync($: EngineInterface) {
177 const fileMode = await readStateFile($)
178 if (fileMode !== mode) await applyMode($, fileMode)
179 await freshConfig($)
180}
181
182// Hooki, które decydują (blokada, notka), czytają config.json same: nie zależą od timera z sync()
183async function freshConfig($: EngineInterface) {
184 if (await loadConfig($)) {
185 rebuild()
186 await update($, configVersion, (n) => (n ?? 0) + 1)
187 }
188}
189
190async function setMode($: EngineInterface, next: RecMode) {
191 await $.fs.write(home + STATE_FILE, JSON.stringify({ mode: next, since: await $.clock.now() }) + '\n')
192 await loadConfig($)
193 await applyMode($, next)
194}
195
196async function configText($: EngineInterface): Promise<string> {
197 const path = home + CONFIG_FILE
198 let made = false
199 try {
200 if (!(await $.fs.exists(path))) {
201 await $.fs.write(path, JSON.stringify(CONFIG_TEMPLATE, null, 2) + '\n')
202 made = true
203 }
204 } catch {
205 return `Nie udało się zapisać ${path}. Utwórz go ręcznie:\n${JSON.stringify(CONFIG_TEMPLATE, null, 2)}`
206 }
207 configRaw = ''
208 await loadConfig($)
209 rebuild()
210 await update($, configVersion, (n) => (n ?? 0) + 1)
211 return [
212 `${made ? 'Utworzono' : 'Konfiguracja'}: ${path}`,
213 `Wczytano: ${config.keepNames.length} widocznych nazwisk, ${config.names.length} ukrywanych, ${config.hide.length} wartości do ukrycia, ${config.allow.length} wyjątków, ${config.privatePaths.length} prywatnych ścieżek, ${config.businessTools.length} narzędzi biznesowych, ${config.closedTools.length} zamkniętych narzędzi; notka dla Claude’a: ${config.noteToClaude ? 'tak' : 'nie'}.`,
214 'Opis pól jest w kluczu "_opis" w pliku. Edytuj plik przy wyłączonym nagrywaniu: jego treść nie jest maskowana w edytorze.',
215 ].join('\n')
216}
217
218export const register: Register = (on) => {
219 on('session.start', async ($, e, next) => {
220 home = (await $.env.get('HOME')) || ''
221 cwd = await $.session.cwd()
222 await $.command.register({
223 name: 'rec',
224 description: 'Tryb nagrywania: maskuje sekrety, dane osobowe i kwoty na ekranie (/rec on | strict | off | config)',
225 argumentHint: '[on|strict|off|config]',
226 immediate: true,
227 })
228 await loadConfig($)
229 mode = 'off'
230 await applyMode($, await readStateFile($))
231 $.clock.every(2000, () => {
232 sync($).catch(() => {})
233 })
234 return next(e)
235 })
236
237 on('command.run', { command: 'rec' }, async ($, e) => {
238 const arg = String(e.args || '').trim().toLowerCase()
239 if (arg === 'config' || arg === 'konfiguracja') return { text: await configText($) }
240 if (arg === 'off' || arg === 'wyłącz' || arg === 'wylacz') {
241 await setMode($, 'off')
242 $.ui.toast('○ REC OFF: maskowanie wyłączone.' + (blocked ? ` Zablokowano ${blocked} prób dostępu do prywatnych plików.` : ''))
243 blocked = 0
244 return {}
245 }
246 if (arg === 'on' || arg === 'włącz' || arg === 'wlacz' || arg === 'strict') {
247 const next: RecMode = arg === 'strict' ? 'strict' : 'on'
248 await setMode($, next)
249 $.ui.toast(next === 'strict' ? '● REC STRICT: maskowanie rozszerzone (procenty i duże liczby).' : '● REC ON: maskowanie włączone.', { timeoutMs: 3000 })
250 return {}
251 }
252 return {
253 text: [
254 `Stan: ${LABEL[mode]}`,
255 '/rec on: maskowanie sekretów, danych osobowych i kwot; blokada prywatnych plików',
256 '/rec strict: jak on, plus wszystkie procenty i duże liczby',
257 '/rec off: wyłącza',
258 '/rec config: tworzy / pokazuje plik konfiguracji',
259 ].join('\n'),
260 }
261 })
262
263 // Notka dla Claude’a: tylko gdy włączona w konfiguracji (domyślnie nie, żeby nie zmieniać kontekstu)
264 on('prompt.submit', async ($, e, next) => {
265 await freshConfig($)
266 const note = mode !== 'off' && config.noteToClaude ? noteOn(config) : noteSent ? NOTE_OFF : null
267 if (!note) return next(e)
268 noteSent = mode !== 'off' && config.noteToClaude
269 return next({ ...e, context: [...(e.context ?? []), note] })
270 })
271
272 // Prywatne pliki i zamknięte narzędzia: odmowa, zanim narzędzie ruszy
273 on('tool.call', async ($, e, next) => {
274 if (mode === 'off') return next(e)
275 await freshConfig($)
276 const args = e as unknown as Record<string, unknown>
277 let hit: string | null = closedTool(e.tool, config.closedTools) ? `narzędzie ${e.tool}` : null
278 if (!hit) hit = privatePathHit(toolCallTargets(args).join('\n'), config.privatePaths, config.allowPaths)
279 if (!hit) {
280 // dowiązania symboliczne, ../ i ścieżki względne: sprawdzamy, dokąd ścieżka naprawdę prowadzi
281 for (const p of toolCallPaths(args)) {
282 const full = p.startsWith('/') ? p : p.startsWith('~/') ? home + p.slice(1) : cwd + '/' + p
283 const stat = await $.fs.stat(full, { resolve: true }).catch(() => undefined)
284 const real = stat?.realPath
285 if (real && real !== full) {
286 const realHit = privatePathHit(real, config.privatePaths, config.allowPaths)
287 if (realHit) {
288 hit = `${realHit} (przez dowiązanie lub ścieżkę względną)`
289 break
290 }
291 }
292 }
293 }
294 if (hit) {
295 blocked += 1
296 return {
297 deny: `Recording mode is on, so "${hit}" stays closed while the screen is being recorded. Continue without it, or ask the user to run /rec off first.`,
298 }
299 }
300 if (businessSource(e.tool, args, config.businessTools)) businessCalls.add(e.tool_use_id)
301 return next(e)
302 }).catch(($, e, next) =>
303 // bez handlera silnik pomija hook, który padł, i narzędzie rusza: podczas nagrywania odmawiamy
304 mode === 'off' || next.called
305 ? next(e)
306 : { deny: `Recording mode is on and its file check failed, so ${e.tool} did not run. Try again, or ask the user to run /rec off first.` },
307 )
308
309 // Wiersze tekstowe: prompty, odpowiedzi, wyjście komend
310 on('ui.render', { component: ['UserMessage', 'AssistantMessage', 'CommandOutput'] }, async ($, e, next) => {
311 const m = await read($, modeAtom)
312 await read($, configVersion)
313 if (m === 'off' || typeof e.props.text !== 'string') return next(e)
314 return next({ ...e, props: { ...e.props, text: masked(e.props.text) } } as typeof e)
315 })
316
317 // Wiersz narzędzia: argumenty maskowane; wynik narzędzia biznesowego ukryty w całości
318 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
319 const m = await read($, modeAtom)
320 await read($, configVersion)
321 if (m === 'off') return next(e)
322 const business = businessSource(e.props.tool, e.props.input, config.businessTools) || businessCalls.has(e.props.tool_use_id)
323 if (business) businessCalls.add(e.props.tool_use_id)
324 const props = { ...e.props, input: deepMask(e.props.input, masked) }
325 if (e.props.output !== undefined) props.output = deepMask(e.props.output, business ? hideText : masked)
326 return next({ ...e, props })
327 })
328
329 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
330 const m = await read($, modeAtom)
331 await read($, configVersion)
332 if (m === 'off') return next(e)
333 const business = businessSource(e.props.tool, null, config.businessTools) || businessCalls.has(e.props.tool_use_id)
334 return next({ ...e, props: { ...e.props, output: deepMask(e.props.output, business ? hideText : masked) } })
335 })
336
337 // Zwinięta grupa odczytów i wyszukiwań: silnik rysuje ją z własnych danych (przepisane `calls`
338 // nie trafiają na ekran), więc podczas nagrywania rozwijamy ją na wiersze ToolUse, maskowane wyżej
339 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
340 const m = await read($, modeAtom)
341 if (m === 'off' || e.props.isExpanded) return next(e)
342 return next({ ...e, props: { ...e.props, isExpanded: true } })
343 })
344
345 // Pytania Claude’a (AskUserQuestion) też mogą zawierać dane
346 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
347 const m = await read($, modeAtom)
348 if (m === 'off') return next(e)
349 return next({ ...e, props: { ...e.props, questions: deepMask(e.props.questions, masked) as unknown[] } })
350 })
351
352 // Wskaźnik w stopce przy polu wpisywania: widoczny zawsze, także w trakcie pracy Claude’a
353 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
354 const m = await read($, modeAtom)
355 const { Box, Text } = $.ui.resolve(e)
356 const modes = Array.isArray(e.props.modes) ? e.props.modes : []
357 return (
358 <Box flexDirection="row">
359 {m === 'off' ? <Text color="gray">{LABEL.off}</Text> : <Text color={REC_RED} bold>{LABEL[m]}</Text>}
360 {modes.length > 0 ? <Text dimColor>{' · ' + modes.join(' & ')}</Text> : null}
361 </Box>
362 )
363 })
364}
365hooks/privacy.ts 684 lines1// Maskowanie sekretów, danych osobowych i kwot na potrzeby nagrywania ekranu.
2// Czyste funkcje bez wywołań $: testowalne i bez dostępu do sieci.
3// To heurystyka wyświetlania, nie granica bezpieczeństwa: regex przepuści
4// nietypowy zapis, a model i tak widzi oryginał w kontekście.
5
6export const DOTS = '••••••••'
7export const HIDE = '•••'
8export const HIDDEN_OUTPUT = '••• ukryte podczas nagrywania'
9
10export type Mode = 'off' | 'on' | 'strict'
11
12export type RecConfig = {
13 keepNames: string[]
14 names: string[]
15 hide: string[]
16 allow: string[]
17 privatePaths: string[]
18 allowPaths: string[]
19 businessTools: string[]
20 closedTools: string[]
21 noteToClaude: boolean
22}
23
24export const EMPTY_CONFIG: RecConfig = {
25 keepNames: [],
26 names: [],
27 hide: [],
28 allow: [],
29 privatePaths: [],
30 allowPaths: [],
31 businessTools: [],
32 closedTools: [],
33 noteToClaude: false,
34}
35
36// ---------------------------------------------------------------- sekrety
37
38const SECRET_PATTERNS: RegExp[] = [
39 /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|$)/g,
40 /sk-ant-[A-Za-z0-9_-]{16,}/g,
41 /\bsk-(?:proj-|live-|test-|svcacct-|or-v1-)?[A-Za-z0-9_-]{20,}/g,
42 /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}/g,
43 /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
44 /\bglpat-[A-Za-z0-9_-]{20,}/g,
45 /\bnpm_[A-Za-z0-9]{30,}/g,
46 /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
47 /\bAIza[0-9A-Za-z_-]{30,}/g,
48 /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
49 /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
50 /\b(?:hf|r8|pk_live|sk_live|rk_live|pk_test|sk_test|whsec|pat|key|gsk|xai|dop_v1|shpat|shpss)_[A-Za-z0-9]{20,}/g,
51 /\bxai-[A-Za-z0-9]{20,}/g,
52 /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g,
53 /\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/-]{16,}=*/gi,
54]
55
56// hasło w URL-u: scheme://user:HASŁO@host
57const URL_CREDENTIALS = /\b([a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:)([^\s@/]{3,})(@)/gi
58
59// NAZWA=wartość, "nazwa": "wartość", hasło: wartość
60const ASSIGNMENT =
61 /((?<![\p{L}])[\p{L}0-9_.-]*(?:API[_-]?KEY|APIKEY|SECRET|TOKEN|PASSWORD|PASSWD|PASSPHRASE|HAS[ŁL]O|HAS[ŁL]A|PWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|CLIENT[_-]?SECRET|CREDENTIALS?|COOKIE|SESSION[_-]?ID|AUTH(?!ORS?(?![\p{L}])))[\p{L}0-9_.-]*)(["']?\s*[=:]\s*["']?)([^\s"'`,;}{]{4,})/giu
62
63const PIN = /((?<![\p{L}])(?:PIN|CVV2?|CVC2?|kod PIN|kod CVV)[ \t]*[:=][ \t]*)(\d{3,8})(?!\d)/giu
64
65// ---------------------------------------------------------------- osoby
66
67const EMAIL_SRC = '[\\p{L}0-9._%+-]+@[\\p{L}0-9.-]+\\.[A-Za-z]{2,}'
68const EMAIL = new RegExp(`(?<![\\p{L}0-9._%+-])${EMAIL_SRC}`, 'gu')
69
70const NAME_WORD = "\\p{Lu}[\\p{L}'’.-]*"
71const DISPLAY_NAME = new RegExp(
72 `(["']?)(${NAME_WORD}(?:[ \\t]+${NAME_WORD}){0,3})\\1([ \\t]*<[ \\t]*${EMAIL_SRC}[ \\t]*>)`,
73 'gu',
74)
75const PERSON_KEY =
76 /("(?:displayName|display_name|fullName|full_name|firstName|first_name|lastName|last_name|givenName|given_name|familyName|family_name|real_name|realName|senderName|sender_name|authorName|author_name|username|user_name|imie|imię|nazwisko|imie_nazwisko)"\s*:\s*")([^"]{1,80})(")/gu
77const PERSON_LABEL =
78 '(?:From|To|Cc|Bcc|Reply-To|Attendees?|Organizer|Invitees?|Guests?|Assignees?|Sender|Owner|[Aa]uthor|Full name|Contact|Customer|' +
79 'Od|Do|DW|UDW|[Aa]utor|Nadawca|Odbiorca|Adresat|Klient|Kontrahent|Właściciel|Uczestnicy|Uczestnik|Organizator|Zaproszeni|Przypisan[ya]|' +
80 'Imię i nazwisko|Imie i nazwisko|Imię|Nazwisko|Adres(?: zamieszkania| zameldowania| do korespondencji)?|Osoba kontaktowa|Kontakt|Pracownik|Pacjent)'
81const PERSON_LINE = new RegExp(`^([ \\t>*-]*${PERSON_LABEL}[ \\t]*:[ \\t]*)(\\S.*)$`, 'gmu')
82// ta sama etykieta w komórce tabeli markdown: "| Owner: Jan Nowak |"; wartość do końca komórki
83const PERSON_CELL = new RegExp(`(\\|[ \\t]*${PERSON_LABEL}[ \\t]*:[ \\t]*)([^|\\n]*[^|\\s])`, 'gu')
84
85// ---------------------------------------------------------------- dane osobowe
86
87const PHONE_US = /(?<![\w.])(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}(?![\w.])/g
88const PHONE_INTL = /(?<![\w.+])(?:\+|00)\d{1,3}[\s.-]?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}(?![\w.])/g
89// polskie komórki bez prefiksu: 600 100 200, 600-100-200 (pierwsza cyfra 4-8)
90const PHONE_PL = /(?<![\w.,])[4-8]\d{2}([\s-])\d{3}\1\d{3}(?![\w.,])/g
91// stacjonarne: (22) 123 45 67, 22 123-45-67
92const PHONE_PL_LANDLINE = /(?<![\w.,])\(?\d{2}\)?\s\d{3}[\s-]\d{2}[\s-]\d{2}(?![\w.,])/g
93const PHONE_LABEL =
94 /((?<![\p{L}])(?:tel(?:efon)?|tel\.|kom(?:órka|orka)?\.?|nr tel\.?|phone|mobile|cell|fax)[ \t]*[.:]?[ \t]*)(\+?[\d \t().-]{7,20}\d)/giu
95
96const SSN = /(?<![\w-])\d{3}-\d{2}-\d{4}(?![\w-])/g
97const EIN = /(?<![\w-])\d{2}-\d{7}(?![\w-])/g
98const CARD = /(?<![\w-])[2-6]\d{3}(?:[ -]?\d){9,15}(?![\w-])/g
99const IBAN = /(?<![A-Z0-9])[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?(?![A-Z0-9])/g
100// polski NRB bez "PL": 26 cyfr, zwykle 2 + 6×4
101const NRB = /(?<![\d])\d{2}(?: ?\d{4}){6}(?![\d])/g
102const PESEL = /(?<![\d])\d{11}(?![\d])/g
103const NIP = /(?<![\w-])(?:PL ?)?(?:\d{3}-\d{3}-\d{2}-\d{2}|\d{3}-\d{2}-\d{2}-\d{3})(?![\w-])/g
104const NIP_PL_PREFIX = /(?<![\w])PL ?\d{10}(?![\d])/g
105const ID_LABEL =
106 /((?<![\p{L}])(?:PESEL|NIP|REGON|KRS|BDO|nr\.?[ \t]+dowodu(?:[ \t]+osobistego)?|dow[oó]d(?:[ \t]+osobisty)?|seria[ \t]+i[ \t]+n(?:ume)?r|nr\.?[ \t]+paszportu|paszport|passport(?:[ \t]+(?:no\.?|number))?|nr\.?[ \t]+rachunku|numer[ \t]+rachunku|nr\.?[ \t]+konta|numer[ \t]+konta|rachunek(?:[ \t]+bankowy)?|konto(?:[ \t]+bankowe)?|routing|account|acct|a\/c|aba|swift|bic|iban|driver'?s[ \t]+license|prawo[ \t]+jazdy|tax[ \t]+id|vat[ \t]+(?:id|no\.?)|SSN|EIN)(?:[ \t]*(?:number|no\.?|num|nr\.?|#))?[ \t]*[:#№.-]?[ \t]*)([A-Z]{0,3}[ \t-]?\d[\dA-Z \t-]{3,40}[\dA-Z])/giu
107const CARD_ENDING = /((?:ending(?:[ \t]+in)?|last[ \t]+(?:4|four)(?:[ \t]+digits)?|końcówk[aąi]|kończąc[aąej]+[ \t]+się[ \t]+na)[ \t]*[:#]?[ \t]*)(\d{4})(?!\d)/giu
108const BIRTH =
109 /((?<![\p{L}])(?:DOB|D\.O\.B\.|date of birth|birth[ \t]?date|birthday|born(?: on)?|data urodzenia|ur\.|urodzon[ya](?: dnia)?)[ \t]*[:-]?[ \t]*)([\p{L}0-9 ,/.-]{4,20}\d)/giu
110
111const STREET_EN =
112 /\b\d{1,6}[ \t]+(?:[NSEW]\.?[ \t]+)?(?:\p{Lu}[\p{L}'.-]*[ \t]+){1,3}(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Parkway|Pkwy|Highway|Hwy|Circle|Cir|Terrace|Ter|Trail|Trl|Square|Sq)\b\.?(?:,?[ \t]+(?:Apt|Apartment|Suite|Ste|Unit|#)\.?[ \t]*[\w-]+)?/gu
113const STREET_PL =
114 /(?<![\p{L}])(?:[Uu]l\.|[Aa]l\.|[Pp]l\.|[Oo]s\.|[Uu]lica|[Aa]leja|[Aa]leje|[Pp]lac|[Oo]siedle)[ \t]*(?:[\p{Lu}0-9][\p{L}0-9.'-]*[ \t]+){1,4}\d{1,4}[A-Za-z]?(?:[ \t]?\/[ \t]?\d{1,4}[A-Za-z]?)?(?:[ \t]*(?:m\.|lok\.|lokal|m)[ \t]*\d{1,4})?/gu
115const POSTAL_PL = /(?<![\d-])\d{2}-\d{3}(?![\d-])([ \t]+\p{Lu}[\p{L}-]+(?:[ \t]+\p{Lu}[\p{L}-]+)?)/gu
116const PO_BOX = /\bP\.?\s?O\.?\s+Box\s+\d+/gi
117const SKRYTKA = /(?<![\p{L}])skr(?:ytka|\.)[ \t]+poczt(?:owa|\.)[ \t]+(?:nr[ \t]+)?\d+/giu
118const STATE_ZIP =
119 /(,\s*(?:A[KLRZ]|C[AOT]|D[CE]|FL|GA|HI|I[ADLN]|K[SY]|LA|M[ADEINOST]|N[CDEHJMVY]|O[HKR]|PA|RI|S[CD]|T[NX]|UT|V[AT]|W[AIVY]))\s+\d{5}(?:-\d{4})?\b/g
120const IPV4 = /(?<![\w.])((?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3})(?![\w.])/g
121
122// ---------------------------------------------------------------- kwoty
123
124// 1 234 567,89 | 1.234.567,89 | 1,234,567.89 | 1234,5 | 1234.5 (spacje: zwykła, twarda, wąska)
125const NUM = "(?:\\d{1,3}(?:[ \\u00a0\\u202f.,']\\d{3})+(?:[.,]\\d+)?|\\d+(?:[.,]\\d+)?)"
126const MULT =
127 '(?:\\s?(?:tys\\.|mln\\.?|mld\\.?|bln\\.?|tys|tysi(?:ąc|ące|ęcy)|milion(?:y|ów|a)?|miliard(?:y|ów|a)?|million|millions|billion|billions|thousand|bn|mio|k|K|M|B)(?![\\p{L}]))?'
128const CUR_SYM = '(?:US\\$|C\\$|A\\$|[$€£¥₴₽₹₩₺¢₿]|zł|zl|Kč)'
129const CUR_CODE = '(?:PLN|USD|EUR|GBP|CHF|JPY|CNY|CZK|SEK|NOK|DKK|HUF|UAH|CAD|AUD|NZD|RUB|INR|BTC|ETH|USDT|USDC)'
130const CUR_WORD =
131 '(?:zł|zl|złotych|złote|złoty|zlotych|groszy|gr|dolarów|dolary|dolar|dolara|euro|funtów|funty|franków|koron|hrywien|jenów|dollars?|bucks|pounds|euros|cents?)'
132const MONEY_PRE = new RegExp(`(?<![\\p{L}\\d])${CUR_SYM}\\s?-?${NUM}${MULT}|(?<![\\p{L}\\d])${CUR_CODE}\\s?-?${NUM}${MULT}`, 'gu')
133const MONEY_POST = new RegExp(
134 `(?<![\\p{L}\\d.,])-?${NUM}(?:,-|,–)?${MULT}\\s?(?:${CUR_SYM}|${CUR_CODE}|${CUR_WORD})(?![\\p{L}])`,
135 'gu',
136)
137
138// Liczby obok słów biznesowych: "przychód 84 tys.", "marża 40%", "MRR is 84k"
139const FIN_WORD = new RegExp(
140 '(?<![\\p{L}.])(?:' +
141 'revenues?|mrr|arr|gmv|profits?|profit share|margins?|ebitda|income|earnings|payroll|salar(?:y|ies)|wages?|compensation|comp|bonus(?:es)?|equity|stakes?|ownership|valuation|runway|burn(?: rate)?|cash(?:flow)?|balances?|budgets?|spend(?:ing)?|pric(?:e|es|ing)|fees?|invoices?|payouts?|distributions?|dividends?|tax(?:es)?|sales|ltv|cac|aov|sponsor(?:s|ships?)?|cpm|rpm|retainers?|commissions?|royalt(?:y|ies)|refunds?|expenses?|debts?|loans?|funding|investments?|deals?|net|gross|paid|pays?|owed?|costs?|' +
142 'przych[oó]d\\p{L}*|obr[oó]t\\p{L}*|obrot\\p{L}*|zysk\\p{L}*|strat[aęy]?|marż\\p{L}*|marz[ay]|doch[oó]d\\p{L}*|wynagrodze\\p{L}*|pensj\\p{L}*|płac\\p{L}*|wypłat\\p{L}*|budżet\\p{L}*|budzet\\p{L}*|koszt\\p{L}*|cen[aęyi]?|cenni\\p{L}*|faktur\\p{L}*|kwot\\p{L}*|sald[oa]|stan konta|kredyt\\p{L}*|pożyczk\\p{L}*|dług\\p{L}*|zadłużen\\p{L}*|inwestycj\\p{L}*|wycen\\p{L}*|premi\\p{L}*|prowizj\\p{L}*|podat\\p{L}*|vat|netto|brutto|sprzedaż\\p{L}*|sprzedaz\\p{L}*|wydatk\\p{L}*|wydatek|opłat\\p{L}*|rabat\\p{L}*|stawk\\p{L}*|dywidend\\p{L}*|udział\\p{L}*|kapitał\\p{L}*|oszczędnoś\\p{L}*|należnoś\\p{L}*|zobowiąza\\p{L}*' +
143 ')(?![\\p{L}])',
144 'giu',
145)
146const FIGURE = new RegExp(
147 `(?<![\\p{L}\\d.,])${NUM}(?:\\s?(?:%|‰|proc\\.|procent\\p{L}*|percent(?![\\p{L}])|x(?![\\p{L}])))?${MULT}`,
148 'gu',
149)
150// tryb strict: procenty i duże liczby wszędzie
151const PERCENT = new RegExp(
152 `(?<![\\p{L}\\d.,])-?\\d+(?:[.,]\\d+)?\\s?(?:%|‰|proc\\.|procent\\p{L}*|percent(?![\\p{L}])|p\\.p\\.|pp(?![\\p{L}]))`,
153 'gu',
154)
155const BIG_FIGURE = new RegExp(
156 "(?<![\\p{L}\\d.,/:#_-])(?:" +
157 "\\d{1,3}(?:[ \\u00a0\\u202f.,']\\d{3})+(?:[.,]\\d+)?" + // pogrupowane
158 '|\\d{4,}[.,]\\d+' + // 1234,56
159 '|\\d{5,}' + // 12345
160 `|\\d+(?:[.,]\\d+)?(?:\\s?(?:tys\\.|mln\\.?|mld\\.?|tys|tysi(?:ąc|ące|ęcy)|milion(?:y|ów|a)?|miliard(?:y|ów|a)?|million|billion|thousand|bn|k|K|M|B)(?![\\p{L}]))` +
161 ')(?![\\p{L}\\d_-])',
162 'gu',
163)
164
165// ---------------------------------------------------------------- imiona i nazwiska
166
167// Pojedyncze imiona będące zwykłymi słowami: nie są uczone z adresów e-mail.
168const COMMON_WORDS = new Set(
169 'Will Mark Grant Bill Rich Art Hope Faith Joy May June April August Summer Rose Page Chase Hunter Max Ray Dawn Sky Brook Lane Dean Gene Guy Frank Sterling Major Price Young King Long Little Wood Hill Stone Field Ford Hall Bell Rice Banks Case Cook Fox Gray Green Brown White Black Day Lee West North South Love Church Park Street Bishop Mason Miller Baker Carter Cole Wells Hart Moon Star Van Von De Del La Le Da Di St Mr Mrs Ms Dr Jr Sr Claude Code Team Support Admin Info Hello The And Ai Slack Gmail Google Calendar Meeting Sync Notes Update Review Weekly Daily Monday Tuesday Wednesday Thursday Friday Saturday Sunday Pan Pani Biuro Kontakt Zespół Dział Lato Wiosna Zima Jesień Róża Wiktoria Maj Lipiec Sierpień Kwiecień Marzec Luty Styczeń'.split(
170 ' ',
171 ),
172)
173const ROLE_LOCAL_PARTS =
174 /^(?:info|hello|hi|team|support|help|admin|noreply|no-reply|donotreply|contact|sales|billing|accounts?|notifications?|news|newsletter|marketing|office|mail|security|privacy|legal|press|jobs|careers|hr|ops|dev|bot|alerts?|updates?|calendar|invites?|biuro|kontakt|sekretariat|faktury|ksiegowosc|kadry|rekrutacja|sklep|zamowienia|pomoc|obsluga)$/i
175
176// Polska fleksja: końcówki doklejane do tematu (Kowalsk-iego, Ann-ie, Nowak-iem).
177const PL_SUFFIX = '(?:iego|iemu|owie|owi|owej|ową|owa|ami|ach|iej|iem|imi|ich|em|om|ów|ie|im|a|ą|ę|e|i|y|o|u)?'
178const FOLD: Record<string, string> = { ą: 'a', ć: 'c', ę: 'e', ł: 'l', ń: 'n', ó: 'o', ś: 's', ź: 'z', ż: 'z', Ą: 'A', Ć: 'C', Ę: 'E', Ł: 'L', Ń: 'N', Ó: 'O', Ś: 'S', Ź: 'Z', Ż: 'Z' }
179
180export function foldPl(s: string): string {
181 return s.replace(/[ąćęłńóśźżĄĆĘŁŃÓŚŹŻ]/g, (c) => FOLD[c] ?? c)
182}
183
184function escapeRegExp(s: string): string {
185 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
186}
187
188// Wzorzec niewrażliwy na polskie znaki: Wisniewska z e-maila łapie też Wiśniewską
189const PL_CLASS: Record<string, string> = { a: 'aą', c: 'cć', e: 'eę', l: 'lł', n: 'nń', o: 'oó', s: 'sś', z: 'zźż' }
190function plPattern(s: string): string {
191 let out = ''
192 for (const ch of foldPl(s)) {
193 const lower = ch.toLowerCase()
194 const cls = PL_CLASS[lower]
195 out += cls ? '[' + (ch === lower ? cls : cls.toUpperCase()) + ']' : escapeRegExp(ch)
196 }
197 return out
198}
199
200// Temat nazwiska/imienia, do którego dokleja się końcówki: Kowalski -> Kowalsk, Anna -> Ann
201function stem(word: string): string {
202 if (word.length >= 5 && /(?:sk|ck|dzk)[iaą]$/i.test(word)) return word.slice(0, -1)
203 if (word.length >= 4 && /[aeiouyąęó]$/i.test(word)) return word.slice(0, -1)
204 return word
205}
206
207type NameForm = { text: string; inflect: boolean }
208
209function keepSet(keepNames: string[]): Set<string> {
210 const keep = new Set<string>()
211 for (const n of keepNames) {
212 const clean = String(n || '').replace(/\s+/g, ' ').trim()
213 if (!clean) continue
214 for (const v of [clean, foldPl(clean)]) {
215 keep.add(v)
216 keep.add(v.toLowerCase())
217 keep.add(v.toLowerCase().replace(/ /g, '-'))
218 for (const part of v.split(/[ -]/)) if (part) keep.add(part)
219 }
220 }
221 return keep
222}
223
224// explicit: imię z konfiguracji (maskowane zawsze); inaczej wyuczone z e-maila
225function nameForms(full: string, keep: Set<string>, explicit: boolean): NameForm[] {
226 const clean = String(full || '').replace(/\s+/g, ' ').trim()
227 if (!clean || clean.length > 60 || keep.has(clean)) return []
228 const forms: NameForm[] = []
229 const parts = clean.split(/[ -]/).filter((p) => /^\p{Lu}[\p{L}'’.]*$/u.test(p) && p.length >= 2)
230 if (parts.length >= 2) {
231 for (const v of [clean]) {
232 forms.push({ text: v.toLowerCase(), inflect: false })
233 forms.push({ text: v.toLowerCase().replace(/ /g, '-'), inflect: false })
234 forms.push({ text: v.toLowerCase().replace(/ /g, '_'), inflect: false })
235 forms.push({ text: v.toLowerCase().replace(/ /g, '.'), inflect: false })
236 }
237 }
238 for (const p of parts) {
239 if (keep.has(p)) continue
240 if (!explicit && COMMON_WORDS.has(p)) continue
241 for (const v of [p]) {
242 forms.push({ text: v, inflect: true })
243 forms.push({ text: v.toUpperCase(), inflect: false })
244 }
245 }
246 return forms
247}
248
249function namesFromEmail(email: string): string | null {
250 const local = email.split('@')[0] ?? ''
251 if (ROLE_LOCAL_PARTS.test(local)) return null
252 const parts = local.split(/[._+-]/).filter((p) => /^\p{L}{2,}$/u.test(p))
253 if (parts.length < 2 || parts.length > 4) return null
254 return parts.map((p) => p.charAt(0).toUpperCase() + p.slice(1).toLowerCase()).join(' ')
255}
256
257// ---------------------------------------------------------------- walidatory
258
259function luhn(digits: string): boolean {
260 let sum = 0
261 for (let i = 0; i < digits.length; i++) {
262 let d = Number(digits[digits.length - 1 - i])
263 if (i % 2 === 1) {
264 d *= 2
265 if (d > 9) d -= 9
266 }
267 sum += d
268 }
269 return sum % 10 === 0
270}
271
272export function validPesel(s: string): boolean {
273 if (!/^\d{11}$/.test(s)) return false
274 const w = [1, 3, 7, 9, 1, 3, 7, 9, 1, 3]
275 const d = s.split('').map(Number) as [number, number, number, number, number, number, number, number, number, number, number]
276 const sum = w.reduce((acc, x, i) => acc + x * (d[i] ?? 0), 0)
277 if ((10 - (sum % 10)) % 10 !== d[10]) return false
278 const month = d[2] * 10 + d[3]
279 const day = d[4] * 10 + d[5]
280 return month % 20 >= 1 && month % 20 <= 12 && day >= 1 && day <= 31
281}
282
283export function validNip(s: string): boolean {
284 const d = s.replace(/\D/g, '')
285 if (d.length !== 10) return false
286 const w = [6, 5, 7, 2, 3, 4, 5, 6, 7]
287 const sum = w.reduce((acc, x, i) => acc + x * Number(d[i]), 0) % 11
288 return sum !== 10 && sum === Number(d[9])
289}
290
291export function validIban(s: string): boolean {
292 const c = s.replace(/\s/g, '').toUpperCase()
293 if (!/^[A-Z]{2}\d{2}[A-Z0-9]{8,30}$/.test(c)) return false
294 const moved = c.slice(4) + c.slice(0, 4)
295 let rem = 0
296 for (const ch of moved) {
297 const v = /\d/.test(ch) ? ch : String(ch.charCodeAt(0) - 55)
298 for (const digit of v) rem = (rem * 10 + Number(digit)) % 97
299 }
300 return rem === 1
301}
302
303function publicIp(ip: string): boolean {
304 const [a = 0, b = 0] = ip.split('.').map(Number)
305 if (a === 10 || a === 127 || a === 0 || ip === '255.255.255.255') return false
306 if (a === 192 && b === 168) return false
307 if (a === 172 && b >= 16 && b <= 31) return false
308 if (a === 169 && b === 254) return false
309 return true
310}
311
312// Liczby w zdaniu ze słowem biznesowym ("marża 40%", "przychód: 1 234 567")
313function maskFigures(text: string): string {
314 const ranges: Array<[number, number]> = []
315 for (const m of text.matchAll(FIN_WORD)) {
316 const s = m.index ?? 0
317 const e = s + m[0].length
318 let a = Math.max(0, s - 30)
319 let b = Math.min(text.length, e + 45)
320 const before = text.slice(a, s)
321 const stop = Math.max(before.lastIndexOf('\n'), before.lastIndexOf(';'), before.search(/[.!?]\s[^.!?]*$/))
322 if (stop >= 0) a += stop + 1
323 const after = text.slice(e, b)
324 const end = after.search(/\n|;|[.!?](?:\s|$)/)
325 if (end >= 0) b = e + end
326 ranges.push([a, b])
327 }
328 if (!ranges.length) return text
329 return text.replace(FIGURE, (fig: string, offset: number) => {
330 if (/^(?:19|20)\d\d$/.test(fig)) return fig // rok
331 return ranges.some(([a, b]) => offset >= a && offset < b) ? HIDE : fig
332 })
333}
334
335// "/wzorzec/flagi" w konfiguracji to regex, wszystko inne to dosłowny tekst (bez rozróżniania wielkości liter)
336function userPattern(item: string): RegExp | null {
337 const m = item.match(/^\/(.+)\/([a-z]*)$/)
338 try {
339 if (m) return new RegExp(m[1] ?? '', [...new Set(((m[2] ?? '') + 'g').split(''))].join(''))
340 return new RegExp(escapeRegExp(item), 'giu')
341 } catch {
342 return null
343 }
344}
345
346// ---------------------------------------------------------------- maskowanie
347
348export type MaskerOptions = {
349 strict?: boolean
350 values?: string[] // wartości z .env: tylko w pamięci
351 names?: string[]
352 keepNames?: string[]
353 hide?: string[]
354 allow?: string[]
355}
356
357export type Masker = (text: string) => string
358
359export function makeMasker(opts: MaskerOptions = {}): Masker {
360 const strict = !!opts.strict
361 const KEEP = keepSet(opts.keepNames ?? [])
362 const values = (opts.values ?? []).filter((v) => v.length >= 6).sort((a, b) => b.length - a.length)
363 const valueRe = values.length ? new RegExp(values.map(escapeRegExp).join('|'), 'g') : null
364 const hideRes = (opts.hide ?? []).map(userPattern).filter((r): r is RegExp => r !== null)
365 const allow = (opts.allow ?? []).filter((a) => a.trim()).sort((a, b) => b.length - a.length)
366 const allowRe = allow.length ? new RegExp(allow.map(escapeRegExp).join('|'), 'giu') : null
367
368 const plain = new Set<string>()
369 const inflected = new Set<string>()
370 let nameRe: RegExp | null = null
371 let dirty = false
372 // wyniki maskowania; nowe nazwisko unieważnia wszystkie, bo tekst sprzed nauki mógł je zawierać
373 const cache = new Map<string, string>()
374 const learn = (full: string, explicit = false) => {
375 if (plain.size + inflected.size > 4000) return
376 for (const f of nameForms(full, KEEP, explicit)) {
377 const set = f.inflect ? inflected : plain
378 if (!set.has(f.text)) {
379 set.add(f.text)
380 dirty = true
381 cache.clear()
382 }
383 }
384 }
385 for (const n of opts.names ?? []) learn(n, true)
386 const nameRegex = () => {
387 if (dirty) {
388 const alts: string[] = []
389 for (const t of [...plain].sort((a, b) => b.length - a.length)) alts.push(plPattern(t))
390 for (const t of [...inflected].sort((a, b) => b.length - a.length)) {
391 const s = stem(t)
392 alts.push(s.length >= 3 ? plPattern(s) + PL_SUFFIX : plPattern(t) + PL_SUFFIX)
393 if (s !== t) alts.push(plPattern(t))
394 }
395 nameRe = alts.length
396 ? new RegExp(`(?<![\\p{L}\\p{N}_])(?:${alts.join('|')})(?:['’]s)?(?![\\p{L}\\p{N}_])`, 'gu')
397 : null
398 dirty = false
399 }
400 return nameRe
401 }
402
403 return function mask(text: string): string {
404 if (typeof text !== 'string' || text.length === 0) return text
405 const hit = cache.get(text)
406 if (hit !== undefined) return hit
407 const out = maskOnce(text)
408 if (cache.size > 3000) cache.clear()
409 cache.set(text, out)
410 return out
411 }
412
413 function maskOnce(text: string): string {
414 let out = text
415
416 // wyjątki: zamieniane na znaczniki z prywatnego obszaru Unicode, przywracane na końcu
417 const kept: string[] = []
418 if (allowRe) {
419 out = out.replace(allowRe, (m) => {
420 kept.push(m)
421 return '\uE000' + String.fromCharCode(0xe100 + kept.length - 1) + '\uE001'
422 })
423 }
424
425 if (valueRe) out = out.replace(valueRe, DOTS)
426 for (const re of hideRes) out = out.replace(re, HIDE)
427 for (const re of SECRET_PATTERNS) out = out.replace(re, DOTS)
428 out = out.replace(URL_CREDENTIALS, (_m, head: string, _p: string, at: string) => head + DOTS + at)
429 out = out.replace(ASSIGNMENT, (_m, name: string, sep: string) => name + sep + DOTS)
430 out = out.replace(PIN, (_m, head: string) => head + '••••')
431
432 // osoby: uczone z e-maili i pól kontaktowych, zanim e-maile znikną
433 for (const m of out.matchAll(EMAIL)) {
434 const n = namesFromEmail(m[0])
435 if (n) learn(n)
436 }
437 out = out.replace(DISPLAY_NAME, (_m, q: string, name: string, addr: string) => {
438 learn(name)
439 return KEEP.has(name) ? q + name + q + addr : HIDE + addr
440 })
441 out = out.replace(PERSON_KEY, (_m, head: string, value: string, tail: string) => {
442 learn(value)
443 return head + (KEEP.has(value) ? value : HIDE) + tail
444 })
445 // "Do: sprawdzić backup" to nie adresat: po "Do:" maskujemy tylko wartość od wielkiej litery
446 const notPerson = (label: string, value: string) => /(?:^|[\s|>*-])Do[ \t]*:/.test(label) && !/^["'\p{Lu}]/u.test(value)
447 out = out.replace(PERSON_LINE, (line: string, label: string, value: string) =>
448 KEEP.has(value.trim()) || notPerson(label, value) ? line : label + HIDE,
449 )
450 out = out.replace(PERSON_CELL, (cell: string, label: string, value: string) =>
451 KEEP.has(value.trim()) || notPerson(label, value) ? cell : label + HIDE,
452 )
453 out = out.replace(EMAIL, '•••@•••')
454 const re = nameRegex()
455 if (re) out = out.replace(re, HIDE)
456
457 // identyfikatory i numery kont: przed kwotami i telefonami, bo to też ciągi cyfr
458 out = out.replace(ID_LABEL, (m: string, head: string, value: string) =>
459 (value.match(/\d/g) ?? []).length >= 5 ? head + HIDE : m,
460 )
461 out = out.replace(IBAN, (m) => (validIban(m) || /^PL\d{2}(?: ?\d{4}){6}$/.test(m) ? HIDE : m))
462 out = out.replace(NRB, (m) => (validIban('PL' + m) ? HIDE : m))
463 out = out.replace(CARD, (m) => {
464 const digits = m.replace(/\D/g, '')
465 return digits.length >= 13 && digits.length <= 19 && luhn(digits) ? '•••• •••• •••• ••••' : m
466 })
467 out = out.replace(PESEL, (m) => (validPesel(m) ? '•••••••••••' : m))
468 out = out.replace(NIP, (m) => (validNip(m) ? '•••-•••-••-••' : m))
469 out = out.replace(NIP_PL_PREFIX, (m) => (validNip(m) ? 'PL••••••••••' : m))
470 out = out.replace(SSN, '•••-••-••••')
471 out = out.replace(EIN, '••-•••••••')
472 out = out.replace(CARD_ENDING, (_m, head: string) => head + '••••')
473 out = out.replace(BIRTH, (_m, head: string) => head + HIDE)
474
475 // adresy
476 out = out.replace(STREET_PL, HIDE)
477 out = out.replace(STREET_EN, HIDE)
478 out = out.replace(POSTAL_PL, '••-••• •••')
479 out = out.replace(PO_BOX, HIDE)
480 out = out.replace(SKRYTKA, HIDE)
481 out = out.replace(STATE_ZIP, (_m, head: string) => head + ' •••••')
482
483 // kwoty: przed telefonami, żeby "600 000 000 zł" było kwotą
484 out = out.replace(MONEY_PRE, HIDE)
485 out = out.replace(MONEY_POST, HIDE)
486
487 // telefony
488 out = out.replace(PHONE_LABEL, (_m, head: string) => head + '••• ••• •••')
489 out = out.replace(PHONE_INTL, '+•• ••• ••• •••')
490 out = out.replace(PHONE_US, '•••-•••-••••')
491 out = out.replace(PHONE_PL, '••• ••• •••')
492 out = out.replace(PHONE_PL_LANDLINE, '(••) ••• •• ••')
493 out = out.replace(IPV4, (ip) => (publicIp(ip) ? '•••.•••.•••.•••' : ip))
494
495 // liczby biznesowe
496 if (strict) {
497 out = out.replace(PERCENT, HIDE)
498 out = out.replace(BIG_FIGURE, (m) => (/^(?:19|20)\d\d$/.test(m) ? m : HIDE))
499 }
500 out = maskFigures(out)
501
502 if (kept.length) out = out.replace(/\uE000([\uE100-\uEFFF])\uE001/g, (_m, c: string) => kept[c.charCodeAt(0) - 0xe100] ?? '')
503 return out
504 }
505}
506
507// Wartości z plików .env: dość długie, by być sekretem, i nie zwykłe słowa czy liczby.
508export function secretValuesFromEnv(text: string): string[] {
509 const values: string[] = []
510 for (const line of String(text || '').split(/\r?\n/)) {
511 const m = line.match(/^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.*)\s*$/)
512 if (!m) continue
513 let v = (m[1] ?? '').trim()
514 if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
515 if (v.length < 8) continue
516 if (/^(true|false|null|none|yes|no)$/i.test(v)) continue
517 if (/^\d+(\.\d+)?$/.test(v)) continue
518 if (/^https?:\/\/[^@]*$/i.test(v)) continue // adres bez danych logowania
519 if (/your[-_ ]?(key|token|secret)|here$|^<.*>$|^xxx|^changeme$/i.test(v)) continue
520 values.push(v)
521 }
522 return [...new Set(values)].sort((a, b) => b.length - a.length)
523}
524
525// Maskuje każdy napis w zwykłych danych, zachowując kształt.
526export function deepMask(value: unknown, mask: Masker, depth = 0): unknown {
527 if (depth > 12) return value
528 if (typeof value === 'string') return mask(value)
529 if (Array.isArray(value)) return value.map((v) => deepMask(v, mask, depth + 1))
530 if (value && typeof value === 'object') {
531 const proto = Object.getPrototypeOf(value)
532 if (proto !== Object.prototype && proto !== null) return value
533 const out: Record<string, unknown> = {}
534 for (const [k, v] of Object.entries(value)) out[k] = deepMask(v, mask, depth + 1)
535 return out
536 }
537 return value
538}
539
540// Wynik narzędzia biznesowego: czytelny tekst znika, krótkie znaczniki (type: 'text') zostają.
541export function hideText(s: string): string {
542 if (typeof s !== 'string' || s.length === 0) return s
543 return s.length > 24 || /\s/.test(s) ? HIDDEN_OUTPUT : s
544}
545
546// ---------------------------------------------------------------- prywatne pliki
547
548// Wbudowane prywatne ścieżki. Własne foldery i pliki: "privatePaths" w config.json.
549const PRIVATE_PATHS: RegExp[] = [
550 /(^|[\\/\s"'`=])\.env(?!\.(?:example|sample|template|dist|defaults)\b)(\.[A-Za-z0-9_-]+)?(?=$|[\s"'`;|&)<>])/i,
551 /(^|[\\/\s"'`=])\.envrc(?=$|[\s"'`;|&)<>])/i,
552 /\.credentials\.json/i,
553 /\.git-credentials/i,
554 /[\\/]\.claude\.json\b/i,
555 /\.claude[\\/]projects[\\/][^\\/\s"'`]+[\\/]memory/i,
556 /(^|[\\/\s"'`=~])\.(?:ssh|aws|gnupg|password-store|kube|docker)(?=$|[\\/\s"'`;|&)])/i,
557 /\.config[\\/](?:gh|rclone|op|gcloud|doctl)(?=$|[\\/])/i,
558 /\bid_(?:rsa|ed25519|ecdsa|dsa)\b/i,
559 /(^|[\\/\s"'`=])\.(?:netrc|npmrc|pypirc|pgpass|my\.cnf)\b/i,
560 /\.(?:pem|p12|pfx|key|kdbx|keystore|jks|asc|gpg)(?=$|[\s"'`;|&)<>])/i,
561 /(^|[\\/\s"'`=_-])secrets?\.(?:json|ya?ml|toml|env|txt)\b/i,
562]
563
564// Słowa biznesowe to też zwykłe słowa ("echo budżet"), więc liczą się tylko w tokenach,
565// które wyglądają na ścieżkę: zawierają / albo \ albo kończą się rozszerzeniem.
566const BUSINESS_PATHS: RegExp[] = [
567 /taxes?[-_]?20\d\d/i,
568 /(?:^|[\\/_-])(?:payroll|invoices?|contracts?|financials?|finances?|budgets?|forecasts?|cap[-_ ]?table|term[-_ ]?sheets?|offer[-_ ]?letters?|business[-_ ]?plans?|faktur[ayi]?|umowy|umowa|wynagrodzeni[ae]|płace|place|lista[-_ ]?płac|budżet|budzet|biznesplan|zeznani[ae]|pit[-_ ]?\d{1,2}|kadry|ksiegowosc|księgowość)(?=$|[\\/._-])/i,
569]
570
571const looksLikePath = (tok: string) => /[\\/]/.test(tok) || /\.[\p{L}\d]{1,5}$/u.test(tok)
572
573function businessPathHit(text: string): string | null {
574 for (const tok of text.split(/[\s"'`;|&()<>=,]+/)) {
575 if (!tok || !looksLikePath(tok)) continue
576 for (const re of BUSINESS_PATHS) {
577 const m = tok.match(re)
578 if (m) return m[0].replace(/^[\\/_-]/, '')
579 }
580 }
581 return null
582}
583
584// Nazwy, do których rozwija się glob w komendzie (cat .en*, cat ~/.ss?/id_*)
585const CANONICAL_PRIVATE = ['.env', '.env.local', '.env.production', '.envrc', 'id_rsa', 'id_ed25519', '.netrc', '.npmrc', '.pypirc', '.pgpass', 'credentials.json', '.credentials.json', '.git-credentials', '.ssh', '.aws', '.gnupg', 'secrets.json', 'secret.yaml']
586
587function globToRegExp(glob: string): RegExp | null {
588 let src = ''
589 for (const ch of glob) {
590 if (ch === '*') src += '[^/]*'
591 else if (ch === '?') src += '[^/]'
592 else src += escapeRegExp(ch)
593 }
594 try {
595 return new RegExp('^' + src + '$', 'i')
596 } catch {
597 return null
598 }
599}
600
601function globHit(text: string): string | null {
602 for (const raw of text.split(/[\s"'`;|&()<>=]+/)) {
603 if (!/[*?[]/.test(raw)) continue
604 for (const seg of raw.split('/')) {
605 if (!/[*?[]/.test(seg) || seg.replace(/[*?[\]]/g, '').length < 2) continue
606 const re = globToRegExp(seg.replace(/\[[^\]]*\]/g, '?'))
607 const hit = re && CANONICAL_PRIVATE.find((n) => re.test(n))
608 if (hit) return `${raw} (→ ${hit})`
609 }
610 }
611 return null
612}
613
614export function privatePathHit(text: string, extra: string[] = [], allowPaths: string[] = []): string | null {
615 let s = String(text || '')
616 if (!s) return null
617 for (const a of allowPaths) if (a.trim()) s = s.split(a.trim()).join(' ')
618 for (const re of PRIVATE_PATHS) {
619 const m = s.match(re)
620 if (m) return m[0].trim().replace(/^["'`\\/_=-]/, '')
621 }
622 const biz = businessPathHit(s)
623 if (biz) return biz
624 const flat = s.replace(/\\/g, '/').toLowerCase()
625 for (const item of extra) {
626 const needle = String(item || '').replace(/\\/g, '/').toLowerCase().trim()
627 if (needle && flat.includes(needle)) return String(item)
628 }
629 return globHit(s)
630}
631
632// Narzędzia, których argumenty to treść albo polecenia dla innych agentów, a nie ścieżki.
633const NO_PATH_TOOLS = new Set(['Agent', 'Task', 'TodoWrite', 'AskUserQuestion', 'SendMessage', 'ExitPlanMode', 'EnterPlanMode', 'Skill', 'ToolSearch', 'WebSearch'])
634const PATH_KEYS = ['file_path', 'path', 'notebook_path', 'command', 'cwd', 'url', 'glob']
635
636// Napisy z argumentów wywołania, które mogą wskazywać plik albo nieść polecenie.
637// Wbudowane narzędzia: tylko pola ze ścieżkami; MCP: wszystkie napisy (best effort).
638export function toolCallTargets(e: Record<string, unknown>): string[] {
639 const tool = String(e.tool ?? '')
640 if (NO_PATH_TOOLS.has(tool)) return []
641 const out: string[] = []
642 if (tool.startsWith('mcp__')) {
643 const walk = (v: unknown, depth: number) => {
644 if (depth > 6) return
645 if (typeof v === 'string') out.push(v)
646 else if (Array.isArray(v)) v.forEach((x) => walk(x, depth + 1))
647 else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) if (k !== 'tool' && k !== 'tool_use_id' && k !== 'agentId') walk(x, depth + 1)
648 }
649 walk(e, 0)
650 return out
651 }
652 for (const k of PATH_KEYS) if (typeof e[k] === 'string') out.push(e[k] as string)
653 if (tool === 'Glob' && typeof e.pattern === 'string') out.push(e.pattern)
654 return out
655}
656
657// Pola, które warto rozwiązać przez realpath (dowiązania symboliczne, ../, ścieżki względne)
658export function toolCallPaths(e: Record<string, unknown>): string[] {
659 const out: string[] = []
660 for (const k of ['file_path', 'path', 'notebook_path']) if (typeof e[k] === 'string' && e[k]) out.push(e[k] as string)
661 return out
662}
663
664// ---------------------------------------------------------------- narzędzia biznesowe
665
666const BUSINESS_TOOL =
667 /(?:^|__|_)(?:clickup|slack|gmail|outlook|notion|asana|linear|jira|clay|qbo|quickbooks|xero|fireflies|hubspot|salesforce|pipedrive|stripe|calendar|gcal|google_drive|drive|sheets|fakturownia|ifirma|wfirma|infakt|ksef)|search_threads|get_thread|get_message|list_drafts|get_draft|read_file_content|download_file_content|search_files|list_recent_files|get_file_metadata|profit_loss|cash_flow|balance_sheet|payroll|session_transcripts|export_transcript/i
668const BUSINESS_COMMAND = /\bgws(?:\.cmd|\.exe)?\b|fireflies|quickbooks/i
669const FINANCE_TOOL = /__(?:qbo_|quickbooks|profit_loss|cash_flow|balance_sheet|benchmarking_quickbooks|money_onboarding|company_info)/i
670
671export function businessSource(tool: unknown, input: unknown, extra: string[] = []): boolean {
672 const name = String(tool || '')
673 if (BUSINESS_TOOL.test(name)) return true
674 const command = input && typeof input === 'object' && typeof (input as Record<string, unknown>).command === 'string' ? ((input as Record<string, unknown>).command as string) : ''
675 const hay = (name + ' ' + command).toLowerCase()
676 if (extra.some((x) => x && hay.includes(String(x).toLowerCase()))) return true
677 return command ? BUSINESS_COMMAND.test(command) : false
678}
679
680export function closedTool(tool: unknown, extra: string[] = []): boolean {
681 const name = String(tool || '')
682 return FINANCE_TOOL.test(name) || extra.some((x) => x && name.toLowerCase().includes(String(x).toLowerCase()))
683}
684types/index.d.ts 8 lines1export type RecMode = 'off' | 'on' | 'strict'
2
3declare module 'claude-code' {
4 interface PluginState {
5 'recording-mode': { mode: RecMode; configVersion: number }
6 }
7}
8