Checks a prompt for personal data before it is sent: blocks unique identifiers, asks before sending the rest

Personal Claude Code mods.
| Mod | Command | Description |
|---|---|---|
git-graph | /git-graph | Toggles a side pane showing the working directory's git log as a graph |
pii-guard | — | Checks each prompt for personal data before it is sent: blocks Korean unique identifiers, asks before sending phone numbers, emails, cards, accounts, addresses and attachments |
session-outputs | /outputs | Toggles a pane listing what the current session produced — PRs, branches, Jira/Notion/Confluence/Slack links, edited files, scratchpad files — grouped into what the session produced (output) and links you gave it (input) |
todo-band | /todo, /auto-todo | Toggles a pane showing the session scratchpad's TODO.jsonl (or TODO.md); its todo-plan skill has Claude keep the plan there with dependencies, and /auto-todo has Claude keep it up to date on every request |
herdr-screenshot | — | Its herdr-screenshot skill saves a Herdr pane's screen as a PNG by rendering the pane's ANSI dump in headless Chrome |
The panes stay closed until you run the command; running it again closes the pane.
TODO.jsonlThe todo-plan skill has Claude write one JSON object per line to the session scratchpad:
{"id":"api","title":"Implement API","status":"doing","deps":["schema"]}
{"id":"deploy","title":"Deploy to dev1","status":"waiting","deps":["api"],"note":"user syncs ArgoCD"}
status is todo, doing (agent running it), waiting (waiting on the user to run or confirm something), done or cancelled. The pane orders items by deps, marks a todo whose deps are not all done as blocked, and flags broken lines, unknown deps and cycles. The full rules are in todo-band/skills/todo-plan/SKILL.md.
The hide done button in the pane's header (or h while the pane has focus) hides done and cancelled items (in TODO.md, - [x] lines) for the rest of the session; press show done (N) to bring them back. show keys (or k) prefixes each TODO.jsonl row with its id, e.g. ▶ [api] Implement API; hide keys turns it off.
/auto-todo [on|off]Without auto-todo, TODO.jsonl exists only once Claude picks the todo-plan skill for a multi-step task, and later requests in the session go untracked. With /auto-todo on, every prompt you type carries a hidden instruction to keep TODO.jsonl current: work requests (edits, commands, investigation) are appended as items and their statuses updated; questions that need no work get none. If a turn edits files or runs commands but leaves TODO.jsonl untouched, the next prompt also carries a one-time reminder.
auto ● (on) or auto ○ (off).autoTodoDefault to true in /config./auto-todo off stops the instruction and tells Claude once to stop updating the file on its own; the file and the pane stay.프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.
| 처리 | 유형 |
|---|---|
| 확인 없이 차단 | 주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식) |
| "그래도 보낼까요?" 확인 | 구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부 |
-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.
cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
$HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함D2KodingLigature Nerd Font (한글 2칸 정렬용)/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.
| 그룹 | 출처 |
|---|---|
| output | assistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일 |
| input | 사용자 메시지의 글에 나온 URL |
.)이고, 링크의 path 는 URL 이다. path 는 흐리게 보인다. 지금 없는 파일은 (deleted) 로 표시한다git·gh 명령에서는 입력·출력 모두 GitHub 링크(PR · Branch · GitHub)만 남긴다. 출력은 gh pr create·gh issue create·gh release create·git push 의 것만 읽는다. gh pr view·gh api·git log 같은 조회 명령의 출력과 PR 본문(--body)의 Jira·Notion 링크는 참고 자료라 제외한다git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다/tmp 아래라 재부팅 후에는 사라진다r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다tail -c 로 나눠 읽는다 ($.fs.read 상한)file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다In a Claude Code terminal session:
/plugin install git-graph --marketplace binarythink/claude-marketplace
/plugin install todo-band --marketplace binarythink/claude-marketplace
/plugin install pii-guard --marketplace binarythink/claude-marketplace
/plugin install herdr-screenshot --marketplace binarythink/claude-marketplace
/plugin install session-outputs --marketplace binarythink/claude-marketplace
Answer y to add the marketplace, then pick a scope.
Or from the shell:
claude plugin marketplace add binarythink/claude-marketplace
claude plugin install git-graph@binarythink
claude plugin install todo-band@binarythink
claude plugin install pii-guard@binarythink
claude plugin install herdr-screenshot@binarythink
claude plugin install session-outputs@binarythink
Bump version in the mod's .claude-plugin/plugin.json when publishing a change, then:
claude plugin marketplace update binarythink
claude plugin update git-graph@binarythink
claude --plugin-dir ./git-graph # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graphhooks/register.tsx 60 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { describeFindings, detect, merge, mentionedPaths, TIER } from './detect'
4import type { Finding } from './detect'
5
6// 한 프롬프트에서 열어 볼 @참조 파일 수의 상한
7const MAX_FILES = 20
8
9export const SEND = '그래도 보내기'
10export const HOLD = '보내지 않기'
11
12export const BLOCK_REASON = (findings: readonly Finding[]) =>
13 `고유식별정보(${describeFindings(findings)})가 감지되어 전송하지 않았습니다. `
14 + '개인정보 보호법상 고유식별정보이므로 해당 값을 삭제하거나 마스킹한 뒤 다시 보내 주세요.'
15export const CANCEL_REASON = '개인정보 확인 단계에서 전송을 취소했습니다.'
16export const ERROR_REASON = '개인정보 검사 중 오류가 발생해 전송하지 않았습니다.'
17
18// @경로 로 참조한 파일을 읽어 검사한다. 읽을 수 없는 경로(디렉터리, 4 MiB 초과, 멘션 등)는 건너뛴다.
19async function scanMentions($: EngineInterface, text: string) {
20 const results: Finding[][] = []
21 for (const path of mentionedPaths(text).slice(0, MAX_FILES)) {
22 const content = await $.fs.read(path).catch(() => undefined)
23 if (typeof content === 'string') results.push(detect(content))
24 }
25 return merge(...results)
26}
27
28// 막힌 프롬프트는 엔진이 입력창에 되돌려 두므로 따로 채우지 않는다.
29const drop = (reason: string) => ({ drop: reason })
30
31export const register: Register = on => {
32 on('prompt.submit', async ($, e, next) => {
33 const inPrompt = detect(e.text)
34 const inFiles = await scanMentions($, e.text)
35 const findings = merge(inPrompt, inFiles)
36 const attachments = e.attachments?.length ?? 0
37 if (findings.length === 0 && attachments === 0) return next(e)
38
39 const blocked = findings.filter(({ kind }) => TIER[kind] === 'block')
40 if (blocked.length > 0) return drop(BLOCK_REASON(blocked))
41
42 const items: string[] = []
43 if (findings.length > 0) {
44 items.push(describeFindings(findings) + (inFiles.length > 0 ? ' (참조 파일 포함)' : ''))
45 }
46 if (attachments > 0) items.push(`이미지·문서 첨부 ${attachments}건 (내용은 검사하지 않음)`)
47
48 // 물어볼 수 없는 경우(-p, SDK, 대화상자 닫음)에는 보내지 않는다.
49 const answer = await $.ui
50 .ask(`개인정보로 보이는 항목이 있습니다: ${items.join(', ')}. 그래도 보낼까요?`, {
51 header: '개인정보',
52 options: [HOLD, SEND],
53 })
54 .catch(() => undefined)
55 if (answer === SEND) return next(e)
56
57 return drop(CANCEL_REASON)
58 }).catch(($, e, next) => (next.called ? next(e) : { drop: ERROR_REASON }))
59}
60hooks/detect.ts 236 lines1// 프롬프트 텍스트에서 개인정보로 보이는 항목을 찾는다.
2// 결과에는 유형과 건수만 담는다. 감지한 값 자체는 이 모듈 밖으로 내보내지 않는다.
3
4// block: 확인 없이 전송을 막는다 (고유식별정보).
5// confirm: 전송 전에 한 번 더 묻는다.
6export type Tier = 'block' | 'confirm'
7
8export type Kind =
9 | 'rrn'
10 | 'foreignerRrn'
11 | 'rrnLike'
12 | 'passport'
13 | 'driverLicense'
14 | 'card'
15 | 'account'
16 | 'phone'
17 | 'email'
18 | 'address'
19
20export type Finding = { kind: Kind; count: number }
21
22export const TIER: Record<Kind, Tier> = {
23 rrn: 'block',
24 foreignerRrn: 'block',
25 passport: 'block',
26 driverLicense: 'block',
27 rrnLike: 'confirm',
28 card: 'confirm',
29 account: 'confirm',
30 phone: 'confirm',
31 email: 'confirm',
32 address: 'confirm',
33}
34
35export const LABEL: Record<Kind, string> = {
36 rrn: '주민등록번호 형식',
37 foreignerRrn: '외국인등록번호 형식',
38 rrnLike: '주민등록번호로 보이는 숫자열',
39 passport: '여권번호 형식',
40 driverLicense: '운전면허번호 형식',
41 card: '카드번호 형식',
42 account: '계좌번호로 보이는 숫자열',
43 phone: '휴대전화번호',
44 email: '이메일 주소',
45 address: '주소',
46}
47
48// 같은 유형 안에서는 이 순서로 정렬해 보여 준다.
49const ORDER: readonly Kind[] = [
50 'rrn', 'foreignerRrn', 'passport', 'driverLicense',
51 'rrnLike', 'card', 'account', 'phone', 'email', 'address',
52]
53
54// 문맥 키워드가 값 앞뒤로 이 글자 수 안에 있어야 인정한다.
55const CONTEXT_WINDOW = 24
56
57const near = (text: string, start: number, end: number, keyword: RegExp) =>
58 keyword.test(text.slice(Math.max(0, start - CONTEXT_WINDOW), end + CONTEXT_WINDOW))
59
60const DAYS_IN_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
61
62const isBirthDate = (yymmdd: string) => {
63 const month = Number(yymmdd.slice(2, 4))
64 const day = Number(yymmdd.slice(4, 6))
65 return month >= 1 && month <= 12 && day >= 1 && day <= DAYS_IN_MONTH[month - 1]
66}
67
68// 주민·외국인등록번호: 생년월일 6자리 + 성별자리 1~8 + 6자리.
69// 2020-10 이후 발급분은 뒤 6자리가 임의번호라 체크섬이 보장되지 않으므로 체크섬은 쓰지 않는다.
70// 구분자(-, 공백)가 있으면 block, 13자리가 붙어 있으면 주문번호 등과 구분이 어려워 confirm.
71const RRN = /(?<![\d-])(\d{6})([ \t]?-[ \t]?|[ \t])?([1-8])\d{6}(?![\d-])/g
72
73function findRrn(text: string, add: (kind: Kind) => void) {
74 for (const match of text.matchAll(RRN)) {
75 const [, birth, separator, gender] = match
76 if (!isBirthDate(birth)) continue
77 if (separator === undefined) add('rrnLike')
78 else add(Number(gender) >= 5 ? 'foreignerRrn' : 'rrn')
79 }
80}
81
82// 여권번호: 구형 M12345678, 신형 M123A4567. 영문+숫자 식별자와 겹치므로 키워드가 있어야 인정한다.
83const PASSPORT = /(?<![A-Za-z0-9])[MSRODG](?:\d{8}|\d{3}[A-Z]\d{4})(?![A-Za-z0-9])/g
84const PASSPORT_KEYWORD = /여권|passport/i
85
86// 운전면허번호: 지역코드(11~28)-연도2-일련6-검증2. 구분자가 없으면 키워드가 있어야 인정한다.
87const LICENSE = /(?<![\d-])(1[1-9]|2[0-8])(-?)\d{2}\2\d{6}\2\d{2}(?![\d-])/g
88const LICENSE_KEYWORD = /면허|license/i
89
90// 휴대전화번호
91const PHONE = /(?<![\d-])01[016789][-. ]?(\d{3,4})[-. ]?(\d{4})(?![\d-])/g
92
93// 0000-0000, 1234-5678 처럼 예시로 흔히 쓰는 값
94const isDummyPhone = (middle: string, last: string) => {
95 const digits = middle + last
96 return /^(\d)\1+$/.test(digits) || digits === '12345678' || digits === '1234567'
97}
98
99// 이메일 주소. 예약 도메인과 noreply 주소는 제외한다.
100const EMAIL = /(?<![A-Za-z0-9._%+-])([A-Za-z0-9._%+-]+)@([A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,})(?![A-Za-z0-9-])/g
101const RESERVED_DOMAIN = /(^|\.)(example\.(com|net|org)|test|example|invalid|localhost)$/i
102const NOREPLY = /(^|[.+-])no-?reply$/i
103
104// 카드번호: 13~19자리 + Luhn + 발급사 접두(3,4,5,6,9).
105// 4자리 묶음 구분자가 없으면 주문번호 등과 겹치므로 키워드가 있어야 인정한다.
106const CARD = /(?<![\d-])(?:\d{4}[- ]){3}\d{1,7}(?![\d-])|(?<![\d-])\d{13,19}(?![\d-])/g
107const CARD_KEYWORD = /카드|card/i
108// 결제 대행사들이 공개한 테스트 카드번호
109const TEST_CARDS = new Set([
110 '4111111111111111', '4242424242424242', '4000056655665556', '4012888888881881',
111 '5555555555554444', '5105105105105100', '2223003122003222',
112 '378282246310005', '371449635398431', '6011111111111117', '3530111333300000',
113])
114
115const passesLuhn = (digits: string) => {
116 let sum = 0
117 for (let i = 0; i < digits.length; i += 1) {
118 let n = Number(digits[digits.length - 1 - i])
119 if (i % 2 === 1) {
120 n *= 2
121 if (n > 9) n -= 9
122 }
123 sum += n
124 }
125 return sum % 10 === 0
126}
127
128// 계좌번호: 형식이 은행마다 달라 키워드가 있을 때만 인정한다.
129const ACCOUNT = /(?<![\d-])(?:\d{2,6}-\d{2,6}-\d{2,7}(?:-\d{1,3})?|\d{10,14})(?![\d-])/g
130const ACCOUNT_KEYWORD = /계좌|예금주|입금|은행|뱅크|농협|우체국|새마을금고|신협|account|bank/i
131
132// 주소: 시·도 + 시·군·구 + 도로명/동 + 번지까지 이어질 때만 인정한다.
133const ROAD_ADDRESS = /[가-힣]{1,10}(?:특별시|광역시|특별자치시|특별자치도|시|도)\s+[가-힣]{1,10}(?:시|군|구)\s+(?:[가-힣]{1,10}(?:시|군|구)\s+)?[가-힣0-9]{1,20}(?:로|길)\s*\d{1,5}(?:-\d{1,5})?/g
134const LOT_ADDRESS = /[가-힣]{1,10}(?:시|군|구)\s+[가-힣0-9]{1,10}(?:동|읍|면|리|가)\s+(?:산\s*)?\d{1,5}(?:-\d{1,5})?(?:번지)?(?![\d-])/g
135
136/** 텍스트에서 개인정보로 보이는 항목을 유형별 건수로 센다. */
137export function detect(text: string): Finding[] {
138 const counts = new Map<Kind, number>()
139 const add = (kind: Kind) => counts.set(kind, (counts.get(kind) ?? 0) + 1)
140 // 앞에서 이미 다른 유형으로 센 숫자열을 계좌·카드로 다시 세지 않는다.
141 const taken: Array<[number, number]> = []
142 const isTaken = (start: number, end: number) => taken.some(([s, e]) => start < e && s < end)
143 const take = (match: RegExpMatchArray) => taken.push([match.index!, match.index! + match[0].length])
144
145 for (const match of text.matchAll(RRN)) {
146 if (isBirthDate(match[1])) take(match)
147 }
148 findRrn(text, add)
149
150 for (const match of text.matchAll(PASSPORT)) {
151 if (near(text, match.index!, match.index! + match[0].length, PASSPORT_KEYWORD)) add('passport')
152 }
153
154 for (const match of text.matchAll(LICENSE)) {
155 const start = match.index!
156 const end = start + match[0].length
157 if (isTaken(start, end)) continue
158 if (match[2] === '-' || near(text, start, end, LICENSE_KEYWORD)) {
159 add('driverLicense')
160 take(match)
161 }
162 }
163
164 for (const match of text.matchAll(PHONE)) {
165 take(match)
166 if (!isDummyPhone(match[1], match[2])) add('phone')
167 }
168
169 for (const match of text.matchAll(CARD)) {
170 const start = match.index!
171 const end = start + match[0].length
172 if (isTaken(start, end)) continue
173 const digits = match[0].replace(/[- ]/g, '')
174 const isGrouped = digits.length !== match[0].length
175 if (digits.length < 13 || digits.length > 19) continue
176 if (!/^[345689]/.test(digits) || /^(\d)\1+$/.test(digits)) continue
177 if (!passesLuhn(digits) || TEST_CARDS.has(digits)) continue
178 if (!isGrouped && !near(text, start, end, CARD_KEYWORD)) continue
179 add('card')
180 take(match)
181 }
182
183 for (const match of text.matchAll(ACCOUNT)) {
184 const start = match.index!
185 const end = start + match[0].length
186 if (isTaken(start, end)) continue
187 const digits = match[0].replace(/-/g, '')
188 // 2026-10-14 같은 날짜가 걸리지 않도록 10자리 이상만 본다.
189 if (digits.length < 10 || /^(\d)\1+$/.test(digits)) continue
190 if (near(text, start, end, ACCOUNT_KEYWORD)) add('account')
191 }
192
193 for (const match of text.matchAll(EMAIL)) {
194 const [, local, domain] = match
195 if (RESERVED_DOMAIN.test(domain) || NOREPLY.test(local)) continue
196 if (domain.toLowerCase().endsWith('users.noreply.github.com')) continue
197 add('email')
198 }
199
200 const addresses = new Set<number>()
201 for (const pattern of [ROAD_ADDRESS, LOT_ADDRESS]) {
202 for (const match of text.matchAll(pattern)) {
203 // 도로명과 지번 패턴이 같은 위치를 함께 잡으면 한 건으로 센다.
204 if ([...addresses].some(at => Math.abs(at - match.index!) < 20)) continue
205 addresses.add(match.index!)
206 add('address')
207 }
208 }
209
210 return ORDER.filter(kind => counts.has(kind)).map(kind => ({ kind, count: counts.get(kind)! }))
211}
212
213/** 여러 출처의 결과를 유형별로 합친다. */
214export function merge(...lists: Finding[][]): Finding[] {
215 const counts = new Map<Kind, number>()
216 for (const list of lists) {
217 for (const { kind, count } of list) counts.set(kind, (counts.get(kind) ?? 0) + count)
218 }
219 return ORDER.filter(kind => counts.has(kind)).map(kind => ({ kind, count: counts.get(kind)! }))
220}
221
222/** "주민등록번호 형식 1건, 이메일 주소 2건" — 값은 담지 않는다. */
223export const describeFindings = (findings: readonly Finding[]) =>
224 findings.map(({ kind, count }) => `${LABEL[kind]} ${count}건`).join(', ')
225
226/** 프롬프트에서 `@경로` 로 참조한 파일 경로를 뽑는다. MCP 리소스(`@server:uri`)는 제외한다. */
227export function mentionedPaths(text: string): string[] {
228 const paths = new Set<string>()
229 for (const match of text.matchAll(/(?<![^\s(])@(?:"([^"\n]+)"|([^\s"'`)]+))/g)) {
230 const path = (match[1] ?? match[2]).replace(/#L\d+(?:-\d+)?$/, '')
231 if (path.includes(':') || path.endsWith('/')) continue
232 paths.add(path)
233 }
234 return [...paths]
235}
236