본문으로 건너뛰기

© 2026 Molayo

Zenn헤드라인2026. 05. 26. 22:47

하네스 엔지니어라면 알아두어야 할 Claude Code Plugin의 함정

요약

Claude Code 플러그인 개발 시 발생할 수 있는 동작상의 함정과 환경 변수 차이를 분석합니다. hooks.json, SKILL.md, Bash tool subprocess 간의 실행 환경 차이를 이해하여 안정적인 플러그인을 구축하는 방법을 다룹니다.

핵심 포인트

  • 플러그인 코드 실행 장소에 따른 환경 변수 세트의 차이 인지
  • hook command는 사용자의 $SHELL이 아닌 /bin/sh로 실행됨
  • plugin-level hook과 skill frontmatter의 보안 및 역할 비대칭성 이해
  • Claude Code 버전별 동작 차이 및 공식 문서와 실제 구현의 괴리 주의

안녕하세요, StoreHero에서 CPO를 맡고 있는 나가타입니다.

최근 사내에서 Claude Code의 plugin을 정비할 기회가 늘어나고 있습니다. hooks.json에서 SessionStart에 처리를 끼워 넣거나, SKILL.md에서 가드레일(guardrail)을 작성하는 과정에서 "공식 문서대로 작성했는데 작동하지 않는다", "CLI에서는 작동하는데 다른 환경에서는 작동하지 않는다"와 같은 현상에 몇 번이나 부딪혔습니다.

자신의 의도를 plugin에 정확히 반영하기 위해서는 먼저 Claude Code 측의 동작을 정확히 파악해야 합니다. 그래서 Claude Code v2.1.146-150을 대상으로 probe 기반으로 불분명한 부분을 조사한 결과, 전형적인 함정들을 상당히 정리할 수 있었습니다. 본 기사에서는 그 조사 결과 중 CLI에서 plugin을 구성할 때 특히 사고 발생률이 높은 6가지 논점을 발췌하여, 하네스 엔지니어(harness engineer)를 위해 재구성하여 전달해 드립니다.

참고로, 본 기사에서 다루는 동작이 Claude Code의 정식 사양인지, 아니면 구현상의 버그인지는 현 시점에서는 확실히 알 수 없습니다. 어디까지나 검증한 v2.1.146-150 시점에서 관측된 사실로서 읽어 주시기 바랍니다. 향후 릴리스에서 동작이 변할 가능성도 충분히 있습니다.

전제

함정 이야기에 들어가기에 앞서, Plugin의 구조와 공식 문서가 약속하는 환경 변수를 짚고 넘어가겠습니다. 이후에 소개할 함정의 대부분은 여기서 소개하는 구조에서 파생됩니다.

Plugin의 3가지 코드 실행 장소

my-plugin/
├── .claude-plugin/
│ └── plugin.json # plugin metadata + userConfig 선언
...
정의 장소실행 타이밍
① plugin-level hookhooks/hooks.jsonplugin 활성화 중, SessionStart / PreToolUse 등의 이벤트 발생 시
② skill frontmatter hookSKILL.md의 YAML frontmatter해당 skill이 invoke된 후, 대응 이벤트 발생 시
③ Bash tool subprocessSKILL.md 본문의 코드 블록 등Claude가 bash tool을 호출하는 순간

이 세 가지는 별도의 파일로 정의되고, 별도의 프로세스로 기동되며, Claude Code가 전달하는 환경 변수 세트도 서로 다릅니다. "①에서 가져온 환경 변수가 ③에서는 비어 있다"라는 사고는 이 경계를 모르면 이해할 수 없는 동작으로 보입니다. 또한 ① ②의 hook command는 사용자의 $SHELL이 아니라 /bin/sh로 기동된다는 점에도 주의하십시오.

보충: 이층 trust 모델 (필자의 멘탈 모델)

