claude-safe-zsh

src에서 TODO 찾아줘

grep -rn TODO src --include=*.ts

src/app.ts:12: // TODO retry on timeout

SafeZsh for Claude

Claude Code Bash 도구에서 no matches found 같은 zsh 오류를 막습니다. Claude는 bash로 쓰고 Bash 도구는 zsh로 실행하는데, 이 플러그인은 zsh가 그 명령을 bash처럼 읽게 합니다.

이 플러그인이 없으면 위 명령은 no matches found: --include=*.ts로 멈추고 grep은 실행되지 않습니다.

설치

Claude Code 터미널 프롬프트에서:

/plugin install safe-zsh --marketplace M1nu0x0/claude-safe-zsh

Add marketplace?에 y를 누르고 scope는 user를 고릅니다.

셸에서는:

claude plugin install safe-zsh --marketplace M1nu0x0/claude-safe-zsh

Claude Desktop에서는 프롬프트 옆 + → Plugins → Add marketplace에 M1nu0x0/claude-safe-zsh를 넣고 safe-zsh를 추가합니다. 다음 세션부터 적용됩니다.

전과 후

$ grep -rn TODO src --include=*.ts
(eval):1: no matches found: --include=*.ts
$ echo =====; ls
(eval):1: ==== not found
$ T="a.py b.py"; pytest $T
ERROR: file or directory not found: a.py b.py
zsh 기본값
$ grep -rn TODO src --include=*.ts
src/app.ts:12:  // TODO retry on timeout
$ echo =====; ls
=====
README.md  src
$ T="a.py b.py"; pytest $T
2 passed
이 플러그인을 켠 zsh

만든 이유

macOS는 로그인 셸이 zsh라서 Bash 도구도 zsh로 명령을 실행하지만, Claude는 bash 문법으로 명령을 씁니다.

anthropics/claude-code#103027은 12주 동안 Bash 호출 61,040번 중 2,570번에서 zsh 오류를 셌습니다. 9월 하순부터는 1,000번당 51번이었고, 그중 76%는 명령이 0으로 끝나 오류가 출력 중간에 묻힌 채 Claude가 그대로 읽고 넘어갔습니다.

이 이슈가 Claude Code에서 고쳐지면 이 플러그인은 필요 없어집니다. CI가 매일 이슈 상태를 확인합니다.

