SLOPSHOPPER

data-guard

Asks before a git commit or git add takes in large files, data files, personal-looking table columns or Jupyter notebooks with outputs. Only names are ever…

newguardcommandprocess
★ 1v0.1.0MITupdated 2026-10-12samuelbfernandes/moddeck/mods/data-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · data-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /data-guard ⎿ data-guard: Data guard · on ⎿ data-guard: Large file limit 5 MB · notebooks checked · columns checked ⎿ data-guard: This session: 0 checked · 0 asked · 0 held · 0 notebooks stripped ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

data-guard

Asks once before a git commit or git add takes in the wrong things for a data project. No model calls, no cost.

What it checks

When a Bash command runs git commit or git add (also inside && chains), data-guard looks at what would be committed: the staged files (git diff --cached), the modified tracked files for commit -a, and for git add the files it names, or -A, ., -u (git status). It flags:

  1. Large files: over maxFileMb (default 5 MB).
  2. Data files: csv, tsv, parquet, feather, rds, rdata, h5, hdf5, xlsx, xls, sav, dta, sqlite, db, pkl, npy, npz, zip, gz, unless .gitignore ignores them or .gitattributes sends them to git lfs.
  3. Personal-looking columns in csv/tsv files up to 4 MB: column names like email, ssn, dob, birth, phone, address, patient, mrn, ip_address, or first/last/full name. Only the column names are shown, never a value.
  4. Notebooks (.ipynb) staged with outputs or execution counts.

Nothing found: the command passes silently. Otherwise one dialog: Hold it (default; Esc also holds), Commit anyway, Fix it (holds and tells the model what to do), and, when notebooks are involved, Strip outputs. Strip outputs rewrites the working copy of those notebooks without outputs and execution counts (indent 1, trailing newline, as nbformat does), runs git add on them, and lets the command continue. If other findings remain it still holds the command. Note that git add stages the whole working file, including unstaged edits to it.

Command

/data-guard [on|off|status] shows the settings and this session's counts (checked, asked, held, notebooks stripped). on/off is remembered, and mirrored in the state atom data-guard.isOff (boolean, default false) so the moddeck side bar can show it.

Settings

SettingDefault
enabledtrueMaster switch. When false, /data-guard on cannot override it.
maxFileMb5Large file limit in MB.
checkNotebookstrueCheck .ipynb outputs.
checkColumnstrueCheck csv/tsv header names.

