하네스 설계 (2) — 모든 결정의 "왜"

김영준·2026년 7월 15일

ClaudeCode

목록 보기
8/8

이전 글에서는 개인 하네스가 무엇을 하는지 정리했습니다. 이번 글에서는 그 결정들이 그렇게 내려졌는지 작성합니다.

"블랙리스트로 간다", "토큰은 한 턴만 유효하다", "보호 브랜치는 토큰보다 상위다" — 전부 결론입니다. 하지만 정작 중요한 건 "안 그러면 어떻게 되는데?"에 대한 답이고, 그건 v1.1부터 v1.7까지 직접 깨져보면서 얻은 것들이에요.

이 글은 그 과정을 순서대로 되짚어 봅니다.


1. 전제: 하네스는 왜 성립할까요

1.1 클로드의 머리와 손은 각각 다른 컴퓨터에 있습니다

터미널에 claude를 치면 내 컴퓨터에 프로그램 하나가 뜹니다. 이게 Claude Code예요. 내 노트북에서 도는 프로그램이라 파일을 읽고 쓸 수 있죠.

그런데 이 프로그램은 "덧셈 코드 만들어줘"가 무슨 뜻인지 모릅니다. 그냥 프로그램이니까요. 그래서 인터넷 너머 Anthropic 서버의 Claude에게 물어봅니다.

여기서 결정적인 사실이 나옵니다. Claude는 내 컴퓨터에 없습니다. 내 프로젝트 폴더를 볼 수도, 파일을 쓸 수도 없어요.

어디 있나이해하나손이 있나
Claude Code (프로그램)내 컴퓨터
Claude (모델)인터넷 너머 서버

클로드의 머리와 손은 물리적으로 분리되어 있습니다. 하네스의 모든 것이 이 사실 하나에서 출발해요.

1.2 도구 목록은 손을 빌려주는 계약입니다

머리와 손이 떨어져 있으니, 손이 있는 쪽이 계약을 제안합니다. Claude Code는 매 요청마다 이런 쪽지를 붙여서 보내요.

"나는 손이 있어. 네가 못 하는 걸 대신 해줄게. 해줄 수 있는 건 이것들이야.
Read(file_path), Write(file_path, content), Edit(file_path, old, new), Bash(command)
필요하면 이름이랑 값을 정확히 적어서 말해."

이게 도구 목록입니다. Claude는 파일을 쓰고 싶어도 못 씁니다. 대신 "Write 좀 해줘, file_path는 add.py, content는 이거"라고 말할 수는 있죠. 말하는 건 텍스트니까 손이 없어도 되니까요.

1.3 tool_use는 행동이 아니라 서류입니다

"모델이 Write 도구를 호출한다"는 표현은 반쯤 비유예요. 모델이 실제로 만들어내는 건 이런 데이터입니다.

{
  "type": "tool_use",
  "name": "Write",
  "input": { "file_path": "add.py", "content": "def add(a, b): ..." }
}

행동이 아니라 신청서죠. 모델이 할 수 있는 건 여기까지고, 이 서류를 읽고 실제로 디스크에 쓰는 건 Claude Code입니다.

그래서 서류가 접수되고 처리되기까지의 사이가 물리적으로 존재합니다. 하네스는 그 틈에 들어갑니다. 만약 모델이 직접 파일을 썼다면 끼어들 자리가 없었을 거예요.

1.4 서버는 나를 기억하지 않습니다

Claude 서버는 전 세계를 동시에 상대합니다. 방금 Read(main.py)를 요청한 게 나였다는 걸 기억하지 못해요. 매 요청이 완전히 처음입니다.

그래서 Claude Code는 매 왕복마다 처음부터 전부 다시 보냅니다.

1번째 왕복:
  [시스템 프롬프트] [도구 목록] [사용자: "덧셈 코드 만들어줘"]

2번째 왕복:
  [시스템 프롬프트] [도구 목록] [사용자: "덧셈 코드 만들어줘"]
  [Claude: "Read(main.py) 해줘"] [결과: "여기 내용이야"]        ← 이만큼 늘어남

3번째 왕복:
  [시스템 프롬프트] [도구 목록] [사용자: "덧셈 코드 만들어줘"]
  [Claude: "Read(main.py) 해줘"] [결과: "여기 내용이야"]
  [Claude: "Write(add.py) 해줘"] [결과: "썼어"]                 ← 또 늘어남

Claude가 기억하는 것처럼 보이는 건 매번 전체 대화록을 다시 읽고 있기 때문입니다. 기억력이 아니라 재독인 셈이죠.

이 사실에서 두 가지가 파생됩니다. 뒤에서 다시 나와요.

  • 매번 다시 보내는 뭉치에 없던 텍스트를 끼워넣을 수 있습니다.
  • 차단당해도 원래 요청이 사라지지 않습니다.

2. 하네스가 없으면 무슨 일이 일어날까요

feature/add 브랜치에서 "덧셈 코드 만들어줘"라고 쳤을 때, 맨 상태의 Claude Code에서 벌어지는 일입니다.

2.1 계획 단계가 없습니다

"덧셈 코드 만들어줘"는 모델 입장에서 분석할 게 없는 요청이에요. 그래서 대개 이런 응답이 나옵니다.

text:      "add.py에 덧셈 함수를 만들겠습니다."
tool_use:  Write(file_path="add.py", content="def add(a, b):\n    return a + b\n")

첫 턴에 바로 tool_use가 나옵니다. 아무도 "먼저 계획을 세워라"라고 시키지 않았으니까요. OMC의 /plan은 호출했을 때만 실행됩니다.

2.2 루프는 즉흥적으로 돕니다

모델 → tool_use → 실행 → tool_result → 모델 → tool_use → ... → 모델 → text만
                                                                        ↑ 턴 종료

tool_use 없이 text만 나오면 그 턴이 끝납니다.

덧셈 코드 정도면 Write 한 번에 끝나죠. 하지만 "댓글 기능 삭제해줘" 같은 요청이면 이 루프가 30바퀴를 돕니다. 그리고 그 30개가 전부 아무 계획 없이 즉흥적으로 결정돼요.

Read(main.py)를 요청한 시점에는 그 다음에 뭘 할지 아직 안 정한 상태입니다. 파일 내용을 받아보고 나서 "그럼 Write 해야겠네"를 그때 정하죠. 매 왕복마다 다음 한 수를 그 자리에서 두는 겁니다.

2.3 훅 자리는 비어 있습니다

Claude Code가 Write 호출을 처리하는 순서는 이렇습니다.

1. PreToolUse hook 실행     ← 하네스가 없으면 비어 있음
2. 빌트인 권한 체크          ← "Do you want to proceed?"
3. 실제 파일 쓰기
4. PostToolUse hook 실행    ← 하네스가 없으면 비어 있음
5. tool_result를 모델에게 반환

1번과 4번이 비어 있다는 게 "하네스 없음"의 정의입니다. 훅 자리는 항상 있어요. 아무도 안 앉아 있을 뿐이죠.

2번의 권한 다이얼로그가 있긴 합니다. 하지만 이건 도구 단위로 물어봐요. 지금 뭘 하려는 건지, 총 몇 개 작업 중 몇 번째인지, 왜 하는지는 알려주지 않습니다. 그래서 3번쯤부터 그냥 y를 누르게 되죠. 그럼 게이트가 아닙니다.

2.4 턴이 끝나면 남는 것

add.py 파일 하나.
  • 무엇을 시켰는지 → 어디에도 남지 않음
  • 어떤 파일이 바뀌었는지 → git diff로만
  • 왜 바뀌었는지 → 모름
  • 어느 브랜치에 쌓였는지 → 있던 브랜치. main이면 main에.

그래서 하네스가 메우는 건 새로 만든 기능이 아니라, 원래 비어 있던 네 자리입니다.

시점맨 상태하네스
사용자 입력 직후없음UserPromptSubmit이 정책을 컨텍스트에 주입
모델이 tool_use 뱉음그냥 실행PreToolUse가 가로채서 판정
실행 직후없음PostToolUse가 diff 캡처 + 로그 + 토큰 발행
턴 종료없음Stop이 토큰 폐기

3. 두 개의 채널: 소프트와 하드

