
Skill에 전부 맡기는 것은 무리가 있다: Claude Code의 Hook을 사용하여 반복적인 지시를 없애기
요약
Claude Code 사용 시 Skill이나 CLAUDE.md만으로는 반복되는 지시를 완벽히 제어하기 어렵다는 한계를 분석합니다. 이를 해결하기 위해 Hook 기능을 활용하여 특정 도구 실행 직전에 올바른 명령어를 강제하거나 잘못된 행위를 차단하는 구체적인 방법을 제시합니다.
핵심 포인트
- Skill과 CLAUDE.md는 에이전트의 판단에 의존하므로 완벽한 제어가 어려움
- settings.json의 deny 설정은 단순 문자열 매칭 방식이라 우회 가능성이 있음
- PreToolUse Hook을 사용하면 도구 실행 직전 명령어를 검사하고 제어할 수 있음
- 단순 차단을 넘어 올바른 실행 방법을 즉시 전달하는 환경 구축이 중요함
CLAUDE.md에도 Skill에도 썼는데, 또 똑같은 주의를 주고 있다
Claude Code에게 똑같은 주의를 몇 번이고 주고 있었습니다. 패키지를 허용되지 않은 방식으로 설치하려고 한다거나, ~/.claude 설정 파일을 직접 수정한다거나, 메모리를 우리가 정한 곳이 아닌 다른 곳에 두는 등의 일 말입니다.
이 모든 내용은 CLAUDE.md에 적어 두었습니다. Skill도 만들었습니다. 그런데도 발생합니다.
매번 똑같은 주의를 주고 있다는 시점에서, 고쳐야 할 것은 상대가 아니라 환경 쪽이었습니다.
게다가 상대는 기억하지 못합니다. 인간 동료라면 세 번 정도 말하면 다음부터 주의하겠지만, 에이전트(Agent)는 세션이 바뀌면 백지상태로 돌아갑니다 (이 성질에 대해서는 다른 기사에서도 썼습니다). 기억하게 만드는 방향의 대책은 의미가 없으며, 하려고 하는 그 순간에 매번 가르쳐줄 수밖에 없습니다.
이후에 나오는 Hook의 실물은 dotfiles-public에 있습니다.
첫 번째 예시부터 살펴보겠습니다. 제 디바이스는 전부 Nix로 관리하고 있기 때문에, pip install도 brew install도 입력하게 하고 싶지 않습니다.
nix-tool-install이라는 Skill에는 nix run과 nix shell의 구분 사용법도, home.nix에 추가할 경우의 작성법도 적어 두었습니다. frontmatter에는 "패키지를 설치하고 싶어질 때 읽는다"라는 발화 조건도 붙였습니다.
그래도 열리지 않습니다. Skill이 열릴지 여부는 에이전트가 "지금은 Skill을 읽어야 할 상황이다"라고 판단하느냐에 달려 있기 때문입니다. 패키지를 설치하는 작업만 하고 있다고 생각할 때, 굳이 절차서를 찾아 나서지는 않습니다.
금지 리스트는 행위가 아니라 문자열을 막고 있다
settings.json에는 거부 리스트를 작성할 수 있습니다. 저도 작성하고 있습니다.
"deny": [
"Bash(brew install *)",
"Bash(brew reinstall *)",
...
이것은 전방 일치(Prefix match) 방식의 문자열 매칭이며, 커맨드의 해석이 아닙니다. 같은 발상으로 Bash(pip install *)를 추가해도, 다음 두 가지 모두 통과합니다.
/tmp/venv/bin/pip install requests # 경로를 포함하여 실행됨
make setup && pip install requests # 행두가 make이므로 매칭되지 않음
에이전트는 편법을 찾는 것이 아니라, 그저 솔직하게 make setup을 실행하고 있을 뿐입니다. 그럼에도 결과는 같으며, 선언된 범위 외에 패키지가 늘어납니다.
작성 방식을 엄격하게 하면 이 두 가지는 막을 수 있습니다. 막지 못하는 쪽의 문제는, deny로 결정할 수 있는 것이 "통과시키느냐 막느냐"뿐이라는 점에 있습니다. 차단당한 에이전트는 다음에 무엇을 할지 스스로 결정합니다. pip가 안 되면 pipx를 시도하고, 그것도 안 되면 python -m pip를 시도합니다. 틀렸다고 지적받았을 뿐이기에, 다른 틀린 방식으로 넘어갈 수밖에 없습니다.
차단한 직후에 올바른 방법을 전달하기
위쪽이 지적만 했을 경우, 아래쪽이 가르쳐주었을 경우입니다.
이를 구현하려면 settings.json에 "어떤 도구의 직전에 어떤 스크립트를 실행할 것인가"를 작성합니다.
"hooks": {
"PreToolUse": [
{
...
matcher에 작성한 도구(Edit|Write와 같이 |로 여러 개 지정 가능)가 실행되기 직전에, 스크립트가 표준 입력(Standard Input)으로 호출 내용의 본체를 전달받습니다. 그다음 판정하여 통과시킬지 막을지를 표준 출력(Standard Output)으로 반환하기만 하면 됩니다.
Hook은 실행 직전의 커맨드 문자열 그 자체를 받기 때문에, 문자열의 위치를 보고 판정할 수 있습니다. 경로가 포함된 실행도 복합 커맨드도 잡아낼 수 있습니다.
cmd=$(jq -r '.tool_input.command // ""')
# 커밋 메시지 등에 적힌 것만으로 발화하지 않도록, 인용부호 안의 내용을 먼저 제거
stripped=$(printf '%s' "$cmd" | sed "s/'[^']*'//g" | sed 's/"[^"]*"//g')
...
인용부호를 제거하는 처리는 오탐(False positive)을 피하기 위해 넣었습니다. git commit -m "pip install 금지 추가"와 같은 경우를 위해서입니다.
와 같은 명령어로 발화하면, 금지하려는 행위와 아무런 관련이 없는 커밋까지 중단됩니다. 금지하고 싶은 것은 문자열이 아니라 행위이므로, 문자열로서만 등장하는 부분은 먼저 삭제합니다.
여기까지는 deny를 정확하게 만든 것뿐입니다. 후크 (Hook)의 진정한 가치는 거부와 동시에 본문을 반환할 수 있다는 점에 있습니다.
{"hookSpecificOutput":{
"hookEventName":"PreToolUse",
"permissionDecision":"deny",
...
'Fleet'라는 것은 제가 관리하는 모든 디바이스를 통칭하여 부르는 것뿐입니다. 이 문장은 설치를 실행하려고 하는 순간에만 표시됩니다. CLAUDE.md에 같은 내용을 적으면, 패키지와 무관한 작업 사이에도 계속해서 읽히게 됩니다. 전달할 대상과 전달할 타이밍이 정해져 있다면, 규칙을 두는 장소는 후크 (Hook)가 더 적합합니다.
올바른 목적지는 만지는 파일마다 다르다
가르쳐줄 내용은 상대가 만지려고 하는 파일에 따라 달라집니다.
~/.claude/settings.json이나 ~/.claude/CLAUDE.md는 제 환경에서는 dotfiles로부터 배포되는 복사본입니다. 원래는 ~/dotfiles/.claude/에 있으며, 배포할 때마다 덮어씌워집니다. 에이전트 (Agent) 입장에서 보면 둘 다 일반적인 파일이며, 어느 쪽이 원본인지 외관상으로는 알 수 없습니다. 앞에 있는 것은 ~/.claude/ 쪽이므로 그곳을 편집합니다.
편집은 성공하고, 보고도 "설정을 수정했습니다"라고 되며, 다음에 다시 배포했을 때 조용히 사라집니다. 그 자리에서는 아무 일도 일어나지 않고 에러도 발생하지 않기 때문에, 사라졌다는 사실을 알아챌 단서가 남지 않습니다.
또 하나는 메모리 (Memory)의 위치입니다. 원본은 ~/memory/에 둡니다. 이곳도 dotfiles로 배포하는 영역이며, 다른 디바이스에서 읽을 수 없는 메모리는 의미가 없습니다. 그런데 Claude Code의 시스템 프롬프트 (System Prompt)는 위치로서 ~/.claude/projects/<project>/memory/를 지시합니다. 프로젝트별로 나뉘어 해당 머신에만 존재하는 디렉토리입니다.
이곳이 CLAUDE.md로는 이길 수 없는 곳이었습니다. 저의 규칙과 하네스 (Harness)의 규칙이 동시에 읽히며, 둘 다 "메모리는 여기에 써라"라고 말하고 있습니다. 에이전트 (Agent)는 모순을 깨닫지 못한 채 나중에 읽은 쪽을 따릅니다.
두 가지 모두 거부 문구에 목적지 그 자체를 넣어서 해결했습니다.
| 에이전트가 쓰려고 하는 위치 | 원본 | 거부 문구가 반환하는 것 |
|---|---|---|
~/.claude/settings.json | ~/dotfiles/.claude/settings.json | 대응하는 dotfiles 쪽의 경로 |
~/.claude/projects/<project>/memory/ | ~/memory/{type}/ | 파일명으로부터 구성한 배치 위치 |
목적지 구성은 한 줄이면 충분합니다.
# ~/.claude/... 를 ~/dotfiles/.claude/... 로 교체
dotfiles_path="${file_path/#$home\/.claude\//$home/dotfiles/.claude/}"
"dotfiles 쪽을 편집해 주세요"라는 일반론이 아니라, 지금 편집하려고 한 파일에 대응하는 구체적인 경로가 반환됩니다. 같은 일을 CLAUDE.md에서 하려고 하면 파일별 대응표를 전부 작성해야 합니다.
참고로 ~/memory/MEMORY.md는 색인 (Index)이며, 세션 시작 시의 후크 (Hook)가 각 메모리의 frontmatter로부터 매번 다시 만듭니다. 하지만 색인을 다시 만든다고 해서 충분하지는 않습니다. 재생성은 색인의 정확성을 회복하지만, 쓰기 위치까지는 바로잡지 못합니다. 프로젝트 측에 작성된 메모리는 스캔 대상에 포함되지 않으므로 색인에 올라가지 않으며, 다시는 발견되지 않습니다.
규약의 전문이 아니라, 부족한 항목만을 지목한다
이번에는 에이전트 (Agent)에게 Skill을 만들게 하는 쪽의 이야기입니다. 여기서는 한 번 실패했습니다.
최초의 구현은 skill-creator라는 플러그인 (Plugin)을 통한 생성을 필수적으로 만드는 것이었습니다. 경로를 하나로 좁히면 그 끝에서 규약이 지켜질 것이라고 생각했기 때문입니다. 그런데 이 플러그인이 설치되어 있지 않은 환경이 있어, 규약을 만족하는 SKILL.md를 작성해도 통과할 수 없게 되었습니다.
다시 작성한 버전은 경로를 확인하지 않습니다. frontmatter를 추출하여 그 내용을 검사합니다.
printf '%s\n' "$frontmatter" | grep -q '^description:' \\
|| deny "frontmatter에 description: 이 없습니다. 어떤 skill인지, 언제 사용하는지를 작성할 것 (발화 판정(triggering)에 사용됨)."
충족하지 못한 항목만을 지목하기 때문에, 에이전트(Agent)는 그 자리에서 바로 수정할 수 있습니다. 규약의 전문을 매번 읽게 할 필요도 없습니다. 막고 싶은 행위가 '특정 도구를 사용하지 않는 것'이 아니라 '규약을 만족하지 않는 것을 만드는 것'이라면, 검사해야 할 대상은 경로가 아니라 결과물이었습니다.
Skill에 쓰는 것, Hook에 쓰는 것
Skill에 전부 맡길 생각이었습니다. 금지 사항도, 올바른 절차도, 저장 위치 규칙도, 적어두면 읽힐 것이라 믿었습니다. 지금은 다음과 같이 나누어 관리합니다.
| Skill | Hook |
|---|---|
| 언제 동작하는가 | 호출되었을 때 |
| ... |
후보 항목은 자신이 반복해서 주의를 주고 있는 항목을 세어보면 찾을 수 있습니다. 그중 실행 직전의 입력을 보고 판정할 수 있는 것이 Hook이 됩니다. 판정에 문맥(Context) 이해가 필요한 것은 Hook에 담을 수 없습니다.
Hook으로 옮김으로써 CLAUDE.md에 쓰지 않아도 되는 항목들도 생겼습니다. ~/.claude를 직접 편집하지 말라는 금지 사항은 어디에도 적혀 있지 않습니다. 편집하려고 시도하는 순간, 대응하는 dotfiles 쪽의 경로가 반환되기 때문입니다.
지킬 수 있는 범위는 Hook을 설치한 도구의 범위만큼입니다. 제가 작성한 Hook이 보고 있는 것은 Bash와 Edit/Write이며, MCP를 통한 도구가 동일한 행동을 하더라도 그대로 통과됩니다. 접점이 늘어나면 그만큼 더 작성해야 합니다.
그럼에도 불구하고, Hook을 설치한 범위 내에서는 같은 주의 사항을 입으로 반복할 일이 없어졌습니다. Skill에 적었을 때는 닿지 않았던 규칙이, 실행 직전이라는 단 한 지점에서 정확히 전달되고 있습니다.
Discussion

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