What it never does

  • Never logs, stores or shows file contents. A first line (or a notebook's JSON shape) is read only to look at column names or outputs, then dropped. The dialog shows paths, sizes and column names.
  • Never writes anything except the notebooks you choose to strip, and runs only read-only git commands (rev-parse, diff, status, check-attr, check-ignore, cat-file, show), with short timeouts, plus git add on stripped notebooks.
  • Never blocks git because it broke: any error or timeout lets the command through untouched. A dialog with nobody to answer it (a -p run) holds.

Limits: it reads the command text, so git -C other-dir commit is checked against the session folder, and anything built by a script or alias is not seen.

Source 3 files
hooks/register.tsx 291 lines
1// data-guard: asks before a `git commit` or `git add` takes in the wrong things for a data project.
2//
3// WHAT IT LOOKS AT (git only, read-only, short timeouts): the files that would be committed, from
4// `git diff --cached`, and for `git add` the files it names, from `git status`. It flags large files, data files that are
5// neither ignored nor in git lfs, csv/tsv first lines with personal-looking column NAMES, and notebooks with outputs.
6//
7// NEVER LOGS OR STORES CONTENTS. Paths, counts and column names are all that is ever shown. File text is read only to
8// look at a first line or the JSON shape of a notebook and is dropped at once.
9//
10// FAIL OPEN: any error or timeout (a git that is missing, a process that throws, a hook outrunning its budget) lets the
11// command through untouched. The only refusal is the person's own answer: Hold it, Fix it, or Esc.
12import { atom, read, update } from 'claude-code'
13import type { EngineInterface, Register } from 'claude-code'
14
15import type { DataGuardTally } from '../types'
16import {
17  fixText,
18  firstLineOf,
19  hasAny,
20  isDataLike,
21  isNotebook,
22  isTable,
23  notebookHasOutputs,
24  parseNameStatus,
25  parsePorcelain,
26  personalColumns,
27  planOf,
28  questionText,
29  addNames,
30  stripNotebook,
31} from './detect'
32import type { Findings, GitPlan } from './detect'
33
34type Dollar = EngineInterface
35type Cfg = { isEnabled: boolean; maxMb: number; checkNotebooks: boolean; checkColumns: boolean }
36
37const COMMAND = 'data-guard'
38const MB = 1024 * 1024
39const READ_LIMIT = 4 * MB
40const GIT_TIMEOUT_MS = 8000
41const MAX_CANDIDATES = 3000
42const MAX_STATS = 500
43const MAX_READS = 30
44
45const COMMIT = 'Commit anyway'
46const FIX = 'Fix it'
47const HOLD = 'Hold it'
48const STRIP = 'Strip outputs'
49
50const isOffAtom = atom({ plugin: 'data-guard', key: 'isOff' } as const, false)
51const EMPTY: DataGuardTally = { checked: 0, asked: 0, held: 0, stripped: 0 }
52const tallyAtom = atom({ plugin: 'data-guard', key: 'tally' } as const, EMPTY)
53
54const num = (v: unknown, d: number, lo: number, hi: number): number => (typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, v)) : d)
55
56const readConfig = (o: Record<string, unknown>): Cfg => ({
57  isEnabled: o.enabled !== false,
58  maxMb: num(o.maxFileMb, 5, 0.1, 1000),
59  checkNotebooks: o.checkNotebooks !== false,
60  checkColumns: o.checkColumns !== false,
61})
62
63async function safe<T>(f: () => T | Promise<T>): Promise<T | undefined> {
64  try {
65    return await f()
66  } catch {
67    return undefined
68  }
69}
70
71async function isOff($: Dollar): Promise<boolean> {
72  return (await safe(() => $.store.get('off'))) === true
73}
74
75async function setSwitch($: Dollar, off: boolean): Promise<void> {
76  if (off) await $.store.set('off', true)
77  else await $.store.delete('off')
78  await update($, isOffAtom, () => off)
79}
80
81const bump = (k: keyof DataGuardTally, by = 1) => (t: DataGuardTally): DataGuardTally => ({ ...t, [k]: t[k] + by })
82const count = ($: Dollar, k: keyof DataGuardTally, by = 1) => safe(() => update($, tallyAtom, bump(k, by)))
83
84// ---- looking ------------------------------------------------------------------------------------------------------
85
86type Source = 'index' | 'tree'
87type Seen = { findings: Findings; notebookPaths: string[]; top: string }
88
89/** Read-only look at what the plan would put in a commit. Throws on a broken engine call (the caller fails open). */
90async function inspect($: Dollar, plan: GitPlan, cfg: Cfg): Promise<Seen | undefined> {
91  const git = async (argv: string[], top: string | undefined, stdin?: string) => {
92    const r = await $.process.run(['git', ...argv], { ...(top === undefined ? {} : { cwd: top }), timeoutMs: GIT_TIMEOUT_MS, ...(stdin === undefined ? {} : { stdin }) })
93    return r.exitCode === 0 && !r.isStdoutTruncated ? r.stdout : undefined
94  }
95
96  const where = await git(['rev-parse', '--show-toplevel', '--show-prefix'], undefined)
97  if (where === undefined) return undefined // not a git folder
98  const [topLine = '', prefixLine = ''] = where.split('\n')
99  const top = topLine.trim()
100  const prefix = prefixLine.trim().replace(/\/$/, '')
101  if (top === '') return undefined
102
103  const cand = new Map<string, Source>()
104  if (plan.commit) {
105    for (const f of parseNameStatus((await git(['diff', '--cached', '--name-status', '-z'], top)) ?? '')) cand.set(f.path, 'index')
106    if (plan.commit.isAll) for (const f of parseNameStatus((await git(['diff', '--name-status', '-z'], top)) ?? '')) cand.set(f.path, 'tree')
107  }
108  if (plan.adds.length) {
109    const status = parsePorcelain((await git(['status', '--porcelain', '-z', '--untracked-files=all'], top)) ?? '')
110    for (const s of status) {
111      const isNew = s.x === '?'
112      if (!isNew && s.y === ' ') continue // already staged and unchanged since
113      for (const a of plan.adds) {
114        const wanted = a.paths.length === 0 ? a.isAll || a.isTrackedOnly : a.paths.some(p => addNames(p, prefix, s.path))
115        if (wanted && !(a.isTrackedOnly && !a.isAll && isNew)) {
116          cand.set(s.path, 'tree')
117          break
118        }
119      }
120    }
121  }
122  const paths = [...cand.keys()].filter(p => !p.includes('\n')).slice(0, MAX_CANDIDATES)
123  const findings: Findings = { large: [], data: [], columns: [], notebooks: [] }
124  if (paths.length === 0) return { findings, notebookPaths: [], top }
125
126  // git lfs and .gitignore coverage, best effort: a failure here only means "not covered".
127  const lfs = new Set<string>()
128  const attr = await safe(() => git(['check-attr', 'filter', '--stdin', '-z'], top, paths.join('\0')))
129  if (attr) {
130    const f = attr.split('\0')
131    for (let i = 0; i + 2 < f.length; i += 3) if (f[i + 2] === 'lfs') lfs.add(f[i]!)
132  }
133  const ignored = new Set<string>()
134  const ign = await safe(() => $.process.run(['git', 'check-ignore', '--stdin', '-z'], { cwd: top, timeoutMs: GIT_TIMEOUT_MS, stdin: paths.join('\0') }))
135  if (ign && ign.exitCode === 0) for (const p of ign.stdout.split('\0')) if (p) ignored.add(p)
136
137  // sizes: the index' blob for staged files, the working file for the rest
138  const size = new Map<string, number>()
139  const inIndex = paths.filter(p => cand.get(p) === 'index').slice(0, MAX_STATS)
140  if (inIndex.length) {
141    const out = await git(['cat-file', '--batch-check'], top, inIndex.map(p => `:${p}`).join('\n') + '\n')
142    const lines = (out ?? '').split('\n')
143    inIndex.forEach((p, i) => {
144      const m = /^[0-9a-f]+ blob (\d+)$/.exec(lines[i] ?? '')
145      if (m) size.set(p, Number(m[1]))
146    })
147  }
148  for (const p of paths.filter(p => cand.get(p) === 'tree').slice(0, MAX_STATS)) {
149    const st = await safe(() => $.fs.stat(`${top}/${p}`))
150    if (st && st.kind === 'file') size.set(p, st.size)
151  }
152
153  const limit = cfg.maxMb * MB
154  for (const p of paths) {
155    const bytes = size.get(p)
156    const isLfs = lfs.has(p)
157    if (bytes !== undefined && bytes > limit && !isLfs) findings.large.push({ path: p, bytes })
158    if (isDataLike(p) && !isLfs && !ignored.has(p)) findings.data.push(p)
159  }
160
161  // contents: only ever a first line or the shape of a notebook; nothing is kept
162  const body = async (p: string): Promise<string | undefined> => {
163    const bytes = size.get(p)
164    if (bytes === undefined || bytes > READ_LIMIT) return undefined
165    return cand.get(p) === 'index' ? git(['show', `:${p}`], top) : safe(() => $.fs.read(`${top}/${p}`))
166  }
167  const notebookPaths: string[] = []
168  if (cfg.checkColumns) {
169    for (const p of paths.filter(p => isTable(p) && !lfs.has(p)).slice(0, MAX_READS)) {
170      const text = await body(p)
171      const names = text === undefined ? [] : personalColumns(firstLineOf(text))
172      if (names.length) findings.columns.push({ path: p, names })
173    }
174  }
175  if (cfg.checkNotebooks) {
176    for (const p of paths.filter(isNotebook).slice(0, MAX_READS)) {
177      const text = await body(p)
178      if (text !== undefined && notebookHasOutputs(text)) notebookPaths.push(p)
179    }
180    findings.notebooks = notebookPaths
181  }
182  return { findings, notebookPaths, top }
183}
184
185// ---- asking -------------------------------------------------------------------------------------------------------
186
187async function ask($: Dollar, text: string, hasNotebooks: boolean): Promise<string> {
188  const surfaces = await safe(() => $.session.surfaces())
189  if (surfaces !== undefined && surfaces.length === 0) return HOLD // nobody to ask
190  try {
191    // Hold it first: it is the option the dialog opens on, so Enter and Esc both mean no.
192    return await $.ui.ask(text, { options: hasNotebooks ? [HOLD, COMMIT, FIX, STRIP] : [HOLD, COMMIT, FIX], header: 'Data guard' })
193  } catch {
194    return HOLD
195  }
196}
197
198/** Rewrites the notebooks without outputs and stages them. Returns the paths that could not be done. */
199async function strip($: Dollar, top: string, paths: string[]): Promise<string[]> {
200  const failed: string[] = []
201  const done: string[] = []
202  for (const p of paths) {
203    const text = await safe(() => $.fs.read(`${top}/${p}`))
204    const out = typeof text === 'string' ? stripNotebook(text) : undefined
205    if (out === undefined) {
206      failed.push(p)
207      continue
208    }
209    if (out !== text) await $.fs.write(`${top}/${p}`, out)
210    done.push(p)
211  }
212  if (done.length) {
213    const r = await $.process.run(['git', 'add', '--', ...done], { cwd: top, timeoutMs: GIT_TIMEOUT_MS })
214    if (r.exitCode !== 0) failed.push(...done)
215  }
216  return failed
217}
218
219const deny = (why: string) => ({ deny: why }) as never
220
221// ---- the command --------------------------------------------------------------------------------------------------
222
223const WORDS = new Set(['', 'status', 'on', 'off'])
224
225async function runCommand($: Dollar, cfg: Cfg, args: string): Promise<string> {
226  const word = args.trim().toLowerCase().replace(/\.$/, '')
227  if (!WORDS.has(word)) return `Data guard · "${args.trim().slice(0, 30)}" is not an option · use /data-guard on, off or status`
228  if (word === 'on' || word === 'off') {
229    if (!cfg.isEnabled) return 'Data guard · disabled by the enabled setting (change it in /config)'
230    try {
231      await setSwitch($, word === 'off')
232    } catch {
233      return 'Data guard · that did not work, nothing changed'
234    }
235  }
236  const off = !cfg.isEnabled || (await isOff($))
237  const t = await safe(() => read($, tallyAtom))
238  return [
239    `Data guard · ${off ? 'off' : 'on'}${cfg.isEnabled ? '' : ' (enabled setting is false)'}`,
240    `Large file limit ${cfg.maxMb} MB · notebooks ${cfg.checkNotebooks ? 'checked' : 'not checked'} · columns ${cfg.checkColumns ? 'checked' : 'not checked'}`,
241    `This session: ${t?.checked ?? 0} checked · ${t?.asked ?? 0} asked · ${t?.held ?? 0} held · ${t?.stripped ?? 0} notebooks stripped`,
242  ].join('\n')
243}
244
245export const register: Register = (on, options) => {
246  const cfg = readConfig(options)
247
248  on('session.start', async ($, e, next) => {
249    const stored = (await safe(() => $.store.get('off'))) === true
250    await safe(() => update($, isOffAtom, () => stored))
251    await safe(() =>
252      $.command.register({ name: COMMAND, description: 'Ask before committing large, data or personal-looking files: /data-guard [on|off|status]', argumentHint: '[on|off|status]' }),
253    )
254    return next(e)
255  })
256
257  on('command.run', { command: COMMAND }, async ($, e) => ({ text: await runCommand($, cfg, e.args) })).catch(() => ({ text: 'Data guard · could not read its own state' }))
258
259  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
260    if (!cfg.isEnabled || (await isOff($))) return next(e)
261    const plan = planOf(e.command)
262    if (!plan.commit && plan.adds.length === 0) return next(e)
263    await count($, 'checked')
264
265    const seen = await inspect($, plan, cfg)
266    if (!seen || !hasAny(seen.findings)) return next(e)
267
268    const f = seen.findings
269    await count($, 'asked')
270    const verb = plan.commit ? 'git commit' : 'git add'
271    const answer = await ask($, questionText(f, verb, cfg.maxMb), seen.notebookPaths.length > 0)
272
273    if (answer === COMMIT) return next(e)
274    if (answer === STRIP && seen.notebookPaths.length > 0) {
275      const failed = await strip($, seen.top, seen.notebookPaths)
276      if (failed.length) {
277        await count($, 'held')
278        return deny(`Not run: could not strip ${failed.length} notebook(s): ${failed.slice(0, 3).join(', ')}.`)
279      }
280      await count($, 'stripped', seen.notebookPaths.length)
281      if (f.large.length + f.data.length + f.columns.length === 0) return next(e)
282      await count($, 'held')
283      return deny('Notebook outputs were stripped and staged. Not run: large, data or personal-looking files are still in it. Fix those or run the command again and choose Commit anyway.')
284    }
285    await count($, 'held')
286    if (answer === FIX) return deny(fixText(f))
287    return deny('Not run: held by the person in the Data guard dialog. Nothing was committed or staged.')
288  }).catch(($, e, next) => next(e)) // FAIL OPEN: a guard that breaks must not wedge git
289}
290
291
hooks/detect.ts 261 lines
1// Pure helpers: reading a git command, the file lists git prints, and the checks. Nothing here touches the engine,
2// and nothing returns a file's content: only names, paths and counts.
3
4export const DATA_EXTENSIONS = [
5  'csv', 'tsv', 'parquet', 'feather', 'rds', 'rdata', 'h5', 'hdf5', 'xlsx', 'xls', 'sav', 'dta', 'sqlite', 'db', 'pkl', 'npy', 'npz', 'zip', 'gz',
6] as const
7
8const DATA_SET = new Set<string>(DATA_EXTENSIONS)
9
10export const extOf = (path: string): string => {
11  const name = path.slice(path.lastIndexOf('/') + 1)
12  const dot = name.lastIndexOf('.')
13  return dot <= 0 ? '' : name.slice(dot + 1).toLowerCase()
14}
15export const isDataLike = (path: string): boolean => DATA_SET.has(extOf(path))
16export const isNotebook = (path: string): boolean => extOf(path) === 'ipynb'
17export const isTable = (path: string): boolean => extOf(path) === 'csv' || extOf(path) === 'tsv'
18
19// ---- the command ---------------------------------------------------------------------------------------------------
20
21export type AddSpec = { isAll: boolean; isTrackedOnly: boolean; paths: string[] }
22export type GitPlan = { commit: { isAll: boolean } | undefined; adds: AddSpec[] }
23
24/** Splits on unquoted && || ; | & and newlines, then into words (quotes removed). Best effort, never throws. */
25export function segments(command: string): string[][] {
26  const out: string[][] = []
27  let words: string[] = []
28  let cur = ''
29  let has = false
30  let quote = ''
31  const endWord = () => {
32    if (has) words.push(cur)
33    cur = ''
34    has = false
35  }
36  const endSeg = () => {
37    endWord()
38    if (words.length) out.push(words)
39    words = []
40  }
41  for (let i = 0; i < command.length; i++) {
42    const c = command[i]!
43    if (quote) {
44      if (c === quote) quote = ''
45      else if (c === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]
46      else cur += c
47    } else if (c === '"' || c === "'") {
48      quote = c
49      has = true
50    } else if (c === '\\' && i + 1 < command.length) {
51      cur += command[++i]
52      has = true
53    } else if (c === ';' || c === '|' || c === '&' || c === '\n' || c === '(' || c === ')') endSeg()
54    else if (c === ' ' || c === '\t') endWord()
55    else {
56      cur += c
57      has = true
58    }
59  }
60  endSeg()
61  return out
62}
63
64const GLOBAL_WITH_ARG = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path'])
65
66/** Finds the `git commit` and `git add` parts of a command line. */
67export function planOf(command: string): GitPlan {
68  const plan: GitPlan = { commit: undefined, adds: [] }
69  for (const words of segments(command)) {
70    let i = 0
71    while (i < words.length && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!) || words[i] === 'env' || words[i] === 'command')) i++
72    const bin = words[i]
73    if (bin !== 'git' && !(bin ?? '').endsWith('/git')) continue
74    i++
75    while (i < words.length && words[i]!.startsWith('-')) i += GLOBAL_WITH_ARG.has(words[i]!) ? 2 : 1
76    const sub = words[i]
77    const rest = words.slice(i + 1)
78    if (sub === 'commit') {
79      const isAll = rest.some(w => w === '--all' || (/^-[A-Za-z]+$/.test(w) && /^-[^mFCcStu]*a/.test(w)))
80      plan.commit = { isAll: (plan.commit?.isAll ?? false) || isAll }
81    } else if (sub === 'add') {
82      let isAll = false
83      let isTrackedOnly = false
84      let skip = false
85      let afterDashes = false
86      const paths: string[] = []
87      for (const w of rest) {
88        if (afterDashes) paths.push(w)
89        else if (w === '--') afterDashes = true
90        else if (w === '-A' || w === '--all') isAll = true
91        else if (w === '-u' || w === '--update') isTrackedOnly = true
92        else if (w === '-n' || w === '--dry-run' || w === '-p' || w === '--patch' || w === '-i' || w === '--interactive' || w === '-e' || w === '--edit') skip = true
93        else if (!w.startsWith('-')) paths.push(w)
94      }
95      if (!skip) plan.adds.push({ isAll, isTrackedOnly, paths })
96    }
97  }
98  return plan
99}
100
101// ---- what git prints -----------------------------------------------------------------------------------------------
102
103export type Entry = { status: string; path: string }
104
105/** `git diff --name-status -z`: `M\0path\0`, renames and copies `R100\0old\0new\0`. Deleted files are dropped. */
106export function parseNameStatus(out: string): Entry[] {
107  const parts = out.split('\0')
108  const res: Entry[] = []
109  for (let i = 0; i < parts.length; ) {
110    const status = parts[i++] ?? ''
111    if (status === '') continue
112    const two = status[0] === 'R' || status[0] === 'C'
113    const path = two ? (i++, parts[i++]) : parts[i++]
114    if (path && status[0] !== 'D') res.push({ status: status[0]!, path })
115  }
116  return res
117}
118
119/** `git status --porcelain -z`: `XY path\0`, a rename adds `orig\0`. Deleted files are dropped. */
120export function parsePorcelain(out: string): { x: string; y: string; path: string }[] {
121  const parts = out.split('\0')
122  const res: { x: string; y: string; path: string }[] = []
123  for (let i = 0; i < parts.length; i++) {
124    const p = parts[i]!
125    if (p.length < 4) continue
126    const x = p[0]!
127    const y = p[1]!
128    if (x === 'R' || x === 'C') i++
129    if (x !== 'D' && y !== 'D') res.push({ x, y, path: p.slice(3) })
130  }
131  return res
132}
133
134const clean = (p: string): string => {
135  const out: string[] = []
136  for (const part of p.split('/')) {
137    if (part === '' || part === '.') continue
138    if (part === '..') out.pop()
139    else out.push(part)
140  }
141  return out.join('/')
142}
143
144const globToRe = (g: string): RegExp =>
145  new RegExp(
146    '^' +
147      g
148        .replace(/[.+^${}()|\\]/g, '\\$&')
149        .replace(/\*\*/g, '\u0000')
150        .replace(/\*/g, '[^/]*')
151        .replace(/\?/g, '[^/]')
152        .replace(/\u0000/g, '.*') +
153      '$',
154  )
155
156/** Does `git add <spec>`, run from `prefix` (the folder under the repo root, `sub/` or ''), name this repo-relative path? */
157export function addNames(spec: string, prefix: string, path: string): boolean {
158  const joined = clean(spec.startsWith(':') ? spec.replace(/^:(\([^)]*\)|[!^/]*)/, '') : `${prefix}/${spec}`)
159  if (joined === '') return true
160  if (/[*?[]/.test(joined)) return globToRe(joined).test(path) || globToRe(`${joined}/**`).test(path)
161  return path === joined || path.startsWith(`${joined}/`)
162}
163
164// ---- checks --------------------------------------------------------------------------------------------------------
165
166const PERSONAL: RegExp[] = [
167  /(^|_)e_?mail($|_)/, /(^|_)ssn($|_)/, /(^|_)dob($|_)/, /birth/, /(^|_)phone($|_)/, /(^|_)telephone($|_)/, /address/, /patient/, /(^|_)mrn($|_)/,
168  /(^|_)(first|last|sur|full|given|family|middle)_?name($|_)/, /(^|_)surname($|_)/, /^(firstname|lastname|fullname)$/,
169]
170
171const normalise = (c: string): string =>
172  c
173    .replace(/^/, '')
174    .trim()
175    .replace(/^["']+|["']+$/g, '')
176    .replace(/([a-z])([A-Z])/g, '$1_$2')
177    .toLowerCase()
178    .replace(/[^a-z0-9]+/g, '_')
179    .replace(/^_+|_+$/g, '')
180
181/** The column names in a table's first line that look like personal data (as written, cut to 30 characters). */
182export function personalColumns(firstLine: string): string[] {
183  const sep = firstLine.includes('\t') ? '\t' : firstLine.includes(';') && !firstLine.includes(',') ? ';' : ','
184  const hits: string[] = []
185  for (const raw of firstLine.replace(/\r$/, '').split(sep)) {
186    const n = normalise(raw)
187    if (n !== '' && PERSONAL.some(re => re.test(n))) hits.push(raw.replace(/^/, '').trim().replace(/^["']+|["']+$/g, '').slice(0, 30))
188  }
189  return hits
190}
191
192/** The first line of a text, and nothing else of it. */
193export const firstLineOf = (text: string): string => {
194  const i = text.indexOf('\n')
195  return (i < 0 ? text : text.slice(0, i)).slice(0, 4000)
196}
197
198type Cell = { cell_type?: string; outputs?: unknown; execution_count?: unknown }
199type Notebook = { cells?: Cell[] }
200
201const parseNb = (text: string): Notebook | undefined => {
202  try {
203    const nb = JSON.parse(text) as unknown
204    return nb && typeof nb === 'object' && Array.isArray((nb as Notebook).cells) ? (nb as Notebook) : undefined
205  } catch {
206    return undefined
207  }
208}
209
210/** True when a code cell holds an output or an execution count. A text that is not a notebook is false. */
211export function notebookHasOutputs(text: string): boolean {
212  const nb = parseNb(text)
213  return !!nb?.cells?.some(c => c && typeof c === 'object' && ((Array.isArray(c.outputs) && c.outputs.length > 0) || (c.execution_count !== undefined && c.execution_count !== null)))
214}
215
216/** The notebook without outputs and execution counts, as nbformat writes it (indent 1, trailing newline); undefined when it is not one. */
217export function stripNotebook(text: string): string | undefined {
218  const nb = parseNb(text)
219  if (!nb) return undefined
220  for (const c of nb.cells!) {
221    if (!c || typeof c !== 'object' || c.cell_type !== 'code') continue
222    c.outputs = []
223    c.execution_count = null
224  }
225  return `${JSON.stringify(nb, null, 1)}\n`
226}
227
228// ---- the question --------------------------------------------------------------------------------------------------
229
230export type Findings = {
231  large: { path: string; bytes: number }[]
232  data: string[]
233  columns: { path: string; names: string[] }[]
234  notebooks: string[]
235}
236
237export const hasAny = (f: Findings): boolean => f.large.length + f.data.length + f.columns.length + f.notebooks.length > 0
238
239const MB = 1024 * 1024
240export const mbText = (bytes: number): string => `${(bytes / MB).toFixed(bytes >= 10 * MB ? 0 : 1)} MB`
241const list = (items: string[], max = 4): string => (items.length <= max ? items.join(', ') : `${items.slice(0, max).join(', ')} +${items.length - max} more`)
242
243export function questionText(f: Findings, verb: string, maxMb: number): string {
244  const lines = ['Data guard found something in what is about to be saved:']
245  if (f.large.length) lines.push(`• Large (over ${maxMb} MB): ${list(f.large.map(l => `${l.path} ${mbText(l.bytes)}`))}`)
246  if (f.data.length) lines.push(`• Data files, not ignored or in LFS: ${list(f.data)}`)
247  for (const c of f.columns.slice(0, 3)) lines.push(`• Personal-looking columns in ${c.path}: ${list(c.names, 5)}`)
248  if (f.columns.length > 3) lines.push(`• Personal-looking columns in ${f.columns.length - 3} more files`)
249  if (f.notebooks.length) lines.push(`• Notebooks with outputs: ${list(f.notebooks)}`)
250  lines.push('', `Run this ${verb}?`)
251  return lines.join('\n')
252}
253
254export const fixText = (f: Findings): string => {
255  const steps: string[] = []
256  if (f.large.length || f.data.length) steps.push('unstage them (git restore --staged <file>) and add them to .gitignore, or track them with git lfs')
257  if (f.columns.length) steps.push('remove or anonymise the personal columns')
258  if (f.notebooks.length) steps.push('clear the notebook outputs (or choose Strip outputs next time)')
259  return `Not run: the person chose to fix it first. Suggested: ${steps.join('; ')}.`
260}
261
types/index.d.ts 22 lines
1/** What data-guard counted this session. Plain numbers; never a path or a value. */
2export type DataGuardTally = {
3  /** git commit / git add commands looked at. */
4  checked: number
5  /** Questions asked. */
6  asked: number
7  /** Commands held (Hold it, Fix it, Esc). */
8  held: number
9  /** Notebooks stripped of outputs. */
10  stripped: number
11}
12
13declare module 'claude-code' {
14  interface PluginState {
15    'data-guard': {
16      /** The persisted on/off switch ($.store 'off'), mirrored here so the moddeck side bar can read it. */
17      isOff: boolean
18      tally: DataGuardTally
19    }
20  }
21}
22