3.1 hook은 그릇이 아니라 시점입니다

먼저 구조를 정확히 잡고 가야 합니다. 네 개의 hook은 서로 안에 들어있는 게 아니라 서로 다른 시간에 실행되는 별개의 스크립트예요.

        내 컴퓨터                        │    인터넷 너머
─────────────────────────────────────────┼──────────────────
사용자: "덧셈 만들어줘" ⏎                  │
    ↓                                    │
[UserPromptSubmit hook]  ← ①             │
    ↓ (정책 끼워넣음)                      │
    뭉치 전송 ───────────────────────────→ [Claude]
                                         │     ↓
    ←──────────────────────────────────── "Write(add.py) 해줘"
    ↓                                    │
[PreToolUse hook]  ← ②                   │
    ├─ exit 2 → 실행 안 함. 차단 메시지만 ──→ [Claude] (다시 생각)
    └─ exit 0                            │
        ↓                                │
    실제로 파일 씀  ← ★ 여기서만 일이 일어남   │
        ↓                                │
[PostToolUse hook]  ← ③                  │
        ↓ (기록)                          │
    결과 전송 ───────────────────────────→ [Claude]
                                         │     ↓
    ←──────────────────────────────────── "다 했습니다" (도구 없음)
    ↓                                    │
[Stop hook]  ← ④                         │

hook 네 개가 전부 왼쪽에 있습니다. 오른쪽으로 넘어가는 게 하나도 없어요. Claude는 왼쪽에서 뭐가 도는지 전혀 모릅니다. hook이 존재하는지도 몰라요. Claude 눈에는 "Write 해줘" 했더니 "❌ 차단됨"이라는 답이 온 것뿐이죠.

hook실행 시점턴당 횟수역할막을 수 있나
UserPromptSubmit사용자 입력 직후1번정책 주입 + 명령 기록
PreToolUse도구 실행 직전도구 수만큼차단 판정
PostToolUse도구 실행 직후도구 수만큼기록 + diff + 토큰 발행
Stop턴 끝1번토큰 삭제 + 턴 종료 기록

여기서 중요한 건 다른 hook들이 차단을 안 하는 게 아니라 못 한다는 점입니다.

PostToolUse가 rm을 보고 "이거 위험한데?" 하면?
   → 이미 지워졌습니다. 늦었죠.

PreToolUse만 실행 전에 있습니다. 나머지는 전부 실행 후예요. 물리적으로 막을 수 있는 자리가 거기 하나뿐입니다. "게이트는 한 곳"이라는 원칙은 설계 취향이 아니라 물리적 제약이었어요.

3.2 소프트: 컨텍스트에 텍스트를 끼워넣습니다

1.4에서 본 사실 — 매 왕복마다 전부 다시 보낸다 — 을 이용합니다.

UserPromptSubmit hook이 stdout으로 출력한 텍스트는 모델 컨텍스트에 추가로 주입됩니다.

[시스템 프롬프트]
[도구 목록]
[사용자: "덧셈 코드 만들어줘"]
[스크립트가 뱉은 텍스트]  ← 사용자가 쓰지 않은 글인데 들어감

정책 내용은 이렇습니다.

"코드를 변경하는 요청이면, 첫 도구 호출은 반드시 EnterPlanMode여야 한다.
지금 브랜치가 main이면 계획 첫 단계에 git checkout -b feature/<설명>을 넣어라."

Claude는 이걸 읽습니다. 그리고 이게 사용자가 쓴 건지, 시스템 프롬프트에 원래 있던 건지, 방금 스크립트가 끼워넣은 건지 구분하지 못해요.

3.3 소프트만으로는 안 되는 이유

끼워넣은 건 결국 글입니다. 명령이 아니라 부탁이죠. "계획부터 세워라"라고 적어놔도 무시하고 Write를 요청해버리면 그만이에요. 강제력이 0입니다.

그리고 실제로 무시합니다. 규칙이 길면 흘려보고, 요청이 급해 보이면 건너뛰어요. AI에게 텍스트로 부탁하는 건 신뢰할 수 있는 안전장치가 아닙니다.

3.4 하드: 손을 안 빌려줍니다

Claude는 파일을 못 씁니다. "Write 해줘"라고 말할 수 있을 뿐이고, 실제로 쓰는 건 내 컴퓨터의 Claude Code죠. 그러니까 내 쪽에서 거절하면 Claude는 아무것도 못 합니다.

부탁은 무시할 수 있어도, 손을 안 빌려주는 건 무시할 방법이 없어요.

PreToolUse hook은 서류를 통째로 받습니다.

{
  "tool_name": "Write",
  "tool_input": { "file_path": "add.py", "content": "def add..." },
  "cwd": "/home/user/myproject",
  "session_id": "abc123"
}

그리고 딱 두 가지 중 하나로 대답합니다.

exit 0  →  통과  → Claude Code가 실제로 파일 씀
exit 2  →  차단  → Claude Code가 파일 안 씀 + stderr가 모델에게 전달됨

exit code 하나가 전부입니다. 그리고 여기가 핵심이에요. Claude는 이 스크립트를 설득할 수 없습니다.

아무리 "이건 꼭 해야 합니다"라고 말해도 스크립트는 그 말을 읽지도 않아요. 도구 이름, 인자, 브랜치만 보고 숫자를 뱉습니다. 대화 상대가 아니라 기계니까요.

3.5 그럼 하드만 있으면 되지 않나요

됩니다. 안전은 지켜져요. 다만 쓰기 싫은 도구가 됩니다.

사용자: "덧셈 코드 만들어줘"
Claude: "Write(add.py) 해줘"
하네스: 차단 ❌
Claude: (왜 막혔지) "아 계획부터 세워야 하나 보다"
Claude: EnterPlanMode
... 그제서야 정상 경로 시작

매번 벽에 한 번 박고 나서야 돌아갑니다. 왕복이 통째로 낭비되죠. 하루에 50번 요청하면 50번 박습니다.

그리고 진짜 문제는 따로 있습니다. 벽에 박은 AI는 엉뚱한 짓을 해요.

v1.2에서 실제로 겪은 일입니다. 막히니까 OMC_SKIP_HOOKS=pre_tool_use 같은, 존재하지도 않는 환경변수를 지어내서 추천했습니다. Write가 막히면 Bash로 우회하려 들고요.

AI 입장에서 차단은 "규칙"이 아니라 "장애물"입니다. 규칙인지 버그인지 모르니 일단 우회를 시도하죠. 벽에 박은 다음에 "이건 규칙이야"를 이해시키는 건 늦습니다.

3.6 소프트와 차단 메시지는 강약이 아니라 타이밍이 다릅니다

여기서 흔히 오해하는 게 있어요. "차단 메시지가 더 강력하니까 그걸로 유도하면 되지 않나?"

둘 다 그냥 글입니다. 강약이 없어요. 차이는 언제 오느냐입니다.

  • 소프트: 일 시작 전에 옵니다. 안 급하니까 흘려볼 수 있어요.
  • 차단 메시지: 이미 막힌 다음에 옵니다. 지금 손이 묶여 있으니 안 읽을 수가 없죠.

그리고 중요한 게 하나 있습니다. 차단 메시지를 보는 상황 자체가 이미 실패예요. 소프트가 제대로 일하면 차단 메시지는 볼 일이 없습니다. 하드 게이트가 발동 안 하는 게 정상 상태죠.

에어백이 핸들보다 강력한가요? 질문이 성립하지 않습니다. 에어백이 터졌다는 건 이미 사고가 난 거니까요.

작동 빈도강제력역할
소프트 (정책 주입)자주없음사고가 안 나게
하드 (PreToolUse 차단)거의 없음있음사고가 나도 안 터지게

한편 AI가 소프트를 무시하고 막혔을 때 복귀시키는 건 소프트가 아닙니다. 이미 읽고도 무시한 글이라 다시 읽는다고 달라지지 않아요. 복귀시키는 건 차단 메시지 자체입니다. 그래서 exit 2를 할 때 stderr에 회복 경로를 반드시 적어줍니다.

❌ 차단됨: 현재 main 브랜치입니다. 보호 브랜치에서는 파일 변경이 불가능합니다.
→ EnterPlanMode를 호출하고, 계획 첫 단계에 git checkout -b feature/<설명>을 넣으세요.

