SLOPSHOPPER

pii-guard

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

newprompt
v0.1.0no licenseupdated 2026-10-14binarythink/claude-marketplace/pii-guard
A shopper browsing a rack in a slop shop
README

claude-marketplace

Personal Claude Code mods.

ModCommandDescription
git-graph/git-graphToggles 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/outputsToggles 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-todoToggles 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-band: TODO.jsonl

The 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.

  • No argument toggles; the pane header shows auto ● (on) or auto ○ (off).
  • The setting lasts for the session only. To start every session on, set the plugin option 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.

pii-guard: 전송 전 개인정보 확인

프롬프트(붙여넣은 텍스트 포함)와 @경로로 참조한 파일을 전송 전에 검사한다.

처리유형
확인 없이 차단주민등록번호·외국인등록번호(구분자 있는 형식), 여권번호·운전면허번호(키워드 또는 구분자 형식)
"그래도 보낼까요?" 확인구분자 없는 13자리 주민번호 형식, 휴대전화, 이메일, 카드(Luhn), 계좌(키워드), 주소(시·도~번지), 이미지·문서 첨부
  • 대화상자와 차단 사유에는 유형과 건수만 표시하고 감지한 값은 표시하지 않는다.
  • 막힌 프롬프트는 엔진이 입력창에 되돌려 두므로 값을 지우고 다시 보낼 수 있다.
  • 물어볼 수 없는 경우(-p, SDK)나 검사 중 오류가 나면 보내지 않는다(fail-closed).
  • 모델이 도구로 읽는 파일·명령 출력은 검사하지 않는다. 첨부 이미지·문서는 내용 검사 없이 확인만 한다.

herdr-screenshot: pane 화면 PNG 저장

Herdr pane 안에서 "이 화면 캡처해줘" 처럼 요청하면 스킬이 scripts/cap.sh 를 실행한다.

cap.sh [--pane <id>] [--out <png|dir>] [--title <text>] [--lines N]
  • 기본은 현재 pane($HERDR_PANE_ID)의 보이는 화면. --lines N 이면 scrollback 포함
  • 측정 패스로 콘텐츠 크기를 잰 뒤 그 크기로 2배 해상도 스크린샷을 찍으므로 넓은 pane 도 잘리지 않는다
  • 필요: Google Chrome, 폰트 D2KodingLigature Nerd Font (한글 2칸 정렬용)

session-outputs: 세션 산출물 뷰어

/outputs 로 pane 을 열고 닫는다. 현재 진행 중인 세션의 transcript(~/.claude/projects/<slug>/<session id>.jsonl)와 scratchpad 를 읽어 수집 출처별로 두 그룹에 나눠 보여준다. 다른 세션의 산출물은 보이지 않는다.

그룹출처
outputassistant 의 글과 도구 입력(MCP 호출 포함)에 나온 URL, Write·Edit·NotebookEdit 로 만든 파일, git·gh 명령의 입출력, 세션 중 checkout 된 branch, 세션 scratchpad 의 파일
input사용자 메시지의 글에 나온 URL
  • 같은 항목이 두 그룹에서 나오면 처음 나온 쪽에만 보인다. 사용자가 준 참고 링크를 assistant 가 다시 언급해도 input 에 남는다
  • 그룹 안에서는 세션에 처음 나온 순서이고, 파일과 링크가 섞인다. scratchpad 파일은 output 의 맨 끝에 온다
  • 각 행은 type(PR · Branch · Jira · Confluence · Notion · Slack · GitHub · Web · File · Scratch), name, path 순이다. 파일의 name 은 파일 이름, path 는 worktree 기준 폴더(root 는 .)이고, 링크의 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 링크는 참고 자료라 제외한다
  • branch 는 git checkout -b·git switch -c·git worktree add -b·git push -u 명령, git push 출력의 pull/new/<branch> URL 에서 찾는다
  • scratchpad 는 /tmp 아래라 재부팅 후에는 사라진다
  • r 로 다시 읽는다. 열려 있으면 10초마다 갱신한다
  • pane 에 포커스가 있을 때 j/k 로 한 item 씩 이동하고, c 는 선택한 item 의 이름, p 는 경로(파일은 절대 경로, 링크는 URL, URL 없는 branch 는 이름)를 클립보드에 복사한다
  • 4 MiB 가 넘는 transcript 는 tail -c 로 나눠 읽는다 ($.fs.read 상한)
  • 링크는 path 칸의 URL 을 클릭하면 열린다 (OSC 8 hyperlink). 파일은 OSC 8 을 못 그리는 터미널에서 file:// URL 이 덧붙지 않도록 링크를 걸지 않는다. p 로 절대 경로를 복사한다
  • attribution 링크(claude.ai/code/session_…, claude.com/claude-code), api.* 엔드포인트, Read·WebFetch 등 도구 결과 속 URL 은 잡음이라 제외한다

Install

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

Update

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

Develop

claude --plugin-dir ./git-graph   # loads the working copy, reloads on save
claude plugin validate .
claude plugin test ./git-graph
Source 2 files
hooks/register.tsx 60 lines
1import 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}
60
hooks/detect.ts 236 lines
1// 프롬프트 텍스트에서 개인정보로 보이는 항목을 찾는다.
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