SLOPSHOPPER

pii-mask

도구 결과를 Claude가 읽기 전에 가린다: 권한 없는 요청자·외부 워크스페이스·가림모드면 개인정보까지, 그 밖은 토큰·키만

guardprompt
★ 3v0.5.0NOASSERTIONupdated 2026-10-19chat-prompt/gpters-ai-toolkit/apps/claude-code-mods/pii-mask
A shopper browsing a rack in a slop shop
README

pii-mask

도구가 돌아간 뒤, Claude가 결과를 읽기 직전에 민감정보를 가리는 mod. 원본 파일·DB는 건드리지 않는다. 슬랙 봇(OpenClaw) 기준으로 만들었다.

상황가리는 것
평소 (권한 있는 사람의 업무 요청·자동화)API 키·토큰·비밀번호만
개인정보 권한 없는 요청자 / 외부 슬랙 워크스페이스 / 디스코드·텔레그램 등 외부 메신저 / 메시지에 "가림모드"위 + 전화번호(뒤 4자리만 남김)·이메일(첫 글자+도메인)·주민번호 뒷자리
세션 첫 메시지를 아직 못 봤을 때안전하게 개인정보까지
  • 한 번 개인정보까지 가리는 모드가 된 세션은 끝까지 그 모드를 유지한다.
  • 가린 값은 기록에 남지 않고 건수만 로그에 남는다.
  • 마스킹 중 오류가 나면 원문을 흘리지 않고 결과를 통째로 숨긴다.

설치

터미널 세션 프롬프트에서:

/plugin install pii-mask --marketplace chat-prompt/gpters-ai-toolkit

셸에서:

claude plugin install pii-mask --marketplace chat-prompt/gpters-ai-toolkit

이미 gpters-ai-toolkit 플러그인을 쓰고 있으면 /plugin install pii-mask@chat-prompt-gpters-ai-toolkit 로도 된다. 설치 전부터 떠 있던 Claude·봇 세션은 재시작해야 적용된다.

확인: 봇에게 echo "Bearer abcdefghijklmnop1234" 를 시켜 결과가 Bearer [토큰 가림] 으로 보이면 정상.

개인 설정

설치할 때 나오는 화면이나 /config 에서 바꾼다. ~/.claude/settings.json 에 직접 쓸 때는 pluginConfigs["pii-mask@chat-prompt-gpters-ai-toolkit"].options.

설정기본값뜻
piiAllowedIds(비어 있음)개인정보를 그대로 봐도 되는 슬랙 User ID. 쉼표로 구분. 예: U0XXXXXXXXX,U0YYYYYYYYY
homeWorkspaceId(비어 있음)우리 슬랙 워크스페이스 Team ID. 이 밖에서 온 요청은 발신자와 상관없이 개인정보까지 가린다

piiAllowedIds 를 비워 두면 사람이 보낸 슬랙 요청은 전부 개인정보까지 가린다. 업무에서 번호·메일이 필요하면 채운다. 관리자가 번호를 보려면 homeWorkspaceId 도 채워야 한다 — 비어 있으면 워크스페이스 정보가 붙은 메시지는 모두 외부로 본다. 토큰·키는 설정과 상관없이 항상 가린다.

설정과 상관없이 토큰·키만 가리는 경우가 둘 있다: 발신자가 봇(is_bot: true, 형제봇·자동화)일 때, 메시지에 발신자 정보(sender)가 없을 때(터미널에서 직접 쓰는 세션 등). 단 외부 워크스페이스·외부 메신저·"가림모드"이거나, 세션이 이미 개인정보까지 가리는 모드가 됐으면 이때도 개인정보까지 가린다.

끄기·지우기

claude plugin disable pii-mask@chat-prompt-gpters-ai-toolkit
claude plugin uninstall pii-mask@chat-prompt-gpters-ai-toolkit

고치기

  • 가리는 규칙은 hooks/mask.ts 의 RULES. 위에서부터 차례로 적용하고, 토큰을 먼저 가려 숫자 오인을 막는다.
  • 발신자·워크스페이스 판정은 슬랙 메시지 메타데이터(sender, group_space) 기준이다.
claude plugin validate apps/claude-code-mods/pii-mask
claude plugin test apps/claude-code-mods/pii-mask

참고 사례글: https://www.gpters.org/ax-lab/post/claude-code-mod-pii-mask-PFBmhxen9IuDBuP