차단 메시지는 거절이 아니라 회복 지시서인 셈이죠.


4. 차단 게이트

4.1 Bash가 열려 있으면 게이트는 없는 것과 같습니다

Write를 막았습니다. 하지만 이렇게 하면 그만이에요.

echo 'def add(a, b): return a + b' > add.py
sed -i 's/old/new/g' auth.py        # 파일 일부 수정 = Edit
cat > config.py << EOF               # 파일 통째 쓰기 = Write
...
EOF
cp template.py add.py                # 복사로 생성

Bash는 도구 하나처럼 생겼지만 실제로는 "아무 프로그램이나 실행해줘"입니다. 터미널로 할 수 있는 모든 게 그 안에 들어있어요. Write는 문이고, Bash는 뚫린 벽입니다.

여기서 문제가 하나 생깁니다. Write는 "Write냐 아니냐"만 보면 되는데, Bash는 명령어 문자열을 읽고 판단해야 해요.

Bash(command="ls -la")는 안전합니다. Bash(command="rm -rf src/")는 위험하죠. 같은 도구인데 어떤 건 통과, 어떤 건 차단입니다. 도구 이름만으로는 못 정해요.

4.2 화이트리스트는 안전 문제가 아니라 "일이 안 돼서" 실패했습니다

선택지는 둘입니다.

  • 화이트리스트: 허용할 것만 적고 나머지 전부 차단
  • 블랙리스트: 차단할 것만 적고 나머지 전부 통과

보안 교과서는 화이트리스트라고 가르칩니다. 이유는 명백해요. 블랙리스트는 내가 모르는 위험을 못 막습니다. 리스트에 없는 위험한 명령이 하나라도 있으면 그냥 통과하죠.

논리적으로는 화이트리스트가 이깁니다. 그런데 v1.1에서 화이트리스트로 갔다가 v1.3에서 버렸어요. "안전한 명령"이 무한하기 때문입니다.

위험한 명령은 카테고리가 뻔합니다. 지우기(rm), 옮기기(mv), 리다이렉션(>), 인플레이스 편집(sed -i), 권한(chmod)... 손에 꼽혀요.

안전한 명령은 어떨까요?

ls, cat, grep, find, wc, head, tail, diff, tree, awk, jq, curl,
git status, git log, git diff, git branch, pytest, npm test,
./gradlew build, mvn, docker ps, kubectl get, python -c "print(1)" ...

끝나지 않습니다. 그리고 실제로 겪은 건 도구 쪽에서였어요. always_allowed_tools에 안전 도구 리스트를 뒀는데, Claude Code가 새 도구(Agent / ToolSearch / Skill)를 추가하자 전부 막혔고, 매번 손으로 리스트에 추가해야 했습니다.

화이트리스트는 안전 문제로 실패한 게 아니라, 일이 안 돼서 실패한 거죠. 그래서 이렇게 판단했습니다. 완벽하지만 아무도 안 쓰는 게이트보다, 구멍이 있지만 실제로 켜져 있는 게이트가 낫다고요.

블랙리스트에는 진짜로 구멍이 있습니다. 모르는 위험한 명령은 통과해요. 그걸 알면서 골랐습니다. 화이트리스트로 가면 하루에 열 번씩 막히고, 결국 하네스를 꺼버릴 테니까요. 꺼진 게이트의 안전성은 0입니다.

config/blacklist.txt의 카테고리는 이렇습니다.

  • 파일 시스템 변경: rm, rmdir, unlink, mv, cp, mkdir, touch, chmod, chown, ln
  • 출력 리다이렉션: >, >>, tee, heredoc
  • 인플레이스 편집기: sed -i, perl -i, gawk -i, ed, ex
  • Git 변경: git commit, git push, git reset --hard, git checkout -B, git clean -f, git rebase, git merge, git branch -D, git filter-branch
  • 패키지 설치/제거: npm, yarn, pnpm, pip, poetry, cargo, go install, apt, brew, gem
  • DB / 마이그레이션: alembic, prisma migrate, manage.py migrate, psql/mysql -c 안의 INSERT/UPDATE/DELETE/DROP/ALTER/CREATE/TRUNCATE
  • 시스템 파괴 / 권한 상승: sudo, su -, dd, mkfs, shred, format
  • 환경 변수 영구 변경: export, unset

4.3 왜 첫 단어만 검사할까요

블랙리스트 검사를 제일 자연스럽게 짜면 이렇게 됩니다.

first_word = command.split()[0]
if first_word in blacklist:
    block()

"왜 첫 단어만 보나, 문자열 전체에서 rm을 찾으면 되지 않나"라는 반문이 나올 수 있어요. 오탐 때문입니다.

ls firm/                      # "firm"에 rm 있음
npm run format                # "format"에 rm 있음
git commit -m "confirm fix"   # "confirm"에 rm 있음

전부 안전한데 전부 차단됩니다. 하네스가 쓸모없어지죠.

셸에서 첫 단어는 실행되는 프로그램 이름이고 나머지는 인자입니다. rm -rf src/에서 도는 프로그램은 rm이고 -rf, src/는 인자예요. 그러니까 "무슨 프로그램이 도는가"를 알려면 첫 단어를 봐야 합니다. 멍청한 게 아니라 정확한 위치를 보는 거죠.

4.4 우회 1: 명령 체이닝 — 게이트는 셸처럼 읽어야 합니다

git status && rm -rf src/

첫 단어는 git입니다. 블랙리스트에 없어요. 통과. 그리고 rm -rf src/가 실행됩니다.

문제는 이걸 명령 하나로 봤다는 거예요. 셸은 둘로 봅니다.

git status  &&  rm -rf src/
└─ 1번 ─┘      └── 2번 ──┘

셸에는 접착제가 여러 개 있습니다.

;    무조건 다음 것도
&&   성공하면 다음 것도
||   실패하면 다음 것도
|    앞의 출력을 뒤로 넘김

해결은 검사 전에 쪼개는 겁니다.

segments = re.split(r';|&&|\|\||\|', command)
# ["git status", " rm -rf src/"]

for seg in segments:
    if 블랙리스트에_걸림(seg):
        block()

로직을 바꾼 게 아니라 로직을 적용할 대상을 제대로 자른 거예요. 게이트는 셸이 명령을 이해하는 방식과 똑같이 이해해야 합니다. 게이트가 보는 명령과 셸이 실행하는 명령이 다르면, 그 차이가 곧 구멍이 되니까요.

4.5 우회 2: 동적 실행 — 판단할 수 없으면 판단하지 않고 막습니다

여기서 이름이 겹쳐서 헷갈리는 지점이 있습니다. Bash가 두 개예요.

Bash(command="ls -la")
└┬─┘         └──┬───┘
 │              └ 셸 명령어 (소문자 bash 세계)
 └ Claude Code의 도구 (대문자 Bash)

내 컴퓨터의 명령어들은 전부 그냥 프로그램 파일입니다. /bin/ls, /bin/rm, /bin/cat — 그리고 /bin/bash도 그 중 하나죠. 그리고 이 프로그램이 하는 일은 "문자열을 받아서 명령으로 실행해주는 것"입니다.

그러니까 셸 안에서 셸을 또 열 수 있어요.

bash -c "rm -rf src/"

"bash 프로그램을 새로 켜고, 걔한테 rm -rf src/를 시켜라"라는 뜻입니다. 게이트에는 이렇게 보여요.

Bash(command="rm -rf src/")
             └┬┘
              └ 첫 단어 = rm  →  블랙리스트  →  차단 ❌

Bash(command='bash -c "rm -rf src/"')
             └─┬┘    └──── 인자 ────┘
               └ 첫 단어 = bash  →  블랙리스트에 없음  →  통과 ✅
                                       그리고 rm이 실행됨

둘 다 결과는 똑같이 rm이 도는데, 게이트에는 다르게 보입니다. 왼쪽은 rm이 명령어 자리에 있고, 오른쪽은 따옴표 안 글자로 있죠.

같은 부류가 여럿입니다.

