Metadata-Version: 2.4
Name: sumunjang
Version: 0.2.0
Summary: AI API 경계에서 한국 개인정보를 막는 로컬 게이트웨이 — base_url 한 줄로 붙이는 온디바이스 마스킹 프록시
Project-URL: Homepage, https://github.com/HaChanho/sumunjang
Project-URL: Repository, https://github.com/HaChanho/sumunjang
Author: 문지기 (sumunjang)
License: Apache-2.0
License-File: LICENSE
Keywords: korean,llm,masking,pii,privacy,proxy,가명처리,개인정보
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Natural Language :: Korean
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Requires-Dist: uvicorn>=0.30
Provides-Extra: dev
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 수문장 (sumunjang)

[![CI](https://github.com/HaChanho/sumunjang/actions/workflows/ci.yml/badge.svg)](https://github.com/HaChanho/sumunjang/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/sumunjang)](https://pypi.org/project/sumunjang/)
[![Python](https://img.shields.io/pypi/pyversions/sumunjang)](https://pypi.org/project/sumunjang/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)

**AI에게 물어보기 전에, 개인정보는 문 앞에서 멈춘다.**

Claude Code나 ChatGPT에 로그·문서·설정 파일을 붙여넣는 순간, 주민등록번호·계좌번호·
API 키가 그대로 해외 API 서버로 전송됩니다. 개인정보보호위원회의 「생성형 AI 서비스
이용자를 위한 개인정보 보호 가이드」(2026.5)조차 *"이름은 가명으로, 숫자는 XXX로"* 라는
**수동 마스킹**을 권고할 뿐, 이를 자동화하는 무료 도구는 안내되지 않습니다.

수문장은 `base_url` 한 줄만 바꾸면 동작하는 **로컬 게이트웨이**입니다. 나가는 요청에서
한국 개인정보를 찾아 가명값으로 바꾸고, 돌아온 답변에서는 원문으로 되돌립니다.

규칙으로 판별할 수 있는 것만 막습니다. 무엇을 못 잡는지는 [한계](#한계)에 적었고,
그중 상당수는 점수로도 공개합니다.

```
당신          수문장                     Anthropic API
 │              │                            │
 │─ 900101-1234568 조회해줘 ─→               │
 │              │─ [주민등록번호_1] 조회해줘 ─→│   ← 서버는 원문을 본 적이 없다
 │              │←─ [주민등록번호_1]는 ... ──│
 │←─ 900101-1234568 는 ... ─│                │   ← 당신 화면에는 원문이 보인다
```

## 빠른 시작

```bash
# 게이트웨이 실행
uvx sumunjang proxy --port 4000

# Claude Code가 수문장을 거쳐 나가게 한다
export ANTHROPIC_BASE_URL=http://127.0.0.1:4000
claude
```

OpenAI 호환 클라이언트도 같은 게이트웨이를 씁니다.

```bash
export OPENAI_BASE_URL=http://127.0.0.1:4000
sumunjang proxy --upstream https://api.openai.com
```

Codex CLI는 `~/.codex/config.toml`에 제공자를 하나 더 두면 됩니다. Codex는
2026년 2월부터 Responses API만 쓰므로 `wire_api`는 기본값 그대로 둡니다.

```toml
model_provider = "sumunjang"

[model_providers.sumunjang]
name = "sumunjang"
base_url = "http://127.0.0.1:4000/v1"
env_key = "OPENAI_API_KEY"
```

파일이나 파이프에도 바로 쓸 수 있습니다.

```bash
$ echo "고객 900101-1234568, 010-9876-5432, kim@corp.co.kr" | sumunjang mask -
고객 [주민등록번호_1], [전화번호_1], [이메일_1]

$ sumunjang scan config.env        # 찾기만 한다. 발견 시 종료 코드 1 → CI 게이트로 사용
                                   # 기본은 카테고리·좌표만 — 값은 --show-values 로
$ sumunjang report                 # 골든셋으로 탐지 성능을 스스로 채점
```

설치해서 쓰려면:

```bash
pip install sumunjang      # 또는 uv pip install sumunjang
```

골든셋이 패키지에 함께 실려 있어, 저장소를 클론하지 않아도 `sumunjang report` 로
아래 수치를 그대로 재현할 수 있습니다.

## 무엇을 막는가

**검증식으로 판별하는 것** — 값 스스로가 자기가 무엇인지 증명합니다.

| 종류 | 검증 방식 |
|---|---|
| 주민등록번호 | 검증식(가중치 mod 11) **또는** 생년월일 정합성 |
| 사업자등록번호 | 검증식(가중치 1371 3713 5) |
| 카드번호 | 자릿수(13~19, ISO/IEC 7812) + 카드사 식별번호 대역 + Luhn (국내전용은 예외, 아래) |
| 휴대전화번호 | 통신사 식별번호 + 자릿수 (`+82` 표기 포함) |
| 이메일 | 형식 + 최상위 도메인 조건 |
| API 키·토큰 | Anthropic / OpenAI / GitHub / AWS / Slack 공표 접두사 |

**문맥으로 판별하는 것** — 검증식이 없거나 공개되지 않아, 앞에 붙은 말을 증거로 씁니다.

| 종류 | 왜 형태만으로는 안 되는가 | 앵커 예 |
|---|---|---|
| 계좌번호 | 검증식이 없고 형식이 은행마다 다르다 (3-3-6, 6-2-6, 4-3-6 …) | `계좌:`, `예금주`, `입금` |
| 여권번호 | 영문 1자 + 숫자 8자. 제품 코드·대기번호와 겹친다 | `여권번호:` |
| 운전면허번호 | 검사번호 산출식이 공개돼 있지 않다 | `면허:` |
| 이름 | 형태 자체가 없다. `최윤서`와 `배송팀`은 생김새가 같다 | `성명:`, `예금주:`, `담당:` |

이름은 **구조화된 문서 한정**입니다. `성명: 최윤서`처럼 `키: 값` 형태만 잡고,
`어제 김수현 책임이랑 통화했는데` 같은 자유서술 속 이름은 잡지 않습니다. 성씨 사전
(상위 50개, 인구 약 97%)으로 한 겹 더 거릅니다 — 커버리지가 아니라 오탐 때문입니다.
연·소·도·선·설 같은 희귀 성씨는 *연락·소속·도착·선택·설정* 의 첫 음절이기도 해서,
3%를 더 얻자고 오탐을 몇 배로 늘립니다.

표기 변형도 함께 받습니다. 하이픈 없이 붙여 쓴 주민등록번호(`8803121000068`), 점으로
구분한 전화번호(`010.9876.5432`), 국가번호를 붙인 전화번호(`+82-10-2255-8830`), 전각
숫자, 자모 분해(NFD) 표기, 보이지 않는 문자를 끼워 넣은 우회까지.

보이지 않는 문자는 **범주로 한 겹, 목록으로 한 겹** 막습니다. 목록만으로는 계속
뚫렸습니다 — U+200B를 막으면 U+2063으로, 그것을 막으면 U+00AD·U+2066으로 우회합니다.
목록은 언제나 공격자보다 늦습니다. 그래서 서식 제어 문자(Cf)와 폭 0 결합 문자(Mn)를
범주째 걷어냅니다.

다만 범주만으로도 닫히지 않습니다. **눈에 보이지 않는 것과 유니코드가 분류하는 방식이
일치하지 않기 때문입니다.** U+3164 한글 채움 문자는 범주가 `Lo`(글자)인데 화면에는
아무것도 그리지 않고, 한국에서 공백 닉네임용으로 널리 쓰입니다. U+2800 점자 공백은
`So`, U+00A0은 `Zs`입니다. 어느 한쪽도 혼자서는 충분하지 않습니다.

정규화한 뒤 매칭하되 좌표는 원문 기준으로 되돌리므로 가려지는 위치가 어긋나지
않습니다. 되돌아가는 지도에는 시작과 **끝을 둘 다** 적습니다 — 시작만 적었더니
NFC가 세 코드포인트를 한 글자로 합친 자리에서 `김수현`이 `[이름_1]ᅧᆫ`으로
반쯤 남았습니다.

**2020년 10월 이후 발급된 주민등록번호는 뒷자리가 임의번호**라 검증식을 통과하지 않습니다.
검증식만 관문으로 두면 그 세대를 통째로 놓치므로, 생년월일 정합성을 별도 관문으로 둡니다.

**국내전용 카드(9로 시작)는 Luhn을 따르지 않습니다.** 검증번호 산출 방식과 위치가
카드사마다 달라서입니다. Luhn만 관문으로 두면 한국 카드를 체계적으로 놓치므로 따로
다룹니다. 반대로 Luhn은 16자리 숫자 열 개 중 하나를 그냥 통과시키므로, 카드사 식별번호
대역이 아닌 숫자(전표번호 등)는 먼저 잘라냅니다.

### 증거가 없으면 관문을 더 요구한다

규칙이 열 종류지만 원칙은 하나입니다. **값이 스스로 증명하는 만큼만 관문을 덜어줍니다.**

| 상황 | 요구하는 것 | 이유 |
|---|---|---|
| 하이픈 **있는** 주민등록번호 | 검증식 **또는** 생년월일 | 하이픈 자체가 의도의 증거다 |
| 하이픈 **없는** 주민등록번호 | 검증식 **그리고** 생년월일 | 13자리는 밀리초 타임스탬프·상품 바코드와 겹친다 |
| 국제 브랜드 카드 | Luhn | 국제 규격을 따른다 |
| 국내전용 카드 | 구분자 표기 | 검증할 수단이 없다. 사람이 옮겨적은 흔적을 대신 본다 |

같은 원칙이 **앵커의 사정거리**도 정합니다. 값이 증거를 담고 있으면(검증식, 하이픈 묶음,
생년월일 관문) 앵커는 같은 줄 안에서 느슨해도 됩니다 — `계좌: 국민은행 110-234-567890`
처럼 은행 이름이 끼어드는 쪽이 오히려 보통이니까요. 반대로 값이 아무것도 증명하지
못하면(이름, 맨 숫자 계좌번호, 여권번호, 구분자 없는 국내전용 카드) `키: 값` 형태를
요구합니다.

이 구분을 처음엔 지키지 않았다가 오탐 셋을 만들었습니다 — `여권 발급 대기열 A00000001`,
`이체 수수료 정산 2026080612345678`, `카드 결제 모듈 롤백 커밋 9410123456789012`.
셋 다 앵커 근처에 우연히 놓인 숫자였습니다. 지금은 오탐 골든셋에 박혀 있습니다.

대가도 함께 적습니다. 앵커 없이 붙여 쓴 2020년 10월 이후 발급분, 구분자 없는 국내전용
카드, 자유서술 속 이름과 주소는 **놓칩니다.** `goldenset-gaps/` 에 정답으로 박아 두고
점수로 공표합니다.

### 한 번 가린 값은 문맥이 바뀌어도 계속 가린다

마스킹은 문맥에 기대지만(앵커) **복원은 문맥과 무관합니다.** 이 비대칭이 유출을
만듭니다. 실제 왕복에서 잡힌 경로입니다.

```
① 도구가 파일을 읽음      담당자: 김수현        → 담당자: [이름_1]     가려짐
② 모델이 표시를 언급       "내용이 `[이름_1]` 같은 표시로…"
③ 프록시가 복원           "내용이 `김수현` 같은 표시로…"             ← 앵커가 사라짐
④ 다음 턴에 기록 재전송    탐지기가 못 알아봄                        업스트림 유출
```

복원이 값을 **탐지기가 알아볼 수 없는 문맥으로 옮겨 놓습니다.** 그래서 스스로를
증명하는 값(검증식·형식)은 살아남고 앵커에 기대는 값만 샜습니다 — 우연이 아니라
구조였습니다.

세션은 자기가 가린 값을 알고 있습니다. 규칙 탐지에 더해 **세션이 아는 값을 함께
가려** 고리를 닫습니다. 앵커가 없어도, 코드블록 안이어도, 모델이 설명 대상으로
언급해도 마찬가지입니다.

탐지와 재탐색이 **같은 텍스트**를 보고, 동일성을 **정규형**으로 판단해야 이 고리가
닫힙니다. 재탐색이 원문을 그대로 훑던 동안에는 제로폭 하나로 빠져나갔고, 세션이
원문 조각을 키로 삼던 동안에는 NFD로 처음 본 값이 자기를 못 알아봤습니다 —
프록시가 자기 출력을 다시 못 알아보는 자기 유발 유출이었습니다.

이 결함은 저장소에 포함된 에코 서버로는 재현되지 않습니다. 세 턴 이상의 대화와
실제 복원이 있어야 나타납니다.

### 겹치는 탐지는 합집합으로 가린다

전화번호와 카드번호는 자릿수를 공유할 수 있습니다. `010-9921-3348-0000-0002` 에서
전화번호는 앞 13자, 카드번호는 뒤 19자를 차지합니다. 겹침을 "앞선 탐지가 이겼으니
뒤는 버린다"로 처리하면 카드번호 뒷 8자리가 평문으로 남습니다.

겹치는 탐지는 합집합 한 구간으로 병합해 **탐지기가 표시한 바이트를 하나도 남기지
않습니다.** 합쳐진 자리의 이름은 민감도 서열을 따릅니다 — 축은 *이 값 하나로 다른 문을
얼마나 열 수 있는가, 그 자물쇠를 바꿀 수 있는가* 하나입니다. 주민등록번호가 1위인 것은
한국 본인확인의 마스터키이면서 유출돼도 바꿀 수 없기 때문이고, 사업자등록번호가
최하위인 것은 국세청에서 공개 조회되기 때문입니다.

## 설계 원칙

**개인정보가 지나가는 경로에 서드파티 코드를 두지 않습니다.**
탐지·마스킹 모듈은 파이썬 표준 라이브러리만 사용합니다. 외부 패키지는 네트워크 계층에만
쓰이며 직접 의존성은 `httpx`, `uvicorn` 둘뿐입니다 ([SBOM.md](SBOM.md)).

**결정적입니다.** 같은 입력이면 언제나 같은 출력이고, API 키도 네트워크도 필요 없습니다.
AI 모델을 탑재하지 않으므로 실행할 때마다 결과가 달라지지 않습니다.

**조용히 실패하지 않습니다.** 복원은 정확히 일치하는 표시만 되돌립니다. 모델이 형태를
바꿔버린 경우 추측해서 복구하지 않습니다 — 잘못된 복원이 미복원보다 위험하기 때문입니다.

**모르면 차단합니다 — 경로에서도, 본문에서도.**

본문은 **모든 문자열을 가리고 예외만 둡니다.** 처음에는 "여기와 여기를 가린다"는
자리 목록을 두었는데, 그것은 화이트리스트처럼 보여도 실제로는 블랙리스트였습니다 —
*아는 자리만 가린다*는 곧 *모르는 자리는 샌다*는 뜻입니다. 리뷰에서 그 대가가
드러났습니다. Anthropic의 `document` 블록(사용자가 문서를 첨부하는 정식 경로),
`tools[].description`, `metadata.user_id`, OpenAI의 `prediction.content`,
`messages[].name`, 최상위 `user`, `file.file_data`가 전부 원문 그대로 나갔습니다.
게이트웨이 경로에는 "모르면 차단"을 적용해 놓고 본문에는 "모르면 통과"를 쓰고
있었던 셈입니다.

예외는 **추론 블록과 base64로 실린 첨부의 알맹이** 둘뿐입니다. 문자열만이 아니라
숫자와 사전의 키까지 훑습니다 — `{"rrn": 8803121000068}`처럼 숫자형으로 들어온
식별자가 그대로 나가던 자리입니다.

예외를 판정하는 방식이 중요합니다. **얕은 신호를 믿지 않습니다.** 키 이름이나 값
접두사만으로 판정하다 세 번 뚫렸습니다 — `signature`라는 키면 어디서든, `type: base64`라고
적어두기만 하면 뭐든, `data:`로 시작하면 무엇이든 통과했습니다. 셋의 공통점은
**공격자가 그 신호를 직접 쓸 수 있다**는 것입니다. 그러면 그것은 예외 조건이 아니라
우회 스위치입니다.

그래서 근거를 **요청자 바깥**에서 찾습니다.

- **출처** — 우리가 그 값을 내보낸 적이 있는가. 추론 블록은 서명을 대조합니다.
  요청자는 우리가 내보낸 적 없는 서명을 만들어낼 수 없습니다.
- **값** — 이 값에서 개인정보가 보이지 않는가. base64 첨부는 실제로 디코드되는지,
  미디어 타입이 그림·소리·PDF인지, 그리고 **탐지 규칙에 걸리지 않는지**까지 봅니다.
  디코드 여부만 물었을 때 뚫렸습니다 — base64 알파벳은 숫자와 영문자를 전부
  포함하므로 구분자 없이 적은 한국 식별자와 API 키는 *인코딩 없이도* 조건을
  만족합니다. `AKIAIOSFODNN7EXAMPLE…`은 그 자체가 유효한 base64입니다.
  **흉내낼 수 있는 조건은 조건이 아닙니다.**

추론 블록을 통째로 두는 이유는 두 가지입니다. 모델이 만든 글이라 사용자 원문이 있을
수 없고(인바운드가 모두 가려진 상태라 모델은 원문을 본 적이 없습니다), 서명이 본문을
보증하므로 본문만 가리면 **서명이 보증하지 못하는 본문**이 되어 다음 턴이 거부됩니다.

모델명·역할·식별자까지 훑지만 대개 손상되지 않습니다. 탐지기는 한국 개인정보처럼
생긴 것에만 반응하므로 `claude-opus-4`에는 아무 일도 일어나지 않습니다. 식별자에
개인정보가 들어 있어 업스트림이 거부한다면 그것은 **눈에 보이는 실패**입니다.

경로도 아는 것만 다룹니다.

| 경로 | 처리 |
|---|---|
| `/v1/messages`, `/v1/messages/count_tokens` | 마스킹해서 전달 (Anthropic) |
| `/v1/chat/completions` | 마스킹해서 전달 (OpenAI Chat Completions) |
| `/v1/responses` | 마스킹해서 전달 (OpenAI Responses — Codex CLI가 쓰는 경로) |
| `/v1/models` | 그대로 전달 — 본문이 없어 개인정보가 실릴 수 없음 |
| 그 밖의 모든 경로 | **차단.** 업스트림에 닿지 않음 |

세 번째 경로는 실제로 막혀 봤기 때문에 있습니다. Codex CLI를 물렸더니 404가
났습니다 — 설계대로 동작한 것이지만, 그 결과 *"OpenAI 호환"* 이라 말하면서
OpenAI의 대표 코딩 에이전트를 통째로 못 받는 상태였습니다. OpenAI는 2026년 2월
Codex에서 chat/completions 지원을 완전히 제거했고, 마이그레이션 안내에 이렇게
적었습니다 — *"조직이 LLM 프록시나 게이트웨이를 쓴다면 그것이 Responses API를
지원하는지 확인하라."* 수문장이 정확히 그 게이트웨이입니다.

붙이는 데 든 것은 이 표의 줄 하나와 **복원할 자리 하나**입니다. 마스킹은 한 줄도
새로 쓰지 않았습니다 — 본문 전체를 훑는 방식이라 프로토콜을 모르기 때문입니다.
자리 목록을 두었다면 `input[]`·`instructions`·`function_call_output.output`을
하나씩 적어야 했고, 목록을 두 벌 관리한 대가가 이미 OpenAI 쪽 유출 넷이었습니다.

경로는 점 구간(`..`)을 먼저 접은 뒤에 비교합니다. `/v1/models/../../v1/organizations/me`는
접두사 검사를 통과하지만 HTTP 클라이언트가 정규화해 전혀 다른 엔드포인트를 사용자 키로
호출합니다. 통과 경로의 경로·쿼리도 마스킹합니다 — 본문이 없다는 것이 경로에 개인정보가
없다는 뜻은 아닙니다.

경로 비교는 구분자까지 봅니다 — `startswith`만 쓰면 `/v1/models_backup` 같은 미지
경로가 통과해 기본값이 무너집니다.

모르는 경로를 흘려보내면 마스킹을 거치지 않은 본문이 그대로 나갑니다. 마스킹 도중
예외가 나도 마찬가지로 요청을 버립니다 — 보안 도구의 실패 모드는 통과가 아니라
차단이어야 합니다.

마스킹 계층 자체는 프로토콜과 무관합니다. 프로토콜마다 다른 것은 *본문의 어느 자리에
사람이 쓴 텍스트가 있는가* 와 스트리밍 형식뿐이라, 새 프로토콜을 붙이는 일이 표에 줄
하나를 더하는 일이 되도록 두었습니다.

세 번째 프로토콜(Responses)을 붙이면서 이 말이 사실인지 확인했습니다. **마스킹은
한 줄도 새로 쓰지 않았습니다.** 새로 정한 것은 복원할 자리 하나
(`output[]` 항목의 `content[]` 안)뿐이고, 그 규칙 하나가 셋을 동시에 갈랐습니다 —
`output_text`·`refusal`은 되돌리고, `function_call.arguments`와
`reasoning.summary[]`는 각각 다른 칸을 쓰므로 자연히 빠집니다. 나머지는 스트리밍
형식이었습니다.

**스트리밍은 신뢰성을 택했습니다.** 조각난 응답에서 가명 표시를 복원하면 청크 경계에서
깨집니다. 그래서 업스트림에는 통짜로 요청해 복원을 끝낸 뒤, 클라이언트에게만 스트리밍
형태로 다시 흘려보냅니다. 첫 응답이 조금 늦는 대신 복원이 깨지지 않습니다.

## 실환경 검증 기록

검증을 세 등급으로 나눠 적습니다. 무엇을 확인했고 무엇을 아직 안 했는지가
섞이면 안 되기 때문입니다.

| 등급 | 뜻 | 해당 |
|---|---|---|
| **실제 서비스 왕복** | 진짜 클라이언트 → 진짜 API 서버 | Anthropic Messages |
| **실제 클라이언트 왕복** | 진짜 클라이언트 → 로컬 대역 서버 | OpenAI Responses |
| **에코 fixture** | 우리가 쓴 요청 모양만 | OpenAI Chat Completions |

에코 fixture는 매번 무언가를 놓쳤습니다. 세 번 시도해 세 번 다, 자체 테스트가
전부 통과하는 코드에서 새 결함이 나왔습니다.

### Claude Code → api.anthropic.com (2026-08-06, 실제 서비스 왕복)

사용자가 입력한 것은 **파일 경로 한 줄**뿐입니다.

```
$ claude
> /tmp/결제장애-로그.txt 읽고 왜 결제가 실패했는지 분석해줘
```

수문장이 기록한 **업스트림으로 실제 나간 본문**:

```
  user_rrn=[주민등록번호_1]        ← 원문: 850312-1000003
  user_phone=[전화번호_1]
  user_email=[이메일_3]
  card=[카드번호_1]
  merchant_brn=[사업자등록번호_1]
  ANTHROPIC_API_KEY=[시크릿_1]
```

사용자는 개인정보를 붙여넣은 적이 없습니다. 도구가 파일을 읽어 `tool_result`에 담는
순간 6종이 전송될 뻔했고, 여기에 도구가 시스템 프롬프트에 넣는 사용자 이메일까지 더해
한 요청에서 8건이 가려졌습니다. 업스트림 본문에 남은 원문은 0건이었습니다.

**가려도 답변 품질이 떨어지지 않았습니다.** 마스킹된 로그를 받은 모델은 장애 원인을
분석하면서 "로그에 크레덴셜이 평문으로 찍혀 있다"는 보안 문제까지 지적했습니다.
장애 분석에 필요한 것은 *누구의* 카드인지가 아니라 카드 결제가 실패했다는 구조이기
때문입니다.

이 검증 과정에서 자체 테스트로는 잡히지 않던 결함 두 건도 드러났습니다. 추론(thinking)
블록을 SSE 규약대로 `delta`로 흘리지 않아 다음 턴 요청이 400으로 거부되던 문제와,
반복되는 값을 "0건"으로 보고해 작동이 멈춘 것처럼 보이던 문제입니다. 둘 다 저장소에
포함된 에코 서버로는 재현되지 않는 종류였습니다.

### Codex CLI 0.144.1 → 로컬 대역 서버 (2026-08-08, 실제 클라이언트 왕복)

**클라이언트는 진짜이고 업스트림은 로컬 대역 서버입니다.** `api.openai.com`까지의
왕복은 아직 아니므로 위 등급을 그대로 적습니다.

여기서 처음 확인한 것이 이 경로의 존재 이유였습니다. 물리자마자
`404 지원하지 않는 경로: /v1/responses`가 났습니다. 그래서 이 판에 붙였습니다.

붙인 뒤 두 턴이 돌았습니다. 사용자가 입력한 것은 역시 파일 이름 한 줄입니다.

```
1턴  Codex가 exec_command 로 `cat 결제장애-로그.txt` 를 실행
2턴  그 내용이 function_call_output 으로 되돌아옴 → 수문장이 가림
```

업스트림이 받은 것은 **8종 13건의 가명 표시, 원문 0건**입니다. 사용자 화면에는
원문이 돌아왔습니다 — 복원이 Responses SSE 경로에서도 깨지지 않았습니다.

에코 fixture로는 볼 수 없던 것을 셋 봤습니다.

- **Codex의 `instructions`는 20,751자입니다.** 시스템 프롬프트가 마스킹 대상이라는
  것이 가정이 아니라 실측이 됐습니다.
- **도구 이름이 `shell`이 아니라 `exec_command`입니다.** 우리가 상상해 쓴 요청
  모양은 진짜와 다릅니다.
- **도구 설명 속 예시 이메일까지 가려집니다.** `tools[].description`은 원문이
  그대로 나가던 유출 경로였으므로 가리는 것이 의도이지만, 그 대가가 눈에 보이는
  형태로 나타났습니다 ([한계](#한계)에 적었습니다).

## 무엇을 못 잡는지도 점수로 냅니다

골든셋은 고치는 순간 포화됩니다. 어려운 문서를 넣어 점수를 떨어뜨려도, 드러난 결함을
고치고 나면 다시 만점이 되어 처음과 구분이 가지 않습니다. 만점 셋만 내놓으면 *"쉬운 것만
골라 놓고 만점이라 하는 것 아니냐"* 는 물음에 답할 수 없습니다.

그래서 **못 잡는다고 선언한 것들을 점수로 함께 공개합니다.**

| 셋 | 의미 | 현재 |
|---|---|---|
| `goldenset/` | 회귀 기준선. 깨지면 되던 것이 망가진 것 | 재현율 1.000 · 정밀도 1.000 |
| `goldenset-hard/` | 표기 변형과 오탐 함정 | 재현율 1.000 · 정밀도 1.000 |
| `goldenset-gaps/` | **못 잡는다고 선언한 것들.** 낮은 것이 정상 | 재현율 0.778 |

`goldenset-hard/` 는 도입 당시 **0.684 / 0.765** 였습니다. 그 자리가 드러낸 결함 넷을
고쳐 지금 수치가 됐습니다. `goldenset-gaps/` 는 **0.235에서 시작했습니다** — 문맥 앵커
규칙으로 계좌·여권·면허·이름과 좁혀 뒀던 관문 셋을 되찾아 0.778이 됐고, 남은 것은
자유서술 속 이름과 주소입니다. gaps 항목이 `hard` 로 옮겨가는 것이 곧 개선 기록입니다.

```bash
sumunjang report          # 세 셋을 나란히 채점

# CI 회귀 게이트 — 공표한 수치 아래로 내려가면 종료 코드 1
sumunjang report --only goldenset --only goldenset-hard \
  --min-recall 1.0 --min-precision 1.0
```

점수를 찍기만 하면 회귀를 아무도 못 봅니다. 위 수치는 곧 계약이므로 CI가 매 푸시마다
그 계약을 지키는지 확인합니다.

이 수치는 **독립적인 성능 증명이 아니라 회귀를 감시하는 기준선**입니다. 골든셋을 만든
사람이 도구도 만들었습니다.

## 한계

정직하게 적습니다.

- **자유서술 속 이름과 주소는 못 잡습니다.** 이름은 `성명: 최윤서` 같은 구조화된 표기만
  잡습니다. `어제 김수현 책임이랑 통화했는데` 는 앵커가 없어 규칙으로 판별할 수 없습니다.
  주소는 아예 다루지 않습니다 — 어디서 끝나는지를 규칙으로 정할 수 없습니다.
  둘 다 `goldenset-gaps/G2` 에 정답으로 박아 두고 0점으로 공표합니다.
- **앵커 없는 계좌·여권·면허번호도 못 잡습니다.** 검증식이 없거나(계좌) 공개되지
  않아서(면허 검사번호), 앞에 붙은 말이 유일한 근거입니다. `정산 항목 110-234-567890`
  처럼 앵커 없이 놓인 숫자는 계좌번호로 보지 않습니다.
- **관문을 좁힌 대가가 있습니다.** 앵커 없이 붙여 쓴 2020년 10월 이후 주민등록번호와,
  앵커 없이 붙여 쓴 국내전용 카드는 놓칩니다. 위 표의 `gaps` 셋에 들어 있습니다.
- **이름 탐지에는 남은 오탐 여지가 있습니다.** `담당: 배송팀` 처럼 성씨로 시작하는
  3자 낱말이 `키: 값` 자리에 오면 이름으로 읽힙니다. 개인정보가 아닌 값을 가리는
  쪽이 이름을 흘리는 쪽보다 낫다고 보고 이 방향을 택했습니다.
- **Luhn은 오탐을 완전히 막지 못합니다.** 전치·오타 검출용이라 `1111-2222-3333-4444`
  같은 규칙적 반복도 통과합니다. 카드사 식별번호 대역으로 한 겹 더 거르지만 그 안에서는
  여전히 통과합니다.
- **구분자 없이 이어 붙인 식별자는 못 잡습니다.** `88031210000688803121000068…` 처럼
  유효한 주민등록번호를 연달아 붙이면, 앞뒤에 숫자가 붙어 있으면 더 긴 번호의
  일부로 보는 경계 조건 때문에 탐지되지 않습니다. 그 조건이 없으면 타임스탬프·
  주문번호가 전부 걸리므로 맞바꾼 대가입니다.
- **요청 헤더에 개인정보가 있으면 요청을 거부합니다.** 가려서 보내지 않는 이유는
  가명 표시가 한글이라 HTTP 헤더로 인코딩되지 않기 때문입니다. 자격증명과 프로토콜
  협상 헤더(`x-api-key`, `anthropic-version` 등)는 그대로 넘깁니다.
- **첨부 파일 안은 보지 않습니다.** base64로 실린 이미지·문서의 내용은 해독하지 않으므로,
  그 안의 개인정보는 그대로 나갑니다. 마스킹 예외는 여기와 추론 서명, 도구 호출을 잇는
  뿐입니다. 주소(URL)와 식별자는 한때 예외로 뒀다가 되돌렸습니다. 가리면 첨부가
  깨지지만 **깨지는 것은 눈에 보이고 유출은 보이지 않기** 때문입니다.
- **숫자로 들어온 값이 개인정보로 보이면 문자열로 바뀝니다.** 16자리 숫자 ID의 약 5%,
  밀리초 타임스탬프의 약 0.5%가 이에 해당합니다. 로그·CSV를 JSON으로 옮겨 붙이는
  경로에서 식별자가 숫자형으로 들어오기 때문에 훑지 않을 수 없고, 훑으면 이 비율은
  피할 수 없습니다. 형이 바뀌어 업스트림이 거부한다면 그것은 눈에 보이는 실패입니다.
- **같은 API 키를 쓰는 여러 대화는 한 세션을 공유합니다.** 대화 B의 응답에 대화 A가 만든
  가명 표시가 그대로 들어 있으면 A의 원문으로 복원됩니다. 같은 값에 같은 이름을 주려면
  세션을 공유해야 하므로 감수한 대가이며, 다른 자격증명 사이에서는 일어나지 않습니다.
- **세션 상한 1만 건에 닿으면 요청을 거부합니다.** 오래된 값을 버리는 방식이었는데,
  버린 값은 다음 턴에 다시 가려지지 않아 그대로 유출됐습니다 — 대화 기록은 매 턴 다시
  전송되므로 퇴출은 과거를 지우는 게 아니라 보호를 푸는 것이었습니다. 메모리 상한과
  유출 방지를 둘 다 지키려면 버리는 대신 거부해야 합니다.
- **세션은 인증 자격(API 키) 단위로 나뉩니다.** 하나를 공유했더니 다른 대화의 개인정보가
  주입됐습니다. 같은 자격의 여러 대화는 여전히 한 세션을 쓰며, 그건 의도입니다 —
  같은 값에 같은 이름을 주어야 모델이 문맥을 잃지 않습니다.
- **세션이 아는 값이 다른 낱말과 겹치면 과도하게 가려집니다.** 두 글자 이름 `이준`이
  세션에 있으면 `이준비 완료`가 `[이름_1]비 완료`가 됩니다. 앞쪽 경계만 보기 때문인데,
  뒤쪽까지 막으면 `김수현씨`·`김수현 책임`처럼 조사·직함이 붙은 형태를 놓칩니다.
  과도한 마스킹은 유출보다 안전한 쪽이라 이 방향을 택했습니다.
- **큰 본문을 처리하는 동안 다른 요청이 기다립니다.** 마스킹은 동기 CPU 작업이라
  그 시간만큼 이벤트 루프가 멈춥니다. 개인정보가 촘촘히 박힌 본문(110바이트마다 4건)으로
  잰 값입니다 — 세션이 비어 있으면 400KB에 0.15초, 상한 근처(9,900건)에서는 0.54초입니다.
  비용은 본문 길이와 세션 크기 양쪽에 붙습니다. 정확성이 아니라 가용성의 한계이고,
  스레드로 빼면 풀리지만 그러면 마스킹 코어에 동시성이 들어옵니다 — 지금 코어가
  단일 스레드라서 얻는 것(같은 입력이면 같은 출력, 경쟁 상태 없음)이 더 크다고 보고
  두었습니다.
- **에이전트가 파일을 쓸 때 가명 표시가 그대로 박힐 수 있습니다.** 모델이 도구에게
  건네는 인자(`tool_use.input`)는 복원하지 않습니다. 여기를 복원하면 모델이 부르는
  **모든 도구**가 원문을 받는데, `Write`·`Bash`는 사용자 기계에서 돌지만 `WebFetch`나
  MCP 서버 호출은 다른 네트워크 출구입니다. 프록시는 도구 이름만 알 뿐 그것이
  로컬인지 원격인지 알 방법이 없어, 모르면 복원하지 않는 쪽을 택했습니다.
  다만 실제 왕복 33회에서는 한 번도 발생하지 않았습니다 — 에이전트는 내용을 자기
  몸으로 통과시키는 대신 `cp`나 변환 스크립트로 처리하는 편을 택합니다.
- **모델이 가명 표시를 값이 아니라 설명 대상으로 언급하면 복원이 문장을 왜곡합니다.**
  모델이 "마스킹 표시(`[주민등록번호_1]`)가 붙어 있었다"고 메타 언급하면 그 자리도
  원문으로 되돌아갑니다. 값으로 쓴 것인지 표시를 설명한 것인지 구분하려면 문맥 판단이
  필요해 현재 구조로는 완전히 풀 수 없습니다.
- **도구 설명 안의 예시 값도 가려집니다.** MCP 도구가 사용법을 설명하며 적어 둔
  `jane.doe@example.com` 같은 예시가 `[이메일_3]`이 되어 모델에게 갑니다.
  `tools[].description`은 원문이 그대로 나가던 유출 경로였으므로 가리는 쪽이
  맞지만, 도구 설명이 예시로 형식을 알려주던 몫은 그만큼 줄어듭니다. 예시인지
  진짜인지는 생김새로 구분할 수 없고, 진짜를 흘리는 쪽이 더 나쁩니다.
- **OpenAI 경로는 실제 서비스까지 왕복하지 않았습니다.** Responses 경로는 진짜
  클라이언트(Codex CLI)로 두 턴을 돌렸지만 업스트림은 로컬 대역 서버였고,
  Chat Completions 경로는 저장소에 포함된 에코 서버로만 시험했습니다. 실제 API
  서버까지 왕복을 마친 것은 Anthropic 경로뿐입니다 —
  [실환경 검증 기록](#실환경-검증-기록)에 등급으로 나눠 적었습니다.
- **Responses 경로에서 추론 요약은 가명 표시인 채로 화면에 남습니다.** 모델이
  만든 추론 요약(`reasoning.summary[]`)은 되돌리지 않습니다. Anthropic 쪽에서
  thinking 블록을 되돌리지 않는 것과 같은 판단입니다. 값이 화면에서 가려져 보이는
  것은 눈에 띄는 흠이고, 되돌려야 할 자리를 늘리는 것은 보이지 않는 위험입니다.

## 개발

```bash
uv venv && uv pip install -e ".[dev]"
uv run pytest
```

테스트는 API 키도 네트워크도 없이 돕니다. 업스트림은 저장소에 포함된 에코 서버를 실제
ASGI 앱으로 띄워 검증하므로, 누구나 같은 결과를 재현할 수 있습니다.

`tests/test_leak.py` 는 다른 테스트와 성격이 다릅니다. 카테고리도 좌표도 묻지 않고
**"골든셋의 정답 값이 마스킹 결과에 부분 문자열로도 남아 있지 않은가"** 하나만 검사합니다.
재현율·정밀도는 스팬이 정확히 일치했는지를 세므로 "절반만 가렸다"는 잡아도 *남은 절반이
평문으로 나갔다*는 사실 자체는 드러내지 못합니다. 실제로 그 틈으로 결함이 하나 빠져나간
적이 있습니다.

## 라이선스

Apache License 2.0

---

2026 오픈소스 개발자대회 출품작 (팀 문지기)