공식 문서에 적혀 있는 내용은 아니며, 동작으로부터 역산한 저의 해석입니다만, 기밀값이 plugin-level hook의 env에만 도달하는 것과 같은 비대칭성을 보면, "뒷단 처리는 plugin-level, 사용자에게 보여주는 지시서는 skill"이라는 이층 구조로 해석하면 이후의 이야기를 정리하기 쉬워집니다. hooks/hooks.json은 plugin install 시 validator가 실행되어 작성자 = plugin 제작자와 동일시되는 위치에 있는 반면, SKILL.md는 사용자가 나중에 교체하거나 다른 plugin에서 빌려올 수 있는 위치에 있다는 비대칭성이 배경에 있습니다. 어디까지나 하나의 해석 방법으로서 참고해 주십시오.

공식 문서가 약속하고 있는 환경 변수

Hooks reference에는 Claude Code가 hook에 제공하는 표준 경로 변수로 3가지가 나열되어 있습니다.

변수용도
CLAUDE_PROJECT_DIR프로젝트의 루트 디렉토리
CLAUDE_PLUGIN_ROOTplugin의 설치 경로 디렉토리
CLAUDE_PLUGIN_DATAplugin의 영구 데이터 디렉토리 (update를 넘어 유지됨)

이 외에도 특정 hook 이벤트나 실행 환경에서만 설정되는 변수도 있습니다.

CLAUDE_ENV_FILESessionStart / Setup

/CwdChanged

/FileChanged

시의 파일 경로. export FOO=bar

를 작성하면 후속 Bash tool 호출 시 환경 변수로 전달할 수 있습니다.

  • CLAUDE_EFFORT

— 해당 턴의 effort 레벨 (low / medium / high / xhigh / max). PreToolUse / PostToolUse / Stop / SubagentStop hook에서 참조 가능

  • CLAUDE_CODE_REMOTE