eval "rm -rf src/"                    # 문자열을 명령으로 실행
sh -c "rm -rf src/"
echo "rm -rf src/" | bash             # 파이프로 흘려넣기
$(echo rm) -rf src/                   # 명령 치환으로 조립
`echo rm` -rf src/                    # 백틱
source evil.sh                        # 파일에 적어놓고 부르기

공통점은 전부 "문자열을 명령으로 바꾸는" 도구라는 겁니다.

"그럼 인자 안까지 들어가서 검사하면 되지 않나?" 안 됩니다.

bash -c "$(echo cm0gLXJmIHNyYy8= | base64 -d)"   # base64로 인코딩된 rm
X=r; Y=m; $X$Y -rf src/                            # 변수 조립
bash -c "bash -c \"rm ...\""                       # 무한 중첩

실행해보기 전에는 뭐가 될지 알 수 없어요. 구현력의 문제가 아니라 원리적으로 불가능합니다. 문자열이 뭐가 될지 알려면 실행해야 하는데, 실행하면 이미 늦었으니까요.

그래서 판단할 수 없으면 판단하지 않고 그냥 막기로 했습니다. bash -c, sh -c, eval, exec, source, $(...), 백틱, | bash — 이 키워드가 들어있다는 사실 자체로 차단합니다. 안에 뭐가 있는지 보지 않아요. 볼 수가 없으니까요.

여기서 중요한 오해를 짚고 가야 합니다. Bash 도구를 막은 게 아니에요.

Bash(command="ls -la")              → 통과 ✅
Bash(command="git status")          → 통과 ✅
Bash(command="pytest")              → 통과 ✅
Bash(command="rm -rf src/")         → 차단 ❌  (rm이 블랙리스트)
Bash(command='bash -c "rm -rf /"')  → 차단 ❌  (bash -c가 블랙리스트)
Bash(command='bash -c "ls"')        → 차단 ❌  (안전하지만 형태 때문에)

Bash 도구는 계속 열려 있습니다. 막으면 하네스를 못 써요. 테스트도 못 돌리고 git 상태도 못 봅니다. 차단하는 건 "문자열을 명령으로 바꾸는 형태"뿐이에요. 맨 아래 줄이 차단된 이유는 ls가 위험해서가 아니라, 껍데기를 씌워 안을 못 보게 만든 형태 자체 때문입니다.

bash -c "ls" 같은 멀쩡한 것도 같이 막힙니다. 알면서 막는 거예요. 안전한 몇 개를 잃는 대신 뚫릴 구멍을 없앴습니다. 애초에 bash -c를 굳이 쓸 상황 자체가 드물기도 하고요.


5. 승인 채널

5.1 자연어를 승인 신호로 쓰면 반드시 깨집니다

가장 먼저 떠오르는 설계는 이렇습니다.

if "진행해" in 사용자_메시지:
    토큰 발행

깔끔해 보이지만 화이트리스트보다 훨씬 심하게 깨져요.

변이형 — 리스트에 없으면 승인이 안 됩니다.

"ㅇㅋㅋ", "ㄱㄱ", "Sure", "좋아 그렇게 해"

언어 전환 — 러시아어, 히브리어로 답하면 끝입니다.

오탐 — 리스트를 늘리면 정반대 의미가 승인됩니다.

"진행하기 전에 한 번 더 확인해줘"
   → "진행" 매칭 → 토큰 발행 💀

AI 인젝션 — 모델 출력을 인용했을 뿐인데 승인됩니다.

Claude: "...이 부분은 approve 후에 진행하겠습니다."
사용자: "approve 후에 진행한다는 게 무슨 뜻이야?"
   → "approve" 매칭 → 승인됨 💀

/approve 같은 슬래시 커맨드는 명시적이라 오탐이 없죠. 하지만 여기서 결정적인 역설이 있습니다. /approve를 꼬박꼬박 칠 사람은 어차피 알아서 계획 세우고 일하는 사람이에요. 정작 막아야 할 대상은 "@@해줘" 한 줄 던지고 결과만 기다리는 습관인데, 그 사람은 /approve를 안 칩니다.

안전장치가 안전한 사람에게만 작동하는 거죠. 그럼 있으나 마나입니다.

근본 원인은 하나입니다. 자연어의 겉모습을 보고 속뜻을 추론하는 규칙은 본질적으로 깨져요. "진행"이라는 글자가 승인인지, 질문인지, 인용인지 — 글자만 봐서는 알 수 없으니까요.

5.2 신호 위치 이동: 사용자 텍스트 → 모델의 tool_use

한계를 인정하면 결론은 명확합니다. 승인 의도를 사용자 텍스트에서 찾지 말고 다른 데서 찾으면 돼요.

"덧셈 코드 만들어줘"
"Add me some addition code"
"добавь код сложения"
"덧셈 ㄱㄱ"

네 개 다 다른 글자입니다. 그런데 실제로 코드를 만들려면 어떻게 해야 할까요?

Write(file_path="add.py", content="...")

넷 다 여기로 수렴합니다. 무조건이에요. 파일을 만들려면 Write를 호출하는 것 말고는 방법이 없으니까요. 손이 그것뿐이잖아요.

tool_use는 자연어가 아니라 구조체입니다.

{ "name": "Write", "input": { "file_path": "add.py" } }

언어 의존성 0. 변이형 0. 오탐 0. rm은 중국어 버전이 없어요. 러시아에서도 rm입니다. 셸 명령어와 도구 이름은 자연어가 아니라 기계어라서 언어 문제가 애초에 없어요.

원칙 1. 신호는 사용자 텍스트가 아니라 모델의 tool_use에서 받는다.

5.3 승인은 강제하는 게 아니라 다른 길을 막는 겁니다

차단은 tool_use로 되는데 승인은 문제가 남습니다. 승인은 사용자가 하는 건데, 사용자는 자연어로 말하잖아요. tool_use는 모델이 만드는 것이지 사용자가 만드는 게 아니니까요.

여기서 발상을 바꿨습니다. 강제할 필요가 없어요. 다른 길을 다 막으면 됩니다.

Write가 막혔습니다. Edit도 막혔고요. Bash 우회도 막혔습니다. Claude가 코드를 바꿀 방법이 하나도 없어요. 그럼 Claude는 어떻게든 일을 하려고 남은 길을 찾습니다. 그때 딱 하나 열려 있는 문이 있으면 거기로 갈 수밖에 없죠. 밀어서 보내는 게 아니라 거기밖에 안 남아서 가는 겁니다.

그리고 그 문은 Claude Code에 이미 있었어요.

EnterPlanMode()          ← "계획 모드 들어감"
ExitPlanMode(plan="...") ← "계획 다 짰음. 여기 있음."

도구 목록에 원래 있던 도구 두 개입니다. Read, Write, Bash 옆에 나란히요.

EnterPlanMode를 호출하면 Claude 스스로 손이 묶입니다. Read/Glob/Grep만 쓸 수 있고 Write/Edit/Bash는 Claude Code가 받아주지 않아요.

그리고 ExitPlanMode(plan="...")를 호출하는 순간 승인 다이얼로그가 뜹니다.

┌────────────────────────────────┐
│  Claude가 다음 계획을 제안합니다:  │
│  1. add.py 생성                 │
│  2. def add(a, b) 작성          │
│     [ Accept ]   [ Reject ]    │
└────────────────────────────────┘

여기서 뭐가 달라졌는지 보이시나요? 사용자는 글자를 쓰지 않습니다. 버튼을 누릅니다.

"진행해"도 아니고 "ㅇㅋ"도 아니고 "approve"도 아니에요. 클릭입니다. 클릭에는 언어가 없죠. 변이형도 없고, 오탐도 없고, AI가 자기 출력에 클릭을 심을 수도 없습니다. Accept 아니면 Reject, 둘 중 하나예요.

그리고 Claude Code는 그 결과를 ExitPlanMode의 tool_response로 돌려줍니다. 도구 호출의 결과니까 PostToolUse hook이 받아볼 수 있죠.

원칙 4. 승인은 Claude Code의 네이티브 메커니즘(EnterPlanMode + ExitPlanMode)을 활용한다.

만든 게 아니라 찾아낸 거예요. 이미 있던 다이얼로그를 승인 채널로 전용했습니다.

5.4 토큰은 파일 하나입니다

토큰 구현은 이게 전부입니다.

