세 줄 요약
- Claude Code의 개인 설정을 점검한 결과, push를 막는 훅(hook)에서 우회 경로가 발견되었습니다.
- 메모 보관 위치를 재정비하고, 설정을 Git으로 관리하여 변경 사항을 추적할 수 있게 했습니다.
- AI가 제시하는 점검 결과도 맹신하지 않고, 실제 명령어와 테스트로 확인하는 것이 중요했습니다.
서론
Claude Code를 매일 사용하다 보면, CLAUDE.md,
・훅(hook)・메모(auto memory)・서브 에이전트・권한 허용 목록 등이 조금씩 쌓입니다. 스스로 '충분히 구축했다'고 생각하더라도, 공식 사양이 바뀌거나 예전에 설정했던 내용이 남아있어 자신도 모르는 사이에 구식이 되어갑니다.
그래서 '내 하네스 엔지니어링(harness engineering) 어때? 공식보다 느린가?'라고 Claude Code 스스로에게 물어보고, 점검과 수정을 그대로 맡겨보았습니다. 이 글은 그 기록입니다.
얻게 된 것은 다음 세 가지였습니다.
훅에 '우회' 구멍이 있었다 (막아야 할 push가 조건에 따라 조용히 통과하고 있었습니다) 프로젝트 메모에 어디서든 통하는 규칙이 갇혀있었다 ~/.claude
이것이 버전 관리되고 있지 않았습니다.
그리고 이 구멍은 Claude의 초기 평가에서는 '고칠 필요 없다'는 것이었습니다. AI의 점검 결과를 어떻게 확인했는지도 포함하여 작성하겠습니다.
점검 전 구성
점검 전 개인 설정은 대략 다음과 같은 구조였습니다.
| 요소 | 내용 |
|---|
~/.claude/CLAUDE.md | 답변 방식 및 작업 진행 선호도 (약 2KB) |
~/.claude/hooks/ | PreToolUse 훅 4개 (위험한 조작 방지, push 전 셀프 리뷰 강제 등) |
~/.claude/settings.json | 훅・허용 목록(약 50개)・플러그인 |
| 프로젝트 메모 | ~/.claude/projects/<project>/memory/MEMORY.md를 인덱스로, 메모 약 120개 |
리포지토리의 .claude/ | |
| |
| 경로로 제한된 rules, 서브 에이전트 8개, 스킬 | |
지켜줬으면 하는 규칙은 메모에 적는 것뿐만 아니라, 훅에서 deny / ask를 반환하여 기계적으로 막는 방침이었습니다. 기본적인 방향성은 나쁘지 않았습니다. 문제는 그것이 실제로 작동하는지 아무도 확인하지 않았다는 것이었습니다.
1. 가드용 훅이 '우회'되는 구조적 결함이 있었다
무엇이 일어나고 있었나
push 전에 셀프 리뷰를 마쳤는지 확인하는 훅이 있습니다. 이 훅은 명령어 내부의 cd <dir> 또는 git -C <dir>로부터 대상 리포지토리를 읽어와, 해당 리포지토리에 대해 판정했습니다.
dir=$(printf '%s' "$cmd" | sed -nE 's/.*(^|[;&|[:space:]])cd[[:space:]]+([^;&|[:space:]]+).*/\2/p' | head -1)
# ...
head=$(git -C "$dir" rev-parse HEAD 2>/dev/null)
...
여기에 cd "$HOME/dev/project" && git push와 같은 명령어가 들어오면, 훅은 $HOME을 확장하지 않기 때문에, "$HOME/dev/project"라는 존재하지 않는 디렉토리를 찾아갑니다. 그러자 'git 리포지토리가 아니다'라고 판단하고 그대로 exit 0으로 통과시켜 버립니다.
처음에는 Claude가 이 문제를 '경로에 공백이 포함될 때 정도만 발생하므로, 고칠 필요 없다'고 했습니다. '정말로 안 고쳐도 되나요?'라고 다시 물어보자, 훅에 실제 명령어를 흘려 테스트했고, 평가를 철회했습니다.
cd "$HOME/dev/project" && git push => allow
cd /Users/me/dev/project && git push => deny
같은 리포지토리로의 push인데도, 경로 작성 방식만으로 결과가 달라지고 있습니다. Claude는 변수 포함된 경로를 자주 사용하므로, 실제로 발생하는 구멍입니다.
수정 방법: 해석한 경로가 존재하지 않으면 막는다
case "$dir" in /*) ;; *) dir="$cwd/$dir" ;; esac
Fail closed: $HOME/...와 같은 확장되지 않은 경로는 그렇지 않으면 비(非)리포지토리처럼 보여 통과할 수 있습니다.
[ -d "$dir" ] || deny "대상 리포지토리를 특정할 수 없으므로 중단합니다 (해석된 경로: ${dir})। 변수를 사용하지 않는 절대 경로로 cd 하거나, git -C <절대경로> 로 실행해 주세요."
이것으로 막을 수 있는 것은 이번에 발견된 '해석된 경로가 존재하지 않음' 우회입니다. 예를 들어 cd가 두 번 사용되는 명령어의 경우, 존재하는 다른 디렉토리를 잘못 인식하는 오판까지는 막을 수 없습니다. 정규 표현식으로 셸 명령어를 해석하는 이상, 구멍을 완전히 없애기는 어렵고, 발견된 것을 하나씩 막아나갈 수밖에 없습니다.
방침으로 말할 수 있는 것은, 가드용 후크(hook)는 판별에 실패했을 때 '통과'가 아니라 '중단'으로 변경하는 것입니다. 중단할 때 재시도 방법을 permissionDecisionReason으로 반환해 주면, Claude는 절대 경로로 다시 시도하여 진행할 수 있습니다.
우연히 발견한 역방향 오탐지
같은 후크가 원격 브랜치를 삭제하는 것만 하는 git push origin --delete <branch>까지 '셀프 리뷰가 완료되지 않았다'고 하여 막고 있었습니다. 삭제는 아무 커밋도 보내지 않기 때문에, 리뷰가 필요하지 않습니다.
'push가 한 번이고 그것이 --delete / -d 일 때'만 통과하도록 수정했습니다. 일반적인 push가 같은 명령어에 섞여 있다면, 이전처럼 중단됩니다.
후크 테스트는 기대값이 있는 스크립트로
후크를 테스트하려고 git push를 포함하는 JSON을 후크로 흘려보내는 명령어를 작성하면, 그 테스트 명령어 자체가 실제 후크에 의해 막히게 됩니다. 테스트 케이스는 파일에 작성하고 문자열을 분할해 두면 회피할 수 있습니다.
후크 테스트 스크립트
#!/bin/bash
P="git pu""sh"
t() {
...
수정된 실행 결과입니다.
cd "$HOME/dev/project" && git push => deny (want deny)
git push origin --delete foo => allow (want allow)
git push origin --delete foo && git push => deny (want deny)
...
실제로는, 막아야 할 것과 통과시켜야 할 것을 합쳐 16가지 패턴을 흘려보내 모두가 기대한 대로 작동하는지 확인했습니다. 기대값을 나란히 적어두면, 다음에 후크를 건드릴 때도 한 번에 재확인할 수 있습니다.
2. 허용 목록(Allow List)에 '무엇이든 실행 가능' 항목이 섞여 있었다
permissions.allow는 승인 다이얼로그에서 '지금은 묻지 않기'를 선택할 때마다 하나씩 늘어납니다. 어느새 약 50개 정도가 되었고, 그 안에 Bash(python3:*)와 Bash(node:*)가 있었습니다.
인터프리터를 통째로 허용하면, python3 -c '...'로 무엇이든 실행할 수 있습니다. 다른 항목을 세밀하게 제한했더라도, 허용 목록에서 지키고 있는 의미가 없어집니다.
python3:*
・node:*
와 같이 다른 항목과 겹치는 것을 삭제 - 정말 막고 싶은 작업은 허용 목록이 아니라 후크(hook)로 지켜지고 있음을 확인해야 합니다. deny
허용 목록은 승인할 때마다 쌓이는 것입니다. 가끔 검토하여, 인터프리터나 셸을 통째로 허용한 너무 광범위한 항목이 없는지 확인해 보세요. 또한, 시작 시 권한 모드에 따라 허용 목록 자체가 참조되지 않을 수 있으므로, 본인의 사용 방식에서 실제로 작동하는지도 함께 확인하면 안심할 수 있습니다.
3. '확인 요청'을 '중단(Stop)'으로 강화하기
Prisma 5 계열 (5.22)의 prisma migrate diff --shadow-database-url
개발용 DB를 전달하면, 샤도우 DB(shadow DB)로서 내용이 리셋됩니다. 실수로 개발 DB의 URL을 전달하는 사고를 막기 위해, 훅(hook)에서 ask (확인을 요청함)를 반환하도록 했습니다.
하지만, 개발 DB를 전달하고 있다는 것이 명백한 경우에는 확인 과정 없이도 잘못입니다. 그래서 $DATABASE_URL이나 개발 DB 이름이 전달되면 deny로 설정하고, 그 외의 경우(테스트용 DB 또는 다른 이름의 DB)만 ask로 남겨두었습니다.
if printf '%s' "$cmd" | grep -Eq -- '--shadow-database-url[[:space:]=]+["'"]?(\\$?DATABASE_URL\)?|[^[:space:]]*/<dev_db_name>(["'"]?[:space:]]|$))'; then
decide deny "--shadow-database-url에 개발 DB를 전달하면 내용이 전부 사라집니다. SQL을 직접 흘려서 확인하거나, 다른 이름의 빈 DB를 만들어서 전달해 주세요."
fi
여기서 전달하고 싶은 것은, Prisma 자체의 이야기라기보다는 사용 방식에 대한 것입니다. 명백히 잘못된 것은 deny로, 판단이 필요한 것은 ask로 설정하는 것입니다. 이렇게 하면 확인 횟수를 늘리지 않으면서 방어력을 높일 수 있습니다.
4. 메모는 '무게'보다 '효과 방식'과 '배치 위치'
'무겁다'는 표현은 과장이었다
Claude는 처음 프로젝트 메모의 색인(MEMORY.md)을 '무겁다'고 평가했습니다. 이것에 대해 다시 질문하여 크기를 측정하게 했습니다.
74 행 / 6,758 문자 / 9,616 바이트
측정한 것은 행 수, 문자 수, 바이트수이며 토큰 수는 측정하지 않았습니다. 가령 한 글자가 하나의 토큰이라고 가정하면 약 7천 토큰 정도이고, 이는 200K 토큰 컨텍스트의 4%가 채 안 됩니다. 이것은 가정을 기반으로 한 근사치이며, 실제 토큰 수는 토크나이저에 따라 달라질 수 있습니다. 그럼에도 불구하고, 컨텍스트의 수십 %를 차지하는 크기는 아니라고 할 수 있습니다.
진짜 문제는 크기가 아니라, 효과 방식입니다. 색인에는 1줄 제목과 링크만 나열되어 있고, 내용은 필요한 때 Claude가 링크를 열어 찾아가는 구조입니다. 코드 작성 규칙이 여기에 있으면 읽히지 않고 지나쳐지는 경우가 생길 수 있습니다. 리포지토리의 .claude/rules에 paths:를 붙여두면, 해당 파일을 건드릴 때 자동으로 로드됩니다.
프로젝트용 메모에 범용 규칙이 갇혀 있었다
또 다른 문제는 배치 위치였습니다. 프로젝트 메모는 그 프로젝트 폴더에서 작업할 때만 읽힙니다. 그런데 내용을 보니, 어디서든 통용될 수 있는 규칙들이 약 10개 정도 섞여 있었습니다.
- 숫자나 영향 범위는 주장별로 '실측/코드 확인/추산(조건부)' 중 하나로 근거를 제시한다
- 도달하지 않는 리스크를 현재형의 결함으로 작성하지 않는다
- 새로운 기능은 가장 가까운 기존 기능을 특정하여, 그 규약을 따르고 발전시킨다
- 테스트는 사양의 줄마다 대응표를 만든 후 작성하고, 일부러 구현을 망가뜨려 빨간색이 되는 것을 확인한다
- 의존성을 수동으로 올릴 때는 Dependabot / Renovate의 쿨다운(cooldown) 기간이 지난 버전을 선택한다
이러한 규칙들은 프로젝트 고유의 용어(티켓 번호, 사람 이름 등)를 제외하고 한 줄씩 축소하여 글로벌인 ~/.claude/CLAUDE.md로 옮겼습니다. 이렇게 하면 어느 리포지토리에서 작업하든 효과가 있습니다.
배치 위치 판단 기준은 다음과 같이 정리할 수 있습니다.
| 배치 위치 | 로드되는 범위 | 적합한 내용 |
|---|
~/.claude/CLAUDE.md | 모든 폴더 | 어디서든 통용될 작업 방식 |
| 프로젝트 메모 | 해당 프로젝트만 | 환경의 URL, 절차, 과거 경위 |
리포지토리의 .claude/rules (paths: 포함) | 해당 파일을 건드릴 때, 팀원 전체 | 코드 작성 규칙 |
| 훅 (Hook) | 매번/기계적으로 | 절대 해서는 안 되는 작업 |
~/.claude를 화이트리스트 방식으로 git 관리하기
- 점검 도중에, 훅을 수정할 때마다 수동으로 백업을 해왔다는 것을 깨달았습니다. 일부는 변경 전 복사본을 놓친 부분이 있습니다. 설정 자체가 버전 관리가 되어 있지 않은 것이 가장 큰 약점이었습니다.
~/.claude
여기에 히스토리(history), 세션(session), 캐시(cache)까지 포함되어 있기 때문에 통째로 관리할 수는 없습니다. 그래서 모두 무시한 다음, 관리하고 싶은 것만 되돌리는 .gitignore를 작성했습니다.
# Whitelist: track only hand-written config
/*
!/.gitignore
...
cd ~/.claude
git init -b main
git add -A && git status --short # make sure only the intended files are listed
...
git add를 실행하기 전에, settings.json이나 훅(hook)에 API 키 등이 포함되어 있지 않은지 grep으로 확인해 주세요. 심볼릭 링크(symbolic link) 스킬은 !/skills/xxx/ 패턴(끝 슬래시는 디렉터리에만 적용됨)으로는 감지되지 않으므로, 링크된 위치에서 별도로 관리합니다.
자체 가드와 충돌할 경우, 예외는 좁게 만든다
제 개인 훅이 'main으로의 직접 push'를 막고 있었기 때문에, 이 설정 리포지토리로의 push도 차단되었습니다. PR(Pull Request)을 만들지 않는 개인 리포지토리이므로, 다음 조건을 모두 충족할 때만 예외로 처리했습니다.
- 명령어 내의 push가 단 1회일 경우
- push 대상 remote 이름이 명시된 경우
- 해당 대상의 push용 URL이 단 1개이며, 설정 리포지토리의 URL과 일치하는 경우
전송지 결정은 전용 check-push-target.py로 분리했습니다. 이 스크립트는 명령어를 실행하지 않고 인수를 읽어와 현재 브랜치, push.default, remote의 push refspec 등을 로컬 Git 설정에서 확인합니다. 훅 측에서도 판단에 실패할 경우 차단하도록 했습니다.
push_checker="$(dirname "$0") /check-push-target.py"
if ! push_reason=$(printf '%s' "$input" | python3 "$push_checker"); then
decide deny "push의 전송지 체크에 실패했으므로 차단합니다. 훅을 확인해 주세요."
...
예외를 허용하는 경우는 remote 이름이 명시되고, 해당 remote의 git remote get-url --push --all이 반환하는 URL이 단 1개이며, 설정 리포지토리의 URL과 일치할 때뿐입니다. URL을 직접 지정한 경우에는 Git의 URL 재작성(rewriting) 설정도 관련되므로 예외로 처리하지 않습니다.
전송지 URL 판단만으로도 두 번 수정했습니다.
- 처음에는 '해당 리포지토리의
origin이 설정 리포지토리인가'만을 확인했습니다. 이 방식으로는 git push other main처럼 다른 remote나 URL로 보내는 push까지 예외 처리되어 버렸습니다. 다음에는 대상 remote의 URL을 보도록 수정했지만, git remote get-url은 보통 fetch용 URL만 반환합니다. 만약 pushurl이 다른 대상을 가리키거나, push 대상이 여러 개 등록되어 있다면 놓치게 됩니다. 그래서 --push --all로 push용 URL 전체를 가져오고, 단 1개일 경우에 한해
대상지를 생략한 git push는 어디로 전송되는지가 브랜치 설정에 따라 달라지기 때문에 예외 대상에서 제외했습니다. 두 가지 모두 놓친 부분은 아티클 리뷰 과정에서 지적받아 알게 되었습니다.
추가 테스트 결과, 원래의 금지 규칙은 명령어에 main이 명시된 경우만 감지하고 있어, main 브랜치 상에서의 순수한 git push는 통과하는 것을 확인했습니다. 이 부분도 수정하여, 현재 브랜치나 Git의 전송 설정으로부터 전송지가 main/master/develop가 되는 경우에는 차단하도록 했습니다. 작업 브랜치에서도 upstream이나 remote의 push refspec이 이러한 브랜치를 가리키면 차단됩니다. develop를 통합 대상으로 하는 리포지토리도 있으므로, main/master와 같은 보호 대상에 추가했습니다. 해석할 수 없는 복합 명령어나 matching, --mirror처럼 전송 범위를 안전하게 제한할 수 없는 지정도 막고, 대상지와 refspec을 명시하도록 안내합니다.
셀프 리뷰(self-review) 훅이나 GitHub 측의 보호 기능에 의존하지 않고, 이 가드 자체만으로 로컬에서 막을 수 있음을 확인했습니다. GitHub 측의 보호 설정은 이번 점검 대상에는 포함하지 않았습니다.
테스트를 위해 fetch와 push URL이 다른 리포지토리(r1)와, push 대상이 두 개 있는 리포지토리(r2)도 생성하여 테스트한 결과입니다.
git -C ~/.claude push -u origin main => allow (want allow)
git -C ~/.claude push other main => deny (want deny)
git -C ~/.claude push https://github.com/x/else.git main => deny (want deny)
...
이번에 추가한 가드(guard)의 회귀 테스트는 38건이었으며, 모두 기대했던 대로 작동하는 것을 확인했습니다. 이 테스트는 테스트용 리포지토리에서 훅에 커맨드 문자열을 전달하여 진행했으며, 실제 push는 수행하지 않았습니다.
6. 추가로 발견된 구식 설정
주제와는 약간 벗어나지만, 점검하는 김에 발견한 '조용히 고장 나고 있던' 설정을 두 가지 소개합니다. 둘 다 에러가 발생하지 않아 저 자신은 알아차릴 수 없었습니다.
존재하지 않는 스크립트를 호출하는 훅
git에 통합하기 위해 내부를 살펴보던 중, Stop 훅이 이전에 사용하던 스크립트를 매번 호출하고 있지만, 해당 파일 자체가 이미 존재하지 않다는 것을 알게 되었습니다. async: true로 실행되고 있었기 때문에, 매번 조용히 실패하고 있었습니다. 설정을 git에 통합할 때는, 훅이 호출하는 스크립트가 실제 존재하는지 확인해 볼 좋은 기회입니다.
서브 에이전트의 구식 도구 이름
리포지토리의 서브 에이전트 정의(.claude/agents/*.md) 내 tools: 부분에, 현재는 존재하지 않는 MultiEdit과 LS가 남아 있었습니다. 이 역시 조용히 무시됩니다. 이를 확인하는 가장 쉬운 방법은 해당 에이전트를 실행하여 사용 가능한 도구 목록을 나열하게 하는 것입니다.
Do not do any work. Just reply with the exact names of every tool you have available, one per line.
tools:에 Read, Grep, Glob, LS라고 작성된 에이전트의 답변은 Read, Grep, Glob이었습니다. tools:로 제한했다고 생각했던 도구가 실제로는 사용되지 못하고 있는 사고를 발견하는 데도 사용할 수 있습니다.
배운 점
1. AI의 평가 역시, AI에게 시켜서 확인해야 한다
이번에 Claude는 자신의 평가를 두 가지나 철회했습니다.
- '수정할 필요 없음' → 훅에 실제 커맨드를 전달하자 push가 통과해 버림
- '메모가 무거움' → 글자 수를 측정하여 대략 계산해보니, 컨텍스트의 몇 퍼센트 정도였음
둘 다