
Claude Code의 권한 확인을 스킵하기 전에 배치한 7가지 후크 — 75일간 운용하며 겪은 사고 기록
요약
Claude Code를 권한 확인 없이 자동 모드로 사용할 때 발생할 수 있는 사고 사례와 이를 방지하기 위한 7가지 PreToolUse 후크 활용법을 다룹니다. 단순한 프롬프트 지시가 아닌, 결정론적인 Python 코드를 통해 도구 호출을 제어하는 실전 가이드를 제공합니다.
핵심 포인트
- 권한 확인 스킵 시 확률적 지시(프롬프트) 대신 결정론적 코드(후크)를 사용해야 함
- PreToolUse 후크는 Python 프로세스로 동작하며 exit code로 도구 실행을 제어함
- 차단 시 stderr에 이유를 명시해야 Claude가 올바른 절차로 전환 가능함
- 파일 삭제, 기밀 유출 등 실제 사고 사례를 바탕으로 한 보안 가드레일 구축 방법 제시
이 기사에 대하여
Claude Code를 매일 업무에서 75일간, 권한 확인을 거의 하지 않는 설정으로 실행하고 있습니다. 그 75일 동안, 이미 공개된 자동화 도구를 22개 출하했습니다.
그 과정에서 실제로 사고를 겪었습니다. 예상치 못한 파일 삭제, 기밀 파일 읽기, 존재하지 않는 파일명 날조, 그리고 "도구를 호출하지 않았는데 호출했다고 보고하는" 가장 까다로운 유형까지 겪었습니다.
이 기사는 그 사고마다 PreToolUse 후크를 하나씩 추가해 나간 기록입니다. 설계 사상에 대한 이야기가 아니라, 무슨 일이 일어났고 어떤 코드로 막았는지를 작성합니다.
대상 독자는 "Claude Code를 --dangerously-skip-permissions나 auto mode로 실행하고 싶지만, 무방비하게 실행하는 것은 두려운" 분들입니다.
결론부터 말씀
권한 확인을 스킵하려면, 확인을 없애는 것이 아니라, 확인을 "결정론적인 코드"로 대체하는 것이 유일하게 제대로 된 방법이었습니다.
- 매번 y/n을 누르는 운용은, 누르는 사람이 익숙해져서 내용을 읽지 않게 된다. 실질적으로 노가드(no guard) 상태가 된다.
CLAUDE.md에 산문으로 "삭제하기 전에 확인해라"라고 적는 것은, 확률적으로만 지켜진다.PreToolUse후크는 Python 프로세스이므로, 작성한 대로 100% 동작한다.
사고에 대응하며 추가한 것이 다음 7가지입니다 (이 외에 세션 저장 누락을 감지하는 운용용 후크가 1개 더 있습니다).
~/.claude/hooks/
├── security-guard.py # 기밀 파일·우회 경로·브라우저 입력
├── deletion-guard.py # 삭제 커맨드 전 종류
...
PreToolUse 후크의 최소 지식
~/.claude/settings.json에 도구 이름의 정규 표현식과 실행할 커맨드를 작성합니다.
{
"hooks": {
"PreToolUse": [
...
후크 측은 stdin으로부터 JSON을 받아 종료 코드(exit code)로 판정을 반환합니다.
| 종료 코드 | 의미 |
|---|---|
0 | 통과. 도구 호출이 그대로 실행됨 |
2 | 차단. 도구는 실행되지 않으며, stderr의 내용이 Claude에게 피드백됨 |
즉, 후크는 "Claude에게 이유를 전달하며 멈출" 수 있습니다. 이 점이 중요합니다. 단순히 차단만 하면 Claude는 같은 행동을 반복해서 시도합니다. 왜 멈췄는지, 다음에 무엇을 해야 하는지를 stderr에 작성하면, Claude는 그 내용을 바탕으로 올바른 절차로 전환합니다.
def block(msg_lines):
print("\n".join(msg_lines), file=sys.stderr)
sys.exit(2)
사고 1: "아마 무관한 것 같으니 삭제하겠습니다"
가장 처음 겪은 사고입니다. 작업 디렉토리에 _tmp_로 시작하는 파일들이 어지럽게 흩어져 있었고, Claude가 "임시 파일로 판단되므로 삭제하겠습니다"라고 판단하여 삭제했습니다. 제가 다른 용도로 만들던 작업 중인 파일이었습니다.
문제는 삭제 그 자체가 아니라, 추측이 이유가 되었다는 점입니다.
- "아마 본 세션 외의 파일일 것"
- "파일명으로 보아 임시 파일"
- "컨텍스트를 절약하기 위해"
모두 인간이라면 "뭐, 그럴 수도 있겠네" 하고 넘길 법한 논리입니다. 하지만 삭제는 불가역적이므로, 확률적인 판단을 허용해서는 안 되는 영역이었습니다.
막는 방법
삭제에 해당하는 구문을 전부 열거하고, exit 2로 차단합니다. rm만 보고 있어서는 의미가 없으며, Python의 shutil.rmtree나 git clean에서도 동일한 결과가 발생합니다.
DELETE_PATTERNS = [
(r'(?:^|\s|;|&&|\|\|)rm(?:\s+-[a-zA-Z]+)+\s', 'rm', 'rm'),
(r'(?:^|\s|;|&&|\|\|)rm\s+(?!-)\S', 'rm', 'rm'),
...
]
실운용에서 부족했던 것은 ;나 &&로 연결된 커맨드였습니다. ls foo && rm -rf foo는 행두(line start)만 보고 있으면 ls로 보입니다. 구분자로 세그먼트를 나눈 뒤, 각 세그먼트를 대조하고 있습니다.
승인은 "토큰"으로 전달
전부 차단하면 이번에는 업무가 되지 않으므로, 승인했을 때만 통과시키는 메커니즘을 추가했습니다. 승인은 JSON 파일로 전달합니다.
{
"ts": 1785700000,
"uses_left": 10,
...
후크는 다음 사항을 모두 만족할 때만 통과(pass-through)시킵니다.
- 발행 후 30분 이내 (기본값. TTL)
uses_left > 0(사용할 때마다 차감)- 삭제 대상 경로가
scope_paths의 하위에 있음 - 파일명이
scope_patterns중 하나와 일치함 - 명령 유형이
scope_commands에 포함됨
이 5가지 조건 중 3~5번 조건이 핵심입니다. "build/ 아래의 *.bak를 rm으로 삭제"를 승인했는데, 다른 디렉토리의 .py를 git rm으로 삭제하려고 하면 토큰이 있어도 차단됩니다.
승인을 "단 한 번의 허가"가 아니라 "범위가 지정된 허가"로 만든 이유는, rm을 10번 입력하는 작업마다 승인을 요구하면 사용자가 기계적으로 승인하게 되기 때문입니다. 승인 횟수를 줄이고 승인의 질을 높이는 것이 목표였습니다.
사고 2: 기밀 파일로의 읽기 경로가 생각보다 많음
API 키는 ~/.secrets/<서비스명>/에 격리해 두었습니다. 그럼에도 불구하고 읽으려고 시도하는 경로는 하나가 아니었습니다.
정직하게 cat ~/.secrets/gmail/credentials.json을 실행하는 것은 당연히 차단합니다. 문제는 우회로입니다.
base64 ~/.secrets/foo/credentials.json # 인코딩하여 읽기
xxd ~/.secrets/foo/id_rsa # 16진수로 읽기
python -c "print(open('credentials.json').read())" # 별도 프로세스로 읽기
모두 "읽기" 의도이지만, cat만 보고 있으면 통과됩니다. 읽기 계열 명령어를 하나의 정규 표현식(Regular Expression)으로 묶고, 기밀 경로와의 조합일 때만 차단하고 있습니다.
_READ_CMDS = r"(?:cat|type|head|tail|less|more|bat|xxd|od|strings|hexdump|base64|file)"
BASH_DENY = [
(rf"{_READ_CMDS}[^|&;]*[/\]\.secrets[/\]", "~/.secrets/ 읽기는 금지되어 있습니다"),
...
[^|&;]*를 사이에 넣은 이유는 파이프(|)나 명령어 구분자를 가로질러 오탐(False Positive)이 발생하는 것을 방지하기 위해서입니다. cat README.md | grep secrets는 차단하고 싶지 않기 때문입니다.
동일한 후크를 브라우저 조작 MCP 도구에도 적용하고 있습니다 (matcher가 mcp__claude-in-chrome__.*를 포함하고 있는 이유입니다). 파일에서 읽지 못하게 막더라도, 웹 폼에 입력해 버릴 수 있다면 의미가 없기 때문입니다.
사고 3: 존재하지 않는 이름을 당당하게 써 내려감
이것은 삭제보다 발견이 늦었습니다.
Claude가 "/market-analysis 스킬을 실행합니다"라고 썼지만, 그런 스킬은 존재하지 않았습니다. 기존 스킬 목록에 /market-report와 /market-scan이 있었기에, 인접 영역에서 "있을 법한 이름"을 합성해낸 것입니다.
파일 경로, 함수명, MCP 도구명에서도 같은 일이 일어납니다. 까다로운 점은 에러가 발생하지 않는 경우가 있다는 것입니다. 문서에 작성된 가공의 경로는 누군가 그것을 열려고 하기 전까지는 아무도 알아차리지 못합니다.
차단 방법은 Edit / Write 내용에서 이름처럼 보이는 문자열을 추출하여 실재 여부를 확인하는 것입니다. 실재하지 않으면 exit 2로 중단하고, "이 이름을 찾을 수 없습니다. ls나 Glob으로 확인해 주세요"라고 반환합니다.
이것이 완벽하지는 않습니다 (새로 생성하는 파일명은 당연히 "아직 존재하지 않으므로" 제외 조건이 필요합니다). 그럼에도 불구하고, 가공의 이름이 결과물에 섞이는 빈도는 명확하게 낮아졌습니다.
사고 4: 도구를 호출하지 않았는데 "완료했습니다"라고 함
가장 무서웠던 것이 이것입니다.
긴 세션의 후반부에서 Claude가 "8개의 파일을 생성했습니다"라고 보고했습니다. 하지만 실제로는 도구 호출(Tool Call) 로그에 Write가 단 한 건도 없었습니다. 파일은 존재하지 않았습니다. 거짓말을 했다기보다, 관측할 수 없었던 부분을 "있어야 할 결과"로 채워 넣은 동작입니다.
또 하나, 이와 나란히 발생하는 현상은 "도구 호출이 깨지는" 현상이었습니다. 일본어 밀도가 높은 응답이 계속되면, 도구 호출을 위한 XML이 깨져서 malformed 상태가 됩니다. 그리고 깨진 호출이 이력에 남으면, 그것이 본보기가 되어 재발률이 높아집니다.
이것은 후크(Hook)만으로는 막을 수 없다
솔직히 말하면, 이 두 가지는 PreToolUse 후크만으로는 근본적으로 막을 수 없습니다. 후크가 볼 수 있는 것은 "도구를 호출하려는 순간"뿐이며, 호출하지 않은 것은 관측할 수 없기 때문입니다.
제가 한 일은 두 가지입니다.
- 철수 기준을 결정했다 — ①
malformed가 동일 세션 내에서 누적 2회 ② 호출하지 않았는데 완료 보고 ③ 동일한 도구 결과가 2회 ④ 자신의 응답 블록 복제, 중 하나라도 해당되면 즉시 세션 종료. "아직 괜찮겠지"라는 자기 판단을 개입시키지 않음 - 전염 경로를 차단했다 — 대화 로그를 저장할 때, 결정론적인 Python 스크립트로 깨진 어구를 정화(Sanitize)함. 다음 세션이 깨진 로그를 읽고 재발하는 것을 방지
두 번째는 malformed-read-guard.py로서 PreToolUse:Read에 걸어두었습니다. 청소부 역할을 LLM에게 맡기지 않는 것이 핵심입니다. 깨진 것을 읽게 해서 고치게 하면, 고치는 쪽이 오염됩니다.
Windows 고유의 함정 (여기서 2번, 조용히 망가졌습니다)
stdin은 cp932로 들어온다
후크는 stdin으로부터 JSON을 받습니다. Windows의 Python은 표준 입력을 cp932로 읽기 때문에, 일본어가 포함된 경로 나 프롬프트가 깨집니다.
예외가 발생하지 않는다는 점이 최악이었습니다. 깨진 문자열은 정규 표현식에 일치하지 않을 뿐이므로, 후크는 조용히 exit 0을 합니다. 지키고 있다고 생각했지만, 아무것도 지키지 못하는 상태가 지속됩니다.
import io, sys
if hasattr(sys.stdin, "buffer"):
sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
...
새로운 후크를 작성했다면, 반드시 일본어가 포함된 페이로드(Payload)로 테스트하십시오. ASCII만 사용한 테스트는 모두 통과합니다.
콘솔의 글자 깨짐은 "겉모습일 뿐"이다
반대의 패턴도 있습니다. Git Bash에서 print()한 일본어가 깨져 보이더라도, write_text(encoding="utf-8")로 작성한 실제 바이트는 올바른 UTF-8입니다.
이 부분을 "수정이 적용되지 않았다는 증거"로 오독하여 더 복잡한 명령어로 재검증하려고 하면, 그 복잡함 자체가 또 다른 손상을 불러옵니다. 파일 내용의 확인은 콘솔 출력이 아니라 에디터나 Read로 수행하는 것이 정답이었습니다.
빠지기 쉬운 함정: 서브 에이전트가 가드를 스스로 해제했다
이것은 설계 미스로 기록해 둡니다.
deletion-guard는 exit 2를 할 때, stderr로 "승인 후에는 다음 명령으로 토큰을 발행해 주세요"라는 안내를 내보냅니다. 인간이 승인한 뒤 메인이 발행한다는 전제였습니다.
그런데, 서브 에이전트로 기동된 Claude가 그 안내 명령을 스스로 실행하여, 스스로 토큰을 발행하고, 삭제를 실행했습니다. 가드의 전제(토큰 = 인간의 명시적 허가)가 서브 에이전트의 자기 발행으로 무너진 것입니다.
대처는 규약 측에서 "서브 에이전트는 승인 토큰을 스스로 발행해서는 안 된다. 차단되면 에러를 그대로 부모에게 반환한다"라고 명문화했습니다.
교훈은 일반화할 수 있습니다. 에러 메시지에 적은 회복 절차는, 멈춰 세운 상대가 실행해 버릴 수 있다. "이것은 인간이 할 일"이라는 전제는 코드나 권한으로 표현하지 않으면 지켜지지 않습니다.
효과와, 솔직한 한계
75일간 운용하며 느낀 점입니다.
효과가 있었던 것
- 삭제 사고는 0건이 되었습니다. 승인을 요청받는 횟수는 범위 지정 토큰 (scoped token)을 도입한 이후 체감상 1/5 이하로 줄었습니다.
- 기밀 경로에 대한 읽기 시도가 후크 (hook) 로그에 남는 형태로 여러 차례 차단되었습니다.
- 가공의 이름이 결과물에 섞이는 빈도가 명확하게 낮아졌습니다.
효과가 없었던 것·한계
- 후크는 도구 호출 (tool call)만 볼 수 있습니다. 모델이 무엇을 생각하고 있는지는 볼 수 없으며, 도구를 호출하지 않고 거짓 보고를 하는 것은 막을 수 없습니다.
- 오탐 (false positive)이 발생합니다. 정당한 삭제 작업도 차단됩니다. 이는 사양으로서 받아들이고 있습니다 (비가역적인 작업에서는, 오탐의 비용이 미탐 (false negative)의 비용보다 훨씬 저렴합니다).
- Claude Code 본체에도 가드 레이어 (guard layer)가 있어, 후크를 통과하더라도 본체 측에서 거부되는 경우가 있습니다. 이는 사용자 측에서 무효화할 수 없습니다.
- 이것은 감옥이 아니라 난간입니다. 악의적인 회피를 막기 위한 설계가 아니라, 확률적인 사고를 결정론적으로 대체하기 위한 설계입니다.
요약
권한 확인을 스킵하고 싶다면, 순서는 다음과 같았습니다.
- 우선 실제로 사고를 겪어보세요 (사고를 겪지 않고 설계하면, 상상 속의 리스크를 방어하느라 진짜 리스크를 놓치게 됩니다).
- 사고가 발생할 때마다
PreToolUse후크를 하나씩 추가하세요. exit 2의 stderr에 "왜 중단했는지·다음에 무엇을 해야 하는지"를 작성하세요.- 전부 차단하면 업무가 되지 않으므로, **범위 지정 승인 토큰 (scoped approval token)**으로 통로를 열어두세요.
- Windows라면 stdin을 UTF-8로 명시적 디코딩하고, 일본어가 포함된 페이로드 (payload)로 테스트하세요.
산문 형태의 규칙은 확률적으로만 지켜지지만, 후크는 작성한 대로 동작합니다. 이 차이가 권한 확인을 없애도 되는지 여부를 가르는 분기점이었습니다.
이 구성 세트(CLAUDE.md + 가드 후크 + 세션 복구)를 환경에 맞춰 제작하여 납품하는 서비스도 제공하고 있습니다 → Claude Code의 안전 운용 세트를 만듭니다 (코코나라)
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기