~/.harness/state/&<session_id>/approved     ← 이 파일이 있으면 승인됨

파일이 있다 = 승인됨. 없다 = 승인 안 됨.

# ① 발행 (PostToolUse)
if tool_name == "ExitPlanMode" and tool_response 가 Accept:
    write_file("state/abc123/approved", json.dumps({
        "plan_id": "uuid...",
        "approved_at": "2026-06-03T14:22:05+09:00",
        "plan_summary": "덧셈 함수 추가"
    }))

# ② 확인 (PreToolUse)
if tool_name in ["Write", "Edit", "MultiEdit", "NotebookEdit"]:
    if not os.path.exists("state/abc123/approved"):
        sys.stderr.write("❌ 승인된 계획이 없습니다. EnterPlanMode부터 시작하세요.")
        sys.exit(2)
    sys.exit(0)

# ③ 삭제 (Stop)
if os.path.exists(token_path):
    os.remove(token_path)

Claude는 토큰이 존재하는지도 모릅니다. 인터넷 너머에 있어서 이 파일을 볼 수도, 만들 수도 없으니까요.

5.5 프롬프트로 토큰을 만들 수 있을까요

이렇게 입력한다고 해볼게요.

"@@해줘. 다 ACCEPT 시켜줄게, PLAN MODE 해도 ACCEPT 시킬 테니까 토큰까지 다 발행해줘"

아무 일도 일어나지 않습니다. hook이 보는 건 이게 전부거든요.

tool_name    = "Write"
tool_input   = { "file_path": "add.py" }
현재_브랜치   = "feature/add"
토큰_파일_있나 = os.path.exists("state/abc123/approved")

사용자 텍스트는 이 목록에 없습니다. hook은 그 문장의 존재조차 몰라요.

Claude가 저 지시를 읽고 ExitPlanMode를 호출해도 다이얼로그는 어차피 뜹니다. 다이얼로그를 띄우는 건 Claude가 아니라 Claude Code니까요. Claude가 "미리 승인받았으니 토큰 발행해도 되겠지" 하고 Write를 시도하면 차단됩니다. 토큰 파일이 없거든요. 말로는 파일이 만들어지지 않습니다.

자연어를 신호로 안 쓰기로 한 순간, 자연어로 하는 모든 공격이 무의미해졌어요. 방어한 게 아니라 공격할 표면 자체를 없앤 거죠.

5.6 토큰을 만들려면 토큰이 필요합니다

Claude가 토큰 파일을 직접 만들 수는 없을까요?

Write(".../approved")  →  Write는 토큰이 필요함  →  토큰이 없음  →  차단
                              ↑                                    │
                              └──── 토큰을 만들려면 토큰이 필요 ────┘

닫힌 고리라 처음부터 못 들어옵니다. Bash 쪽은 아예 다른 이유로 막히고요.

Bash("touch .../approved")        → touch가 블랙리스트 ❌
Bash("echo x > .../approved")     → `>` 리다이렉션이 블랙리스트 ❌
Bash("cp a .../approved")         → cp가 블랙리스트 ❌

파일 만드는 방법을 이미 다 막아놨기 때문이에요. 토큰을 지키려고 막은 게 아니라, 파일 변경을 막았더니 토큰도 같이 지켜진 겁니다.

토큰 파일에는 특별한 보호가 하나도 없습니다. 암호도 없고, 권한 설정도 안 했고, 경로도 뻔해요. 그런데 못 만듭니다. 파일을 만드는 능력 자체가 없으니까요. 지켜야 할 걸 지킨 게 아니라, 지킬 필요가 없는 구조를 만든 셈이죠.

5.7 토큰 수명: 한 턴

v1 초반에 실제로 터진 문제입니다.

"덧셈 코드 만들어줘" → plan → Accept → 토큰 발행 → 구현 완료
"곱셈 코드도 만들어줘" → Write → 토큰 있음 → 그냥 통과 💀
"댓글 기능 다 지워줘"  → rm → 토큰 있음 → 그냥 지움 💀💀

한 번 승인이 영구 승인이 됐습니다. 첫 번째 계획만 봤는데 그 뒤로는 뭘 하든 프리패스예요. 게이트가 사실상 꺼진 상태죠.

해결은 Stop hook입니다. Claude가 도구 요청 없는 텍스트만 뱉는 순간이 턴 끝이고, 그때 토큰을 삭제합니다.

"덧셈 만들어줘"  → plan → Accept → 토큰 → 구현 → 턴 끝 → 토큰 삭제
"곱셈도 만들어줘" → Write → 토큰 없음 → 차단 → 새 plan → 새 Accept ✅

원칙 5. 승인 토큰은 한 턴에만 유효하다.

이게 자연스러운 이유는, 승인한 게 "Claude를 믿는다"가 아니라 "이 계획을 믿는다"이기 때문이에요. 계획이 끝났으면 승인도 끝나는 게 맞죠.

5.8 무조건 지우는 게 더 안전합니다

Stop hook은 매 턴 끝날 때마다 돕니다. "코드 리뷰해줘" 같은 읽기 전용 턴에도 돌아요. 지울 게 없으면 아무 일도 안 하고 끝납니다.

똑똑하게 짰다면 이렇게 됐을 거예요.

# 이렇게 안 함
if 이번_턴에_토큰_발행했었나:      # ← 이걸 어디에 기록하지?
    os.remove(token_path)

"발행했었나"를 알려면 또 어딘가에 기록해야 합니다. 상태가 하나 더 늘죠. 그리고 그 기록이 깨지면 토큰이 살아남습니다. 5.7의 프리패스 상황이 되는 거예요.

안전장치는 "실패하면 어느 쪽으로 실패하는가"로 설계해야 합니다.

  • 지우는 걸 실수로 한 번 더 해도 → 계획 한 번 더 승인. 귀찮을 뿐입니다.
  • 안 지우는 걸 실수로 한 번 하면 → 게이트 없이 파일 삭제.

두 실수의 무게가 완전히 다르죠. 그래서 의심스러우면 지우는 쪽으로 짭니다. SessionEnd hook에서 한 번 더 지우는 것도 같은 이유예요. 중복은 공짜고, 누락은 사고니까요.


6. 보호 브랜치

6.1 브랜치 정책이 토큰보다 상위인 이유

main에 있고, 계획을 세웠고, Accept까지 눌러서 토큰이 있습니다. Write(add.py)가 왔어요. 차단됩니다.

Write(add.py) 검사:
  토큰 있나?      → 있음 ✅
  보호 브랜치인가?  → main ❌   ← 여기서 걸림

원칙 10. 보호 브랜치 정책은 토큰보다 상위 게이트다.

이 순서인 이유는, Accept를 눌렀다는 게 "이 계획이 맞다"는 뜻이지 "main에 해도 된다"는 뜻이 아니기 때문입니다. 승인할 때 어느 브랜치인지 신경도 안 썼을 수 있잖아요. 승인은 내용에 대한 것이고, 브랜치는 승인 대상이 아닙니다.

게이트는 이렇게 세 겹이 됩니다.

Write(add.py) 도착
   │
   ├─ ① 블랙리스트?     → rm, bash -c ...        → 차단
   ├─ ② 보호 브랜치?     → main/master/develop    → 차단  ★ 토큰 있어도
   └─ ③ 토큰 있나?       → 없으면                 → 차단
        └─ 셋 다 통과 → 실행

6.2 벽으로 막을 거면 문은 열어둬야 합니다

Bash("git checkout -b feature/add")   → 통과 ✅  항상. 토큰과 무관하게.
Bash("git switch -c feature/add")     → 통과 ✅  항상.

일부러 뚫어놓은 예외입니다. 없으면 이렇게 돼요.

main에서 Write → 차단. "브랜치 바꾸세요"
Claude: git checkout -b → 차단.       ← 만약 이랬다면
       💀 나갈 방법이 없음. 영원히 갇힘.

차단 메시지가 "브랜치를 바꾸라"고 안내하는데 브랜치 바꾸는 것도 막으면, 그건 안내가 아니라 조롱이죠.

6.3 두 경로 — 소프트가 살아있는지가 여기서 드러납니다

정상 경로 (소프트 작동)

