SLOPSHOPPER

recording-mode

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

newspinnerrowsguardcommandtoast
v0.1.1MITupdated 2026-10-09hugo88/claude-code-recording-mode
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · recording-mode
› 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 › /rec ⎿ recording-mode: Stan: ○ REC OFF ⎿ recording-mode: /rec on: maskowanie sekretów, danych osobowych i kwot; blokada prywatnych plików ⎿ recording-mode: /rec strict: jak on, plus wszystkie procenty i duże liczby ⎿ recording-mode: /rec off: wyłącza ⎿ recording-mode: /rec config: tworzy / pokazuje plik konfiguracji ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ○ REC OFF · auto-accept edits
README

recording-mode

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.

Użycie

KomendaCo robi
/recPokazuje bieżący tryb i pomoc
/rec onWłącza tryb nagrywania
/rec strictJak on, plus maskowanie procentów i wszystkich większych liczb
/rec offWyłącza tryb; pokazuje, ile prób dostępu do prywatnych plików zablokowano
/rec configTworzy / 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.

Co robi

Przy włączonym trybie (/rec on):

  • Maskuje na ekranie wiersze narzędzi, wyniki i odpowiedzi Claude'a:
  • klucze API, tokeny, hasła, wartości z .env (czytane do pamięci, nigdzie nie zapisywane);
  • e-maile, telefony, PESEL, NIP, IBAN / NRB (z weryfikacją sum kontrolnych, więc zwykłe liczby zostają);
  • nazwiska z odmianą (Kowalski → Kowalskiego), także wyciągnięte z adresów e-mail;
  • kwoty i liczby przy słowach biznesowych (przychód, pensja, faktura, budżet…).
  • Zamyka prywatne pliki: .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*).
  • Pokazuje wskaźnik ● REC w stopce przy polu wpisywania i (opcjonalnie) w statusline.
  • Rozwija zwinięte grupy narzędzi ("Read 3 files…") na osobne wiersze, bo tylko te da się zamaskować; transkrypt jest przez to dłuższy.
  • Opcjonalnie dodaje do każdego promptu notkę, żeby Claude sam unikał danych wrażliwych w odpowiedziach.

Maskowanie dotyczy tylko tego, co widać na ekranie. Oryginał zostaje w historii sesji i trafia do modelu bez zmian.

Instalacja

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.

Instalacja z klonu repozytorium

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.

Odinstalowanie

Zainstalowany przez /plugin install: claude plugin uninstall recording-mode, potem punkt 2 (tylko mods-data). Z klonu:

  1. Usuń folder moda z CLAUDE_CODE_PLUGIN_DIRS w ~/.claude/settings.json.
  2. rm -rf ~/.claude/mods/recording-mode ~/.claude/mods-data/recording-mode
  3. Jeśli dopisałeś wskaźnik do statusline, usuń ten fragment ze swojego skryptu.

Wskaźnik w statusline (opcjonalnie)

Mod 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 }.

Konfiguracja

/rec config tworzy ~/.claude/mods-data/recording-mode/config.json z opisem pól w kluczu _opis. Zmiany wczytują się same.

PoleDo czego
keepNamesTwoje imię i nazwisko / marka: widoczne także podczas nagrywania
namesOsoby zawsze maskowane (z odmianą)
hideDowolne teksty do ukrycia; "/wzorzec/flagi" to wyrażenie regularne
allowTeksty, których nigdy nie maskować
privatePathsFragmenty ścieżek, których Claude nie otworzy (dopasowanie w całym poleceniu)
allowPathsŚcieżki zwolnione z wbudowanej blokady (np. contracts/ w projekcie Solidity)
businessToolsNarzędzia / komendy, których wyniki są ukrywane w całości
closedToolsNarzędzia, których Claude nie może wywołać podczas nagrywania
noteToClaudetrue: 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.

Ograniczenia

To pomoc przy nagrywaniu, nie zabezpieczenie. Przed publikacją nagrania i tak je obejrzyj.

  • Wynik komend ! (bash wpisany przez użytkownika) nie jest maskowany.
  • Nie są maskowane: pole wpisywania, okna zgody na komendy, panele innych modów.
  • Blokada ścieżek to deny-lista: grep -r po katalogu, twarde dowiązania czy skrypty ją obejdą. Twarda ochrona to permissions.deny w ustawieniach Claude Code.
  • Słowa biznesowe blokują tylko tokeny wyglądające na ścieżkę (/, \ albo rozszerzenie); katalog wpisany bez ukośnika dopisz do privatePaths.
  • Wiersze, które terminal już wypisał przed /rec on, mogą zostać na ekranie bez maskowania (poza trybem fullscreen terminal ich nie przerysowuje). Włącz tryb przed sesją albo zrób /clear.
  • Blokada .env patrzy na całe polecenie, więc odmówi też np. git commit -m "fix .env loading".
  • Podczas nagrywania Claude nie czyta też swojej pamięci (~/.claude/projects/*/memory).
  • Nazwiska w tabelach są maskowane tylko z etykietą w tej samej komórce (| Owner: Jan |), nie po nagłówku kolumny.
  • Nietypową odmianę imion (Marek → Marka) trzeba dopisać ręcznie w names.

Rozwój

claude plugin validate .
claude plugin test .
tsc -p .   # typy generuje Claude Code przy pierwszym załadowaniu moda

Wszystkie dane w testach są fikcyjne.


English

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.

Licencja

MIT

Source 3 files
hooks/register.tsx 365 lines
1// 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}
365
hooks/privacy.ts 684 lines
1// 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}
684
types/index.d.ts 8 lines
1export type RecMode = 'off' | 'on' | 'strict'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'recording-mode': { mode: RecMode; configVersion: number }
6  }
7}
8