go test를 허용했음에도 한 번도 실행되지 않음: 무인 Claude -p에 검증을 시키려면, 허가는 '검증 경로' 자체와 함께 구성해야
요약
무인 Claude 환경에서 `--allowedTools`로 `go test`와 같은 검증 권한을 허용했음에도 불구하고, AI가 실제로 테스트를 실행하지 않고 글쓰기를 완료하는 현상을 분석합니다. 이 문제는 단순히 명령어를 허용한 것이 아니라, '검증 과정 자체'에 대한 명시적인 권한 구성이 필요함을 시사합니다.
핵심 포인트
- AI에게 검증 권한을 주지 않으면, AI는 검증 단계를 건너뛰고 결과물 작성으로 넘어갑니다.
- 무인 환경에서 테스트 실행을 강제하려면, 단순히 도구 목록에 추가하는 것 이상의 '검증 경로' 정의가 필요합니다.
- Claude의 `--allowedTools`는 단순 허용이 아닌, AI에게 수행할 수 있는 행위 자체를 정의하는 핵심 제한 장치입니다.
claude -p
이를 cron이나 launchd, CI에서 무인으로 돌리는데, --allowedTools에 Bash(go test:*)나 Bash(npm test:*)를 추가했음에도 불구하고 AI가 테스트를 한 번도 실행하지 않고 글을 완성해 버립니다. 스킬이나 CLAUDE.md에는 '테스트를 통과한 후에 인용하라'고 적혀 있는데 말입니다. 이 글은 그 현상을 저의 정기 실행으로 겪은 기록, 원인, 그리고 허가 구성 방법에 대한 이야기입니다.
결론부터 말씀드립니다. 허가 규칙은 '안전을 위한 제한'인 동시에, 무인 AI에게는 '할 수 있는 것의 정의'입니다. 검증에 필요한 권한을 주지 않고 '검증하고 나서 쓰라'고 명령하면, AI는 검증을 포기하고 글을 씁니다. 게다가 -p는 이를 실패로 보고하지 않습니다.
Bash(go test:*)를 허가한 무인 실행에서 go test가 한 번도 호출되지 않았습니다.
증상: 매일 아침 10:30에 launchd로부터 claude -p를 시작하고, 그날의 제안 중 하나를 골라 Zenn 기사를 published: true로 작성하고 commit → push까지 무인으로 하는 시스템을 돌리고 있습니다. 사람의 리뷰는 거치지 않습니다. 그래서 스킬(/write-article)에는 '확인된 것만 쓰기' 위한 요구사항들을 나열해 두었는데, 그중 하나가 이것입니다.
Go 코드를 새로 작성할 경우
verify/<slug>/에 두고 go vet/go test를 거친 후에 인용합니다.
이 요구사항을 추가한 것과 같은 커밋(2026-10-17 10:48)으로, 스크립트 측의 --allowedTools에도 Go 계열 허가를 추가했습니다. scripts/daily-article-write.sh 실행 부분은 다음과 같습니다.
claude -p "/write-article 오늘은 ${TODAY}입니다. ${PROPOSAL}에서 하나를 골라 articles/에 published: true인 완성 기사를 작성해 주세요." \
--permission-mode default \
--allowedTools "Read Glob Grep WebSearch WebFetch Edit(articles/**) Edit(proposals/**) Bash(git log:*) Bash(git status:*) Bash(git show:*) Bash(git diff:*) Bash(ls:*) Bash(head:*) Bash(tail:*) Bash(wc:*) Bash(go test:*) Bash(go vet:*) Bash(go build:*)"
...
2분 후인 10:50에 이 스크립트로 /write-article을 실행했습니다 (DRY_RUN, Claude Code 2.1.292). 기사는 11:01에 완성되었고, 스크립트의 기계 검증도 통과했습니다. 그런데 세션 트랜스크립트(~/.claude/projects/<project>/<session-id>.jsonl)를 보면, go test 호출이 단 한 건도 없습니다. 대신 권한 거부가 4건 남아 있었습니다.
| 시각 (JST) | 모델이 하려던 것 | 결과 |
|---|---|---|
| 10:51 | node -e '...'; python3 -c '...'; ...를 6개 연결하여, 제목의 .length와 len()을 비교 | 거부됨. “This Bash command contains multiple operations. The following parts require approval: node -e ..., python3 -c ..., node -e ..., python3 -c ...” |
| 10:52 | grep -o '<긴 정규표현식>' node_modules/zenn-cli/...; echo ----; grep -o ... | 거부됨. 마찬가지로 복합 명령으로 분할되어, grep -o 부분이 승인 대기 상태가 됨 |
| 10:53 | verify/zenn-title-length/title_length_test.go를 작성 (바이트 수・코드 포인트 수・UTF-16 단위 수를 비교하는 Go 테스트) | 거부됨. “Claude requested permissions to write to .../verify/zenn-title-length/title_length_test.go, but you haven't granted it yet.” |
| 10:54 | grep -o '<긴 정규표현식>' node_modules/zenn-cli/... (단독으로 재시도) | 거부됨. “This command requires approval” |
같은 10:54에, git show <rev>:articles/<file>.md | head -2 | tail -1 | wc -m는 통과했습니다.
'82'가 반환되었고, title 줄의 장식(`title:
tool 대신, Claude Code는 해당 규칙을 수용하지만 절대 참조하지 않고 시작 시 경고를 발생시킵니다... Claude Code v2.1.210 이상 필요.
(파일 권한은 Edit(path)와 Read(path)의 규칙만으로 판정합니다. Write(path) 등은 접수되지만 참조되지 않으며, 시작 시 경고가 발생합니다. v2.1.210 이후)
제 .run.log의 10-13 및 10-14분 초반에도 이 경고가 남아 있습니다.
Permission allow rule (--allowed-tools): Write(proposals/**) is not matched by file permission checks — only Edit(path) rules are. Use Edit(proposals/**) instead (Edit rules cover all file-editing tools).
또 다른 것은 무인 실행 특유의 상황입니다. -p로 허가 프롬프트가 나와도, 답변해 줄 사람이 없습니다. Run Claude Code programmatically의 "Turn off permission prompts in unattended runs"에는 다음과 같이 적혀 있습니다.
In a -p run with no host, these requests are denied either way, and the flag also tells Claude not to retry them.
(호스트가 없는 -p 실행에서는 이러한 요청은 어쨌든 거부되며, 플래그는 또한 모델에게 재시도하지 않도록 알려줍니다.)
거부는 도구의 실행 결과로 모델에게 반환되고, 세션은 계속됩니다. 모델 입장에서 보면 'verify/에 쓰려고 했더니 거부되었다'라는 사실이 하나 추가되었을 뿐이며, 나머지 절차는 수행할 수 있습니다. 그래서 기사는 완성되고 종료 코드는 0이지만, 검증만 누락됩니다.
node/python3은 이중으로 빠져 있습니다.
원인 2: 제목의 글자 수를 세는 데 node -e와 python3 -c를 사용하려 한 건은 이유가 두 가지 중첩되어 있습니다.
첫 번째는 단순히 허가가 없다는 것입니다. --allowedTools에 Bash(node:*)도 Bash(python3:*)도 없고, 규칙 없이 통과하는 내장 읽기 전용 명령어 집합에도 포함되어 있지 않습니다. 문서가 언급하는 집합은 다음과 같습니다.
The set includes ls, cat, echo, pwd, head, tail, grep, find, wc, which, diff, stat, du, cd, and read-only forms ofgit. The set is not configurable
게다가 모델은 node -e ...; python3 -c ...; node -e ...; python3 -c ...; node --version; python3 --version와 같이 ;로 6개를 연결하여 한 번에 실행하려고 했습니다. 복합 명령어는 서브 커맨드마다 독립적으로 판정됩니다.
The recognized command separators are &&, ||, ;, |, |&, &, and newlines. A rule must match each subcommand independently.
거부 메시지가 'The following parts require approval'로 -e/-c 네 개를 나열한 것은 바로 이 동작 때문입니다.
두 번째는, 설령 Bash(node:*)을 추가한다 하더라도 파일 시스템 규칙 밖에 있다는 것입니다. 같은 문서의 경고에 다음과 같이 적혀 있습니다.
Read and Edit deny rules apply to Claude's built-in file tools, to file commands Claude Code recognizes in Bash, such as cat
... head, tail, sed, 그리고 tee와 같은 파일 명령어 및 Bash 리디렉션 대상(예: > file, < file)에도 적용됩니다. 하지만 Python이나 Node 스크립트처럼 파일을 간접적으로 읽거나 쓰는 임의의 서브프로세스에는 적용되지 않습니다.
(Read/Edit에 대한 거부 규칙은 내장 파일 도구, Bash 내부의 cat, head, tail, sed, tee와 같은 파일 명령어 및 리디렉션 대상에는 유효합니다. 하지만 파일을 직접 여는 Python/Node 스크립트에는 유효하지 않습니다.)
즉, node를 허용한다면 "반드시 verify/에만 쓸 수 있는 node"는 만들 수 없습니다. 권한을 부여하려면 node가 접근할 수 있는 범위 전체를 통째로 허용해야 합니다. 무인 실행(unattended execution) 환경에서 이 권한을 전달할지 여부는 별도의 판단이 필요하며, 저는 이를 전달하지 않았습니다. 단순히 글자 수를 세기 위해서만 범용 인터프리터를 열어주는 것은 그만한 가치가 없기 때문입니다.
대책: 스킬 요구사항으로부터 역산하여, 허용 권한을 "경로"별로 구성하기
해야 할 일은 3가지입니다.
1. 스킬 요구사항별로 '작성/실행/계산'을 표로 만들고, 이를 허용 권한과 대조하기
10-17의 상태를 그대로 표로 나타내면, 어느 부분이 끊겼는지 한눈에 알 수 있습니다.
| 스킬 요구사항 | 필요한 작업 | 필요한 허용 권한 | 10-17의 상태 |
|---|---|---|---|
Go 코드는 verify/에서 go test를 통과시킨 후 인용 | verify/<slug>/에 테스트 작성 | Edit(verify/**) | 없음 → 거부 |
| 위와 동일 | go test 실행 | Bash(go test:*) | 있음 (한 번도 사용되지 않음) |
| 제목을 70자 이내로 | 글자 수 계산 | Bash(node:*) 등, 또는 허용된 명령어의 파이프라인 | node / python3는 없음 → 거부. `git show |
| 기사 작성 | articles/에 작성 | Edit(articles/**) | 있음 → 통과 |
규칙을 하나 추가하는 것만으로 경로가 연결되는 것은 모두 추가합니다.
- --allowedTools "Read Glob Grep WebSearch WebFetch Edit(articles/**) Edit(proposals/**) Bash(git log:*) ... Bash(go test:*) Bash(go vet:*) Bash(go build:*)" \
+ --allowedTools "Read Glob Grep WebSearch WebFetch Edit(articles/**) Edit(proposals/**) Edit(verify/**) Bash(git log:*) ... Bash(go test:*) Bash(go vet:*) Bash(go build:*)" \
Edit(verify/**)와 같이 앞에 /가 없는 경로는 문서 표에서는 "현재 디렉토리를 기준으로 한 경로 (Path relative to current directory)"로 <cwd>/...에 해결됩니다. 스크립트는 시작 부분에서 리포지토리 루트 디렉터리로 cd를 하므로 의도대로 작동하지만, 모델이 Bash 도구로 다른 디렉터리로 cd한 상태라면 기준점이 흔들립니다. 스킬에는 "Bash에서 cd하지 않기"라고 명시되어 있습니다.
2. 경로를 전달할 수 없는 요구사항은, 스킬 측에서 '해당 안을 선택하지 않는' 조건으로 만들기
반대로, 전달하고 싶지 않은 권한(범용 인터프리터나 DB 연결)이 필요한 검증 과정은 무인 실행에서는 성립되지 않습니다. 이 경우 "검증을 생략하고 작성한다"가 아니라 "그 안을 선택하지 않는다"로 처리해야 합니다. 현재의 /write-article에는 다음과 같이 명시되어 있습니다.
수치/벤치마크/실행 결과를 추측으로 작성하지 않음.
verify/에 없는 DB의 실측값이 필요한 안은 선택하지 않음.
'검증 후 작성하라'와 '검증에 필요한 권한이 없다'가 동시에 성립할 경우, AI는 후자를 묵시적으로 우선합니다. 모순을 AI가 해결하도록 두지 않고, 스킬 선정 조건에서 미리 해결해 놓는다는 개념입니다.
3. 가벼운 측정은 허가된 명령어의 파이프를 이용한다. 단, locale을 고정해야 한다.
10-17에 유일하게 통과한 측정은 git show <rev>:<path> | head -2 | tail -1 | wc -m 였습니다.
git show
head
tail
wc
모두 허가된 명령어이며, 파이프의 각 단계가 독립적으로 판별되어도 모두 통과합니다.
다만 wc -m에는 함정이 있습니다. macOS의 man wc에는 다음과 같이 나와 있습니다.
-m 입력 파일 각각의 문자를 표준 출력으로 작성한다. 현재 locale이 멀티바이트 문자(multibyte characters)를 지원하지 않으면, 이는 -c (바이트 수) 옵션과 동일하다.
10-17 실행은 82(문자수)를 반환했습니다. 한편, 오늘 10-18의 10:30 정기 실행에서 같은 환경을 보면 locale은 LANG=""・LC_CTYPE="C"로, printf 'あ' | wc -m는 3(바이트수)를 반환했습니다. LC_ALL=en_US.UTF-8을 붙이면 1입니다. 10-17 실행은 로그에 "DRY_RUN 이므로 commit / push 하지 않습니다"라고 남아 있어 수동으로 시작한 것으로 추정되며, 터미널의 locale을 상속받았을 가능성이 높습니다. launchd에서 시작하면 LANG이 없고, wc -m는 바이트를 세는 환경이 됩니다. 스크립트가 Homebrew의 PATH를 직접 추가하는 것과 같은 종류의 처리가 필요합니다.
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
+export LC_ALL=en_US.UTF-8
모델에 LC_ALL=en_US.UTF-8 wc -m와 같이 전치시키는 방법도 있었고, 오늘 실행에서는 그 형태도 통과했습니다. 다만 문서에는 다음과 같이 되어 있어 어떤 변수가 "알려진 안전한 변수(known-safe variable)"인지 쓰여 있지 않습니다.
Claude Code는 또한 특정 알려진 안전한 환경 변수의 선두 할당을 제거하고 일치시킵니다. 따라서
Bash(npm test *)
은 NODE_ENV=test npm test와 일치합니다. 다른 변수의 할당이 있으면 allow 규칙은 더 이상 일치시키지 않습니다.
스크립트에서 export 해두면, 이 불확실성을 염두에 두지 않아도 됩니다.
보너스: 거부를 "읽지 않아도 알 수 있는" 형태로 출력하기
이번에 검증이 생략된 것을 깨달은 것은 .run.log의 자체 보고와 트랜스크립트를 읽었기 때문입니다. 문서에 따르면, -p에 --output-format stream-json을 붙이면 거부가 permission_denied 시스템 메시지로 흐르고, 최종 result 메시지에 permission_denials로 목록화됩니다. --permission-prompts none (v2.1.259 이후)를 붙이면, 거부된 것과 재시도하지 않는 것이 모델에게 명확히 전달됩니다. 호스트가 없는 -p에서는 거부가 기본 동작이므로, 이 두 가지는 "행동을 바꾸는" 설정이 아니라 "감사할 수 있는 형태로 만드는" 설정입니다. 제 스크립트는 아직 text 출력 상태이므로, 다음에 수정할 부분은 여기입니다.
요약
- 무인(unattended)
claude -p의 경우, 허가 규칙은 "할 수 있는 것의 정의"입니다. 검증에 필요한 권한을 주지 않고 "검증한 후에 쓰라"고 명령하면, AI는 검증을 생략하고 완주하며 종료 코드는 0이 됩니다. Bash(go test:*)는Edit(verify/**)
이것이 없으면 한 번도 호출되지 않습니다. 권한은 '쓰기 → 실행 → 읽기' 경로별로 설정되어 있는지에 따라 달라집니다.
Write(path)
규칙은 참조되지 않습니다. 경로를 지정하여 쓸 경우 Edit(path)를 사용합니다.
node
/python3
이것들은 권한이 없을 뿐만 아니라 파일 시스템 규칙 밖에 있습니다. 측정만을 위해 열 수 있는지 여부는 별도의 판단이 필요합니다. 전달할 수 없는 권한이 필요한 검증은 스킬 측에서 '해당 방안을 선택하지 않도록' 조건으로 설정해야 합니다.
- 측정은 이미 허가된 명령어의 파이프를 사용하면,
wc -m
의 로케일(locale)을 스크립트로 고정합니다. 거부는
stream-json
의permission_denials에서 기계적으로 포착할 수 있습니다.
자신의 --allowedTools를 스킬이 요구하는 작업별로 하나씩 추적할 수 있습니까? 스킬의 지시와 허가가 불일치했던 사례가 있다면 댓글로 알려주시면 감사하겠습니다.
참고
- Configure permissions — Claude Code Docs (Read and Edit / Read-only commands / Compound commands / Wrappers)
- Run Claude Code programmatically — Claude Code Docs (Auto-approve tools / Turn off permission prompts in unattended runs)
- CLI reference — Claude Code Docs (
--allowedTools
/--permission-mode
/--permission-prompts
/--output-format
) - Error reference — Claude Code Docs ("... is not matched by file permission checks") man wc(macOS,-m항목)
토론

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