— 원격 web 환경에서는 `

표기법의 사전 치환 (Pre-substitution) 동작입니다.

여기서 '사전 치환 (Pre-substitution)'과 '쉘 확장 (Shell expansion)'을 구분해 두겠습니다.

Claude Code 사전 치환 (Pre-substitution) — hook command나 skill body의 문자열을, 커맨드 실행 전에 Claude Code 본체가 ${VAR}를 스캔하여 치환하는 처리
쉘 확장 (Shell expansion) — 실행된 /bin/sh가, 커맨드 문자열 내의 $VAR를 실행 시점에 확장하는 처리

두 가지가 혼재됨으로써, tier(계층)에 따라 치환 가능 여부가 달라집니다. 아래 표는 다음 3곳에 작성한 ${VAR}를 Claude Code 본체가 실행(또는 context로의 load) 직전에 어떻게 다루는지 정리한 것입니다.

① plugin-level hook (hooks/hooks.json의 command 문자열):

{
"hooks": {
"SessionStart": [
...

② skill frontmatter hook (SKILL.md의 frontmatter 내 command):

---
name: my-skill
hooks:
...

③ skill body markdown (SKILL.md의 본문):

# my-skill
이 스킬의 설치 경로는 `${CLAUDE_PLUGIN_ROOT}` 입니다.

v2.1.150의 probe를 통해 관측한 결과(${user_config.KEY}는 비기밀 값으로 검증)는 다음과 같습니다.

표기법plugin-level hookskill frontmatter hookskill body markdown
${CLAUDE_PLUGIN_ROOT}치환됨치환됨치환됨
${CLAUDE_PLUGIN_DATA}치환됨validator가 거부치환됨
${user_config.KEY}치환됨literal 상태로 /bin/sh에 전달치환됨
${CLAUDE_SKILL_DIR}literalliteral치환됨
${CLAUDE_SESSION_ID}literalliteral치환됨
${CLAUDE_PROJECT_DIR}치환됨literal치환됨

여기서 읽어낼 수 있는 원칙은 다음 2가지입니다.

  • 사전 치환은 tier별로 별도의 allowlist(허용 목록)로 운영된다
  • 모든 tier에서 공통적으로 사용할 수 있는 변수는 ${CLAUDE_PLUGIN_ROOT}뿐이다

표의 ${user_config.KEY} 행은 비기밀 값으로 검증한 것입니다. sensitive: true가 붙은 값은 skill body에서의 취급이 다르기 때문에(block 문자열로 치환됨), 이후의 "sensitive: true의 효력은 tier에 따라 다르다" 절에서 모아서 설명하겠습니다.

skill frontmatter에서 userConfig를 참조하면 어떻게 되는가

특히 사고가 나기 쉬운 패턴은 skill frontmatter hook에서 ${user_config.KEY}를 작성하는 경우입니다.

---
name: bad-skill
hooks:
...

이 패턴은 설치(install) 시의 validator는 통과합니다. 하지만 실행 시, Claude Code는 치환하지 않고 ${user_config.hello_message}를 literal 상태 그대로 /bin/sh에 전달합니다. /bin/sh는 이를 변수명으로 해석하려고 시도하지만, .을 포함하고 있어 애초에 유효하지 않은 심볼 이름이므로 다음과 같은 에러가 발생합니다.

/bin/sh: 1: Bad substitution

참고로 ${CLAUDE_PLUGIN_DATA}를 skill frontmatter hook에서 참조하려고 하면, 설치 시의 validator가 다음 메시지로 reject(거부)해 줍니다.

Hook command references ${CLAUDE_PLUGIN_DATA} but only ${CLAUDE_PLUGIN_ROOT} is available for skill hooks (${CLAUDE_PLUGIN_DATA} is plugin-only).

(Hook 명령이 ${CLAUDE_PLUGIN_DATA}를 참조하고 있지만, skill hook에서는 ${CLAUDE_PLUGIN_ROOT}만 사용할 수 있습니다 (${CLAUDE_PLUGIN_DATA}는 plugin 전용입니다).)

사전에 차단해 주는 변수가 있는 반면, 그대로 통과되는 변수도 있으므로 validator(검증기)의 동작을 전적으로 신뢰하지 않는 것이 안전합니다.

${VAR}

치환은 invoke (호출) 시에만 발생

skill body의 ${VAR}

사전 치환에는 또 하나의 맹점이 있습니다. SKILL.md 본문에 작성한 ${CLAUDE_PLUGIN_ROOT} 등은, 해당 skill이 invoke 되어 Claude의 context (컨텍스트)에 로드되는 순간에만 치환되는 동작을 보입니다.

다른 Claude 세션이나, Read / Grep 도구로 파일을 읽었을 경우에는 literal (리터럴) 형태인 ${CLAUDE_PLUGIN_ROOT}가 그대로 보입니다.

---
name: double-faced
user-invocable: true
...
```bash
echo "PLUGIN_ROOT=${CLAUDE_PLUGIN_ROOT}"

이 파일을 「Read tool로 읽었을 경우」와 「invoke 했을 경우」에는 Claude가 받는 context가 달라집니다.

# Read tool 경유 (literal 형태 그대로)
Check the value of ${CLAUDE_PLUGIN_ROOT} here.
echo "PLUGIN_ROOT=${CLAUDE_PLUGIN_ROOT}"
...

이 literal이 실질적인 문제를 일으키는 경우는 SKILL.md를 「파일」로서 다른 경로로 읽도록 설계된 경우입니다. 예를 들어, 도움말 계열의 skill이 다른 SKILL.md를 Read 도구로 표시하는 경우, 사전 치환이 실행되지 않기 때문에 ${...}가 literal 형태 그대로 화면에 나타납니다. invoke를 통해 실행되는 부분과 다른 곳에서 Read 되는 부분에서 동일한 ${VAR} 표기법을 겸용하지 않도록 분리하는 것을 의식하면 사고를 줄일 수 있습니다.

