유튜브·라이브 방송, 화면 공유 회의, 페어 프로그래밍 중에 화면에서만 비밀 키·주민번호·전화번호·이메일 같은 민감한 값을 가려요. 화면만 바꿀 뿐, Claude가 읽는 내용과 저장되는 대화 기록은 그대로예요.

유튜브·라이브 방송, 화면 공유 회의, 페어 프로그래밍, 스크린샷 공유 중에 화면에 뜨는 비밀 키·주민번호·전화번호·이메일 같은 민감한 값을 자동으로 가려줘요.
가리기 전: 연락처 kim.dev@gmail.com 010-1234-5678, 키는 sk-ant-api03-abcd...
가린 후: 연락처 [이메일 가림] [전화번호 가림], 키는 [토큰 가림]
[!WARNING] 화면에서만 가려요. Claude는 원래 값을 그대로 읽고, 저장되는 대화 기록(transcript)에도 원래 값이 그대로 남아요. 화면 가림은 실수로 "보이는 것"만 막아 줄 뿐이에요. 진짜 비밀은 애초에 대화에 넣지 마세요. 꼭 넣었다면 쓰고 바로 로테이션하세요.
!로 직접 실행한 셸 명령의 출력은 가려지지 않아요. Claude가 도구로 실행한 명령의 결과는 가려지지만, 방송 중에!cat .env처럼 직접 치는 명령은 Claude Code가 다른 경로로 그려서 mod가 손댈 수 없어요.
/plugin marketplace add SeongGwangJu/k-mods
/plugin install streamer-mode@k-mods
설치하면 기본으로 켜져 있어요. 한 번 끄면 다음 세션에도 꺼진 채로 시작해요.
/streamer. 켜고 끄기를 토글해요./streamer on / /streamer off. 켜거나 꺼요./streamer status. 지금 켜져 있는지, 어떤 종류를 가리고 있는지, 이번 세션에 몇 개를 가렸는지 보여줘요.· 가림 중 N이 작게 표시돼요 (N = 이번 세션에 가린 서로 다른 항목 수). 꺼져 있으면 표시되지 않아요.스트리머 모드 켰어요 / 껐어요 토스트가 떠요.| 분류 (설정 항목) | 예시 | label 스타일 | dots 스타일 |
|---|---|---|---|
비밀 키·토큰 (mask_secrets, 기본 켜짐) | sk-ant-api03-…, ghp_…, AKIA…, JWT, Bearer …, PEM 개인 키, API_KEY=… | [토큰 가림] (대입문은 API_KEY=[값 가림]) | ●●●●●● |
주민·카드·사업자·면허번호 (mask_korean_id, 기본 켜짐) | 900101-1234567, 4111-1111-1111-1111, 123-45-67891, 12-34-567890-12 | [주민번호 가림] [카드번호 가림] [사업자번호 가림] [면허번호 가림] | ●●●●●● |
전화번호·이메일 (mask_contact, 기본 켜짐) | 010-1234-5678, kim@gmail.com | [전화번호 가림] [이메일 가림] | ●●●●●● |
공인 IP 주소 (mask_ip, 기본 꺼짐) | 8.8.8.8 | [IP 가림] | ●●●●●● |
홈 폴더 사용자 이름 (mask_home, 기본 꺼짐) | /Users/jsg/... | /Users/●●● (스타일과 무관하게 항상 이 모양) | 동일 |
사용자 지정 단어 (custom_words) | 등록한 회사명·실명 등 | [가림] | ●●●●●● |
API 키 종류별로는 Anthropic(sk-ant-), OpenAI(sk-/sk-proj-), GitHub(ghp_/gho_/ghu_/ghs_/ghr_/github_pat_), AWS(AKIA/ASIA), Google(AIza), Slack(xox…), Stripe(sk_live_/rk_live_/sk_test_), npm(npm_), Telegram 봇 토큰, Slack·Discord 웹훅 URL, scheme://user:비밀번호@host 형태의 URL 인증정보(비밀번호만 가림)까지 폭넓게 찾아요. 주민등록번호는 월·일이 실제로 있는 날짜일 때만, 사업자등록번호는 국세청 체크섬이 맞을 때만, 카드번호는 Luhn 체크섬이 맞을 때만 가려요. 날짜·버전 번호·포트 번호 같은 평범한 숫자를 괜히 가리지 않기 위해서예요.
/config 또는 /plugin configure streamer-mode@k-mods)start_on, 기본 켜짐)custom_words): 회사명·실명·고객사명처럼 항상 가리고 싶은 단어를 여러 개 등록할 수 있어요 (대소문자 구분 없이, 글자 그대로 찾아요)style): label(기본, 종류를 보여줌) 또는 dots(그냥 ●●●●●●)Claude Code가 화면에 메시지·명령 결과·도구 입출력을 그릴 때마다 이 mod가 그 내용을 먼저 살펴보고, 위 표에 있는 패턴을 찾으면 화면에 그릴 텍스트만 바꿔치기해요 (기술적으로는 ui.render 이벤트를 가로채 next()에 가려진 내용을 건네줘요). Claude에게 전달되는 실제 내용이나 디스크에 저장되는 대화 기록은 전혀 건드리지 않아요. 오직 "지금 화면에 뭐라고 그릴지"만 바꿔요. 같은 문자열을 반복해서 가리지 않도록 최근 500개까지 결과를 기억해 뒀다가 재사용해요.
true/false)만 이 mod 전용 저장소에 남겨요. 가린 값 자체나 원문은 어디에도 저장하지 않아요.claude plugin validate가 확인한 이 mod의 전체 호출 목록은 $.command.register, $.store.get, $.store.set, $.ui.invalidate, $.ui.toast뿐이에요.!명령 (bash 모드)으로 실행한 명령의 입력·출력 줄은 가려지지 않아요. 직접 확인해 보니 이 줄은 mod가 가로챌 수 있는 ui.render 컴포넌트(AssistantMessage·UserMessage·CommandOutput 등) 중 어디로도 들어오지 않아요. Claude Code 코어가 다른 경로로 그려요. 같은 이유로 CommandOutput.args(명령어 뒤에 보이는 echo 줄)도 읽기 전용이라 가릴 수 없어요. 반면 평범한 프롬프트로 물어봐서 나온 사용자 메시지·Claude 답변·등록한 슬래시 명령(/streamer status 등)의 출력은 실제 세션에서 확인했을 때 모두 정상적으로 가려졌어요.$.ui.invalidate로 됩니다. 단, 확인한 범위 안에서만요. 토글(/streamer off/on)하면 같은 세션에서 이미 그려진 사용자 메시지·Claude 답변이 즉시 다시 그려져 가려지거나 원래대로 돌아오는 것을 실제 세션에서 확인했어요. 다만 화면을 스크롤해서 터미널 스크롤백으로 넘어간, 더는 "그려지고 있지 않은" 줄까지 다시 바뀌는지는 터미널 자체의 한계로 보장되지 않아요(이미 출력된 터미널 글자는 고쳐 쓸 수 없는 경우가 많아요).mask_ip를 켜면 1.2.3.4처럼 4묶음으로 된 버전 문자열이 공인 IP와 모양이 같아 함께 가려질 수 있어요. 흔치 않은 경우라 기본은 꺼둡니다.key=value 탐지는 키 이름을 단어 단위로 봐요. AUTH_TOKEN, apiKey, DB_PASS는 가리고 author, max_tokens, tokenizer는 그대로 둬요. 키 이름이 평범한데 값만 비밀인 경우(예: x=sk-... 형식이 아닌 임의 문자열)는 놓칠 수 있어요.AskUserQuestion(Claude가 선택지를 묻는 질문)은 question과 선택지의 label/description/preview만 가려요. 질문의 짧은 칩 라벨(header)은 글자 수 제한이 있어 손대지 않았어요. 또한 Claude Code는 다시 쓴 내용이 도구 스키마와 조금이라도 안 맞으면 조용히 원본을 그리므로, 아주 드물게는 가려지지 않을 수도 있어요.이 저장소(k-mods)를 위해 새로 작성한 mod예요. 외부 코드를 가져오지 않았어요.
테스트한 Claude Code 버전: 2.1.291
hooks/register.ts 128 lines1import type { On, PluginOptions } from 'claude-code'
2
3import { buildMaskConfig, initialToggleFrom } from './config'
4import { createMaskCache } from './mask/cache'
5import { createSessionCounter } from './mask/session-counter'
6import {
7 appendHintTail,
8 maskAskUserQuestionProps,
9 maskAssistantMessageProps,
10 maskCommandOutputProps,
11 maskToolGroupProps,
12 maskToolResultProps,
13 maskToolUseProps,
14 maskUserMessageProps,
15 type MaskRuntime,
16} from './render-props'
17
18// $.store에 저장할 때 쓰는 키. 세션이 끝나도, 기기를 재시작해도 남는다.
19const STORE_KEY = 'isOn'
20const CACHE_LIMIT = 500
21
22function statusText(runtime: MaskRuntime, isOn: boolean): string {
23 const { config } = runtime
24 const groups: string[] = []
25 groups.push(`비밀·토큰: ${config.maskSecrets ? '켜짐' : '꺼짐'}`)
26 groups.push(`주민·카드·사업자·면허번호: ${config.maskKoreanId ? '켜짐' : '꺼짐'}`)
27 groups.push(`전화번호·이메일: ${config.maskContact ? '켜짐' : '꺼짐'}`)
28 groups.push(`IP 주소: ${config.maskIp ? '켜짐' : '꺼짐'}`)
29 groups.push(`홈 폴더 이름: ${config.maskHome ? '켜짐' : '꺼짐'}`)
30 groups.push(`사용자 지정 단어: ${config.customWords.length}개`)
31
32 const header = `스트리머 모드: ${isOn ? '켜짐' : '꺼짐'} (이번 세션에 가린 항목 ${runtime.counter.count}개)`
33 return [header, ...groups.map(g => `- ${g}`)].join('\n')
34}
35
36export function register(on: On, options: PluginOptions): void {
37 const config = buildMaskConfig(options)
38 const cache = createMaskCache(CACHE_LIMIT)
39 const counter = createSessionCounter()
40 const runtime: MaskRuntime = { config, cache, counter }
41
42 // 세션이 시작되기 전까지는 userConfig 기본값을 쓰고, session.start에서
43 // $.store에 저장된 값이 있으면 그걸로 덮어쓴다 (꺼둔 채로 설치 재시작해도 꺼진 채 유지).
44 let isOn = initialToggleFrom(options)
45
46 on('session.start', async ($, e, next) => {
47 const stored = await $.store.get(STORE_KEY)
48 if (typeof stored === 'boolean') {
49 isOn = stored
50 }
51
52 await $.command.register({
53 name: 'streamer',
54 description: '화면에서 비밀·개인정보를 가려요. 인자 없이 쓰면 켜고 끄기예요.',
55 argumentHint: '[on|off|status]',
56 immediate: true,
57 })
58
59 return next(e)
60 })
61
62 on('command.run', { command: 'streamer' }, async ($, e) => {
63 const arg = e.args.trim().toLowerCase()
64
65 if (arg === 'status') {
66 return { text: statusText(runtime, isOn) }
67 }
68
69 if (arg !== '' && arg !== 'on' && arg !== 'off') {
70 return { text: '사용법: `/streamer` (켜고 끄기), `/streamer on`, `/streamer off`, `/streamer status`' }
71 }
72
73 const desiredOn = arg === '' ? !isOn : arg === 'on'
74
75 if (desiredOn === isOn) {
76 $.ui.toast(isOn ? '이미 켜져 있어요' : '이미 꺼져 있어요')
77 return {}
78 }
79
80 isOn = desiredOn
81 await $.store.set(STORE_KEY, isOn)
82 $.ui.invalidate('ui.render')
83 $.ui.toast(isOn ? '스트리머 모드 켰어요' : '스트리머 모드 껐어요')
84 return {}
85 })
86
87 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
88 if (!isOn) return next(e)
89 return next({ ...e, props: maskAssistantMessageProps(e.props, runtime) })
90 })
91
92 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
93 if (!isOn) return next(e)
94 return next({ ...e, props: maskUserMessageProps(e.props, runtime) })
95 })
96
97 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
98 if (!isOn) return next(e)
99 return next({ ...e, props: maskCommandOutputProps(e.props, runtime) })
100 })
101
102 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
103 if (!isOn) return next(e)
104 return next({ ...e, props: maskToolUseProps(e.props, runtime) })
105 })
106
107 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
108 if (!isOn) return next(e)
109 return next({ ...e, props: maskToolResultProps(e.props, runtime) })
110 })
111
112 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
113 if (!isOn) return next(e)
114 return next({ ...e, props: maskToolGroupProps(e.props, runtime) })
115 })
116
117 on('ui.render', { component: 'AskUserQuestion' }, async ($, e, next) => {
118 if (!isOn) return next(e)
119 return next({ ...e, props: maskAskUserQuestionProps(e.props, runtime) })
120 })
121
122 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
123 if (!isOn) return next(e)
124 const tail = appendHintTail(e.props.tail, `가림 중 ${counter.count}`)
125 return next({ ...e, props: { ...e.props, tail } })
126 })
127}
128hooks/config.ts 36 lines1import type { MaskConfig } from './mask/types'
2
3/**
4 * streamer-mode의 plugin.json `userConfig`가 register(on, options)의 `options`로
5 * 들어올 때의 모양. 실제 타입은 `claude-code`의 생성된 `PluginOptions`이지만,
6 * 순수 함수·테스트에서 가볍게 쓰기 위해 여기서 우리 쪽 모양을 따로 적어 둔다.
7 */
8export interface StreamerModeOptions {
9 readonly start_on?: boolean
10 readonly mask_secrets?: boolean
11 readonly mask_korean_id?: boolean
12 readonly mask_contact?: boolean
13 readonly mask_ip?: boolean
14 readonly mask_home?: boolean
15 readonly custom_words?: readonly string[]
16 readonly style?: string
17}
18
19/** userConfig 값을 세션 내내 바뀌지 않는 MaskConfig로 바꾼다 */
20export function buildMaskConfig(options: StreamerModeOptions): MaskConfig {
21 return {
22 maskSecrets: options.mask_secrets ?? true,
23 maskKoreanId: options.mask_korean_id ?? true,
24 maskContact: options.mask_contact ?? true,
25 maskIp: options.mask_ip ?? false,
26 maskHome: options.mask_home ?? false,
27 customWords: options.custom_words ?? [],
28 style: options.style === 'dots' ? 'dots' : 'label',
29 }
30}
31
32/** 설치 시 기본으로 켤지 (userConfig의 start_on 기본값은 true) */
33export function initialToggleFrom(options: StreamerModeOptions): boolean {
34 return options.start_on ?? true
35}
36hooks/mask/cache.ts 51 lines1/**
2 * 렌더 훅은 자주 돈다. 같은 문자열을 또 가리지 않도록 원문→가려진 문자열 캐시를 둔다.
3 * `Map`은 삽입 순서를 기억하므로, 읽을 때 다시 넣어 "최근 사용" 순서로 옮기면
4 * 간단한 LRU(least-recently-used)가 된다.
5 */
6export interface MaskCache {
7 get(key: string): string | undefined
8 set(key: string, value: string): void
9 readonly size: number
10}
11
12/**
13 * @param limit 캐시에 담아 둘 최대 항목 수. 넘치면 가장 오래전에 쓰인 것부터 지운다.
14 */
15export function createMaskCache(limit: number): MaskCache {
16 const map = new Map<string, string>()
17
18 return {
19 get(key) {
20 if (!map.has(key)) {
21 return undefined
22 }
23
24 const value = map.get(key) as string
25 // 다시 넣어서 "최근 사용"의 맨 뒤로 옮긴다
26 map.delete(key)
27 map.set(key, value)
28 return value
29 },
30
31 set(key, value) {
32 if (map.has(key)) {
33 map.delete(key)
34 }
35
36 map.set(key, value)
37
38 if (map.size > limit) {
39 const oldest = map.keys().next()
40 if (!oldest.done) {
41 map.delete(oldest.value)
42 }
43 }
44 },
45
46 get size() {
47 return map.size
48 },
49 }
50}
51hooks/mask/session-counter.ts 36 lines1/**
2 * 이번 세션에서 가린, 서로 다른 항목의 개수를 센다. 원본 값은 절대 담지 않고
3 * (해시만 담아) 같은 값을 다시 가렸을 때 중복으로 세지 않기 위한 용도로만 쓴다.
4 */
5export interface SessionCounter {
6 /** 해시 키를 기록한다. 이번 세션에 처음 보는 키면 true를 돌려준다. */
7 record(key: string): boolean
8 readonly count: number
9}
10
11// 비정상적으로 긴 세션에서 메모리가 끝없이 늘지 않도록 두는 방어적 상한.
12// 실제로 한 세션에서 이만큼 서로 다른 비밀을 가릴 일은 거의 없다.
13const MAX_TRACKED = 10_000
14
15export function createSessionCounter(): SessionCounter {
16 const seen = new Set<string>()
17
18 return {
19 record(key) {
20 if (seen.has(key)) {
21 return false
22 }
23 if (seen.size >= MAX_TRACKED) {
24 return false
25 }
26
27 seen.add(key)
28 return true
29 },
30
31 get count() {
32 return seen.size
33 },
34 }
35}
36hooks/render-props.ts 180 lines1import type { MaskCache } from './mask/cache'
2import { deepMaskValue } from './mask/deep'
3import { maskForDisplay } from './mask/engine'
4import type { SessionCounter } from './mask/session-counter'
5import type { MaskConfig } from './mask/types'
6
7/** register.ts가 모듈 수준에서 하나만 만들어 모든 렌더 훅에 넘기는 공유 자원 */
8export interface MaskRuntime {
9 readonly config: MaskConfig
10 readonly cache: MaskCache
11 readonly counter: SessionCounter
12}
13
14function mask(text: string, runtime: MaskRuntime): string {
15 return maskForDisplay(text, runtime.config, runtime.cache, runtime.counter)
16}
17
18function maskDeep(value: unknown, runtime: MaskRuntime): unknown {
19 return deepMaskValue(value, text => mask(text, runtime))
20}
21
22// 아래 함수들은 "입력과 똑같은 모양을 돌려주되 필드 하나만 바꾼다"는 패턴이라
23// 제네릭 P로 원래 타입을 그대로 보존한다. 스프레드 결과가 구조적으로는 P와
24// 같아도(같은 키, 호환되는 값 타입) TS가 제네릭을 그렇게까지 좁혀 주진 않으므로
25// `as P`로 명시한다 — 필드 값만 같은 타입으로 바꿔치기하는 좁은 용도라 안전하다.
26
27export function maskAssistantMessageProps<P extends { text: string }>(
28 props: P,
29 runtime: MaskRuntime,
30): P {
31 return { ...props, text: mask(props.text, runtime) } as P
32}
33
34export function maskUserMessageProps<P extends { text: string }>(props: P, runtime: MaskRuntime): P {
35 return { ...props, text: mask(props.text, runtime) } as P
36}
37
38export function maskCommandOutputProps<P extends { text: string }>(
39 props: P,
40 runtime: MaskRuntime,
41): P {
42 return { ...props, text: mask(props.text, runtime) } as P
43}
44
45export function maskToolUseProps<P extends { input: unknown; output?: unknown }>(
46 props: P,
47 runtime: MaskRuntime,
48): P {
49 return {
50 ...props,
51 input: maskDeep(props.input, runtime),
52 ...(props.output !== undefined ? { output: maskDeep(props.output, runtime) } : {}),
53 } as P
54}
55
56export function maskToolResultProps<P extends { output: unknown }>(props: P, runtime: MaskRuntime): P {
57 return { ...props, output: maskDeep(props.output, runtime) } as P
58}
59
60/** ToolGroupCall과 같은 모양. claude-code의 타입을 그대로 재선언하지 않고 구조로만 맞춘다. */
61export interface ToolGroupCallLike {
62 readonly tool_use_id?: string
63 readonly tool: string
64 readonly input: unknown
65 readonly isRunning: boolean
66 readonly isErrored: boolean
67 readonly isInterrupted: boolean
68 readonly output?: unknown
69}
70
71export function maskToolGroupCalls(
72 calls: readonly ToolGroupCallLike[],
73 runtime: MaskRuntime,
74): ToolGroupCallLike[] {
75 return calls.map(call => ({
76 ...call,
77 input: maskDeep(call.input, runtime),
78 ...(call.output !== undefined ? { output: maskDeep(call.output, runtime) } : {}),
79 }))
80}
81
82export function maskToolGroupProps<P extends { calls: readonly ToolGroupCallLike[] }>(
83 props: P,
84 runtime: MaskRuntime,
85): P {
86 return { ...props, calls: maskToolGroupCalls(props.calls, runtime) } as P
87}
88
89// AskUserQuestion의 질문 하나. 엔진은 이 모양이 도구의 입력 스키마와 안 맞으면
90// 고친 내용을 버리고 원본을 그린다 — 그래서 모양이 안 맞는 항목은 아예 손대지
91// 않는다 (어차피 바뀐 내용이 안 먹히므로, 손대도 득이 없고 다른 필드를 깨뜨릴
92// 위험만 있다).
93export interface AskUserOptionLike {
94 readonly label: string
95 readonly description: string
96 readonly preview?: string
97}
98
99export interface AskUserQuestionItemLike {
100 readonly question: string
101 readonly header: string
102 readonly options: readonly AskUserOptionLike[]
103 readonly multiSelect: boolean
104}
105
106function isAskUserOption(value: unknown): value is AskUserOptionLike {
107 if (typeof value !== 'object' || value === null) return false
108 const v = value as Record<string, unknown>
109 return typeof v.label === 'string' && typeof v.description === 'string'
110}
111
112function isAskUserQuestionItem(value: unknown): value is AskUserQuestionItemLike {
113 if (typeof value !== 'object' || value === null) return false
114 const v = value as Record<string, unknown>
115 return (
116 typeof v.question === 'string' &&
117 typeof v.header === 'string' &&
118 typeof v.multiSelect === 'boolean' &&
119 Array.isArray(v.options) &&
120 v.options.every(isAskUserOption)
121 )
122}
123
124function maskAskUserQuestionItem(
125 item: AskUserQuestionItemLike,
126 runtime: MaskRuntime,
127): AskUserQuestionItemLike {
128 return {
129 ...item,
130 // header는 가리지 않는다: 가릴 대상은 question/options로 한정하고,
131 // 칩 하나짜리 짧은 라벨이라 길이 제한에 걸려 스키마 검증이 통째로
132 // 실패할 위험이 더 크다.
133 question: mask(item.question, runtime),
134 options: item.options.map(opt => {
135 const maskedOpt: AskUserOptionLike = {
136 ...opt,
137 label: mask(opt.label, runtime),
138 description: mask(opt.description, runtime),
139 }
140 if (opt.preview !== undefined) {
141 return { ...maskedOpt, preview: mask(opt.preview, runtime) }
142 }
143 return maskedOpt
144 }),
145 }
146}
147
148/**
149 * `questions`는 타입상 `unknown[]`이다. 도구 스키마와 맞는 항목만 가리고,
150 * 모양을 알아볼 수 없는 항목은 원본 그대로 둔다 (건드렸다가 스키마가 깨지면
151 * 엔진이 조용히 원본을 그려, 가리기 전보다 나을 게 없다).
152 */
153export function maskAskUserQuestionProps<P extends { questions: unknown[] }>(
154 props: P,
155 runtime: MaskRuntime,
156): P {
157 const questions = props.questions.map(item =>
158 isAskUserQuestionItem(item) ? maskAskUserQuestionItem(item, runtime) : item,
159 )
160 return { ...props, questions } as P
161}
162
163/**
164 * PromptHint의 tail 뒤에 내 내용을 이어 붙인다.
165 *
166 * 실제 세션에서 확인해 보니(tui.py 라이브 확인, `[DBG hint=... tail=...]`로
167 * 찍어 봄): 엔진이 `hint`를 그린 뒤 `tail`이 있으면 둘 사이에 "·" 구분점을
168 * 엔진이 직접 넣어 준다 (`hint`도 `tail`도 그 점을 포함하지 않았는데 화면엔
169 * "...agents · 가림 중 0"처럼 나온다). 그래서 tail 맨 앞에 내가 또 " · "를
170 * 붙이면 점이 두 번 찍힌다 — tail 값 자체는 구두점 없이 내용만 담아야 한다.
171 * 다만 다른 mod가 이미 tail에 뭔가 붙여 놓은 경우엔, 그 뒤에 내 내용을 잇는
172 * 구분자는 내가 직접 넣어야 한다 (엔진은 hint↔tail 경계만 신경 쓴다).
173 *
174 * @param existingTail 내 앞에 다른 mod가 이미 붙여 둔 tail (없으면 undefined)
175 * @param label 내가 붙이고 싶은 내용 (예: "가림 중 3", 구두점 없이)
176 */
177export function appendHintTail(existingTail: string | undefined, label: string): string {
178 return existingTail ? `${existingTail} · ${label}` : label
179}
180hooks/mask/types.ts 61 lines1/** 가릴 수 있는 항목의 종류. 라벨 문구와 우선순위를 정하는 데 쓴다. */
2export type Category =
3 | 'secret'
4 | 'rrn'
5 | 'license'
6 | 'business'
7 | 'card'
8 | 'phone'
9 | 'email'
10 | 'ip'
11 | 'home'
12 | 'custom'
13
14export type MaskStyle = 'label' | 'dots'
15
16/** userConfig 값을 그대로 옮긴, 세션 동안 바뀌지 않는 설정 */
17export interface MaskConfig {
18 readonly maskSecrets: boolean
19 readonly maskKoreanId: boolean
20 readonly maskContact: boolean
21 readonly maskIp: boolean
22 readonly maskHome: boolean
23 readonly customWords: readonly string[]
24 readonly style: MaskStyle
25}
26
27/**
28 * 겹침 해소 전의 원시 탐지 결과. 아직 스타일(label/dots)을 입히지 않은 상태다.
29 * start/end는 원본 문자열 기준 [start, end) 범위.
30 */
31export interface RawMatch {
32 readonly start: number
33 readonly end: number
34 readonly category: Category
35 readonly priority: number
36 /** key=value 대입문의 값 부분이면 true. 라벨 대신 "[값 가림]"/dots를 쓴다. */
37 readonly isAssignmentValue?: boolean
38}
39
40/**
41 * 겹치는 구간을 고를 때 쓰는 우선순위. 숫자가 클수록 먼저 채택된다.
42 *
43 * 일반 `Record<string, number>`로 선언하면(색인 시그니처) 프로젝트의
44 * `noUncheckedIndexedAccess` 설정 때문에 `PRIORITY.phone` 같은 접근도 전부
45 * `number | undefined`가 되어 버린다. `as const` 객체 리터럴로 선언해 각 키를
46 * 정확히 아는 속성으로 만든다.
47 */
48export const PRIORITY = {
49 custom: 100,
50 secretAssignment: 90,
51 secret: 80,
52 rrn: 70,
53 business: 70,
54 card: 65,
55 license: 60,
56 phone: 55,
57 email: 50,
58 ip: 40,
59 home: 30,
60} as const
61hooks/mask/deep.ts 38 lines1const MAX_DEPTH = 20
2
3function isPlainObject(value: unknown): value is Record<string, unknown> {
4 return typeof value === 'object' && value !== null && !Array.isArray(value)
5}
6
7/**
8 * 객체·배열 모양은 그대로 두고, 그 안의 문자열 값만 전부 가린다.
9 * `ToolUse.input`, `ToolResult.output` 같은 알 수 없는 모양의 값에 쓴다.
10 *
11 * @param value 원본 값 (문자열·숫자·불리언·배열·객체 등 JSON류 데이터)
12 * @param maskFn 문자열 하나를 가리는 함수
13 * @param depth 재귀 깊이 (내부용, 비정상적으로 깊은 구조에서 멈추기 위함)
14 */
15export function deepMaskValue(value: unknown, maskFn: (text: string) => string, depth = 0): unknown {
16 if (depth > MAX_DEPTH) {
17 return value
18 }
19
20 if (typeof value === 'string') {
21 return maskFn(value)
22 }
23
24 if (Array.isArray(value)) {
25 return value.map(item => deepMaskValue(item, maskFn, depth + 1))
26 }
27
28 if (isPlainObject(value)) {
29 const out: Record<string, unknown> = {}
30 for (const key of Object.keys(value)) {
31 out[key] = deepMaskValue(value[key], maskFn, depth + 1)
32 }
33 return out
34 }
35
36 return value
37}
38hooks/mask/engine.ts 118 lines1import type { MaskCache } from './cache'
2import { findContacts } from './contact'
3import { cheapHash } from './hash'
4import { findCustomWords } from './custom-words'
5import { findPublicIps } from './ip'
6import { findHomePaths } from './home'
7import { findKoreanIds } from './korean-id'
8import { findSecrets } from './secrets'
9import type { SessionCounter } from './session-counter'
10import { replacementFor } from './style'
11import type { MaskConfig, RawMatch } from './types'
12
13/** 설정에서 켜진 그룹만 돌려 원시 탐지 결과를 모은다 */
14function collectRawMatches(text: string, config: MaskConfig): RawMatch[] {
15 const all: RawMatch[] = []
16
17 if (config.maskSecrets) all.push(...findSecrets(text))
18 if (config.maskKoreanId) all.push(...findKoreanIds(text))
19 if (config.maskContact) all.push(...findContacts(text))
20 if (config.maskIp) all.push(...findPublicIps(text))
21 if (config.maskHome) all.push(...findHomePaths(text))
22 if (config.customWords.length > 0) all.push(...findCustomWords(text, config.customWords))
23
24 return all
25}
26
27/**
28 * 겹치는 구간 중 하나만 고른다: 우선순위가 높은 것, 같으면 더 긴 것, 그래도
29 * 같으면 앞에 있는 것을 채택한다. (예: key=value의 값이 동시에 sk-ant- 토큰
30 * 모양이면 "대입문" 쪽이 우선순위가 높아 "[값 가림]"으로 남는다.)
31 */
32export function resolveOverlaps(matches: readonly RawMatch[]): RawMatch[] {
33 const sorted = [...matches].sort((a, b) => {
34 if (b.priority !== a.priority) return b.priority - a.priority
35 const lenDiff = b.end - b.start - (a.end - a.start)
36 if (lenDiff !== 0) return lenDiff
37 return a.start - b.start
38 })
39
40 const accepted: RawMatch[] = []
41 for (const m of sorted) {
42 const overlaps = accepted.some(a => m.start < a.end && a.start < m.end)
43 if (!overlaps) accepted.push(m)
44 }
45
46 return accepted.sort((a, b) => a.start - b.start)
47}
48
49/** 확정된 구간들을 원본 문자열에 적용해 가려진 문자열을 만든다 */
50export function applyMatches(text: string, matches: readonly RawMatch[], style: MaskConfig['style']): string {
51 if (matches.length === 0) {
52 return text
53 }
54
55 let out = ''
56 let cursor = 0
57
58 for (const m of matches) {
59 out += text.slice(cursor, m.start)
60 out += replacementFor(m.category, style, m.isAssignmentValue === true)
61 cursor = m.end
62 }
63 out += text.slice(cursor)
64
65 return out
66}
67
68/** 위험을 각오하고 실제 처리를 하는 부분. 절대 원본 값을 담지 않는다. */
69function maskPlain(text: string, config: MaskConfig, counter: SessionCounter): string {
70 const matches = resolveOverlaps(collectRawMatches(text, config))
71
72 for (const m of matches) {
73 // 원본 값 자체가 아니라 해시만 기록한다 (개수 집계용)
74 counter.record(`${m.category}:${cheapHash(text.slice(m.start, m.end))}`)
75 }
76
77 return applyMatches(text, matches, config.style)
78}
79
80/**
81 * 화면에 보여줄 문자열을 가린다. 같은 문자열은 캐시에서 바로 돌려주고, 탐지
82 * 로직이 예기치 않게 실패하면(버그) 원문을 그대로 보여주는 대신 전체를 가린
83 * 자리표시자를 돌려준다 — 가리는 기능의 버그가 비밀을 노출시키는 것보다는,
84 * 엉뚱한 텍스트까지 가리는 쪽이 안전하다.
85 *
86 * @param text 화면에 그릴 원본 문자열
87 * @param config 세션 동안 고정된 설정 (userConfig에서 만든 값)
88 * @param cache 원문→결과 캐시 (register.ts가 모듈 수준에서 하나만 만들어 재사용)
89 * @param counter 이번 세션에 가린 서로 다른 항목 수를 세는 카운터
90 */
91export function maskForDisplay(
92 text: string,
93 config: MaskConfig,
94 cache: MaskCache,
95 counter: SessionCounter,
96): string {
97 if (text === '') {
98 return text
99 }
100
101 const cached = cache.get(text)
102 if (cached !== undefined) {
103 return cached
104 }
105
106 let result: string
107 try {
108 result = maskPlain(text, config, counter)
109 } catch {
110 // 가리는 로직 자체가 깨지면 "실패 열림"(원문 노출)이 아니라 "실패 닫힘"
111 // (전부 가림)으로 답한다. 원문은 어디에도 남기지 않는다.
112 result = '[가림: 표시 오류]'
113 }
114
115 cache.set(text, result)
116 return result
117}
118hooks/mask/contact.ts 82 lines1import { PRIORITY, type RawMatch } from './types'
2
3// 휴대폰: 01[016789] + 3~4자리 + 4자리, 구분자는 -, ., 공백, 또는 없음
4const MOBILE_RE = /(?<!\d)01[016789][-. ]?\d{3,4}[-. ]?\d{4}(?!\d)/g
5
6// 일반전화: 02 / 0[3-6][1-5] (지역번호) / 070 + 3~4자리 + 4자리
7const LANDLINE_RE = /(?<!\d)(?:02|0[3-6][1-5]|070)[-. ]?\d{3,4}[-. ]?\d{4}(?!\d)/g
8
9const EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g
10
11const EXAMPLE_DOMAIN_RE = /(?:^|\.)example\.(com|org|net)$/i
12
13/**
14 * git@github.com 같은 SSH 원격 주소, noreply 메일, example.com류는 "이메일"이
15 * 아니라 흔한 비(非)개인정보 관용구이므로 가리지 않는다.
16 */
17function isExcludedEmail(localPart: string, domain: string): boolean {
18 const local = localPart.toLowerCase()
19 const host = domain.toLowerCase()
20
21 if (local === 'git') {
22 return true
23 }
24 if (local === 'noreply' || local === 'no-reply') {
25 return true
26 }
27 if (host.endsWith('users.noreply.github.com')) {
28 return true
29 }
30 if (EXAMPLE_DOMAIN_RE.test(host)) {
31 return true
32 }
33
34 return false
35}
36
37export function findPhoneNumbers(text: string): RawMatch[] {
38 const matches: RawMatch[] = []
39
40 for (const re of [MOBILE_RE, LANDLINE_RE]) {
41 for (const m of text.matchAll(re)) {
42 if (m.index === undefined) continue
43 matches.push({
44 start: m.index,
45 end: m.index + m[0].length,
46 category: 'phone',
47 priority: PRIORITY.phone,
48 })
49 }
50 }
51
52 return matches
53}
54
55export function findEmails(text: string): RawMatch[] {
56 const matches: RawMatch[] = []
57
58 for (const m of text.matchAll(EMAIL_RE)) {
59 if (m.index === undefined) continue
60 const [whole] = m
61 const at = whole.indexOf('@')
62 const localPart = whole.slice(0, at)
63 const domain = whole.slice(at + 1)
64
65 if (isExcludedEmail(localPart, domain)) continue
66
67 matches.push({
68 start: m.index,
69 end: m.index + whole.length,
70 category: 'email',
71 priority: PRIORITY.email,
72 })
73 }
74
75 return matches
76}
77
78/** mask_contact 그룹: 전화번호(휴대폰+일반전화) + 이메일 */
79export function findContacts(text: string): RawMatch[] {
80 return [...findPhoneNumbers(text), ...findEmails(text)]
81}
82hooks/mask/hash.ts 18 lines1/**
2 * 암호학적으로 안전하지 않은, 가벼운 FNV-1a 해시. 세션 동안 "같은 값을 또 가렸는지"만
3 * 구분하면 되므로 원본 비밀 값은 절대 저장하지 않고 이 해시만 집합에 담아 둔다.
4 *
5 * @param input 해시할 원본 문자열 (절대 보관하지 않음)
6 * @returns 36진수 문자열로 된 짧은 해시
7 */
8export function cheapHash(input: string): string {
9 let hash = 0x811c9dc5
10
11 for (let i = 0; i < input.length; i++) {
12 hash ^= input.charCodeAt(i)
13 hash = Math.imul(hash, 0x01000193)
14 }
15
16 return (hash >>> 0).toString(36)
17}
18hooks/mask/custom-words.ts 36 lines1import { PRIORITY, type RawMatch } from './types'
2
3function escapeRegExp(word: string): string {
4 return word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
5}
6
7/**
8 * 사용자가 등록한 단어(회사명·실명·고객사명 등)를 대소문자 구분 없이, 글자 그대로
9 * 찾는다. 짧은 단어가 긴 단어의 일부를 가로채지 않도록 긴 단어부터 시도한다
10 * (예: "Acme"와 "AcmeCorp"가 둘 다 등록돼 있으면 "AcmeCorp"를 통째로 가린다).
11 */
12export function findCustomWords(text: string, words: readonly string[]): RawMatch[] {
13 const unique = [...new Set(words.map(w => w.trim()).filter(w => w.length > 0))].sort(
14 (a, b) => b.length - a.length,
15 )
16
17 if (unique.length === 0) {
18 return []
19 }
20
21 const pattern = new RegExp(unique.map(escapeRegExp).join('|'), 'gi')
22 const matches: RawMatch[] = []
23
24 for (const m of text.matchAll(pattern)) {
25 if (m.index === undefined || m[0].length === 0) continue
26 matches.push({
27 start: m.index,
28 end: m.index + m[0].length,
29 category: 'custom',
30 priority: PRIORITY.custom,
31 })
32 }
33
34 return matches
35}
36hooks/mask/ip.ts 54 lines1import { PRIORITY, type RawMatch } from './types'
2
3const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)'
4const IPV4_RE = new RegExp(`(?<!\\d)(?:${OCTET}\\.){3}${OCTET}(?!\\d)`, 'g')
5
6// 흔히 쓰는 모든 형태(전체형·압축형 "::")를 넓게 잡는, 잘 알려진 IPv6 정규식.
7// RFC 전체를 완벽히 따르진 않지만(예: "::ffff:1.2.3.4" 같은 내장 IPv4 꼬리는
8// 다루지 않음) 일반적인 주소는 충분히 잡는다 — README에 한계로 적어 둔다.
9const IPV6_RE =
10 /(?<![\w:])(?:(?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,7}:|(?:[A-Fa-f0-9]{1,4}:){1,6}:[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,5}(?::[A-Fa-f0-9]{1,4}){1,2}|(?:[A-Fa-f0-9]{1,4}:){1,4}(?::[A-Fa-f0-9]{1,4}){1,3}|(?:[A-Fa-f0-9]{1,4}:){1,3}(?::[A-Fa-f0-9]{1,4}){1,4}|(?:[A-Fa-f0-9]{1,4}:){1,2}(?::[A-Fa-f0-9]{1,4}){1,5}|[A-Fa-f0-9]{1,4}:(?:(?::[A-Fa-f0-9]{1,4}){1,6})|:(?:(?::[A-Fa-f0-9]{1,4}){1,7}|:))(?![\w:])/g
11
12/** 루프백·사설·링크로컬 대역은 "공인" IP가 아니므로 가리지 않는다 */
13function isPublicIpv4(address: string): boolean {
14 // IPV4_RE가 항상 점 3개로 나뉜 4묶음만 매치하므로 길이 4가 보장된다.
15 const [a, b] = address.split('.').map(Number) as [number, number, number, number]
16
17 if (a === 127) return false // 루프백
18 if (a === 0) return false // 미지정
19 if (a === 10) return false // 사설 10.0.0.0/8
20 if (a === 172 && b >= 16 && b <= 31) return false // 사설 172.16.0.0/12
21 if (a === 192 && b === 168) return false // 사설 192.168.0.0/16
22 if (a === 169 && b === 254) return false // 링크로컬 169.254.0.0/16
23
24 return true
25}
26
27function isPublicIpv6(address: string): boolean {
28 const lower = address.toLowerCase()
29
30 if (lower === '::1' || lower === '::') return false // 루프백·미지정
31 if (/^fe[89ab][0-9a-f]:/.test(lower)) return false // 링크로컬 fe80::/10
32 if (/^f[cd][0-9a-f]{2}:/.test(lower)) return false // 유니크로컬 fc00::/7
33
34 return true
35}
36
37export function findPublicIps(text: string): RawMatch[] {
38 const matches: RawMatch[] = []
39
40 for (const m of text.matchAll(IPV4_RE)) {
41 if (m.index === undefined) continue
42 if (!isPublicIpv4(m[0])) continue
43 matches.push({ start: m.index, end: m.index + m[0].length, category: 'ip', priority: PRIORITY.ip })
44 }
45
46 for (const m of text.matchAll(IPV6_RE)) {
47 if (m.index === undefined) continue
48 if (!isPublicIpv6(m[0])) continue
49 matches.push({ start: m.index, end: m.index + m[0].length, category: 'ip', priority: PRIORITY.ip })
50 }
51
52 return matches
53}
54