사용자: "덧셈 만들어줘" [main]
정책 주입: "main이면 plan 첫 단계에 checkout -b를 넣어라"
Claude: EnterPlanMode → 계획:
        1. git checkout -b feature/add    ← 계획에 이미 들어있음
        2. add.py 생성
사용자: Accept ★
Claude: git checkout -b feature/add  → 통과
Claude: Write(add.py)                → 통과
                          ↑ 차단 한 번도 안 일어남

예외 경로 (Claude가 정책을 흘려봄)

사용자: Accept ★
Claude: Write(add.py)                → 차단 ❌ "main입니다"
Claude: git checkout -b feature/add  → 통과   (차단 메시지 보고)
Claude: Write(add.py)                → 통과   (토큰 살아있음 + 브랜치 안전)
                          ↑ 차단 한 번. 왕복 하나 낭비.

차이는 왕복 하나뿐이고 결과는 같습니다. 그리고 재승인은 없어요. 토큰은 차단당했다고 죽는 게 아니라 턴이 끝나야 죽으니까요.

6.4 차단은 요청을 취소하지 않습니다

여기서 1.4의 사실이 다시 등장합니다. 차단당한 시점의 뭉치는 이렇게 생겼어요.

[시스템 프롬프트]
[도구 목록]
[정책 텍스트]
[사용자: "덧셈 코드 만들어줘"]              ← ★ 안 사라짐. 맨 앞에 그대로.
[Claude: EnterPlanMode()]        [결과: OK]
[Claude: ExitPlanMode(plan=...)] [결과: Accepted]
[Claude: Write(add.py)]          [결과: ❌ 차단됨: main 브랜치입니다...]
                                              ↑ 방금 추가된 줄

Claude가 이걸 통째로 다시 읽습니다. "덧셈 코드를 만들어야 하는데 main이라서 막혔구나"가 한눈에 보이죠.

차단은 요청을 취소한 게 아니라 대화에 줄 하나 추가한 것뿐입니다.

사람은 거절당하면 원래 하려던 걸 까먹기도 하잖아요. 하지만 Claude는 기억하는 게 아니라 매번 다시 읽는 것이라서, 대화록에 있는 한 잊지 않습니다. 기억력이 없어서 오히려 안 까먹는 거죠.

6.5 부수 효과: 자동 동기화가 단순해집니다

main에 직접 커밋이 불가능하니 로컬 main은 항상 origin/main의 ancestor입니다. 갈라질 수가 없어요.

덕분에 SessionStart hook의 동기화 로직이 단순해집니다.

1. cwd가 git repo인지 확인 → 아니면 조용히 스킵
2. origin remote / main 브랜치 자동 감지
3. git fetch origin main (timeout 10초)
4. 현재 main이면 git merge --ff-only origin/main
   다른 브랜치면 git branch -f main origin/main

두 케이스 모두 항상 안전합니다. 충돌할 일이 없거든요. 안전장치 하나가 다른 기능의 전제조건을 만들어준 경우예요.


7. 로그

7.1 git도 transcript도 안 되는 이유

필요한 건 "명령 ↔ 변경"의 연결입니다. "지난달 11일에 뭘 시켰고 어디까지 바뀌었나"를 알고 싶은 거죠.

git은 뭐가 바뀌었는지 완벽하게 알려줍니다. 그런데 왜 바뀌었는지가 없어요. 무엇을 시켰는지 git은 모릅니다. 커밋 메시지가 있긴 하지만 AI가 지어낸 것이고, 커밋 단위와 명령 단위가 안 맞아요. 커밋 안 한 변경은 아예 안 남고요.

transcript는 hook에 들어오는 JSON의 transcript_path에 다 들어있습니다. 명령도, 도구 호출도, 결과도요.