sensitive: true

효과가 tier (티어)에 따라 다름

userConfig에는 sensitive: true 플래그를 설정하는 기능이 있습니다. 이는 「(a) 저장 위치」, 「(b) plugin-level hook의 env (환경 변수)」, 「(c) skill body의 사전 치환」 세 곳에서 취급 방식이 달라지는 동작이며, tier별로 나누어 보면 정리하기 쉽습니다.

{
"name": "my-plugin",
"userConfig": {
...

(a) 저장 위치: 분리됨

비기밀 값은 ~/.claude/settings.json에 평문으로 저장되지만, sensitive: true가 붙은 값은 OS keychain (키체인) 또는 ~/.claude/.credentials.jsonpluginSecrets 섹션으로 분리됩니다. 이는 「다른 사용자나 다른 plugin이 settings.json을 들여다보았을 때 보이지 않도록 하기 위한」 분리입니다.

(b) plugin-level hook의 env: 평문 그대로 전달됨

sensitive: true를 설정하더라도, plugin-level hook의 env에는 평문으로 전달됩니다.

$ env | grep ^CLAUDE_PLUGIN_OPTION_
CLAUDE_PLUGIN_OPTION_API_SECRET=secret-xyz # 평문으로 전달됨

「hook을 작성하는 사람 = keychain을 읽는 것과 동일한 신뢰 경계 (trust boundary)」라는 전제가 Claude Code 측의 구현에서도 읽혀집니다. plugin 제작자가 작성한 코드는 keychain을 직접 읽는 것과 동일한 권한으로 기밀 값에 접근할 수 있다는 설계 사상입니다.

(c) skill body의 사전 치환: block 문자열로 치환됨

이 부분이 sensitive: true가 핵심적으로 작용하는 지점입니다. SKILL.md 본문에 ${user_config.api_secret}

처럼 작성하면, skill이 invoke(호출)되는 순간 Claude Code 본체가 [sensitive option 'api_secret' not available in skill content]라는 block 문자열로 치환합니다. 기밀 값이 Claude의 context / transcript(문맥/기록)로 흘러 들어가는 경로는 여기서 차단됩니다.

부작용으로, skill body의 코드가 그대로라면 의도한 대로 동작하지 않게 됩니다. 예를 들어 다음과 같은 방식은 Bash tool에 전달되는 시점에 Authorization 헤더가 block 문자열로 치환되므로, curl 요청이 실패합니다.

# 동작하지 않는 예: ${user_config.api_secret}는 sensitive: true가 설정되어 있으므로,
# Bash tool에 전달되는 내용은:
# curl -H "Authorization: Bearer [sensitive option 'api_secret' not available in skill content]" ...
...

유출되지 않는 대신 동작하지 않는다는 동작을 통해, "skill body에서 기밀 값을 다루는 설계는 지양하라"는 가드레일(guardrail)이 작동하고 있는 형태입니다.

설계 지침

3가지 tier의 동작을 통해, 기밀 값의 취급 방식은 다음과 같이 정리할 수 있습니다.

  • 기밀 값을 다루는 쉘(shell) 처리는 plugin-level hook의 범위 내로 한정한다. skill frontmatter / Bash tool에는 env(환경 변수)로 전달되지 않으므로, 애초에 기밀 값을 가져올 수 없다.
  • 기밀 key를 ${user_config.X} 형식으로 SKILL.md 본문에 작성해도 유출되지는 않지만 동작하지 않는다. 기밀 값이 필요한 처리는 plugin-level hook 측에서 완결시킨다.
  • 로그에 env를 덤프(dump)하는 처리를 넣을 경우, CLAUDE_PLUGIN_OPTION_*를 마스킹(masking)한다.

최소한의 마스킹으로는 예를 들어 다음과 같은 sed 명령어를 사용하는 방법을 고려할 수 있습니다.

env | grep '^CLAUDE_' | sed 's/\(SECRET\|TOKEN\|KEY\)=.*$/\1=[REDACTED]/'

완벽하다고 할 수는 없지만, 로그를 무심코 남겼을 때 발생할 수 있는 사고를 줄이는 하나의 사례로 참고가 될 것입니다.

skill frontmatter hook은 "한 번 invoke한 후"에 등록된다

여기서부터는 skill / hook의 기동 라이프사이클(lifecycle)에 관한 함정입니다. frontmatter hook은 해당 skill이 한 번 기동된 후에 활성화됩니다. 먼저 등록되는 것이 아니라, 나중에 등록되는 순서입니다.

구체적으로 어떤 일이 발생하는지, 전형적인 실패 사례 2가지를 소개합니다.

실패 사례 1: SessionStart once:true가 영구히 발화되지 않음

---
name: init-once
user-invocable: true
...

기대하는 동작은 "claude를 실행했을 때 [init-once] SessionStart fired가 나오는 것"이지만, 실제로는 다음과 같습니다.

  • claude 실행 → SessionStart event 발화
  • init-once는 아직 invoke되지 않았으므로, 해당 frontmatter hook은 미등록 상태
  • SessionStart event는 plugin-level hook에는 전달되지만, init-once의 frontmatter hook은 스킵됨
  • 사용자가 /my-plugin:init-once를 invoke하면, 이때 비로소 frontmatter hook이 등록됨
  • 하지만 SessionStart는 이미 발화가 끝난 상태 → 영구히 나타나지 않음

"준비 처리"를 목적으로 작성한 hook이 단 한 번도 실행되지 않는 상황이 발생합니다.

실패 사례 2: 자기 자신의 skill block이 작동하지 않음

---
name: self-block
user-invocable: true
...

"자신의 기동을 block(차단)한다"는 것을 기대하는 패턴이지만, 이 역시 효과가 없습니다. load(로드) 후에 hook이 등록되므로, 자기 자신은 멈추지 않고 후속되는 tool 호출만이 대상이 됩니다.

함의는 간단합니다. skill(스킬) 기동 시의 준비 처리나 상주시키고 싶은 hook(훅)은 plugin-level hook (hooks/hooks.json)에 작성한다는 것으로 귀결됩니다. skill frontmatter hook은 "해당 skill이 한 번 invoke(호출)된 이후의 처리"에만 사용할 수 있다고 단정 짓는 것이 사고를 피하는 길입니다.

slash 기동과 자연어 기동에서 발생하는 hook이 다르다

마지막 함정은 skill의 기동 경로에 따라 발생하는 hook이 다르다는 점입니다.

사용자가 skill을 기동하는 경로는 다음 두 가지가 있습니다.

  • 자연어 경유: "○○ skill을 기동해줘"
  • slash 경유: /<plugin>:<skill>

Claude Code 내부에서의 취급은 경로에 따라 다릅니다.

관측 항목자연어 경유slash 경유
Skill tool이 호출됨호출됨호출되지 않음
PreToolUse:Skill 발생발생함발생하지 않음
UserPromptSubmit 발생발생함발생함
UserPromptExpansion 발생 (CLI 전용)발생하지 않음발생함

실험용 hook을 심어서 관측하면, 다음과 같은 순서로 발생했습니다.

# slash 기동: /my-plugin:foo
[tag=user-prompt-submit]
[tag=user-prompt-expansion]
...

가드레일(Guardrail) 설계와 직결되는 함의는 두 가지가 있습니다.

  • PreToolUse:Skill에서 block(차단) 규칙을 작성하더라도, slash 경유에는 효과가 없다. - "기동 경로에 의존하지 않는 탐지"를 원한다면, slash 경로를 포착하는 UserPromptExpansion과 자연어 경로를 포착하는 PreToolUse:Skill을 병용하는 것이 기본이다. UserPromptSubmit에서 prompt(프롬프트) 문자열을 파싱하는 방법도 있지만, 자연어 기동을 놓치기 쉬우므로 부차적인 수단에 머물러야 한다.

OpenTelemetry로 스킬 발생을 측정하는 경우

하네스 엔지니어로서는 "자사에서 배포한 plugin이 제대로 사용되고 있는지"를 OpenTelemetry로 측정하고 싶은 경우도 있을 것입니다. 여기서 공식 텔레메트리(Telemetry)와 자체 hook 중 어느 쪽을 이용하느냐에 따라 동작이 달라지는 분기점이 존재합니다.

먼저 공식 텔레메트리 측에는 Claude Code 자체가 생성하는 claude_code.skill_activated 이벤트가 마련되어 있으며, 이는 경로와 관계없이 skill 기동 시에 발생합니다. invocation_trigger 속성을 통해 기동 경로를 구분할 수 있도록 설계되어 있습니다.

속성취할 수 있는 값
invocation_triggeruser-slash / claude-proactive / nested-skill
skill.name스킬 이름 (단, userSettings / projectSettings에 정의된 사용자 유래 스킬은 OTEL_LOG_TOOL_DETAILS=1을 설정하지 않으면 placeholder인 custom_skill로 표시됨. plugin 유래 스킬은 대부분 실제 이름으로 기록됨)
skill.sourcebundled / userSettings / projectSettings / plugin

즉, Anthropic 공식의 claude_code.skill_activated 이벤트를 구독하고 있다면 slash와 자연어 양쪽 모두를 잡을 수 있습니다. 앞 절에서 보았듯이 자체 hook으로 두 경로를 모두 포착하려면 UserPromptExpansion + PreToolUse:Skill의 병용이 필요하지만, 공식 이벤트라면 하나로 충분하다는 것이 수고로움의 차이입니다. 다만, skill.name 속성은 userSettings / projectSettings에 정의된 사용자 유래 스킬의 경우 기본적으로 placeholder인 custom_skill로 마스킹되어 나타납니다. 이를 스킬 단위로 식별하고 싶다면 OTEL_LOG_TOOL_DETAILS=1을 [설정해야 합니다/사용해야 합니다].

또한 OTEL_LOG_TOOL_DETAILS=1을 함께 활성화해야 한다는 점만 기억해 두세요 (plugin 유래의 skill 명칭은 조건에 따라 가공되지 않은 상태로 나타나는 경우가 많습니다).

요약

6가지 함정을 한 문장씩 정리합니다.

  • 환경 변수 전파는 3개 계층(tier)에서 비대칭적입니다. Bash tool에는 애초에 CLAUDE_PLUGIN_*가 전달되지 않습니다.
  • ${VAR} 사전 치환(pre-substitution)은 계층(tier)마다 별도의 허용 목록(allowlist)을 가집니다. 모든 계층에서 공통으로 사용할 수 있는 변수는 ${CLAUDE_PLUGIN_ROOT}뿐입니다.
  • skill body의 ${VAR} 치환은 호출(invoke) 시에만 이루어집니다. Read tool로 읽으면 리터럴(literal)로 나타납니다.
  • sensitive: true는 저장 위치와 skill body 치환에는 적용되지만, plugin-level hook의 환경 변수(env)로는 평문으로 전달됩니다. 기밀 처리는 plugin-level hook 내부에 격리하여 작성해야 합니다.
  • skill frontmatter hook은 "한 번 호출(invoke)한 후"에 등록됩니다. 준비 작업은 plugin-level에 작성하세요.
  • 슬래시(/) 명령 실행과 자연어 실행 시 트리거되는 hook이 다릅니다. 텔레메트리(telemetry)는 claude_code.skill_activated에 정직하게 기록됩니다.

AI 자동 생성 콘텐츠

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

원문 바로가기
0

댓글

0