CLAUDE.md의 "금지"는 부탁일 뿐이다 — Claude Code를 확실하게 멈추는 hooks 가드레일 3선【명령 하나로 도입】
목차
Claude Code의 금지 규칙을 "CLAUDE.md = 확률적인 부탁 / permissions의 deny = 정적 패턴 매치 / hooks(command형) = 실행 시의 결정적 검증"의 3층으로 정리하고, deny로는 원리적으로 쓸 수 없는 금지 규칙 3가지를 PreToolUse hook으로 강제하는 복붙 레시피를 배포해요. 레시피는 전부 차단 케이스·통과 케이스 양쪽을 실측했어요.
이 글에서 얻을 수 있는 것
복붙으로 도입할 수 있는 PreToolUse hook(command형) 레시피 3본이에요. 모두 permissions의 deny로는 원리적으로 쓸 수 없는 금지 규칙을, 실행 시에 결정적으로 멈춰요.
| 멈추고 싶은 것 | deny로 못 쓰는 이유(한마디) | 레시피 |
|---|---|---|
| 배포 대상이 프로덕션일 때만 배포 스크립트를 멈춘다 | 배포 대상이 $(cat .deploy-target)처럼 실행 시에 정해져서, 명령 문자열에 "prod"가 나타나지 않는다 | 3-1 |
main / master 브랜치에서의 git commit을 멈춘다 | "현재 브랜치"라는 외부 상태를 참조하는 구문이 permissions에 존재하지 않는다 | 3-2 |
rm의 삭제 대상을 tmp/ 아래로만 제한한다 (tmp/../ 탈출도 검출) | Bash 규칙은 문자열 glob뿐이고, ..을 해결한다는 기술이 문서에 없다 | 3-3 |
3본을 한꺼번에, 기존 settings.json을 부수지 않고 일괄 적용할 수 있는 원파일 인스톨러도 배포해요(3-0).
레시피만 필요한 분은 3장부터 읽기 시작해 주세요. 3본 모두 "막혀야 할 것이 막힌다", "통과해야 할 것이 통과한다"의 양방향을 실측하고, git log와 파일 존재로 독립 검증했어요. bypassPermissions / --dangerously-skip-permissions에서도 멈추는 것까지 확인이 끝났어요.
들어가며
Claude Code가 프로덕션 환경을 못 건드리게 하고 싶다. 많은 사람이 우선 CLAUDE.md에 이렇게 적어요.
프로덕션 환경에 대한 조작은 하지 마세요.
다음으로 settings.json의 permissions.deny에 위험한 명령 패턴을 늘어놓아요. 그래도 남는 것이 "이 금지는 정말 매번 멈추는가?"라는 불안이에요.
이 불안에 답하려면, 각 층이 "무엇을 보증하고 무엇을 보증하지 않는가"의 정확한 선 긋기가 필요해요. 공식 문서를 축어로 확인하고 실측해 보니, 필자 자신의 착각이 2가지 바로잡혔어요.
- "deny는 문자열의 중간을 못 본다"는 통설은 오류예요. 공식 명문과 실측 양쪽으로 확인한 결과, deny는 생각보다 강해요. 정말 못 쓰는 것은 "실행 시에 해결되는 값·상태"뿐이에요(1장).
- "hooks = 결정적"도 지나친 말이에요. hooks 중에는 LLM에게 판단시키는 prompt형·agent형이 공식으로 존재하고, 결정적인 것은 command형(그리고 http / mcp_tool형)뿐이에요(2장).
이 바로잡힌 선 긋기 위에서, 3장의 레시피가 "deny의 판 자체에 오르지 않는 금지 규칙"을 멈춰요.
전제
- Claude Code의 버전과 확인일은 다음과 같아요. 동작·인용 모두 이 시점의 것이며, 버전 의존일 가능성이 있어요.
$ date "+%Y-%m-%d %H:%M:%S %Z"
2026-07-17 18:10:47 JST
$ claude --version
2.1.210 (Claude Code)
$ python3 --version
Python 3.14.3
- OS는 macOS예요. 레시피의 훅 스크립트는 python3 표준 라이브러리에만 의존시켰어요(jq 미사용. 배포 시의 이식성 중시).
- 공식 문서 인용은 전부 2026-07-17 확인분이에요. 뒤에서 말하겠지만, 공식 문서의 문구는 예고 없이 바뀌는 일이 있어서 확인일을 명기해요.
- permissions 문서에는 "v2.1.211 이후는
.claude/settings.local.json의 해결 위치가 바뀐다" 같은 버전 조건 딸린 기술이 여럿 있어요. 이 글의 내용을 적용할 때는, 쓰고 있는 버전에서 문서를 확인해 주세요. - 대상 독자는 Claude Code를 실무에 도입하고 있고 "AI가 밟게 하고 싶지 않은 선"을 설정 파일로 관리하고 싶은 분이에요.
전체 구성
Claude Code의 "금지 규칙을 쓰는 곳"은 성질이 다른 층으로 나뉘어요.
| 층 | 실체 | 판단의 성질 | 참조할 수 있는 정보 |
|---|---|---|---|
| CLAUDE.md(지시 파일) | 자연어 지시 | 확률적 (LLM이 따르는 것에 의존) | ― |
| permissions(deny / ask / allow) | 도구 입력 문자열에 대한 정적 글롭 매치 | 결정적이지만 정적 | 도구 입력의 문자열뿐 |
| hooks: command형 | 임의의 스크립트 실행 | 결정적·실행 시 | 파일·git 상태·환경 등 실행 시의 전부 |
| hooks: prompt / agent형 | Claude 모델에 의한 판단 | 비결정적 (LLM 판단) | 훅 입력 + 모델의 판단 |
이 정리 위에서, 3장에서 "deny의 판 자체에 오르지 않는 금지 규칙" 3가지를 hooks로 강제해요.
1. 기존의 2층을 일차 정보로 재점검한다
1-1. CLAUDE.md는 "부탁" (확률적)
우선 제일 바깥층이에요. CLAUDE.md(지시 파일)에 적은 금지 규칙을 지킬지 말지를 최종적으로 정하는 것은 LLM 자신이에요.
공식 문서는 hooks의 존재 이유를 설명하는 한 문장에서, 이 전제를 분명하게 인정하고 있어요(강조는 필자).
They provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them.
— Automate actions with hooks(hooks-guide) (2026-07-17 확인)
필자의 번역: "hooks는 Claude Code의 동작에 결정적인 제어를 부여해, LLM이 실행을 선택하는 데 기대는 게 아니라, 특정 액션이 반드시 일어나는 것을 보증합니다".
뒤집어 말하면, 지시 파일의 준수는 "LLM이 따르기를 선택하는" 것에 의존하고 있다는 게 공식의 자리매김이에요. 실제로 이 글의 검증 중에도, 훅에 도달하기 전에 에이전트 자신의 판단으로 실행이 거부되어 프롬프트 설계를 바꿀 때까지 동작이 변하지 않는 장면이 있었어요("걸려 넘어지기 쉬운 포인트"에서 자세히 다뤄요). CLAUDE.md는 판단의 질을 올리는 층이지, 금지를 보증하는 층이 아니에요.
1-2. deny는 생각보다 강하다 — 와일드카드는 중간 위치에도 쓸 수 있다
다음은 permissions.deny예요. 여기서 통설 하나를 바로잡을게요.
"deny의 패턴은 명령의 앞머리부터 매치니까, 인수 도중에 있는 위험한 문자열은 못 본다" —— 필자도 그렇게 믿고 있었지만, 오류예요. 공식에 명문이 있어요.
Wildcards can appear at any position in the command, including at the beginning, middle, or end
— Configure permissions (2026-07-17 확인)
와일드카드 *는 앞머리·중간·끝 어디에도 둘 수 있어요. 문서에는 Bash(git * main)이 git checkout main에도 git log --oneline main에도 매치한다는 예까지 실려 있어요.
실기에서도 확인했어요. 검증용 리포지터리(구성은 3-0의 "실측의 전제" 참조)의 settings.json에 중간 일치 deny를 일시 추가해요.
{
"permissions": {
"deny": ["Bash(*prod-super-secret-marker*)"]
}
}
이 상태에서, 위험 마커를 인수 중간에 포함한 echo를 가장 느슨한 권한 모드로 실행시켜요.
$ claude -p "Run the shell command: echo start prod-super-secret-marker end -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
Blocked by security policy: matches deny pattern Bash(*prod-super-secret-marker*)
(명령 속의 ...는 프롬프트 후반 지시문의 생략이에요. 이후 실측 로그도 마찬가지)
인수 중간에 있는 문자열로, 제대로 차단됐어요. deny는 "문자열에 나타나는 정보"에 대해서는 위치를 가리지 않고 매치할 수 있는 결정적인 장치예요. "deny는 약하니까 hooks를 쓰자"라는 도입부는, 출발점부터 부정확해요.
1-3. 그래도 deny에 쓸 수 없는 것 — 실행 시에 해결되는 값·상태
그럼 deny의 진짜 한계는 어디인가. 공식 문서가 스스로 인정하는 것은 이거예요.
Bash permission patterns that try to constrain command arguments are fragile.
— Configure permissions (2026-07-17 확인)
"명령 인수를 제약하려는 Bash 퍼미션 패턴은 부서지기 쉽다". 문서가 드는 빠져나가기 예의 필두가 변수 경유예요. Bash(curl http://github.com/ *)로 curl을 GitHub에 제한한 셈이어도, URL=http://github.com && curl $URL 같은 형태에는 매치하지 않아요.
즉 deny의 한계는 "중간이 안 보인다"가 아니라, 매치 대상이 tool_input.command라는 정적 문자열뿐이라는 거예요. 다음 정보는 그 문자열 안에 애초부터 존재하지 않아요.
- 실행 시에 해결되는 값:
$(cat .deploy-target)의 내용, 변수의 전개 결과 - 외부 상태: 현재 git 브랜치, 파일의 내용, 환경의 상태
- 패스 해결의 결과:
tmp/../secret이 실제로 어디를 가리키는가
실행되는 "실체"가 명령 문자열에 나타나지 않는 케이스야말로 정적 패턴 매치의 사각이에요.
이는 hooks에도 되돌아오는 주의예요. 훅 스크립트가 tool_input.command의 문자열만 보고 판단한다면, 참조할 수 있는 정보는 deny와 다르지 않아요. hooks의 가치는 "실행 시에 파일과 상태를 실제로 읽고 판단할 수 있다"에 있고, 3장의 레시피는 전부 이 형태로 쓰여 있어요.
2. hooks의 "결정적"의 정확한 내용
2-1. 공식이 말하는 "deterministic control"
hooks를 "결정적"이라 부르는 근거는 1-1에서 인용한 공식 가이드(hooks-guide)의 한 문장이에요. "They provide deterministic control over Claude Code's behavior" — hooks는 Claude Code의 동작에 대한 결정적 제어로 공식 정의되어 있어요.
2-2. 주의: hooks 안에도 "결정적이지 않은" 형이 있다
여기가 이 글에서 강조하고 싶은 2번째 정정이에요. "hooks = 결정적"이라고 잘라 말하는 건 부정확해요. 공식 가이드는 같은 서두 설명 안에서 이렇게 이어 가요.
For decisions that require judgment rather than deterministic rules, you can also use prompt-based hooks or agent-based hooks that use a Claude model to evaluate conditions.
— Automate actions with hooks(hooks-guide) (2026-07-17 확인)
필자의 번역: "결정적인 규칙이 아니라 판단이 필요한 장면에서는, Claude 모델에게 조건을 평가시키는 prompt 기반/agent 기반 hook도 쓸 수 있습니다".
즉 hooks에는 다음 2계통이 공식으로 병존해요.
| hook의 형 | 판단자 | 성질 |
|---|---|---|
command (그리고 http / mcp_tool) | 당신이 쓴 스크립트 | 결정적 |
prompt / agent | Claude 모델 | 비결정적 (LLM 판단) |
"확실하게 멈추는" 가드레일을 만들고 싶다면, 써야 할 것은 command형이에요. 이 글의 레시피는 전부 "type": "command"로 써요.
2-3. PreToolUse의 구조 — 멈추는 방법은 2가지 방식
PreToolUse는 도구 호출 직전에 발동하는 이벤트예요. 훅 스크립트는 stdin으로 JSON을 받아요.
PreToolUse hooks receive tool_name, tool_input, and tool_use_id.
— Hooks reference (2026-07-17 확인)
Bash 도구의 경우, tool_input.command에 실행되려는 명령 문자열이 들어 있어요. 도구 호출을 멈추는 방법은 2가지예요.
방식 A: exit code 2 + stderr
Exit 2 means a blocking error. Claude Code ignores stdout and any JSON in it. Instead, stderr text is fed back to Claude as an error message.
— Hooks reference (2026-07-17 확인)
방식 B: exit 0 + stdout에 JSON으로 permissionDecision을 출력
permissionDecision: "allow" skips the permission prompt, ... "deny" prevents the tool call. "ask" prompts the user to confirm.
— Hooks reference (2026-07-17 확인. 생략은 필자)
3장의 레시피에서는 레시피②가 방식 A, 레시피①③이 방식 B의 실례예요. 양쪽 쓰는 법을 가져가 주세요.
빠지기 쉬운 사양이 하나 있어요. exit 1로는 안 멈춰요.
For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code.
— Hooks reference (2026-07-17 확인)
Unix 습관대로 "실패 = exit 1"이라고 쓰면, 에러 취급은 되지만 도구는 실행되어 버려요. 차단은 exit 2예요.
또, 여러 PreToolUse hook이 다른 판단을 돌려줬을 때의 우선순위도 명문이 있어요.
When multiple PreToolUse hooks return different decisions, precedence is deny > defer > ask > allow.
— Hooks reference (2026-07-17 확인)
하나라도 deny를 돌려주면 차단이 이겨요. 3장에서 3레시피를 공존시켜도 안전한 것은 이 사양 덕분이에요.
hook과 permission rules의 관계(어느 쪽이 먼저인가·어느 쪽이 이기는가)도 헷갈리기 쉬우니 짚어 둘게요.
Hook decisions don't bypass permission rules. Claude Code evaluates deny and ask rules regardless of what a PreToolUse hook returns
— Configure permissions (2026-07-17 확인)
역방향은 hook이 이겨요. exit 2로 차단하는 hook은 allow 규칙보다 우선되고, permission rules의 평가 전에 도구 호출을 멈춘다고 같은 문서에 명기되어 있어요.
정리하면 다음 흐름이에요(이 글에 관계된 경로만의 간략도예요).
- 흐름도(원문 Mermaid 임베드): https://qiita.com/embed-contents/mermaid#qiita-embed-content__d9c5e9fa35572133fd195abc1a1cfb23
hook은 조이는 방향으로만 작동해요. 공식 가이드의 한 문장이 이 비대칭성을 잘 나타내요.
Hooks can tighten restrictions but not loosen them past what permission rules allow.
— Automate actions with hooks(hooks-guide) (2026-07-17 확인)
2-4. bypassPermissions에서도 hook의 deny는 효과가 있다
가드레일로서 결정적으로 중요한 사양이 이거예요.
PreToolUse hooks fire before any permission-mode check. A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. This lets you enforce policy that users can't bypass by changing their permission mode.
— Automate actions with hooks(hooks-guide) (2026-07-17 확인)
권한 모드를 가장 느슨하게 해도, command형 PreToolUse hook의 deny는 효과가 있다. 이 사양은 3장의 레시피①에서 실기 확인해요(결과: 일치했어요).
3. deny로는 쓸 수 없는 금지 규칙 3가지를 hooks로 강제한다【명령 하나로 적용】
3-0. 적용은 명령 하나 — 기존 설정을 부수지 않는 인스톨러
이 장에서 나눠 드리는 3레시피는, 원파일 인스톨러로 일괄 적용할 수 있어요. 아래의 install_claude_guardrails.py를 한 번 저장하고, 자기 프로젝트 루트에서 실행하기만 하면 돼요.
# 프로젝트 루트에서 실행 (인수 생략 시에는 현재 디렉터리에 적용)
python3 install_claude_guardrails.py
# 적용 내용만 확인하고 싶을 때 (파일 변경 없음)
python3 install_claude_guardrails.py --dry-run
# 일부 레시피만 넣고 싶을 때 (예: ①과 ③)
python3 install_claude_guardrails.py --recipes 1,3
"기존 장치를 부수지 않기" 위한 설계의 요점이에요(모두 실측으로 확인 완료).
.claude/hooks/에 3스크립트를 써 내고,.claude/settings.json의hooks.PreToolUse(matcher"Bash")에 엔트리를 덧붙여요. settings.json이 없으면 hooks 키만으로 새로 만들어요(permissions 등은 추가하지 않아요)- 기존 키는 바꾸지 않아요:
permissions(deny / allow)·env·다른 matcher·PostToolUse·기존 Bash hook은 전부 보존되고, 레시피는 기존 hook 뒤에 덧붙여져요 - 변경 전에
settings.json.bak-<timestamp>를 자동 생성해요. 내용이 다른 동명의 훅 스크립트가 있는 경우에도.bak을 만들고 나서 바꿔요 - 멱등이에요. 2번째 실행은 "nothing to do"로 끝나고, 설정은 바뀌지 않아요
- settings.json이 깨진 JSON인 경우에는, 아무것도 바꾸지 않고 중단해요(hooks 디렉터리도 안 만들어요)
- 홈 디렉터리로의 적용은 거부해요(
~/.claude/settings.json= 사용자 전역 설정을 바꿔 버리기 때문). 파일 시스템 루트도 거부해요 - 이 뒤에 싣는 settings.json의
permissions.allow는 의도적으로 설치하지 않아요(그건 검증 환경용 설정이고, 권한을 느슨하게 하는 변경을 독자 환경에 넣지 않기 위한 설계 판단이에요) - 설치 후, 중립 명령(
echo hello)을 각 훅에 흘리는 스모크 테스트를 자동 실행해요(exit 0·무출력이면 PASS)
install_claude_guardrails.py 전문
#!/usr/bin/env python3
"""One-file installer for the 3 PreToolUse guardrail recipes.
Run from your project root (or pass the project path):
python3 install_claude_guardrails.py # install into ./
python3 install_claude_guardrails.py /path/to/project
python3 install_claude_guardrails.py --recipes 2 # subset
python3 install_claude_guardrails.py --dry-run # show plan only
What it does:
- Writes the recipe scripts into <project>/.claude/hooks/
- Adds matching hook entries to <project>/.claude/settings.json under
hooks.PreToolUse (matcher "Bash"), creating the file if it does not exist
- Leaves every other key (permissions, env, other matchers/events) untouched
- Backs up settings.json and any differing hook script before overwriting
- Idempotent: a second run reports "nothing to do"
- Refuses to run against your home directory (that would edit the user-global
~/.claude/settings.json instead of a project-level one)
It intentionally does NOT touch permissions.allow / permissions.deny.
"""
import argparse
import copy
import datetime
import json
import shutil
import subprocess
import sys
from pathlib import Path
SMOKE_INPUT = '{"tool_input": {"command": "echo hello"}}'
RECIPE_SOURCES = {
"recipe1_resolved_target_guard.py": r'''#!/usr/bin/env python3
"""PreToolUse hook (Recipe 1): block deploy commands whose *resolved* target
is production, even when the literal Bash command string never contains the
word "prod" (e.g. the target is read from a file/subshell at execution time).
"""
import json
import pathlib
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "deploy.sh" not in command:
sys.exit(0) # not our target command; let normal permission flow decide
target_file = pathlib.Path(".deploy-target")
resolved_target = target_file.read_text().strip() if target_file.exists() else "unknown"
if resolved_target.startswith("prod"):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Resolved deploy target is '{resolved_target}' (read from "
".deploy-target at hook time), which is a production target. "
"Blocked by PreToolUse hook regardless of what the literal "
"command string contains."
),
}
}))
sys.exit(0)
''',
"recipe2_protected_branch_guard.py": r'''#!/usr/bin/env python3
"""PreToolUse hook (Recipe 2): block `git commit` while the current branch
is a protected branch (main/master)."""
import json
import subprocess
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "git commit" not in command:
sys.exit(0)
try:
branch = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, check=True,
).stdout.strip()
except Exception:
branch = ""
PROTECTED_BRANCHES = {"main", "master"}
if branch in PROTECTED_BRANCHES:
sys.stderr.write(
f"Blocked: direct `git commit` on protected branch '{branch}' is not "
"allowed. Create a feature branch first.\n"
)
sys.exit(2)
sys.exit(0)
''',
"recipe3_rm_allowlist_guard.py": r'''#!/usr/bin/env python3
"""PreToolUse hook (Recipe 3): only allow `rm` to delete paths that RESOLVE
(after following `..` and symlinks) inside the project's tmp/ directory."""
import json
import os
import re
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
m = re.match(r"^\s*rm(\s+-\S+)*\s+(?P<path>\S+)\s*$", command)
if not m:
sys.exit(0) # not a simple rm we recognize; normal permission flow applies
target = m.group("path")
project_root = os.getcwd()
allowed_root = os.path.realpath(os.path.join(project_root, "tmp"))
resolved = os.path.realpath(os.path.join(project_root, target))
inside_allowed = (
resolved == allowed_root
or resolved.startswith(allowed_root + os.sep)
)
if inside_allowed:
decision, reason = "allow", f"rm target resolves inside tmp/ ({resolved})"
else:
decision, reason = "deny", (
f"rm target '{target}' resolves to '{resolved}', which is outside "
f"the allowed directory '{allowed_root}'."
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
''',
}
RECIPE_KEYS = {"1": "recipe1_resolved_target_guard.py",
"2": "recipe2_protected_branch_guard.py",
"3": "recipe3_rm_allowlist_guard.py"}
def hook_entry(script_name: str) -> dict:
return {
"type": "command",
"command": f'python3 "${{CLAUDE_PROJECT_DIR}}/.claude/hooks/{script_name}"',
}
def fail(msg: str):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("project", nargs="?", default=".",
help="target project directory (default: current directory)")
parser.add_argument("--recipes", default="1,2,3",
help="comma-separated subset to install (default: 1,2,3)")
parser.add_argument("--dry-run", action="store_true", help="show plan only")
args = parser.parse_args()
project = Path(args.project).expanduser().resolve()
if not project.is_dir():
fail(f"project directory not found: {project}")
if project == Path.home():
fail("refusing to install into your home directory — that would edit "
"~/.claude/settings.json (user-global settings). "
"Run from a project root or pass its path")
if project == Path(project.anchor):
fail("refusing to install into the filesystem root")
selected = []
for key in args.recipes.split(","):
key = key.strip()
if key not in RECIPE_KEYS:
fail(f"unknown recipe '{key}' (valid: 1,2,3)")
if RECIPE_KEYS[key] not in selected:
selected.append(RECIPE_KEYS[key])
claude_dir = project / ".claude"
hooks_dir = claude_dir / "hooks"
settings_path = claude_dir / "settings.json"
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
# ---- read current settings (a malformed file aborts before any change)
old_settings = None
if settings_path.exists():
try:
old_settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
fail(f"{settings_path} is not valid JSON ({e}); nothing was changed")
if not isinstance(old_settings, dict):
fail(f"{settings_path} top-level is not a JSON object; nothing was changed")
# ---- plan the settings merge on a copy
new_settings = copy.deepcopy(old_settings) if old_settings is not None else {}
hooks_cfg = new_settings.setdefault("hooks", {})
if not isinstance(hooks_cfg, dict):
fail("settings.json 'hooks' is not an object; refusing to modify")
pre_list = hooks_cfg.setdefault("PreToolUse", [])
if not isinstance(pre_list, list):
fail("settings.json 'hooks.PreToolUse' is not an array; refusing to modify")
bash_entry = None
for entry in pre_list:
if isinstance(entry, dict) and entry.get("matcher") == "Bash":
bash_entry = entry
break
created_matcher = bash_entry is None
if created_matcher:
bash_entry = {"matcher": "Bash", "hooks": []}
hook_list = bash_entry.setdefault("hooks", [])
if not isinstance(hook_list, list):
fail("PreToolUse entry for matcher 'Bash' has a non-array 'hooks'; refusing to modify")
existing_commands = {h.get("command") for h in hook_list if isinstance(h, dict)}
to_add = [hook_entry(n) for n in selected
if hook_entry(n)["command"] not in existing_commands]
if to_add:
hook_list.extend(to_add)
if created_matcher:
pre_list.append(bash_entry)
# ---- plan the script writes
write_plan = [] # (name, dst, needs_backup)
for name in selected:
dst = hooks_dir / name
if dst.exists():
if dst.read_text(encoding="utf-8") == RECIPE_SOURCES[name]:
continue
write_plan.append((name, dst, True))
else:
write_plan.append((name, dst, False))
if not write_plan and not to_add:
print(f"OK: nothing to do — selected recipes already installed in {project}")
return
print(f"Target project : {project}")
print(f"Recipes : {', '.join(selected)}")
for name, dst, needs_backup in write_plan:
action = "update (backup first)" if needs_backup else "write"
print(f" script {action}: {dst.relative_to(project)}")
for entry in to_add:
print(f" settings add : {entry['command']}")
if not to_add:
print(" settings : unchanged (entries already present)")
if args.dry_run:
print("DRY RUN: no files were changed")
return
# ---- execute
hooks_dir.mkdir(parents=True, exist_ok=True)
for name, dst, needs_backup in write_plan:
if needs_backup:
backup = dst.with_name(dst.name + f".bak-{stamp}")
shutil.copy2(dst, backup)
print(f" backed up : {backup.relative_to(project)}")
dst.write_text(RECIPE_SOURCES[name], encoding="utf-8")
if to_add:
if settings_path.exists():
backup = settings_path.with_name(settings_path.name + f".bak-{stamp}")
shutil.copy2(settings_path, backup)
print(f" backed up : {backup.relative_to(project)}")
settings_path.write_text(
json.dumps(new_settings, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# ---- smoke test: a neutral command must pass through (exit 0, no output)
print("Smoke test (neutral command should pass through: exit 0, no output):")
all_ok = True
for name in selected:
proc = subprocess.run(
[sys.executable, str(hooks_dir / name)],
input=SMOKE_INPUT, capture_output=True, text=True, cwd=str(project),
)
ok = proc.returncode == 0 and not proc.stdout.strip() and not proc.stderr.strip()
all_ok = all_ok and ok
print(f" {'PASS' if ok else 'FAIL'}: {name} (exit={proc.returncode})")
if not all_ok:
print("WARNING: smoke test failed — check python3 and script contents",
file=sys.stderr)
sys.exit(1)
print("Done. Unit-test each hook with: echo '<json>' | python3 .claude/hooks/<script>")
if __name__ == "__main__":
main()
이 인스톨러는 34케이스의 테스트로 실측 확인했어요(새 프로젝트 적용과 멱등성·기존 설정과의 머지 보전·깨진 JSON에서의 안전 중단·dry-run·홈 가드·설치처 복사본에서의 레시피 실동작). 인스톨러가 써 내는 3스크립트가 이 글에 실린 레시피와 바이트 일치하는 것도 테스트로 담보했어요. 검증 환경은 macOS + python3(3.14.3)이며, Windows에서의 동작은 미검증이에요(python3 명령 이름·경로 구분자 차이).
손으로 배선하고 싶은 경우: 인스톨러가 settings.json에 하는 배선은 다음 hooks 키의 형태예요(검증 환경에서 쓴 settings.json 전문. permissions.allow는 앞서 말한 대로 검증 환경용이라 인스톨러는 추가하지 않아요). 3레시피를 한 파일에 공존시켜도 간섭하지 않는 것을 확인했어요(matcher는 공통으로 "Bash", 각 스크립트가 대상 외 명령을 내부에서 스루하는 설계).
{
"permissions": {
"allow": [
"Bash(./deploy.sh*)",
"Bash(git commit*)",
"Bash(git add*)",
"Bash(rm *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe1_resolved_target_guard.py\"" },
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe2_protected_branch_guard.py\"" },
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe3_rm_allowlist_guard.py\"" }
]
}
]
}
}
실측의 전제 (검증 환경)
3-1 이후의 실측 로그는, 위의 settings.json과 3스크립트를 쓰고 버리는 git 리포지터리(이하 sandbox-repo. 실제 경로는 가리고 ~/sandbox 표기)에 두고 얻었어요. 실제 프로젝트와 전역 설정에는 손대지 않았어요.
sandbox-repo/
.claude/
settings.json # PreToolUse hook 3본 + permissions.allow
hooks/
recipe1_resolved_target_guard.py
recipe2_protected_branch_guard.py
recipe3_rm_allowlist_guard.py
deploy.sh # 더미 배포 스크립트 (echo 뿐, 파괴적 조작 없음)
.deploy-target # "현재 배포 대상"을 나타내는 상태 파일 (1행)
tmp/scratch.txt # 허용 리스트 안의 삭제 대상 (검증용)
important-root-file.txt # 허용 리스트 밖의 더미 파일
README.md
.claude/ 아래는 위 인스톨러가 생성하는 구성에 검증용 permissions.allow를 더한 거예요. 그 외(deploy.sh·.deploy-target·tmp/ 등)는 검증용 더미예요.
검증 방법의 주의 (정직한 보고): 이 샌드박스는 workspace가 "미 trust" 상태였기 때문에, permissions.allow는 실행 시에 항상 무시됐어요(실제 경고: Ignoring 4 permissions.allow entries from .claude/settings.json: this workspace has not been trusted. ...). trust 수락은 전역 설정 쓰기를 동반하므로 하지 않았고, 이후 검증은 --permission-mode bypassPermissions --dangerously-skip-permissions로 실시했어요. 이 상태에서도 hooks의 발동과 deny의 평가에는 영향이 없다는 것은 확인했지만, "allow 규칙이 순순히 효과를 내는 통상 운용" 그 자체는 이번에 재현하지 못했어요. "trust되지 않으면 hook도 안 듣는다"는 일반화는 오류예요.
참고로 bypassPermissions로의 검증은 "가장 느슨한 권한 모드에서도 hook이 멈추는가"의 확인(2-4 사양의 실기 검증)을 겸하고 있어요.
또, 이후에 싣는 훅 스크립트 3본은 검증 때 쓴 실물에서 주석·docstring을 짧게 한 판이에요. 실행 코드는 동일하고, 게재판 그 자체로도 전 케이스(차단·통과)의 동작 일치를 확인했어요.
3-1. 레시피①: 실행 시에 해결되는 값 — 프로덕션 배포 오폭 방지
deny로 못 쓰는 이유(1행): 배포 대상이 $(cat .deploy-target)처럼 실행 시에 정해지는 경우, 명령 문자열에는 prod라는 글자가 일절 나타나지 않아서, 어떤 deny 패턴을 써도 매치할 방법이 없다.
Bash(*prod*) 같은 deny는 "문자열에 prod가 나타나는" 케이스에는 들어요(1-2에서 실측한 대로). 그러나 "배포 대상을 상태 파일이나 설정에서 읽는" 타입의 스크립트에서는, 위험한지 아닌지가 명령 문자열의 바깥에 있어요.
hook이라면 그 자리에서 상태 파일을 읽고 판단할 수 있어요. 방식 B(permissionDecision JSON)의 실례예요.
.claude/hooks/recipe1_resolved_target_guard.py 전문:
#!/usr/bin/env python3
"""PreToolUse hook (Recipe 1): block deploy commands whose *resolved* target
is production, even when the literal Bash command string never contains the
word "prod" (e.g. the target is read from a file/subshell at execution time).
"""
import json
import pathlib
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "deploy.sh" not in command:
sys.exit(0) # not our target command; let normal permission flow decide
target_file = pathlib.Path(".deploy-target")
resolved_target = target_file.read_text().strip() if target_file.exists() else "unknown"
if resolved_target.startswith("prod"):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Resolved deploy target is '{resolved_target}' (read from "
".deploy-target at hook time), which is a production target. "
"Blocked by PreToolUse hook regardless of what the literal "
"command string contains."
),
}
}))
sys.exit(0)
차단 케이스(막혀야 할 것이 막힌다): .deploy-target의 내용은 prod-billing-db(더미 프로덕션 식별자). Bash에 넘어간 명령은 ./deploy.sh $(cat .deploy-target)로, 문자열상으로는 "prod"를 포함하지 않아요.
$ claude -p "Run the shell command: ./deploy.sh \$(cat .deploy-target) -- ..." --permission-mode default
출력 (발췌):
The command was blocked by a PreToolUse hook before it executed. It did not run.
Resolved deploy target is 'prod-billing-db' (read from .deploy-target at hook
time), which is a production target. Blocked by PreToolUse hook regardless of
what the literal command string contains.
(명령 속 \$는, claude -p를 치는 쪽 셸에서 $(cat ...)이 먼저 전개되는 것을 막는 이스케이프예요. 이로써 Claude에게 넘어가는 프롬프트에는 리터럴 $(cat .deploy-target)이 전달되고, Bash 도구의 tool_input.command도 리터럴 그대로예요)
통과 케이스(통과해야 할 것이 통과한다): .deploy-target을 staging-web으로 바꾸고 같은 명령을 실행.
$ claude -p "Run the shell command: ./deploy.sh \$(cat .deploy-target) -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
출력 (발췌):
Deploying to target: staging-web
Exit: success, no errors, no permission prompts, no denials.
추가 검증 (bypassPermissions에서도 멈추는가): .deploy-target을 prod로 되돌리고, 같은 bypassPermissions 모드로 재실행했더니 결과는 차단이었어요(메시지는 차단 케이스와 동일). deploy.sh의 stdout은 일절 출력되지 않았고, 실행되지 않았음을 확인했어요. 2-4에서 인용한 "PreToolUse hooks fire before any permission-mode check ... even in bypassPermissions mode"와 완전히 일치하는 실기 결과예요.
이 레시피의 .deploy-target을 읽는 부분은 한 예예요. "배포 대상·접속 대상을 정하는 실행 시 정보"를 훅이 그 자리에서 읽는 형태로 바꾸면, 같은 구조가 그대로 쓰여요.
3-2. 레시피②: 상태 의존(현재 브랜치) — 보호 브랜치에서의 git commit 금지
deny로 못 쓰는 이유(1행): git commit -m "..."이라는 문자열은 main 위에서도 feature 브랜치 위에서도 완전히 동일하고, "지금 어느 브랜치에 있는가"를 참조하는 구문이 permissions에는 존재하지 않는다.
deny로 쓸 수 있는 건 "git commit을 항상 금지"라는 난폭한 규칙뿐이에요. "main에서만 금지"라는 조건 분기는, 문자열 매치로는 구조적으로 표현할 수 없어요. 2026-07-17 시점의 permissions 문서 전문을 확인했지만, Bash 규칙이 대조할 수 있는 것은 명령 문자열뿐이고, 외부 상태를 참조하는 규칙 구문은 하나도 없었어요. 파라미터 매치라는 별도 기구도, Bash의 command 필드는 대상 외라고 명기되어 있어요.
Fields that a tool already matches with its own canonicalizing rules are not matchable this way: command for Bash and PowerShell, ...
— Configure permissions (2026-07-17 확인. 생략은 필자)
hook이라면 git branch --show-current를 그 자리에서 실행해 판단할 수 있어요. 방식 A(exit 2 + stderr)의 실례예요.
.claude/hooks/recipe2_protected_branch_guard.py 전문:
#!/usr/bin/env python3
"""PreToolUse hook (Recipe 2): block `git commit` while the current branch
is a protected branch (main/master)."""
import json
import subprocess
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "git commit" not in command:
sys.exit(0)
try:
branch = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, check=True,
).stdout.strip()
except Exception:
branch = ""
PROTECTED_BRANCHES = {"main", "master"}
if branch in PROTECTED_BRANCHES:
sys.stderr.write(
f"Blocked: direct `git commit` on protected branch '{branch}' is not "
"allowed. Create a feature branch first.\n"
)
sys.exit(2)
sys.exit(0)
차단 케이스 (main 브랜치에서 commit):
$ git branch --show-current
main
$ claude -p "Run the shell command: git commit -m 'recipe2 positive test commit' -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
출력:
PreToolUse:Bash hook error: [python3 "${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe2_protected_branch_guard.py"]:
Blocked: direct `git commit` on protected branch 'main' is not allowed. Create a feature branch first.
Claude의 자기 신고에 기대지 않고, git log로 독립 검증해요.
$ git log --oneline
f707f94 initial commit ← 커밋은 늘지 않았다 (올바르게 차단됨)
통과 케이스 (feature 브랜치에서 commit):
$ git checkout -qb feature/recipe2-negative
$ claude -p "Run the shell command: git commit -m 'recipe2 negative test commit' -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
출력:
[feature/recipe2-negative fe73a2c] recipe2 negative test commit
1 file changed, 1 insertion(+)
$ git log --oneline
fe73a2c recipe2 negative test commit ← 새 커밋이 실제로 만들어졌다
f707f94 initial commit
"main에서는 멈추고, feature에서는 통과한다". deny에는 원리적으로 쓸 수 없는 상태 의존 금지 규칙이, hook이라면 30행으로 쓸 수 있어요.
3-3. 레시피③: 패스 해결이 필요한 규칙 — rm의 삭제 대상을 허용 리스트로 검증
deny로 못 쓰는 이유(1행): tmp/../secret은 겉보기 글자로는 "tmp/"로 시작하는 문자열이고, Bash 규칙의 글롭은 명령 문자열을 글자 그대로 매치할 뿐이며, ..을 해결한다는 기술이 문서에 없다.
permissions에는 Read / Edit 도구 전용의 패스 해결 시스템(gitignore 형식, 심볼릭 링크의 특별 취급 있음)이 존재해요. 그러나 2026-07-17 시점의 문서를 확인하는 한, 이 해결 로직이 Bash의 명령 인수에 적용된다는 기술은 어디에도 없어요(Bash 절은 시종 "명령 문자열에 대한 glob"으로만 설명돼요).
그래서 "Bash(rm -rf tmp/*) 같은 allow / deny 규칙은 tmp/../secret 같은 traversal을 통과시켜 버릴 가능성이 높다"는 것은, 문서로부터의 추론이지 대조 실험에 의한 직접 확인이 아니에요(이유는 뒤의 "검증의 한계" 참조). 한편 hook 쪽에서 traversal을 검출할 수 있다는 것은 아래대로 실측했어요.
hook이라면 os.path.realpath로 ..과 심볼릭 링크를 해결한 뒤의 패스를 검증할 수 있어요. 방식 B(allow / deny를 JSON으로 돌려주기)의 실례예요. tmp/ 아래로 해결되면 "allow"(확인 프롬프트를 건너뛰는 지정), 밖으로 나가면 "deny"를 돌려줘요.
.claude/hooks/recipe3_rm_allowlist_guard.py 전문:
#!/usr/bin/env python3
"""PreToolUse hook (Recipe 3): only allow `rm` to delete paths that RESOLVE
(after following `..` and symlinks) inside the project's tmp/ directory."""
import json
import os
import re
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
m = re.match(r"^\s*rm(\s+-\S+)*\s+(?P<path>\S+)\s*$", command)
if not m:
sys.exit(0) # not a simple rm we recognize; normal permission flow applies
target = m.group("path")
project_root = os.getcwd()
allowed_root = os.path.realpath(os.path.join(project_root, "tmp"))
resolved = os.path.realpath(os.path.join(project_root, target))
inside_allowed = (
resolved == allowed_root
or resolved.startswith(allowed_root + os.sep)
)
if inside_allowed:
decision, reason = "allow", f"rm target resolves inside tmp/ ({resolved})"
else:
decision, reason = "deny", (
f"rm target '{target}' resolves to '{resolved}', which is outside "
f"the allowed directory '{allowed_root}'."
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
차단 케이스 (traversal로 tmp/ 밖으로 탈출하려 한다): 에이전트 자신에게 scratch-outside/decoy.txt를 만들게 하고, rm -rf tmp/../scratch-outside/decoy.txt를 실행시켜요.
$ claude -p "... 1. create scratch-outside/decoy.txt 2. rm -rf tmp/../scratch-outside/decoy.txt ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
출력 (발췌. 경로는 가림 처리):
rm target 'tmp/../scratch-outside/decoy.txt' resolves to
'~/sandbox/sandbox-repo/scratch-outside/decoy.txt', which is outside
the allowed directory '~/sandbox/sandbox-repo/tmp'.
(중략)
Outcome: blocked by the PreToolUse hook.
(중략)
The decoy file was not deleted.
파일 존재로 독립 검증해요 (사용자 이름·일시는 가림/생략).
$ ls -la scratch-outside/decoy.txt
-rw-r--r-- 1 sano wheel 46 ... decoy.txt ← 삭제되지 않았다
통과 케이스 (tmp/ 아래의 정상적인 삭제):
$ claude -p "... rm tmp/scratch.txt ..." --permission-mode bypassPermissions --dangerously-skip-permissions
출력 (발췌):
$ rm tmp/scratch.txt; echo "exit status: $?"
exit status: 0
(전략)ALLOWED by the hook — not blocked. Exit status: 0.
$ ls tmp/
(비어 있음. scratch.txt 는 삭제됨 ← 올바르게 허용됨)
이 레시피의 정규식은 rm [옵션] <패스 1개>라는 단순형만 대상으로 한 최소 구현이에요. 복수 패스 지정(rm a b c)과 find ... -delete 등은 대상 외라 별도 대응이 필요해요. 참고로 공식 문서도 deny 쪽에서 같은 종류의 한계를 인정하고 있어요: "The same applies to find with -exec or -delete: a Bash(find *) rule doesn't cover these forms" (Configure permissions, 2026-07-17 확인).
4. 배선 전에 훅 단독으로 유닛 테스트한다
훅 스크립트는 settings.json에 배선하기 전에 단독으로 테스트할 수 있어요. stdin에 JSON을 흘려 넣기만 하면 돼요(hooks-guide의 트러블슈팅 절과 같은 수법이에요).
echo '{"tool_input": {"command": "git commit -m test"}}' | \
python3 .claude/hooks/recipe2_protected_branch_guard.py
echo "exit code: $?"
확인할 것은 다음 2가지예요.
- 방식 A(레시피②)라면: 대상 명령 + 금지 상태일 때 exit code가 2가 되고, stderr에 이유가 나오는가
- 방식 B(레시피①③)라면: stdout에
permissionDecision의 JSON이 나오는가 (exit code는 0)
레시피②는 현재 디렉터리의 git 상태를 읽으니까, 테스트하고 싶은 리포지터리 안에서 실행해 주세요. 필자는 3본 모두 이 절차로 먼저 단독 테스트하고 나서 claude -p로의 통합 확인으로 넘어갔어요. Claude Code를 경유하는 디버그는 1왕복이 무거우니, 이 순서를 추천해요.
걸려 넘어지기 쉬운 포인트·주의점
claude -p에 의한 훅 검증은 "대리 검증"이다
레시피③의 차단 케이스 테스트는, 사실 1번째·2번째 시도에서 실패했어요. "rm -rf tmp/../important-root-file.txt를 실행해 줘"라고 의뢰했더니, rm이 도구 호출에 이르기 전에 에이전트 자신이 "이건 path obfuscation이고, 삭제 대상이 내가 만든 것이 아니다"라고 판단해 실행을 거부했어요. 대상 rm 명령이 훅에 평가되는 일은 한 번도 없었어요.
3번째에, 삭제 대상을 에이전트 자신이 그 자리에서 만든 쓰고 버리는 파일로 바꾸고, "hook의 판단까지 포함해 그대로 보고해 달라"고 명시해서, 겨우 훅의 발동을 테스트할 수 있었어요.
claude -p로의 검증에는 Claude 자신의 안전 판단이라는 다른 레이어가 섞여 들어요. "hook 테스트를 한다는 게, 모델의 거부를 테스트하고 있었다"는 뒤바뀜에 주의해 주세요.
공식 문서의 문구는 예고 없이 바뀐다
hooks와 permission rules의 관계를 설명하는 한 문장은, 필자가 다른 글의 조사에서 확인한 2026-07-06 시점에는 "Deny and ask rules are evaluated regardless of what a PreToolUse hook returns"라는 문구였는데, 2026-07-17의 재확인에서는 본문 2-3에서 인용한 형태로 다시 쓰여 있었어요(의미는 같음). 축어 인용할 경우에는, 공개 직전에 반드시 다시 확인하기를 추천해요.
훅 스크립트 자체가 풀 권한으로 돈다
공식 레퍼런스의 주의 문구는, 레시피를 나눠 주는 글로서 인용해 둬야 할 것이에요.
Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.
— Hooks reference (2026-07-17 확인)
이 글의 레시피도, 내용을 읽고 이해한 뒤에 도입해 주세요.
검증의 한계 (미검증 사항)
이 글의 실측이 커버하지 않는 범위를 명시해요.
- 검증은 전부
claude -p(헤드리스 단발 턴)에 의한 프록시 실험이에요. 백그라운드 에이전트(claude agents) 경로에서의 PreToolUse 발동은 미검증이에요. - deny / allow만으로 레시피③에 상당하는 허용 리스트를 썼을 때 traversal이 실제로 빠져나가는가의 대조 실험은 하지 않았어요(미 trust 환경에서 allow가 무시되기 때문에, trust 수락 = 전역 설정 변경이 필요해져서 단념). 문서 기술로부터의 추론에 머물러요.
- workspace trust를 수락한 통상 운용에서의 allow 규칙의 동작은 미재현이에요.
- hooks의
if필드에 의한 사전 필터는 미검증이에요. 이 글의 3레시피는 스크립트 내부에서 대상 명령을 판단하는 설계로 했어요. 문서상으로는if로 hook 프로세스의 기동 자체를 억제할 수 있고, 퍼포먼스상으로는 그쪽이 유리할 텐데, 실측 비교는 안 했어요. - prompt형·agent형 hook의 실기 동작은 미검증이에요 (사양의 축어 확인만. 이 글의 주제는 command형이라).
정리
3층의 정리를 다시 실어요.
- CLAUDE.md: 확률적. 판단의 질을 올리는 층. 금지의 보증은 못 한다
- permissions의 deny: 정적이지만 결정적. 문자열에 나타나는 정보라면 중간 위치라도 멈출 수 있다 (통설보다 강하다)
- hooks(command형): 실행 시이면서 결정적. 실행 시에 해결되는 값·외부 상태·패스 해결이라는, deny의 판에 오르지 않는 영역을 멈출 수 있다. prompt / agent형은 LLM 판단이라 여기에 세지 않는다
가져가실 것:
- 3-0의 원파일 인스톨러 (기존 설정을 부수지 않고 3레시피를 일괄 적용. 손 배선파를 위한 settings.json 전문 포함)
- 레시피①: 실행 시에 해결되는 값으로 멈춘다 (방식 B:
permissionDecision) - 레시피②: git의 상태로 멈춘다 (방식 A: exit 2 + stderr)
- 레시피③: 패스를 해결하고 나서 멈춘다 (방식 B: allow / deny 양방향)
- 4장의 "배선 전 유닛 테스트" 절차
구분해 쓰는 기준은 단순해요. deny에 쓸 수 있는 것은 deny에 (단순하고 빠르다), 문자열에 나타나지 않는 것만 hooks에. 우선 자기 프로젝트에서 "실행 시에만 알 수 있는 금지 조건"을 하나 찾아서, hook으로 만들어 보세요.
이 글이 도움이 됐다면 추천해 주세요