Blocks LLM-induced Hangul (Korean) corruption in MCP tool calls — Notion · Jira · Confluence. Compares the outgoing text char-by-char against a staged reference file. Docs are in Korean.
한글 깨짐 (Hangul corruption) 을 막는 훅 — LLM 이 tool call 파라미터에 한글을 잘못 써서
멀쩡한 단어가 다른 글자로 바뀌는 것을 저장 직전에 차단한다.
예컨대 추천 이 추첸 이 되고 뜬다 가 뜼다 가 된다.
⚠️ 인코딩 깨짐과 다른 문제다.�·占쏙옙· 네모(□) 처럼 못 읽는 글자가 나오거나, EUC-KR·cp949 파일이 UTF-8 로 열려 깨지거나, 터미널·폰트에서만 깨져 보이는 것은 여기서 다루지 않는다. 그건 인코딩 변환·로케일·폰트 문제이고iconv·chcp·터미널 설정 쪽에서 찾아야 한다.이 저장소가 다루는 것은 읽히는 다른 한글로 바뀌는 경우다 — 깨진 게 아니라 멀쩡한 글자라 오타로 착각하고 넘어간다.
- 증상 — 노션·Jira 에 저장된 한글이 오타처럼 바뀌어 있다. 저장은 성공했고 에러도 없었다
- 원인 — 모델이 긴 한글을 다시 써내는 과정에서 자모 하나가 표류한다. 유니코드
이스케이프(
\uXXXX)로 쓸 때 압도적으로 많지만(23/23), 리터럴 UTF-8 로 써도 0 은 아니다 (0~1/30). 「우린 이스케이프 안 쓰는데」가 안전을 뜻하지 않는다 — 이 훅이 겨냥하는 것이 바로 그 남는 몫이다 - 1차 대응 — 에이전트가 한글을 리터럴 UTF-8 로 쓰고, 이미 있는 한글은 기억으로 다시 쓰지 말고 그대로 복사하게 하는 지시. 플러그인의
SessionStart훅이 세션마다 주입하므로 따로 넣을 것은 없다(MCP 파라미터·Write·Edit·heredoc 모두 해당). 이것으로 대부분 사라지지만 프롬프트 지시라 강제력이 없다 — 훅은 그 다음 관문이다 - 이 훅이 하는 일 — 그러고도 남는 몫을 잡는다. 보낼 본문을 기준본 파일로 미리 써 두면 저장 직전에 문자 단위로 대조해, 어긋나면 틀린 글자를 지목해 차단한다
- 대상 — 노션 · Jira · Confluence MCP 로 한글 본문을 저장하는 경우. Claude Code · Cursor · Windsurf · Codex CLI 에서 동작 (실제 실행 확인은 Claude Code 만. Windsurf 는 마커 방식 미지원)
- 설치 — 터미널
claude에서/plugin marketplace add YourBestDeveloper/hangul-corruption-guard→/plugin install hangul-corruption-guard@yourbestdeveloper→/reload-plugins→ 세션을 새로 연다(훅은 세션 시작 시 로드된다). 물렸는지 확인하는 법은 「설치」 절에 있다 - 쓰는 법 — 사람 쪽 작업 흐름은 바뀌지 않는다. 평소처럼 「이 문서 정리해줘」라고 하면 되고, 기준본을 만들고 저장 도구를 부르는 것은 에이전트다(절차서
/hangul-corruption-guard:stage라 강제력은 없다 — 건너뛰면 쓰기마다 확인 왕복 1회가 붙는 에코 경로로 떨어진다). 훅이 막으면 그 메시지를 읽고 스스로 고친다 - 끄기 — 셸 환경변수
HANGUL_GUARD=off(0·false도 받는다).claude를 띄우기 전에 걸어야 하고 그 세션 전체에 적용된다 — 세션 도중에는 못 끈다.⚠️ 끄면 마커 치환도 함께 꺼진다 — 마커를 쓰던 중이라면 본문 대신@@hangul:…@@문자열이 그대로 저장된다. 끄기 전에 마커를 리터럴 본문으로 되돌린다
- anthropics/claude-code#83033 — "corrupting Hangul" (미해결).
메인테이너가
CLAUDE.md지시를 당면 완화책으로 인정했다 — "the CLAUDE.md instruction in your workaround is the right mitigation"(2026-08-16). 즉 1차 대응은 이 저장소만의 주장이 아니다
⚠️ 아래 수치는 필자가 1인 로컬 세션에서 직접 잰 것이다 — 위 이슈의 데이터가 아니고 재현 스크립트도 공개하지 않았다. 인용하기보다 자기 환경에서 다시 재는 편이 맞다.
- A/B 실측 — 유니코드 이스케이프를 강제하면
23/23손상, 리터럴 UTF-8 이면0~1/30. 음절 오류 밀도 약3~5%. 이23/23은 모델이 코드포인트를 계산한 이스케이프의 수치다 — 스크립트로 생성한 이스케이프에는 적용되지 않는다 - anthropics/claude-code#26141 — 내장 Edit 도구가 같은 방식으로 유니코드를 손상시킨다는 보고. MCP 만의 문제가 아니다
1차 대응(리터럴 UTF-8 지시)은 플러그인의 SessionStart 훅이 세션마다 주입하므로 CLAUDE.md 를 손댈 필요가 없다. 플러그인 없이 이 대응만 쓰려면 규칙 파일에 아래 한 줄을 넣는다.
tool call 파라미터의 한글은 유니코드 이스케이프(\uXXXX) 없이 리터럴 UTF-8 로 쓴다.
⚠️ 이스케이프를 스크립트로 생성하는 것은 차선책이다 — 마커를 대신하지 못한다. (마커 = 본문 대신@@hangul:<경로>@@를 보내면 훅이 기준본 파일 내용으로 바꿔치기하는 방식. 뒤 「파일 참조로 보내기」에서 자세히 다룬다.) 이 지시를 받은 에이전트가json.dumps로 파일을 읽어\uXXXX를 만들어내는 우회를 발명하는 것을 봤다. 코드포인트 산술이라는 오류원이 사라지는 것은 사실이고, 손으로 쓴 이스케이프의 실패(45건 중 13자 표류)는 이 방식으로 없어진다.그래도 마커보다 못한 이유는 둘이다. (1) 만든 결과를 파라미터로 옮겨 적는 단계가 그대로 남는다 — 리터럴 UTF-8 에도 남는 몫이지만(
0~1/30), 이스케이프는 같은 본문이 문자 수로 약 4배라(6,425자 → 25,403자) 옮겨 적을 양 자체가 크다. (2) 틀렸을 때 hex 는 시끄럽게 깨져 주지 않는다 — 한글 음절 블록에서 hex 한 자리가 바뀌는 경우를 전수 계산하면73.6%가 다시 유효한 한글로 착지한다(균등 치환 가정이라 실제 실패 분포와 같다는 보장은 없다).다만 이 훅 안에서는 hex 가 가리지 못한다. 훅은 디코드된 값을 받으므로 이스케이프로 보내든 리터럴로 보내든 대조는 똑같이 돌고, 차단 메시지도 한글과 코드포인트로 나온다. hex 가 실제로 가리는 구간은 훅이 돌지 않는 곳뿐이다 — 검사 대상 화이트리스트(
GATED— 노션·Jira· Confluence 의 본문 저장 도구 목록. 뒤 「현재 검사 범위」) 밖의 도구, 그리고 훅 미로드 세션.그리고
json.dumps를 부를 수 있다는 것은 이미 본문이 파일에 있다는 뜻이다. 그렇다면 그 파일을 staging 폴더에 두고 마커로 가리키면 된다 — 생성도 옮겨 적기도 통째로 사라진다.
「훅도 결국 프롬프트 지시에 기대지 않나?」 맞다 — 기준본을 만드는 것은
stage절차이고 그것도 강제력이 없다. 훅이 없앤 것은 강제력 문제가 아니라 침묵이다. 지시를 건너뛰었을 때 무슨 일이 나는지가 달라진다.
어디까지 했나 표류가 나면 current/를 채웠다문자 단위로 잡아 틀린 글자를 지목한다 기준본 없이 그냥 보냈다 에코로 한 번 막고 값을 보여준다 — 눈으로 봐야 하지만 소리는 난다 훅이 아예 없다 아무 일도 일어나지 않는다. 저장되고, 에러도 없다 즉 지시를 안 따라도 바닥이 「조용한 저장」에서 「시끄러운 확인」으로 올라간다. 기계적 확실성은
current/가 있는 구간에서만 얻고, 그 구간을 넓히는 것이stage절차의 일이다.
보내는 방법의 우선순위 — 위에서부터 시도한다.
- 마커 — 본문을 모델이 써내지 않는다. 조건: 값 전체가 파일 한 구간과 같아질 수 있고,
훅이 물려 있고, Windsurf 가 아니고, 도구가 검사 대상 목록(
GATED) 안일 것.⚠️ 대가가 둘 있다 — 마커를 쓴 호출은permissionDecision: "allow"로 나가 쓰기 도구의 권한 확인을 건너뛰고, 훅이 안 물린 세션에서는 본문 대신 마커 문자열이 저장된다. 쓰기 승인을 사람이 직접 보고 싶은 팀이라면 2번을 기본으로 두는 편이 맞다 - 리터럴 UTF-8 — 검증된 1차 대응(
0~1/30). 위 조건이 하나라도 깨지면 기본값 - 기계 생성 이스케이프(
json.dumps) — 1·2 가 불가능할 때만 - 손으로 쓴 이스케이프 — 금지 (
23/23, 손으로 만든 45건에서 13자 표류)
이 훅을 켠 상태에서 노션 문서를 고치다 잡힌 것이다. 리터럴 관행을 지키고 있었는데도 났다.
잡히지 → 잡힐지
'히' U+D788 (초성 18, 중성 20, 종성 0)
'힐' U+D790 (초성 18, 중성 20, 종성 8) ← 종성만 0 → 8
길이가 같고 자모 하나만 바뀐다. 저장됐다면 오타로 보여 그냥 넘어갔을 것이다 — 저장 전에 막힌 것은 기준본과 문자 단위로 대조했기 때문이다.
같은 호출에서 「잡힐 수 없고」로 길이까지 달라진 것도 걸렸다 — 표류 서명(길이 동일)이 아니라 에코 경로(기준본에 없는 새 한글을 보여주고 같은 값 재전송 시 통과시키는 경로, 뒤 「어떻게 막는가」)가 받아냈다.
긴 한글을 재생산할 때 자모 하나가 어긋난다(발생률은 위 「선행 사례」).
합쳐야 → 합쳤야 (종성만 0 → 20)
뜬다 → 뜼다 (종성만 4 → 20)
부딪힌 → 부딪친 (초성만 18 → 14)
바뀐 결과가 유효한 한글이라 JSON 파싱도, 서버 검증도 통과한다. 그대로 저장된다.
- Jira
editJiraIssue에서 6건 사고 / 표류 지점 약 59군데 (4개 티켓) - 최악은 한 호출에서 40군데 — 비율→발율 ×19, 갱신→갱슱, 비고→뱄고
- 6건 전부 정상 저장됐다. 에러 응답이 없어 사후에야 발견됐다
- 정정 호출 자체가 다시 표류한 적도 있다 (2회차 정정 필요)
- 노션도 예외가 아니다 —
notion-update-page3회에 「바뀜다」가 들어갔다
editJiraIssue 는 부분 수정 수단이 없어 본문 전체를 매번 재전송한다. 노션의 old_str 같은 대조 장치가 없으므로 틀려도 드러날 경로가 없다.
훅이 받는 값은 이미 디코드된 한글이라 「틀렸다」는 판정 자체가 불가능하다 — 의도를 모르기 때문이다. 그래서 의도를 파일로 물질화해 두고 대조한다. 독립적으로 쓴 두 사본이 갈리면 그게 표류다.
줄 단위로 셋 중 하나로 판정한다.
| 판정 | 조건 | 동작 |
|---|---|---|
| 일치 | 기준본에 그 줄이 있다 (정규화 후 비교) | 조용히 통과 |
| 표류 | 기준본의 어떤 줄과 길이가 같은데 소수 글자만 다르다 | 차단 + 틀린 위치·코드포인트 |
| 새 내용 | 닮은 줄이 없다 | 에코 후 확인 — 값을 그대로 보여주며 한 번 막고, 같은 값을 다시 보내면 통과시킨다 |
**·`·[텍스트](링크))·연속 공백을 흡수한다. 그래서 마크업만 바뀐 줄은
표류로 잡히지 않고 통과한다 — 잡는 대상이 글자의 표류이지 서식 변경이 아니기 때문이다.
여기에 호출 단위 검사가 둘 더 붙는다 — 본문을 통째로 갈아끼울 때 기준본 항목이 빠지는 통짜 교체 누락 검사와, old_str 이 삼킨 항목이 사라지는 조용한 삭제 검사. old_str 자체는 더 엄한 문턱으로 본다(아래).
표류가 잡히면 이렇게 차단된다.
[tool_input.fields.description] 기준본과 길이가 같은데 1글자가 다릅니다 — 표류로 보입니다.
기준본: 갱신 내역을 정리했고 스낵바 문구도 함께 손봤다
보낸 값: 갱슱 내역을 정리했고 스낵바 문구도 함께 손봤다
위치 1: 기준 '신' U+C2E0 → 보낸 값 '슱' U+C2B1
기준본 쪽 문자열을 그대로 보내세요. 의도한 수정이라면 기준본을 먼저 고치세요.
허용하는 불일치 개수는 줄 길이에 비례한다 — max(3, 줄길이 ÷ 10) 이므로 200자 줄에서는
20글자까지가 「표류」다(표의 「소수 글자」가 이것이다). 절대값만 쓰면 손상이 심할수록
판정이 약해진다 — 200자 줄에서 5글자가 틀리면 명백한 손상인데 문턱을 넘어 에코로 떨어진다.
반대로 10자 줄에서 4글자가 다르면 애초에 다른 줄일 가능성이 크다.
표류 판별을 "길이 동일 + 소수 글자 차이"로 좁힌 이유: 자모 치환은 길이를 바꾸지 않고(합쳐→합쳤), 문구 수정은 길이가 변한다. 유사도만 보면 정상 수정을 표류로 오판해 원문을 되돌리게 만든다.
old_str 은 더 엄하게 본다. 페이지에 있는 텍스트를 그대로 옮긴 것이어야 하므로 「새 내용일 수 있다」는
여지가 없다. current/ 의 줄과 85% 이상 닮았는데 정확히 다르면 길이가 바뀌었어도 손상으로 보고 차단한다.
여기에 더해 조용한 삭제 검사 — old_str 로 지워지는 항목이 new_str 에 없고 기준본에는 남아 있으면 차단한다.
같은 자리를 고쳐 쓴 것(유사도 0.6 이상)은 삭제가 아니므로 통과시킨다.
막다른 길을 만들지 않는 것이 대조 정확도보다 중요하다. 새 내용 경로는 항상 열려 있어야 한다 — 기준본에 있을 수 없는 일회성 텍스트(새 문서·코멘트)가 영구 차단되면 훅이 작업을 불가능하게 만든다.
| 사람 | 설치한다. 그 뒤로는 평소처럼 문서 작업을 시킬 뿐이다 |
| 에이전트 | 서버 본문을 fetch 해 기준본을 쓰고, 저장 도구를 부른다. 막히면 메시지를 읽고 고친다 |
| 훅 | ① 나가는 값을 기준본과 대조해 어긋나면 저장 전에 막는다 ② 마커를 파일 내용으로 치환한다 ③ 세션 시작 시 리터럴 UTF-8 지시를 주입한다 ④ 기준본 폴더에 .gitignore 를 놓는다 |
훅이 디스크에 남기는 것은 「제거」 절에 있다.
아래 설명과 훅의 차단 메시지는 모두 에이전트를 향한 것이다 — 사람이 /hangul-corruption-guard:stage 를 칠 일은 없다.
<staging>/current/<문서 id>.md 서버 fetch본 → 표류 대조 (내가 안 쓴 사본)
<staging>/target/<문서 id>.md 내 최종본 → 삭제·누락 판정
왜 나누는가. 「독립적으로 쓴 두 사본을 대조한다」는 전제는, 두 사본을 같은 모델이 같은 턴에
쓰면 성립하지 않는다. 표류가 반복되면 기준본에도 같은 오류가 들어가 그대로 통과한다.
current/ 를 서버 fetch 로 채우면 그 사본만은 내가 쓰지 않은 것이 되어 전제가 회복된다 —
안 바꾼 줄을 다시 타이핑하다 어긋난 경우를 확실히 잡는다. 표류 사고의 대부분이 여기다
(Jira 사고 6건이 전부 description 통짜 재전송이었다).
current/ 로 삭제를 판정하지는 않는다. 현재 본문에서 항목을 빼는 것은 정당한 편집이다.
기존 문서를 고칠 때 current/ 가 비어 있으면 에코가 자주 뜬다. 문서 일부만 바꿔도 주변
문장을 앵커로 함께 보내게 되는데, 그 줄들은 target/ 에 없으니 전부 「새 한글」로 걸린다.
current/ 에 서버 본문이 있으면 그대로 매칭돼 조용히 지나간다.
기준본 폴더를 찾는 규칙은 이렇다.
HANGUL_STAGING환경변수가 있으면 그 폴더 하나만 쓴다. 폴백이 아니라 배타적 지정이다 — 프로젝트 폴더도 홈 폴백도 후보에서 빠지고, 아래 자격 검사도 이 경로에는 적용하지 않는다 (사용자가 직접 지정한 곳은 그대로 믿는다)- 없으면 프로젝트
.claude/hangul-staging/→~/.claude/hangul-staging/순으로 본다. 프로젝트는 훅 stdin 의cwd로 판별하므로 설정 없이 동작한다
~/.claude/hangul-staging/current/<문서 id>.md 는 문서 id 만
같으면 어느 저장소에서 작업하든 그 문서의 기준본으로 채택된다. 편리한 만큼 위험도 같다 —
오래된 사본이 남아 있으면 그것이 정답 노릇을 한다. 프로젝트 안에 두는 편이 안전하다.
cwd 한 곳만 보지는 않는다. 거기서 가장 바깥 저장소 루트까지 올라가며 후보를 모으고
가까운 쪽을 먼저 쓴다. 셸이 하위 폴더에 들어가 있다는 이유만으로 프로젝트 기준본이 후보에서
빠지면 마커가 통째로 거부되기 때문이다.
경계가 양쪽에 있다.
| 조건 | 동작 | 이유 |
|---|---|---|
| 하위 저장소(서브모듈·vendor) 안 | 처음 만난 .git 에서 멈추지 않고 바깥 루트까지 올라간다 |
거기서 멈추면 상위 프로젝트 기준본이 빠져 같은 함정이 그대로 남는다 |
저장소 밖 (.git 없음) |
상향 탐색을 하지 않는다 — cwd 와 홈 폴백뿐 |
끝까지 올라가면 /tmp/.claude/hangul-staging 처럼 남이 쓸 수 있는 폴더가 후보가 된다 |
| 후보가 심볼릭 링크·그룹/타인 쓰기 가능·남의 소유 | 건너뛴다 (사유를 차단 메시지에 적는다) | 그 폴더의 파일이 마커 치환과 대조의 기준이 되면 검사가 통째로 뒤집힌다. 그룹 쓰기도 막는 이유는 macOS 기본 그룹 staff 에 로컬 계정 전원이 들어가고 umask 002 서버의 mkdir 이 그냥 0775 를 만들기 때문이다 |
홈 폴백 ~/.claude/hangul-staging |
같은 자격 검사를 받는다 | 예외로 두면 늘 존재하는 그 전역 폴더만 무검증 통로가 된다 |
기준본은 저장소에 올릴 것이 아니다. 서버 본문 사본이고 파일 이름이 page_id 라 읽히지도
않는데, 무시 설정이 없어 소비 프로젝트의 git status 를 더럽혔다(실측 4개). 그래서 훅이 staging
폴더 안에 자기 자신까지 무시하는 .gitignore(주석 2줄 + *)를 놓는다 — 프로젝트의 .gitignore
는 건드리지 않는다. 검사 대상 도구뿐 아니라 아무 MCP 호출에서나 돈다 — 검사 대상 호출이 한
번도 없는 세션에서도 앞 세션이 만들어 둔 폴더를 덮어야 하기 때문이다(실측: MCP 를 쓴 36개 세션
중 21개가 게이트 호출 0회였다).
이 정리 자체의 비용은 0.1ms 대이고 폴더가 없으면 git 도 띄우지 않는다. 다만 그것은 추가분일
뿐이다 — 훅의 matcher 가 mcp__.* 라 모든 MCP 호출마다 python3 프로세스가 새로 뜬다.
이 머신 실측으로 호출당 중앙값 약 210ms(그중 인터프리터 기동만 약 115ms), 독립 측정에서는
중앙값 246ms · p90 447ms 였다.
줄이려면 matcher 를 자기 서버 접두어로 좁힌다.
남의 저장소에 파일을 쓰는 일이라 조건을 좁게 잡는다.
- 없는 폴더는 만들지 않는다 — 아무 프로젝트에나 빈 폴더가 생기면 안 된다
- git 워크트리 안에서만 쓴다 — 밖에서는 할 일이 없는 순수 부작용이다
- 폴더가 링크거나 남이 쓸 수 있으면 건너뛴다
- 이미 추적 중인 파일이 있으면 건드리지 않는다 — 커밋뿐 아니라 인덱스까지 본다(
git ls-files). git 이 실패하면 「모른다」로 보고 쓰지 않는다 - 홈은 건너뛴다 — 홈이 git 저장소인 사람(dotfiles)의 전역 폴백 폴더까지 정할 일은 아니다
- 안 넣기로 한 판정은 1시간 캐시하고, 추적 중이라 못 넣었다는 사실은 세션에 한 번 알린다
HANGUL_STAGING으로 직접 지정한 폴더는 건드리지 않는다 — 그 폴더의 git 정책까지 대신 정할 일은 아니다- 이미 있으면 덮어쓰지 않는다
.claude 가 숨김 디렉토리라 기본 rg 에 원래 안 잡히고, * 가 rg --hidden 까지 막는다 — 실패가 조용해서 「무시돼서 안 봤다」와 「그런 줄이 없다」가 같은 빈 결과다. 대처법은 절차서(commands/stage.md)와 훅의 차단 메시지가 그 자리에서 알려준다.
기준본을 저장소에 올리기로 한 프로젝트라면 위의 「추적 중이면 건드리지 않는다」에 걸려 훅이 아무것도 하지 않는다.
본문을 파라미터에 직접 쓰는 대신 마커를 보내면 훅이 파일 내용으로 치환한다.
보내는 값: "@@hangul:current/PROJ-123.md@@"
실제 전송: <파일 내용 그대로>
얻는 것 하나, 못 얻는 것 하나.
- 표류 원천 차단 — 그 본문이 파라미터 경로에서는 모델 토큰을 거치지 않는다. 같은 본문을 반복 전송할 때(Jira 정정 2회 = 3회 전송) 추가 재생산이 0 이 된다
- 토큰 절감은 크지 않다 — 이미 리터럴로 쓰고 있다면 파일에 한 번 쓰고 마커 40자를 보내는 것이라 거의 본전이다. 이득이 확실한 곳은 본문 전체를 매번 다시 보내는 Jira 다(위 「실제 피해」)
줄을 지정할 수도 있다. old_str 처럼 기존 본문의 조각을 여러 개 보내야 할 때 쓴다 —
조각마다 파일을 만들게 하면 결국 손으로 옮겨 적게 되고, 그 순간 표류가 다시 들어온다.
"@@hangul:current/PROJ-123.md#L12@@" 12번째 줄
"@@hangul:current/PROJ-123.md#L12-L15@@" 12~15번째 줄
마커에는 길이 제한이 없다 — 재생산이 0 회라 쪼갤 이유가 없고, 통짜가 오히려 왕복을 줄인다(실측: old_str 73줄 · new_str 131줄을 포함한 섹션 5개 배치가 한 번에 통과했다).
규칙: 문자열 전체가 마커일 때만 치환한다(본문 중간 삽입은 경계가 모호해 미지원). 형태가 어긋난 마커·없는 파일·staging 폴더 밖 경로는 치환하지 않고 차단한다 — 치환 없이 나가면 본문 대신 마커 문자열이 저장되기 때문이다. 본문을 통째로 갈아끼우는 필드에는 줄 지정 마커를 쓸 수 없다(지정한 줄만 남는다).
치환된 본문도 삭제·누락 검사를 그대로 거친다 — 마커가 검사를 우회하는 구멍이 되면 안 되기 때문이다.
엉뚱한 파일을 가리키면 삭제·누락 검사(target/ 이 있을 때)나 에코(없을 때)에 걸린다.
못 막는 것들.
- 가리킨 파일이 서버보다 오래된 경우. 훅은 서버 본문을 볼 수 없어 「내용이 최신인가」를 판정하지 못한다. 대신 나이는 볼 수 있어서, 6시간 넘은 기준본을 쓰면 한 번 확인을 받는다(경과 시간과 크기를 보여주고, 같은 호출을 반복하면 통과). 오래됐으면 다시 fetch 하는 편이 안전하다
- 줄 번호를 잘못 센 경우 — 조용하다.
#L12를#L13으로 쓰면 훅은 13번째 줄을 정상적으로 돌려준다. 그 줄은 기준본에 실재하는 줄이라 표류 검사도 에코도 통과한다. 원리적으로 검출되지 않으므로 줄 번호는grep -n으로 확인하고 적는다 - 기준본 자체가 틀린 경우. 마커는 기준본을 검증 대상에서 전송 대상으로 바꾼다. 기준본에 표류가 들어 있으면 대조 상대 없이 그대로 저장된다
- 훅이 로드되지 않은 경우 — 실패 양상이 다른 방식과 반대다. 다른 방식은 훅이 없으면 검사만
생략되지만, 마커는 본문 자리에
@@hangul:…@@문자열이 그대로 저장된다. 즉 마커는 훅이 확실히 물려 있는 세션에서만 무료 업그레이드다. 즉시 눈에 띄고 원본이 staging 에 남아 복구는 쉽지만, 알고 써야 한다 - 권한 프롬프트가 사라진다. 마커를 쓴 호출은
permissionDecision: "allow"로 나가므로 호스트의 확인 절차를 거치지 않는다
지원: Claude Code · Cursor · Codex CLI (updatedInput). Windsurf 는 입력 치환을 지원하지 않아 마커를 쓰면 차단하고 안내한다.
MCP 쓰기 도구 중 문서 본문을 저장하는 것만 검사한다 — Notion(update-page·create-pages·create-comment 등),
Jira(editJiraIssue·createJiraIssue·addCommentToJiraIssue), Confluence(create/updateConfluencePage 등).
mcp-atlassian 의 jira_update_issue) 이름이 안 맞아 훅이 조용히 검사를 건너뛴다.
자기 서버의 도구 이름을 core/hangul_corruption_guard.py 의 GATED 튜플에 추가한다(부분 문자열 매칭). 소스 수정이라 설치 사본(~/.claude/plugins/cache/…)을 고치면 업데이트에 덮인다 — 포크해 자기 마켓플레이스로 깔거나 claude --plugin-dir <클론 경로> 로 띄운다.
Write/Edit 등 MCP 밖 도구는 검사하지 않는다(위 #26141 처럼 표류는 거기서도 난다 — 넓히는 것은 열린 과제다).
| 제외 | 범위 | 이유 |
|---|---|---|
| 화이트리스트 밖 도구 | 훅 전체 | 이름 휴리스틱은 오탐이 많다 — browseros·chrome-devtools 의 한글 폼 입력, 한글 법령 질의까지 걸린다. 문서 본문을 저장하는 도구만 명시적으로 검사한다 |
| MCP 아닌 도구 | 훅 전체 | Write/Bash 경로에서는 관측된 적이 없다 |
old_str |
에코 확인만 | 기존 본문이라 target/ 에 없는 게 정상이다. 표류·삭제 검사는 오히려 더 엄하게 돈다(위 「old_str 은 더 엄하게 본다」 참조) |
| 한글 8자 미만의 새 줄 | 에코 확인만 | 라벨·제목 수준이라 확인 소음이 크다. 표류 검사는 길이와 무관하게 돈다 — 조각내서 보내는 것만으로 우회되면 안 된다 |
Claude Code — 터미널 CLI 에서 실행한다.
/plugin marketplace add YourBestDeveloper/hangul-corruption-guard
/plugin install hangul-corruption-guard@yourbestdeveloper
/reload-plugins
훅은 플러그인에 포함돼 있고 세션 시작 시 로드된다 — 설치한 뒤 PreToolUse·SessionStart 가 물리려면 세션을 새로 열어야 한다.
물렸는지 확인한다. 실패가 조용해서(검사가 없어질 뿐 에러가 안 난다) 확인 없이는 알 수 없다.
CORE=$(ls -d ~/.claude/plugins/cache/*/hangul-corruption-guard/*/core/hangul_corruption_guard.py \
2>/dev/null | sort -V | tail -1) # 옛 사본이 남아 있으면 최신을 고른다
[ -n "$CORE" ] || echo '설치본이 없다'
echo "검사 대상: $CORE"
echo '{"session_id":"probe","cwd":"'"$PWD"'","tool_name":"mcp__x__editJiraIssue",
"tool_input":{"issueIdOrKey":"PROBE","fields":{"description":"@@hangul:없는파일.md@@"}}}' \
| python3 "$CORE" 2>&1 | head -1
bash "$(dirname "$CORE")"/../hooks/session-start.sh | head -c 40판정은 출력 문자열로 한다 — 미설치여도 python3 가 똑같이 exit 2 를 내므로 종료코드로는 구분되지 않는다.
기준본 파일을 찾을 수 없습니다가 나오면 코어가 정상이다hookSpecificOutput이 이어서 나오면SessionStart도 정상이다can't open file이 나오면 설치되지 않은 것이다
여기까지는 파일이 멀쩡한지 본 것이다. 이 세션에 실제로 물렸는지는 /hooks 로
PreToolUse·SessionStart 목록에 이 플러그인이 있는지 확인한다.
/plugin 과 /reload-plugins 는 터미널 claude 에만 있다(VS Code 확장 2.1.246 기준
/reload-skills 만 있다). 확장 안에서는 이 명령을 실행할 수 없으니 터미널에서 설치한다.
개발·시험용으로는 저장소를 클론해 직접 연결해도 된다 — 이 방식은 지금 띄우는 세션에만 적용된다.
claude --plugin-dir <이 저장소 경로>/plugin uninstall hangul-corruption-guard@yourbestdeveloper 후 세션을 새로 연다.
소비 프로젝트에 남는 것은 .claude/hangul-staging/ 뿐이니 지우면 된다 — 그 폴더 안
.gitignore 는 훅이 놓은 것이고 같이 지워진다.
머신 전역에는 두 곳이 더 남는다 — 홈 폴백 기준본 ~/.claude/hangul-staging/ 과
승인·판정 표시용 빈 파일 폴더 ~/.claude/hooks/.hangul-echo-cache/. 둘 다 통째로 지워도 된다.
임시로만 끄려면 제거 대신 HANGUL_GUARD=off.
| 필요한 것 | 쓰는 곳 | 없으면 |
|---|---|---|
python3 (3.6+, 표준 라이브러리만) |
검사·치환 코어 | 훅이 통째로 안 돈다. 실패가 조용하다 |
bash |
SessionStart 지시 주입 |
1차 대응이 안 실린다(검사·치환은 정상) |
git |
기준본 폴더 .gitignore 정리 |
그 기능만 건너뛴다 |
macOS·대부분의 Linux 는 셋 다 기본 제공하지만 Windows 는 python3·bash 가 없는 경우가
흔하다. 위 확인 절차도 셸 명령이라 Windows 에서는 Git Bash·WSL 같은 POSIX 셸에서 돌려야 한다.
SessionStart 는 서브에이전트 시작에서 발화하지 않는다. 한글 문서 작업을 위임하면 위임 프롬프트에 지시를 직접 적거나 메인 세션에서 한다.
그 밖의 도구 — adapters/ 에 Cursor · Windsurf · Codex CLI 설정 템플릿이 있다.
설정 파일 위치와 치환할 자리표시자는 adapters/README.md 에 있다.
입력 스키마 정규화와 설정 파일 형식은 테스트로 확인했지만, 실제로 그 도구를 띄워 보지는 않았다.
PreToolUse 검사·치환뿐이다. /hangul-corruption-guard:stage 절차서와 SessionStart 1차 대응은 Claude Code 플러그인 기능이라 따라가지 않는다 — 그 도구의 규칙 파일에 1차 대응 한 줄(「이 훅의 자리」)과 commands/stage.md 의 내용을 직접 넣는다. 안 넣으면 기준본이 없어 전부 에코 경로다.
python3 tests/test_guard.py파이썬 표준 라이브러리만으로 돈다. 훅을 서브프로세스로 띄워 stdin/종료코드 계약을 검증하므로
내부 구현에 묶이지 않는다. .gitignore 관련 검사만 실제 git init 저장소를 만들어 확인하므로
git 이 PATH 에 있어야 한다.
core/hangul_corruption_guard.py 도구 중립 코어. stdin JSON → exit 0/2
extract_call() 도구별 입력 스키마를 하나로 정규화
hooks/hooks.json PreToolUse(검사·치환) + SessionStart(지시 주입)
hooks/session-start.sh 리터럴 UTF-8·원본 복사 관행을 세션 컨텍스트에 넣는다
commands/stage.md 에이전트용 기준본 작성 절차서
.claude-plugin/ 플러그인·마켓플레이스 매니페스트 (저장소 루트가 플러그인 루트)
adapters/ 다른 도구 설정 템플릿 (PreToolUse 만 — SessionStart 는 미대응)
create-pages · createJiraIssue 는 아직 문서 id 가 없어 이름을 맞춘 기준본을 둘 수 없다.
이때는 에코 경로로 동작한다(위 「어떻게 막는가」 판정표).
확실하게 보내려면 본문을 아무 이름의 파일로 staging 에 쓰고 마커로 보내면 된다. 대조 기준이 없어 에코 확인이 한 번 뜨지만, 재전송해도 파일 내용이 그대로 나가므로 재생산은 0 회다.
표류를 뒤늦게 발견했을 때의 절차다. 순서가 중요하다 — 고친 본문을 target/ 에 먼저 쓰지
않으면, 훅이 서버의 손상된 본문을 기준본으로 삼아 「기준본 쪽 문자열을 그대로 보내세요」라고
안내한다. 시키는 대로 하면 손상을 한 번 더 저장한다. (훅이 그 상황을 감지하면 경고를 함께 내지만,
current/ 가 손상본이라는 것은 사람만 안다.)
- 서버 본문을 받아
current/<문서 id>.md에 쓴다. 손상된 채로 그대로 — 무엇이 어떻게 틀어졌는지의 기준이다 - 원문을 확보한다. 세션 트랜스크립트(
~/.claude/projects/*.jsonl에서 문서 id 로 그 tool call 과 그 앞의 원자료를 찾는다) · 노션 페이지 버전 히스토리 · 사용자가 준 원자료 순으로 본다. 없으면current/에서 사전에 없는 음절(발율·갱슱류)을 훑어 목록을 만들고 사용자에게 확인받는다 — 표류한 글자도 유효한 한글이라 기계적으로는 가려낼 수 없다 - 고친 전문을
target/<문서 id>.md에 쓴다. 이 단계를 건너뛰면 위의 함정에 빠진다 - 정정 호출은 마커로 보낸다 —
old_str은@@hangul:current/<문서 id>.md#L<n>@@,new_str은@@hangul:target/<문서 id>.md#L<n>@@. 정정 호출 자체가 다시 표류한 전례가 있다(위 「실제 피해」의 2회차 정정). 마커로 보내면 그 몫이 0 이 된다 - 저장 뒤 다시 fetch 해
target/과diff한다. 눈으로 훑는 확인은 이 저장소 이력에서 두 번 실패했다
길이가 같은 정상 수정(가능하다 → 불가하다, 3일 → 5일)은 표류와 구분되지 않아 차단된다.
오탐 빈도는 측정하지 않았다 — 도입 판단에서는 「길이가 같은 정상 수정마다 한 번 막힌다」로 보수적으로 잡는다.
빠져나가는 방법은 셋이다.
- 기준본(
target/<문서 id>.md)을 먼저 고친다 — 의도한 수정이라면 이게 맞다 - 마커로 보낸다 — 파일 내용이 그대로 나가므로 대조가 필요 없다
HANGUL_GUARD=off로 끈다 — 급할 때의 킬 스위치
⚠️ 2번과 3번을 이어서 쓰면 안 된다. 킬 스위치는 마커 치환보다 먼저 훅을 끝내므로, 마커를 보내던 중에 끄면 본문 대신@@hangul:…@@문자열이 그대로 저장된다. 훅이 로드되지 않은 세션과 같은 실패 양상이다(「마커가 막는 것과 못 막는 것」 참조). 끄기 전에 마커를 리터럴 본문으로 되돌려라. 그리고 셸 환경변수라claude를 띄우기 전에 걸어야 한다 — 세션 도중에는 못 끈다.
- 대조가 잡는 건 두 사본이 갈릴 때다. 기준본을 틀리게 쓰고 파라미터도 똑같이 틀리면 통과한다
- 기준본이 없으면 에코 경로라 쓰기마다 왕복 1회가 붙는다 — 긴 본문은 기준본을 먼저 쓰는 게 빠르다
- 훅이 로드되지 않으면 아무것도 막지 못한다. 기준본을 만들어 둬도 대조하는 주체가 없다 —
/hangul-corruption-guard:stage는 파일을 준비할 뿐이다. VS Code 확장처럼 플러그인 훅이 안 뜨는 환경에서는 저장 후 서버에서 다시 fetch 해diff하는 것 말고 방법이 없다 - 순수하게 새로 쓰는 문장은 원리적으로 대조 대상이 없다. 에코는 거르지 않는다 — 차단하면서 그
값의 승인을 함께 발급하므로, 같은 값을 다시 보내면 통과한다. 보증되는 것은 「그 값을 한 번
보여줬다」와 「같은 값을 두 번 보냈다」뿐이고, 눈으로 놓치면 그대로 저장된다. 4,000자가 넘으면
가운데를 생략해 보여주므로 확인도 부분적이다. 기계적 확실성은
current/가 있는 구간에서만 얻는다 - 한글이 없는 줄은 기준본에서 통째로 버려진다. 코드 블록·영문·URL·숫자만 있는 줄은 대조에도 삭제 검사에도 들어가지 않는다 — 통짜 교체 누락 검사가 그런 줄은 보호하지 못한다
- 에코 승인·오래된 기준본 확인·
.gitignore판정 표시가 캐시 폴더(~/.claude/hooks/.hangul-echo-cache)에 빈 파일로 쌓인다. 파일은 지워지지 않는다 —.gitignore판정만 1시간 뒤 다시 보고(파일은 그대로 남는다) 나머지는 만료가 없다. 통째로 지워도 무방하다 - 깨진 JSON 을 받으면 조용히 통과한다. 호스트가 넘긴 입력을 파싱하지 못하면 검사 없이 지나간다 — 「막다른 길을 만들지 않는다」의 대가다
- 줄 지정 마커의 범위 안 오지정(
#L12→#L13)은 검출되지 않는다. 그 줄도 기준본에 실재하는 줄이라 모든 대조를 통과한다 - 검사 대상은 화이트리스트라, 새로운 문서 저장 도구가 생기면
GATED에 추가해야 한다