본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 06. 15. 08:18

Claude Code의 PreToolUse hook으로 AI 에이전트의 행동을 물리적으로 제어하기

요약

Claude Code의 PreToolUse hook을 활용하여 AI 에이전트의 권한을 동적으로 제어하는 방법을 설명합니다. 정적 설정 파일의 한계를 극복하고 쉘 스크립트를 통해 에이전트의 작업 범위를 물리적으로 격리하는 구현 사례를 다룹니다.

핵심 포인트

  • settings.json의 정적 패턴 매칭은 동적 경로 변화를 막지 못함
  • PreToolUse hook은 도구 호출 전 쉘 스크립트를 실행하여 실행 여부 결정
  • exit code를 통해 도구 실행을 허용하거나 차단 가능
  • stderr를 통해 차단 사유를 에이전트에게 전달하여 추론에 활용

.claude/settings.json

permissions.deny만으로는 AI 에이전트의 폭주를 막을 수 없었습니다.

「안전한 전사(Safe Transcription)」 개발에서는 여러 Claude Code 에이전트가 병렬로 동작하고 있습니다. lead가 dev나 PO에게 worktree 격리 상태로 작업을 위임하는데, 그 격리가 cwd 상속 문제로 인해 일주일에 13번이나 깨졌던 이야기는 다른 기사에 썼습니다. 인시던트 측면의 정리는 그쪽 글에 맡기겠습니다.

이 기사에서는 그 대책으로 구현한 PreToolUse hook의 내용을 작성합니다. "설정 파일로 막을 수 없다면, 쉘 스크립트(shell script)로 막으면 된다"는, 결론부터 말하자면 당연한 이야기입니다.

설정 파일만으로는 AI를 막을 수 없다

처음에 시도한 것은 permissions.deny의 확장이었습니다. .claude/settings.json에서 도구 호출(tool call) 패턴을 거부 리스트화하는 방법입니다.