Claude Code의 안전 검사에도 zsh 문제가 있습니다. ${(flags)...}를 파싱하지 못해 그런 명령을 rm 위험으로 거부합니다(#104925). 이것이 고쳐지면 PreToolUse 훅과 그 안내도 뺍니다. CI가 매일 이 이슈의 상태도 확인합니다.

동작

세션이 시작될 때(startup, resume, clear, compact) SessionStart 훅이 CLAUDE_ENV_FILE에 한 줄을 덧붙입니다. Claude Code는 이 파일을 Bash 도구 명령마다 앞에 붙이므로, 그 줄이 같은 셸에서 실행됩니다.

if [ -n "${ZSH_VERSION:-}" ]; then setopt no_nomatch sh_word_split no_equals; fi

bash에서는 조건이 거짓이라 0으로 끝나므로, 뒤에 이어진 명령도 그대로 실행됩니다.

셸이 zsh이면 훅이 모델에게 두 줄을 더 알려 줍니다. 명령 안에서 직접 띄운 zsh -c는 이 옵션을 물려받지 않으니 스크립트 맨 앞에 setopt no_nomatch sh_word_split no_equals;를 붙이거나 bash -c를 쓰라는 안내, 그리고 zsh ${(flags)...} 확장은 안전 검사가 실행 전에 거부하니 printf '%s\n' "$PATH" | tr ':' '\n'처럼 POSIX sh로 쓰라는 안내입니다. 모델이 따르지 않을 수 있습니다.

PreToolUse 훅은 Claude Code의 안전 검사보다 먼저 Bash 도구 명령을 읽습니다. 작은따옴표 밖에 ${(s/:/)PATH} 같은 zsh ${(flags)...} 확장이 있으면 거부하고 모델에게 이유를 알립니다. 안전 검사가 실행 전에 rm 위험으로 거부할 것이니 printf '%s\n' "$PATH" | tr ':' '\n'처럼 POSIX sh로 쓰라는 내용입니다. 훅은 아무것도 승인하지 않고, 검사를 피하려고 명령을 zsh -c로 감싸지도 않습니다.

막는 것

no_nomatch
--include=*.ts나 ls libs/*/README.md처럼 매치 없는 glob이 명령을 멈추지 않고 그대로 넘어갑니다.
sh_word_split
따옴표 없는 $var가 단어로 나뉘어, for f in $list가 항목마다 한 번씩 돕니다.
no_equals
=로 시작하는 단어를 그대로 둡니다. echo =====가 출력되고, =ls가 /bin/ls로 바뀌지 않습니다.
${(flags)...}
print -l ${(s/:/)PATH} 같은 zsh ${(flags)...} 확장이 엉뚱한 dangerous rm operations 메시지로 거부되지 않습니다. 훅이 진짜 이유와 함께 거부하고, 모델이 POSIX sh로 고쳐 씁니다.

콜론 modifier($B:path), 첨자로 읽히는 $pat[...], 특수 변수 path와 status, ${!v}나 compgen 같은 bash 전용 문법은 막지 못합니다. 이런 경우는 CLAUDE_CODE_SHELL=/bin/bash로 Bash 도구를 아예 bash로 실행할 수 있습니다. $${처럼 안전 검사가 파싱하지 못하는 다른 구문은 여전히 rm 메시지로 거부됩니다.

관련 이슈

배지는 각 이슈의 현재 상태입니다.

#103027
Claude가 zsh에서 bash 문법을 계속 씀. 이 플러그인을 만든 이유.
#67146
같은 문제의 앞선 보고. 활동이 없어 닫힘.
#103576
단어 분리가 안 돼 pytest가 테스트를 하나도 돌리지 않았고, 빈 결과가 그대로 보고됨.
#74888
Monitor 도구 예시가 bash 단어 분리를 가정함.
#101554
zsh에서 bash 내장 명령을 실행함. 이 플러그인으로는 막을 수 없음.
#74746
Bash 도구를 bash로 실행하게 해 달라는 요청. CLAUDE_CODE_SHELL로 닫힘.
#7490
Bash 도구가 쓸 셸을 고르거나, Claude Code를 실행한 셸을 물려받게 해 달라는 요청.
#91498
Bash 도구가 로그인 셸을 실행하니 이름을 바꾸거나 셸 오류를 알려 달라는 제안.
#52521
Bash 도구 이름 변경 제안.
#2444
Bash 도구가 bash가 아니라 기본 셸에서 실행된다는 보고.
#24573
bash 사용자인데도 Bash 도구가 zsh를 고름.
#104925
rm이 없는 명령도 zsh ${(flags)...}처럼 파싱에 실패하면 rm 위험으로 거부됨. PreToolUse 훅을 만든 이유.
#99249
zsh -c 스크립트도 bash 문법으로 판단되는 것으로 보임.
#100419
PermissionRequest 훅 입력에 거부 사유가 없어 원인을 구분할 수 없음.
#99899
서브에이전트에서는 이 승인 창에 자동 거부 시간이 없어 무기한 멈춤.

자주 묻는 질문

내 터미널이나 ~/.zshrc가 바뀌나요?

아니요. 옵션은 Bash 도구의 셸에만 적용됩니다. 터미널, dotfile, 명령 안에서 다시 띄운 zsh -c에는 적용되지 않습니다. 마지막 경우에는 훅이 모델에게 띄우는 방법만 알려 줍니다.

bash를 쓰는데 뭔가 하나요?

거의 없습니다. bash에서는 setopt 줄의 조건이 거짓이라 0으로 끝납니다. PreToolUse 훅은 zsh ${(flags)...} 확장이 있는 명령을 여전히 거부하는데, 이런 명령은 bash에서도 실행되지 않습니다.

그냥 CLAUDE_CODE_SHELL=/bin/bash를 쓰면 되지 않나요?

그것도 되고 더 많이 해결되지만, Bash 도구에서 내 zsh 함수와 설정을 쓸 수 없게 됩니다. 이 플러그인은 zsh를 그대로 두고 가장 자주 깨지는 세 가지만 끕니다.

어디로 데이터를 보내나요?

보내지 않습니다. SessionStart 훅은 Claude Code가 준 파일에 한 줄을 덧붙이고 모델에게 고정된 두 줄을 출력합니다. PreToolUse 훅은 입력으로 받은 Bash 명령을 읽고, zsh ${(flags)...} 확장이 있으면 고정된 거부 메시지를 출력합니다. 둘 다 네트워크 요청은 하지 않습니다.

어떤 OS에서 확인했나요?

Ubuntu와 macOS에서 CI가 돌고, 매일 최신 Claude Code로도 확인합니다.

제거하려면 어떻게 하나요?

셸에서 claude plugin uninstall safe-zsh를 실행합니다.

(eval):1: no matches found는 왜 나오나요?

(eval)은 Bash 도구의 zsh가 명령을 eval로 실행하다 난 오류라는 뜻입니다. zsh에서는 맞는 파일이 없는 glob이 오류라서 명령 전체가 거기서 멈추고, bash는 그 단어를 그대로 넘깁니다. 이 플러그인은 zsh도 bash처럼 그대로 넘기게 합니다(no_nomatch). 터미널에서는 같은 오류가 zsh: no matches found로 보입니다.

==== not found는 뭔가요?

zsh는 =로 시작하는 단어를 그 이름의 명령 경로로 바꿉니다(=ls는 /bin/ls). 그래서 echo =====는 ====라는 명령을 찾다가 실패합니다. 구분선으로 =를 늘어놓아 출력하는 명령이 여기에 걸립니다. 이 플러그인은 이 동작을 끕니다(no_equals).

rm이 없는데 왜 could not be checked for dangerous rm operations로 거부되나요?

Claude Code의 안전 검사는 셸이 실행하기 전에 명령을 파싱하는데, 그 bash 문법에는 ${(s/:/)PATH} 같은 zsh ${(flags)...} 확장이 없습니다. 파싱하지 못한 명령은 rm이 없어도 rm 위험으로 거부됩니다(#104925). zsh가 시작되지도 않으니 setopt로는 막을 수 없습니다. 이 플러그인의 PreToolUse 훅이 먼저 거부하고, 모델에게 printf '%s\n' "$PATH" | tr ':' '\n'처럼 POSIX sh로 쓰라고 알립니다.