Source 3 files
hooks/register.ts 48 lines
1// pii-mask: 도구가 돌아간 뒤, Claude가 결과를 읽기 전에 민감정보를 가린다.
2// 원본 파일·DB는 건드리지 않는다. Claude 눈에 들어가는 결과만 바꾼다.
3// 슬랙에서 개인정보 권한이 없는 사람이 시키거나, 외부 워크스페이스이거나, "가림모드"를 켜면 개인정보까지 가린다.
4// 관리자의 업무 요청·자동화는 토큰·키만 가린다(업무가 안 깨지게).
5import type { Register } from 'claude-code'
6import { atom, read, update } from 'claude-code'
7import { configure, strictReason, maskDeep, total, type MaskCount } from './mask'
8import type { Mode } from '../types'
9
10// 세션 동안 유지되는 모드. 한 번 외부 채널로 확인되면 그 세션은 끝까지 strict.
11// 아직 메시지를 하나도 못 봤으면(unknown) 안전하게 strict로 가린다. 봇 재시작 직후 첫 메시지를 놓치는 경우 대비.
12const mode = atom({ plugin: 'pii-mask', key: 'mode' } as const, 'unknown' as Mode)
13
14export const register: Register = (on, options) => {
15  // 개인 설정(userConfig). /config 또는 settings.json pluginConfigs 에서 바꾼다.
16  configure(options)
17
18  on('prompt.submit', async ($, e, next) => {
19    const reason = strictReason(e.text)
20    if (reason) $.ui.log(`개인정보까지 가립니다: ${reason}`)
21    await update($, mode, m => (reason || m === 'strict' ? 'strict' : 'secrets'))
22    return next(e)
23  }).catch(($, e, next) => next(e))
24
25  on('tool.call', async ($, e, next) => {
26    const ran = await next(e)
27    // 거부됐거나 에러난 호출은 그대로 둔다
28    if (ran.deny !== undefined || ran.isError === true) return ran
29
30    const seen = await read($, mode)
31    const current = seen === 'unknown' ? 'strict' : seen
32    const count: MaskCount = {}
33    const masked = maskDeep(ran.result, count, current)
34    if (total(count) === 0) return ran
35
36    const summary = Object.entries(count).map(([k, n]) => `${k} ${n}`).join(', ')
37    $.ui.log(`${e.tool} 결과에서 ${summary}건을 가렸어요 (${current})`)
38    // 자기 result로 답하면 엔진이 도구의 원래 변환기로 다시 모델용 글을 만든다
39    return {
40      result: masked,
41      context: [`[pii-mask] 이 결과의 민감정보 ${total(count)}건(${summary})은 가려졌다. 원문 값이 필요하면 사람에게 확인을 요청할 것.`],
42    }
43  }).catch(($, e, next) =>
44    // 가리는 도중 실패하면 원문을 흘리지 않고 결과를 통째로 숨긴다
45    next.called ? { deny: `${$.plugin.name}: 마스킹에 실패해 결과를 숨겼어요` } : next(e),
46  )
47}
48
hooks/mask.ts 96 lines
1// 문자열 하나에서 민감정보를 가린다. 규칙은 위에서부터 차례로 적용된다.
2// 토큰을 먼저 가려야 토큰 안의 숫자가 전화번호로 오인되지 않는다.
3
4type Rule = { kind: string; scope: 'secret' | 'pii'; pattern: RegExp; replace: (m: string, ...g: string[]) => string }
5
6export const RULES: Rule[] = [
7  // Authorization 헤더 값
8  { kind: 'token', scope: 'secret', pattern: /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/g, replace: (_m, scheme) => `${scheme} [토큰 가림]` },
9  // 서비스별 키 접두어 (OpenAI·Anthropic sk-, Slack xox*, GitHub gh*_, Airtable pat, AWS AKIA, Google AIza)
10  { kind: 'token', scope: 'secret', pattern: /\b(?:sk-[A-Za-z0-9_-]{16,}|xox[abposr]-[A-Za-z0-9-]{10,}|gh[pousr]_[A-Za-z0-9]{20,}|pat[A-Za-z0-9]{10,}\.[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,})/g, replace: () => '[토큰 가림]' },
11  // KEY=값, "token": "값" 형태 (환경변수·JSON 설정)
12  {
13    kind: 'token',
14    scope: 'secret',
15    pattern: /\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD)[A-Z0-9_]*|"?(?:api_?key|token|secret|password|authorization)"?)(\s*[:=]\s*"?)([^\s"',}]{8,})/gi,
16    replace: (_m, key, sep) => `${key}${sep}[토큰 가림]`,
17  },
18  // 주민등록번호
19  { kind: 'rrn', scope: 'pii', pattern: /\b(\d{6})-?[1-4]\d{6}\b/g, replace: (_m, birth) => `${birth}-*******` },
20  // 휴대폰 번호 (+82 포함) — 뒤 4자리만 남겨 본인 확인은 가능하게
21  { kind: 'phone', scope: 'pii', pattern: /(?<!\d)(?:\+?82[-\s]?1|01)[016789][-.\s]?\d{3,4}[-.\s]?(\d{4})(?!\d)/g, replace: (_m, last4) => `010-****-${last4}` },
22  // 이메일 — 첫 글자와 도메인만 남긴다 (회사 메일인지 개인 메일인지는 보이게)
23  { kind: 'email', scope: 'pii', pattern: /\b([A-Za-z0-9])[A-Za-z0-9._%+-]*@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g, replace: (_m, first, domain) => `${first}***@${domain}` },
24]
25
26export type MaskCount = Record<string, number>
27
28// strict: 토큰 + 개인정보 / secrets: 토큰·키만
29export type Mode = 'strict' | 'secrets'
30
31export function maskText(text: string, count: MaskCount = {}, mode: Mode = 'strict'): string {
32  let out = text
33  for (const rule of RULES) {
34    if (mode === 'secrets' && rule.scope === 'pii') continue
35    out = out.replace(rule.pattern, (...args) => {
36      count[rule.kind] = (count[rule.kind] ?? 0) + 1
37      return rule.replace(...(args as [string, ...string[]]))
38    })
39  }
40  return out
41}
42
43// 도구 결과는 도구마다 모양이 달라서(문자열·객체·배열) 안의 문자열을 전부 훑는다.
44export function maskDeep<T>(value: T, count: MaskCount = {}, mode: Mode = 'strict'): T {
45  if (typeof value === 'string') return maskText(value, count, mode) as T
46  if (Array.isArray(value)) return value.map(v => maskDeep(v, count, mode)) as T
47  if (value && typeof value === 'object') {
48    const out: Record<string, unknown> = {}
49    for (const [k, v] of Object.entries(value)) out[k] = maskDeep(v, count, mode)
50    return out as T
51  }
52  return value
53}
54
55export function total(count: MaskCount): number {
56  return Object.values(count).reduce((a, b) => a + b, 0)
57}
58
59// ── 언제 개인정보까지 가릴지 (슬랙 기준) ──
60// 누가 개인정보를 볼 수 있는지, 어느 워크스페이스가 우리 것인지는 userConfig 로 받는다(register 가 configure 호출).
61// 비워 두면 사람이 보낸 요청은 전부 개인정보까지 가린다(안전한 기본값). 봇·발신자 없는 메시지는 토큰·키만.
62let PII_ALLOWED = new Set<string>()
63// 이 밖(다른 회사·외부 워크스페이스)에서 온 요청은 전부 가린다.
64let HOME_WORKSPACE = ''
65
66// piiAllowedIds 는 쉼표·공백 구분 문자열.
67export function configure(options: { piiAllowedIds?: unknown; homeWorkspaceId?: unknown }) {
68  PII_ALLOWED = new Set(String(options.piiAllowedIds ?? '').split(/[\s,]+/).filter(Boolean))
69  HOME_WORKSPACE = String(options.homeWorkspaceId ?? '').trim()
70}
71
72const SENDER = /"sender"\s*:\s*\{([^}]*)\}/
73const SPACE = /"group_space"\s*:\s*"([^"]+)"/
74const DISCORD = /"chat_id"\s*:\s*"(?:[a-z]+:)?\d{17,20}"|"(?:provider|channel|surface)"\s*:\s*"(?:discord|telegram|whatsapp)"/i
75// 발표·화면 공유 중처럼 사람이 직접 "가림모드"를 켜는 경우
76export const MANUAL = /가림\s*모드/
77
78// 이 메시지를 보고 개인정보까지 가려야 하면 사유를, 아니면 null을 돌려준다.
79export function strictReason(promptText: string): string | null {
80  if (MANUAL.test(promptText)) return '가림모드 요청'
81  if (DISCORD.test(promptText)) return '외부 메신저'
82  const space = promptText.match(SPACE)?.[1]
83  if (space && space !== HOME_WORKSPACE) return '외부 워크스페이스'
84  const sender = promptText.match(SENDER)?.[1]
85  if (sender) {
86    if (/"is_bot"\s*:\s*true/.test(sender)) return null // 형제봇·자동화
87    const id = sender.match(/"id"\s*:\s*"([^"]+)"/)?.[1]
88    if (id && !PII_ALLOWED.has(id)) return '개인정보 권한 없는 요청자'
89  }
90  return null
91}
92
93export function isExternal(promptText: string): boolean {
94  return strictReason(promptText) !== null
95}
96
types/index.d.ts 8 lines
1export type Mode = 'strict' | 'secrets' | 'unknown'
2
3declare module 'claude-code' {
4  interface PluginState {
5    'pii-mask': { mode: Mode }
6  }
7}
8