{
"permissions": {
"deny": [
...

하지만 이 방식에는 두 가지 한계가 있었습니다.

  • 정적인 패턴 매칭(static pattern matching)만 작동한다. 에이전트가 "같은 부모 리포지토리의 절대 경로"를 다른 표현으로 전달하면 그대로 통과합니다.
  • cwd 상태를 판정할 수 없다. subagent가 main repo에 있는 상태에서 상대 경로로 Edit 하면, deny 패턴에 전혀 걸리지 않습니다.

"동적인 상태"를 볼 필요가 있다는 것을 깨달은 것이 hook 도입의 출발점이었습니다.

PreToolUse hook이란 무엇인가

Claude Code에는 PreToolUse라는 hook 메커니즘이 있습니다. 도구 호출 직전에 임의의 쉘 스크립트를 실행할 수 있는 구조입니다.

특성은 다음과 같습니다.

  • stdin으로 payload를 받는다: tool_name / tool_input / cwd / session_id 등이 JSON으로 전달됩니다.
  • exit code로 도구 실행을 제어한다: exit 0 = 통과, exit 2 = 차단.
  • stderr가 Claude에게 reasoning(추론)으로 전달된다: 차단 이유를 적으면 에이전트가 다음 판단 재료로 사용할 수 있습니다.
  • 동적 판정이 가능하다: branch 상태, git status, 환경 변수, 기타 명령 실행 결과 등을 사용하여 판정할 수 있습니다.

.claude/settings.json에 등록하는 예시:

{
"hooks": {
"PreToolUse": [
...

matcher로 대상 도구를 좁히고, command로 실행할 스크립트를 지정하기만 하면 됩니다.

worktree-isolation-guard.sh의 설계

구현한 hook의 판정 로직은 3단계입니다.

1. lead는 무조건 허용

worktree 격리가 문제가 되는 것은 subagent(lead 이외의 에이전트)가 main repo에 쓰려고 할 때뿐입니다. lead는 애초에 main repo에서 작업하는 것이 올바른 역할이므로, 즉시 exit 0.

AGENT_TYPE="${CLAUDE_AGENT_TYPE:-lead}"
if [[ "$AGENT_TYPE" == "lead" || -z "$AGENT_TYPE" ]]; then
exit 0
...

2. subagent의 worktree 루트를 판정한다

subagent의 cwd로부터 git rev-parse --show-toplevel을 통해 worktree의 루트를 취득합니다.

WORKTREE_ROOT="$(git -C "$CALL_CWD" rev-parse --show-toplevel 2>/dev/null || true)"

여기서 취득할 수 없으면 "판단할 수 없으므로 통과"(exit 0). "불확실하면 통과시킨다"가 기본 정책입니다. 너무 엄격하면 정상적인 작업까지 막혀 개발이 움직이지 않게 됩니다.

3. worktree 루트가 main repo와 일치하면 차단

이것이 핵심입니다. subagent의 worktree 루트가 $CLAUDE_PROJECT_DIR

(main repo)와 일치한다면, 그것은 애초에 격리되지 않은 위험한 상태입니다. Bash/Edit/Write를 통째로 차단합니다.

if [[ "$WORKTREE_ROOT" == "$MAIN_REPO" ]]; then
echo "BLOCK: subagent (${AGENT_TYPE}) 가 main repo 에 있습니다." >&2
echo "대응: lead 로 돌아가서, 'git worktree add'로 격리된 worktree 를 생성해 주세요." >&2
...

이렇게 하면 isolation: "worktree"가 cwd 상속 문제로 실패하더라도, hook이 최후의 보루가 됩니다.

보조: Edit/Write의 절대 경로 검사

3단계 판정을 통과한 subagent라도, 절대 경로로 main repo를 직접 가리키고 있다면 차단합니다.

case "$FILE_PATH" in
"$MAIN_REPO"/*|"$MAIN_REPO")
if [[ "$FILE_PATH" != "$WORKTREE_ROOT"/* ]]; then
...

실운영에서의 오탐 제로

hook 도입 후, 1주일 동안 발생하던 13건의 격리 위반은 0건이 되었습니다. 동시에, 오탐(본래 허용해야 할 조작을 차단하는 것)도 0건이었습니다.

"불확실하면 통과시킨다"는 설계가 효과를 발휘하여, 판단 근거가 부족한 케이스는 안전한 쪽으로 기울여 그대로 통과시킵니다. 구체적으로는 다음과 같은 경우입니다.

jq가 설치되어 있지 않은 환경

CLAUDE_PROJECT_DIR이 설정되지 않음

git rev-parse가 실패함 (git 관리 외 디렉토리)

payload가 비어 있음

"hook은 완벽하려고 하지 않는다"는 점이 의외로 중요했습니다. 오탐으로 인해 차단되면, 에이전트는 "다시 시도하기"가 아니라 "태스크가 실패했다"라고 판단하여 멈추는 경우가 많아 생산성에 직격탄을 주기 때문입니다.

hook 설계 시 주의사항

exit code는 0 또는 2 중 하나

PreToolUse hook에서는 exit 1을 피하는 것이 안전합니다. exit 2만이 "Claude에게 reasoning으로서 stderr가 전달되는" 사양이며, exit 1은 Claude에게 전달되지 않는 일반적인 에러 취급을 받습니다. 명시적인 차단은 반드시 exit 2를 사용하세요.

bypass 메커니즘을 포함할 것

완벽한 hook은 존재하지 않으므로, 긴급 상황을 위한 bypass를 처음부터 포함해 둡니다.

if [[ "${WORKTREE_GUARD_BYPASS:-0}" == "1" ]]; then
exit 0
fi

WORKTREE_GUARD_BYPASS=1을 export 하면 모든 동작을 허용합니다. Owner의 판단이 필요한 상황에서만 사용하는 것을 상정하고 있습니다. "hook이 잘못되었을 때 개발이 중단되는" 리스크를 여기서 헤지(hedge)합니다.

branch-aware 추가 검사

특수한 사례지만, .ai/coordination/HANDOFF.md는 통상적으로 lead 전용 파일입니다. 반면 PR 브랜치 상에서는 subagent도 수정할 필요가 있습니다. 그래서 브랜치 명에 따라 allow/block을 전환하고 있습니다.

case "$FILE_PATH" in
*/.ai/coordination/HANDOFF.md)
CUR_BRANCH="$(git -C "$WORKTREE_ROOT" branch --show-current 2>/dev/null || true)"
...

"화이트리스트" 방식이 아니라 "컨텍스트로 판정"하는 방식이 실운영에서는 오탐을 줄였습니다.

에이전트의 선의를 믿으면서, 구조로 보호하기

에이전트에게 "해서는 안 될 일"을 프롬프트로 적는 것에는 한계가 있습니다. LLM은 확률적으로만 따르며, 상황에 따라 판단을 바꿉니다. 그것은 장점이기도 하지만, 가드(guard)로 사용하는 영역에서는 단점이 됩니다.

hook은 "에이전트의 판단에 의존하지 않는, 확정적인 가드"입니다. 프롬프트를 보완하는 것이라고 생각하면 적절합니다. 에이전트에게 맡기고 싶은 영역은 프롬프트로, 확실히 지키고 싶은 영역은 hook으로 분담하는 것입니다.

에이전트가 악의를 가지고 일탈하는 일은 거의 없다고 생각합니다. 하지만 선의로 "편리하니까 부모 리포지토리를 직접 편집해 버리자"라고 판단할 수는 있습니다. 그 선의를 믿으면서도, 구조로 막아내는 것입니다.

permissions.deny

단 한 줄만 작성하면 끝날 일이라고 생각했는데, 결국 145줄의 쉘 스크립트 (Shell Script)가 되었습니다. 그럼에도 불구하고 이보다 더 간단한 해결책을 찾을 수 없었기에, 당분간은 이 구성으로 운영할 계획입니다.

저는 '안전한 전사 (Safe Transcription)'라는 서비스를 만들고 있습니다. 취재나 회의 음성을 저희 전용 서버에서 처리하며, Google이나 OpenAI와 같은 외부 AI 기업에는 전송하지 않도록 설계된 SaaS (Software as a Service)입니다. '사적인 대화를 외부 AI 서비스에 가볍게 넘겨도 괜찮은가?'라는 불안감에서 시작된 프로젝트입니다. 현재 여러 개의 Claude Code 에이전트와 함께 개발하고 있습니다.

Discussion

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0