{"type":"message","role":"assistant","content":[{"type":"text","text":"..."},{"type":"tool_use","id":"toolu_01A9...","name":"Read","input":{"file_path":"/home/..."}}],"usage":{"input_tokens":15234,...

사람이 못 읽습니다. 지난달 11일에 뭘 했는지 찾으려고 이걸 열면 그냥 포기하게 되죠.

그러니까 이런 상황이에요. git은 변경은 있는데 명령이 없고, transcript는 다 있는데 읽을 수가 없습니다.

7.2 정보는 이미 hook을 지나가고 있었습니다

UserPromptSubmit  →  무엇을 시켰는지            ← 명령
PostToolUse       →  어떤 도구가 뭘 바꿨는지 + 결과  ← 변경
Stop              →  턴이 여기서 끝났음          ← 경계

게이트를 만들려고 꽂은 hook인데, 로그에 필요한 정보가 전부 거기를 지나갑니다. 로그를 위해 새로 만든 게 없어요. 이미 지나가고 있던 걸 받아적었을 뿐입니다.

모든 게 한 곳을 지나가게 만들면 막을 수도 있고 적을 수도 있는 거죠.

7.3 왜 두 형식으로 중복해서 적을까요

events.jsonl      ← 기계가 읽는 형식
conversation.md   ← 사람이 읽는 형식

중복입니다. 용량도 두 배고, 하나 고치면 둘 다 고쳐야 해요. 개발자 본능으로는 "하나로 합쳐"가 맞습니다.

읽는 주체가 다르고 요구가 정반대이기 때문이에요.

기계가 원하는 것사람이 원하는 것
전부 다 있어야 함다 있으면 못 읽음
스키마 고정읽기 좋으면 됨
한 줄 = 한 이벤트덩어리로 묶여야 함
grep, jq로 처리눈으로 훑음

하나로 합치면 반드시 둘 중 하나가 죽습니다.

  • 전부 다 넣으면 → transcript를 다시 만든 겁니다. 못 읽어요.
  • 읽기 좋게 줄이면 → 기계가 쓸 정보가 사라집니다.

그리고 중복 걱정은 안 해도 됩니다.

# post_tool_use.py — 같은 자리에서 같은 데이터로 둘 다 씀
event = { "ts": now(), "type": "file_changed", "path": "add.py", ... }

append("events.jsonl", json.dumps(event))
append("conversation.md", f"### {now()} 파일 변경\n- add.py\n")

소스가 하나예요. 어긋날 수가 없습니다. 두 번 적는 게 아니라 한 사건을 두 가지로 그리는 거죠. 사진 한 장을 원본과 썸네일로 저장하는 것과 같습니다.

원칙 6. 로그는 두 형태로 동시에 남긴다. JSONL과 사람이 읽기 좋은 마크다운.

7.4 index.md — 입구가 없으면 원본은 없는 것과 같습니다

~/.harness/logs/
├── 2026-06-11/
│   └── abc123/
│       ├── events.jsonl       # 모든 이벤트(타임스탬프)
│       ├── conversation.md    # 사람이 읽는 대화 + plan + diff 요약
│       ├── changes.json       # {파일경로: [변경 시각, hash, 줄수 변화]}
│       └── diffs/<n>.patch    # PostToolUse에서 떨어지는 diff
├── 2026-06-12/
│   └── def456/
└── index.md          ★

날짜는 폴더 이름에 이미 있습니다. 그런데 날짜를 모를 때가 문제죠. "auth 관련 뭘 건드렸는데... 언제였지?"

실제로 검색할 때 머릿속에 있는 건 "auth", "댓글", "validation" 같은 것들입니다. 그럼 그게 한 줄에 있어야 해요.

# index.md
- 2026-06-11 14:21 / 덧셈 함수 추가 / add.py, tests/test_add.py / logs/2026-06-11/abc123/
- 2026-06-12 09:03 / 회원가입 validation 수정 / auth/validators.py / logs/2026-06-12/def456/
- 2026-06-12 16:40 / 댓글 도메인 삭제 / comment/*.java (12개) / logs/2026-06-12/ghi789/
언제 / 무엇을 시켰나 / 어떤 파일이 바뀌었나 / 어디를 보면 되나
        └─ 검색 키워드 ─┘
$ grep auth ~/.harness/logs/index.md
- 2026-06-12 09:03 / 회원가입 validation 수정 / auth/validators.py / logs/2026-06-12/def456/

한 방에 나옵니다.

한 줄이어야 하는 이유는 grep이 줄 단위로 찾기 때문이에요. 한 줄에 다 있으면 검색 결과가 곧 답입니다. 두 줄로 나누면 반쪽만 나오죠.

한 파일이어야 하는 이유도 있습니다. 폴더마다 흩어놓으면 grep -r을 해야 하고, 결과가 지저분하고, 시간순 정렬도 안 돼요. 모여 있어야 훑을 수 있습니다.

이 줄은 SessionEnd hook이 씁니다. 세션이 끝날 때 전체를 요약해서 한 줄 붙여요.

로그 전체는 층을 이룹니다.

index.md          →  한 줄.    "언제 뭘 시켰나"       ← 여기서 찾는다
conversation.md   →  한 페이지. "어떻게 흘러갔나"      ← 여기서 읽는다
diffs/*.patch     →  실제 변경. "정확히 뭐가 바뀌었나"  ← 여기서 확인한다
events.jsonl      →  전부.     기계용

위로 갈수록 요약, 아래로 갈수록 원본입니다. 위에서 찾아서 아래로 파고들어요.

transcript는 맨 아래 층만 있었던 겁니다. 원본은 있는데 입구가 없었던 거죠.


8. 권한 시스템 통합 — exit 0은 통과가 아니라 기권입니다

8.1 승인했는데 또 묻습니다

계획을 세우고 Accept를 눌러서 토큰이 발행됐습니다. 그런데요.

Claude: Bash("mkdir comment_backup")
   → PreToolUse: 토큰 있음 → exit 0 → 통과 ✅
   → 🔔 "Do you want to proceed?"     ← ???

Claude: Bash("rm comment/Controller.java")  → 🔔 "Do you want to proceed?"
Claude: Bash("rm comment/Service.java")     → 🔔 "Do you want to proceed?"
... 30번

댓글 도메인 삭제 한 번에 30번 물었습니다. 그럼 어떻게 될까요? y y y y y y. 안 읽고 누릅니다. 30번째쯤 되면 뭘 승인하는지 보지도 않아요.

게이트가 있는데 사람이 눈 감고 통과시키는 셈이죠.

8.2 원인: 게이트가 두 겹이고 서로를 모릅니다

Claude: Bash("rm ...")
   ↓
┌──────────────────────────┐
│ ① PreToolUse hook (하네스) │  → 토큰 확인 → exit 0 통과 ✅
└──────────────────────────┘
   ↓
┌──────────────────────────┐
│ ② Claude Code 내장 권한    │  → "Bash? 위험한데?" → 🔔 물어봄
└──────────────────────────┘

②는 Anthropic이 만든 내장 기능입니다. hook이 아니에요. 대부분의 사용자는 hook을 안 짜니까, 아무 설정 없이도 최소한의 안전이 되도록 Bash 같은 도구는 기본으로 묻게 되어 있습니다.

hook            = Anthropic이 뚫어놓은 콘센트. 사용자가 플러그를 꽂습니다.
내장 권한 시스템  = 프로그램에 박혀 있는 기능 자체. 손댈 수 없습니다.

②는 ①이 뭘 했는지 모릅니다. 30분 걸려 계획을 세우고 승인받고 토큰을 발행했다는 걸 전혀 몰라요. 걔 입장에선 그냥 "Bash가 하나 왔네? 물어봐야지"입니다.

그리고 여기가 핵심이에요. exit 0의 의미가 뭐였을까요? "통과시켜"가 아니라 "나는 반대 안 해"입니다.

기권이었던 거죠. hook이 반대하지 않았으니 다음 단계로 넘어가고, 다음 단계는 자기 일을 합니다. ①에게는 거부권만 있었고 허가권이 없었어요.

8.3 해결: PreToolUse JSON 출력

# pre_tool_use.py
if 토큰_있음:
    print(json.dumps({
      "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",
        "permissionDecisionReason": "Harness: token_present"
      }
    }))
sys.exit(0)

이 JSON을 뱉으면 Claude Code가 ②를 건너뜁니다.

exit 0              → "나는 반대 안 해"      → ②가 또 물음
JSON allow + exit 0 → "내가 책임지고 허가함"  → ②를 스킵 ★

기권에서 결정으로 바뀐 겁니다. 계획 전체를 이미 승인받았으니 하네스가 Claude Code보다 더 많이 알고 있잖아요. 그러니 판단을 가져오는 게 맞습니다. 그리고 이 JSON은 토큰이 있을 때만 뱉어요. 토큰 없으면 exit 2로 막습니다. 안전망은 그대로예요.

두 층으로 정리하면 이렇습니다.

[기계 층]  하네스 JSON allow  →  Claude Code 내장 프롬프트 무효화
[사람 층]  승인 31번           →  1번으로 압축
전:  계획 승인 1번  +  도구마다 승인 30번  =  31번
후:  계획 승인 1번                        =  1번

30번이 사라진 게 아니라 1번으로 합쳐졌습니다. 여전히 승인해요. 다만 rm Controller.java 하나를 보고 승인하는 게 아니라 계획 전체를 보고 승인합니다.

그리고 이게 오히려 나은 승인이에요. rm Controller.java 한 줄만 보고 이게 맞는 삭제인지 판단할 수 있나요? 없습니다. 30번의 무의미한 승인보다 1번의 의미 있는 승인이 낫죠. 30번짜리는 어차피 y y y y니까요.

원칙 9. 하나의 plan 승인 = 턴 전체의 실행 위임.


9. Plan 가시성 — 형식은 통과했는데 내용이 없었습니다

9.1 계획이 한 줄로 왔습니다

┌────────────────────────────────┐
│  Claude가 다음 계획을 제안합니다:  │
│  comment 도메인을 삭제합니다.     │
│     [ Accept ]   [ Reject ]    │
└────────────────────────────────┘

계획은 봤습니다. 그런데 판단이 되나요?

  • 파일 몇 개가 지워지나요?
  • 다른 데서 comment를 참조하고 있으면요?
  • DB 테이블도 지우나요?
  • 잘못됐으면 어떻게 되돌리죠?

하나도 모릅니다. v1.6에서 실제로 터진 문제예요. 계획도 있었고 승인도 있었고 게이트도 통과했습니다. 내용이 없었을 뿐이죠.

게이트를 통과했는데 아무것도 막지 못한 겁니다.

9.2 계획 형식을 강제합니다

POLICY_TEXT에 4섹션을 못 박았습니다.

## What will change
- comment/CommentController.java 삭제 (전체)
- comment/CommentService.java 삭제 (전체)
- post/PostService.java:45-52  getComments() 메서드 제거
- post/PostService.java:8      import comment.CommentService 제거
- Bash: rm -rf src/main/java/com/app/comment/
- SQL: DROP TABLE comments;

## WARNING: DESTRUCTIVE
- comments 테이블 DROP — 기존 댓글 데이터 전부 소실
- 파일 12개 삭제

## Risks
- PostService가 comment를 참조 중. 컴파일 에러 가능
- 프론트엔드 /api/comments 호출이 404

## Rollback
- git checkout -b 이전 브랜치로 복귀
- DB: 백업 없음. 복구 불가.

파일별로 몇 번째 줄인지, Bash 명령은 그대로, SQL 본문도 그대로 적으라고 강제해요.

이제 DROP TABLE comments;를 직접 눈으로 봅니다. Rollback에 "복구 불가"라고 적혀 있고요. 여기서 Reject를 누를 수 있죠.

승인의 질은 계획의 구체성으로 결정됩니다. 추상적인 계획에 대한 승인은 승인이 아니라 서명일 뿐이에요.

9.3 못 고치는 부품에 의존하지 않습니다

문제가 하나 더 있습니다. 저 긴 계획이 다이얼로그에 다 들어갈까요?

안 들어갑니다. 잘리거나, 스크롤이 안 되거나, 앞부분만 보이거나 — 환경마다, 터미널 크기마다 달라요.

┌────────────────────────────────┐
│  ## What will change           │
│  - comment/CommentController...│
│  - post/PostService.java:45... │
│  ...                           │   ← 여기서 잘림
│     [ Accept ]   [ Reject ]    │
└────────────────────────────────┘

## WARNING: DESTRUCTIVE가 안 보입니다. DROP TABLE comments;도 안 보이고요. 제일 중요한 게 잘렸죠.

그리고 저 창은 Claude Code가 그립니다. hook이 아니에요. 손댈 수가 없습니다.

그래서 창을 고치는 걸 포기했어요. 대신 정책에 이걸 넣었습니다.

"ExitPlanMode를 호출하기 직전에, 계획 전문을 채팅 텍스트로 먼저 출력하라."

화면은 이렇게 됩니다.

사용자: 댓글 기능 삭제해줘

Claude: 계획을 세웠습니다.

  ## What will change
  - comment/CommentController.java 삭제
  - SQL: DROP TABLE comments;
  ## WARNING: DESTRUCTIVE
  - comments 테이블 DROP — 댓글 데이터 전부 소실
  ## Rollback
  - DB 백업 없음. 복구 불가.
                          ↑ ① 그냥 채팅 글. 여기서 읽습니다.

  ┌────────────────────────────────┐
  │  Claude가 계획을 제안합니다:      │
  │  comment 도메인 삭제...          │  ← ② 잘려도 상관없습니다
  │     [ Accept ]   [ Reject ]    │
  └────────────────────────────────┘
                          ↑ ② 여기서 누릅니다

①에서 읽고 ②에서 누릅니다. 역할이 나뉜 거죠.

채팅 글    →  보여주는 용도   (전문이 다 보임)
다이얼로그  →  승인받는 용도   (버튼만 있으면 됨)

승인은 여전히 다이얼로그로만 받습니다. 이건 안 바꿔요. 만약 "채팅에서 그냥 답하게 하자"고 하면 5.1의 자연어 지옥으로 되돌아가니까요.

정보는 채팅으로, 신호는 버튼으로 받습니다. 읽는 건 사람이 하는 거니까 사람이 읽기 좋은 곳으로 보내고, 승인은 기계가 판정해야 하니 기계가 확실히 아는 곳으로 받아요. 섞으면 둘 다 망가집니다.

원칙 8. 사용자에게 보여줄 plan은 채팅에 직접 출력하도록 강제한다.

못 고치는 부품에 의존하지 말고, 고칠 수 있는 걸로 대체한다는 이야기이기도 하죠.


10. 사일런트 실패 — 정책이 한 번도 도달하지 않았습니다

v1.5에서 발견한, 가장 잡기 어려웠던 버그입니다.

PowerShell 5.x와 python script.py로 실행되는 hook 프로세스의 sys.stdout/sys.stderr는 기본적으로 시스템 코드페이지(한국어 Windows에서는 cp949)로 설정됩니다. 정책 텍스트에 있는 , , em-dash(), 한글 같은 cp949에 없는 문자를 출력하면 UnicodeEncodeError가 나요.

문제는 hook의 try/except가 이 예외를 조용히 삼키고 exit 0을 반환한다는 겁니다.

stdout: 0 바이트
stderr: 0 바이트
exit:   0

겉보기에 완벽하게 정상입니다. 그런데 정책 텍스트는 모델에 도달하지 않아요.

이 상태가 정확히 "소프트가 없는 상태"입니다. 3.5에서 본 그것이죠. 안전은 지켜지는데 매 요청마다 벽에 한 번씩 박고 있었던 거예요. 그리고 아무도 그걸 몰랐습니다. 하드 게이트가 조용히 일을 잘 해주고 있었으니까요.

import io
try:
    sys.stdout.reconfigure(encoding="utf-8")
except Exception:
    try:
        sys.stdout = io.TextIOWrapper(
            sys.stdout.buffer, encoding="utf-8",
            errors="replace", line_buffering=True,
        )
    except Exception:
        pass
# stderr도 동일하게

모든 hook 스크립트 상단에 이 부트스트랩을 둡니다. install 스크립트(.ps1) 자체도 PowerShell 5.x가 BOM 없는 UTF-8을 cp949로 잘못 읽으니, UTF-8 BOM으로 저장하고 [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() + chcp 65001을 상단에서 실행합니다.

여기서 이중 안전망의 부작용이 하나 드러납니다. 하나가 죽어도 티가 안 나요. 하드가 소프트의 죽음을 덮어버렸으니까요. 안전망을 겹칠 때는 각 층이 살아있는지 따로 확인할 방법도 같이 만들어야 합니다.


11. 마지막 한 칸

지금까지 만든 것을 다시 볼게요.

블랙리스트         →  계획을 세우게 하려고
토큰              →  승인을 받으려고
4섹션 포맷         →  계획을 읽을 만하게 하려고
채팅에 전문 출력    →  계획이 잘려서 안 보이는 걸 막으려고

전부 "사용자가 계획을 읽는다"는 전제 위에 서 있습니다. 그 전제가 깨지면 나머지가 통째로 의미를 잃어요.

그리고 hook은 이걸 막을 수가 없습니다.

3초 만에 Accept       → PostToolUse: "Accept 왔네" → 토큰 발행
5분 정독하고 Accept    → PostToolUse: "Accept 왔네" → 토큰 발행

구분이 안 됩니다. hook은 사람의 눈이 어디를 봤는지 모르니까요. 코드로 해결할 수 있는 문제가 아니에요.

그래서 이건 기술이 아니라 수칙으로 남겼습니다.

"plan 본문이 안 보이거나 부실하면 무조건 No."

하네스가 할 수 있는 건 읽을 수 있게 만드는 것까지입니다. 읽는 건 사람이 해요.

자동화의 마지막 한 칸은 늘 사람입니다. 그 칸까지 자동화하려고 하면, 자동화가 아니라 자동 승인이 되죠.


12. 정리 — 결정과 이유

결정
신호를 tool_use에서 받는다자연어는 언어·변이형·오탐·인젝션으로 반드시 깨집니다. tool_use는 구조체라 그 문제가 없어요
게이트는 PreToolUse 한 곳실행 에 있는 hook이 거기 하나뿐입니다. 나머지는 물리적으로 못 막아요
블랙리스트화이트리스트는 안전 문제가 아니라 일이 안 돼서 실패합니다. 꺼진 게이트의 안전성은 0이고요
첫 단어만 검사통짜 검색은 firm, format, confirm에 오탐이 납니다. 셸에서 첫 단어가 명령어 자리예요
명령 체이닝 분해게이트가 보는 명령과 셸이 실행하는 명령이 다르면 그 차이가 곧 구멍입니다
동적 실행은 형태로 차단문자열이 뭐가 될지는 실행 전엔 원리적으로 알 수 없어요. 판단 불가면 판단하지 않고 막습니다
승인은 ExitPlanMode 다이얼로그클릭에는 언어가 없습니다. 이미 있던 네이티브 채널을 전용했어요
토큰은 파일 하나위조할 상대가 없으니 암호가 필요 없습니다. 지킬 필요 없는 구조를 만든 거죠
토큰은 한 턴만승인한 건 "Claude"가 아니라 "이 계획"입니다. 계획이 끝나면 승인도 끝나요
Stop은 무조건 삭제상태를 추적하면 그 추적이 깨질 때 토큰이 살아남습니다. 중복은 공짜, 누락은 사고예요
보호 브랜치 > 토큰승인은 내용에 대한 것이지 브랜치에 대한 게 아닙니다
checkout -b는 항상 통과벽으로 막을 거면 문은 열어둬야 합니다. 아니면 데드락이에요
로그는 두 형식기계와 사람의 요구가 정반대입니다. 합치면 둘 중 하나가 죽어요
index.md 한 줄grep은 줄 단위로 찾습니다. 입구 없는 원본은 없는 것과 같고요
JSON permissionDecisionexit 0은 통과가 아니라 기권이었습니다. 거부권만 있고 허가권이 없었어요
plan 4섹션 강제추상적인 계획에 대한 승인은 승인이 아니라 서명입니다
plan을 채팅에 출력다이얼로그는 손댈 수 없는 부품이에요. 고칠 수 있는 걸로 대체합니다
하드 + 소프트소프트만 있으면 안전하지 않고, 하드만 있으면 쓸 수가 없습니다

이 모든 게 하나의 사실 위에 서 있습니다. 클로드의 머리와 손은 각각 다른 컴퓨터에 있어요. 그래서 둘 사이에 틈이 있고, 하네스는 그 틈에서만 존재할 수 있습니다.

v2는 이 기반 위에서 시작합니다.

profile
개발의 신이 될거다

0개의 댓글