Claude Code 2.1.302 claude -p의 default 권한 모드와 fork 점검
claude -p를 cron이나 CI에서 돌리는 파이프라인이라면 2.1.302 업데이트 뒤에 결과 파일이 비어 있는지부터 보셔야 합니다. 이번 릴리스에서 헤드리스 실행에 영향을 주는 변경이 두 가지 들어갔습니다. 권한 모드를 지정하지 않은 claude -p와 Agent SDK 세션이 특정 조건에서 default 모드로 시작하고, fork 서브에이전트가 -p와 SDK에서도 기본으로 켜집니다. 둘 다 오류를 내지 않고 동작만 바꾸기 때문에 로그만 보고 지나치기 쉽습니다.
릴리스 노트의 해당 두 줄은 아래와 같습니다(CHANGELOG, v2.1.302 릴리스, 확인일 2026-10-18). 이 글은 2.1.301과 2.1.302를 같은 서버에서 나란히 실행해 두 변경이 실제 출력에서 어떻게 보이는지 확인하고, 파이프라인에 넣을 수 있는 짧은 점검 스크립트로 정리합니다.
- Changed `claude -p` and Agent SDK sessions with no permission mode to start in `default` mode on third-party providers, with telemetry off or on a fresh install; `--permission-mode auto` keeps auto
- Changed fork subagents (`subagent_type: "fork"`) to be on by default in `claude -p`, the Agent SDK, the desktop app and VS Code, as they already are in the terminal; `CLAUDE_CODE_FORK_SUBAGENT=0` turns them off, and `claude mcp serve` never offers them
비교에 쓴 두 버전과 실행 조건
2.1.302는 시스템에 설치된 CLI를 그대로 쓰고, 2.1.301은 npm으로 별도 디렉터리에 받아 경로로 불렀습니다. 자동 업데이트가 중간에 버전을 바꾸지 않도록 DISABLE_AUTOUPDATER=1을 모든 실행에 넣었습니다. 변경 내용이 “텔레메트리를 끈 경우”를 조건으로 들기 때문에, 텔레메트리 설정만 바꿔 가며 같은 프롬프트를 실행했습니다.
| 항목 | 값 |
|---|---|
| OS | Debian GNU/Linux 13 (trixie), x86_64 |
| 새 버전 | Claude Code 2.1.302 (릴리스 2026-10-17 05:53 KST) |
| 이전 버전 | Claude Code 2.1.301 (npm으로 별도 설치) |
| 모델 | --model haiku (claude-haiku-5-5), 구독 로그인, 1차 제공자 |
| 공통 환경 | DISABLE_AUTOUPDATER=1, 비교 시 DISABLE_TELEMETRY=1 유무만 변경 |
| 확인일 | 2026-10-18 KST |
시작 권한 모드는 --output-format stream-json --verbose의 첫 줄인 system/init 이벤트에 permissionMode로 찍힙니다. 실행 결과를 사람이 읽기 전에 이 필드 하나만 뽑아 보면, 파이프라인이 어떤 권한 상태로 시작했는지 바로 알 수 있습니다.
텔레메트리를 끈 환경에서 auto가 default로 바뀌는 장면
먼저 쓰기가 없는 짧은 프롬프트로 시작 모드만 비교했습니다. jq로 init 줄의 버전, 권한 모드, analytics_disabled 값을 한 줄로 뽑았습니다.

네 줄 가운데 달라진 것은 하나뿐입니다. 텔레메트리를 켠 상태에서는 두 버전 모두 default로 시작했습니다. 텔레메트리를 끈 상태에서는 2.1.301이 auto로, 2.1.302가 default로 시작했습니다. 그러니 이 변경에 걸리는 것은 개인정보나 사내 정책 때문에 DISABLE_TELEMETRY=1을 넣어 둔 서버, 서드파티 제공자를 쓰는 환경, 그리고 새로 설치한 머신입니다. 내 러너가 해당하는지는 추측할 필요 없이, 위처럼 init 줄의 permissionMode 한 필드를 뽑아 보면 바로 확인됩니다.
쓰기가 거부돼도 exit 0과 success가 나옵니다
모드가 바뀌면 실제로 무엇이 깨지는지 보려고, 같은 텔레메트리 꺼짐 환경에서 파일 하나를 쓰게 했습니다. 허용 도구는 따로 주지 않았습니다. 예전 auto에 기대 동작하던 파이프라인과 같은 조건입니다.

2.1.301은 파일을 만들었습니다. 2.1.302는 파일을 만들지 못했는데, 종료 코드는 0이고 결과 subtype은 success, is_error는 false였습니다. 달라진 것은 permission_denials에 항목이 하나 생겼다는 점뿐입니다. 결과 JSON에 남은 거부 기록은 다음과 같습니다(경로는 줄였습니다).
"permission_denials":[{"tool_name":"Write","tool_use_id":"toolu_01MyGEKMfCyGpPcJqx7e5mLy",
"tool_input":{"file_path":"~/proj/hello.txt","content":"hi"}}],
"is_error":false,"num_turns":2,"subtype":"success"
stream-json 쪽 도구 결과에는 “Claude requested permissions to write to ~/proj/hello.txt, but you haven’t granted it yet.”라는 거부 문구가 남습니다. 종료 코드만 보고 성공을 판단하는 셸 스크립트라면 이 실행을 정상으로 넘깁니다. 캡처의 ls가 보여 주듯 파일을 찾는 다음 단계에서야 “No such file or directory”가 납니다. 이 거부는 결과 JSON 한 필드로 잡을 수 있습니다. 앞서 저장한 결과 파일에 jq를 걸면 거부 건수 1이 나오고, -e로 0건인지 검사하면 종료 코드 1로 파이프라인을 멈출 수 있습니다.
$ jq ".permission_denials|length" result.json
1
$ jq -e ".permission_denials|length == 0" result.json >/dev/null; echo "gate exit=$?"
gate exit=1
해결은 릴리스 노트가 말하는 그대로입니다. --permission-mode auto를 명시하면 2.1.302에서도 파일이 다시 만들어졌고 거부는 0건이었습니다. 다만 저는 auto를 그대로 복구하기보다 작업에 필요한 만큼만 여는 쪽을 먼저 검토합니다. 파일 편집만 필요하다면 acceptEdits로 충분하고, 명령 실행이 필요하면 --allowedTools로 그 명령만 허용합니다. 기본값이 바뀌었다는 것은 언제든 다시 바뀔 수 있다는 뜻이기도 하니, 어느 쪽을 고르든 플래그로 적어 두는 것이 핵심입니다.
fork 서브에이전트가 기본으로 켜졌을 때 달라지는 출력
두 번째 변경은 fork 서브에이전트입니다. fork는 현재 대화 맥락을 그대로 복사해 가지고 시작하는 서브에이전트로, 지금까지 터미널에서만 기본으로 켜져 있었습니다. 2.1.302부터는 -p와 SDK에서도 모델이 subagent_type: "fork"를 고를 수 있습니다. 같은 요청을 fork, fork 끔, general-purpose로 각각 실행해 stream-json 이벤트를 비교했습니다.

기본 상태의 fork는 백그라운드로 시작했고(is_backgrounded=true), result 이벤트가 두 번 나왔습니다. 첫 번째(result_index 0)는 “fork 에이전트가 아직 백그라운드에서 돌고 있어 결과가 없다”는 내용이고, 두 번째(result_index 1)에 비로소 서브에이전트의 답이 들어 있습니다. 두 이벤트 모두 subtype이 success입니다. 첫 result를 만나면 바로 읽기를 멈추는 SDK 소비 코드나 jq 한 줄짜리 파서는 이제 미완성 답을 최종 결과로 받게 됩니다.
토큰 수도 다릅니다. 2.1.302에서 같은 한 단어 응답을 시켰을 때 서브에이전트의 total_tokens는 처음 쓰던 작업 폴더에서 fork가 세 번 실행에 23,411~23,504, general-purpose가 두 번 실행에 15,996~16,003이었습니다. 값은 실행마다 조금씩 달라지고, 빈 데모 폴더로 옮겨 다시 돌렸을 때는 fork 23,288~23,323(23,288은 위 캡처를 찍은 10:46 실행), general-purpose 14,148~14,181로 작업 폴더에 따라서도 달라졌습니다. 서브에이전트 문서에 따르면 fork는 생성 시점의 대화 기록 전체를 물려받고, 다른 서브에이전트는 넘긴 프롬프트만으로 새로 시작합니다. 토큰 수 차이는 이 구조에서 오는 것으로 보입니다. 다만 이 숫자를 그대로 비용으로 읽으면 안 됩니다. 같은 문서는 fork의 시스템 프롬프트와 도구 정의가 부모와 같아서 첫 요청이 부모의 프롬프트 캐시를 재사용하고, 그래서 같은 맥락이 필요한 작업에서는 fork가 새 서브에이전트보다 “costs less”라고 설명합니다(서브에이전트 문서, 확인일 2026-10-18).
실행 방식도 매번 같지 않았습니다. general-purpose는 처음 두 번은 포그라운드였는데, 데모 폴더에서 돌린 실행은 위 캡처까지 모두 fork처럼 백그라운드로 시작해 result를 두 번 냈습니다. 같은 문서에 fork 모드가 켜지면 일부 경우를 빼고 서브에이전트가 백그라운드로 돈다고 나와 있으니, 결과가 두 번 오는 문제는 fork만의 일로 보지 않는 편이 안전합니다. --output-format json으로 같은 fork 요청을 두 번 돌렸을 때도 한 번은 포그라운드로 끝나 subagent_tokens: 23279가 찍혔고, 한 번은 두 번째 결과(result_index 1)만 돌아왔습니다. 출력 모양이 실행마다 같다고 가정하면 안 됩니다.
CLAUDE_CODE_FORK_SUBAGENT=0을 주면 fork는 아예 사라집니다. 이때 모델이 fork를 부르면 아래 오류가 도구 결과로 돌아오고, 2.1.301에서도 같은 문구가 나왔습니다.
ERROR: Agent type 'fork' not found. Available agents: claude, Explore, general-purpose, Plan, statusline-setup
하나 더 눈여겨볼 점이 있습니다. fork가 켜진 2.1.302에서도 init 이벤트의 agents 목록은 claude, Explore, general-purpose, Plan, statusline-setup으로 똑같았습니다. fork는 이 목록에 나오지 않으므로, init만 봐서는 fork가 쓰일 수 있는 상태인지 알 수 없습니다. 실제로 쓰였는지는 최종 결과의 subagent_stats.by_type에 fork가 몇 번 찍혔는지로 확인하는 편이 정확합니다.
파이프라인 앞에 붙이는 점검 스크립트
두 변경은 실패가 아니라 모양이 다른 성공으로 나타납니다. 그러니 종료 코드가 아니라 내용을 검사해야 합니다. 그래서 파이프라인과 같은 환경 변수와 플래그로 아주 작은 카나리 작업 하나를 먼저 돌려 보는 스크립트를 만들었습니다. 파일 하나를 쓰게 한 뒤 시작 권한 모드가 기대한 값인지, 권한 거부가 0건인지, 파일이 실제로 생겼는지를 확인하고 하나라도 어긋나면 1로 끝납니다.
#!/usr/bin/env bash
# headless-check.sh EXPECTED_MODE [claude에 그대로 넘길 플래그...]
set -u
expected="$1"; shift
work=$(mktemp -d); cd "$work"
claude --version
claude -p 'Use the Write tool to create a file named canary.txt containing ok. Then say done.' \
--model haiku --output-format stream-json --verbose --max-turns 3 "$@" < /dev/null > run.jsonl 2>&1
echo "claude exit code: $?"
python3 - "$expected" <<'PY'
import json, sys, os
exp = sys.argv[1]; mode = None; results = []; denials = 0
for line in open('run.jsonl'):
try: j = json.loads(line)
except ValueError: continue
if j.get('subtype') == 'init': mode = j.get('permissionMode')
if j.get('type') == 'result':
results.append(j.get('subtype')); denials += len(j.get('permission_denials') or [])
print(f"permissionMode: {mode} (expected {exp})")
print(f"result events: {len(results)} {results}")
print(f"permission denials: {denials}")
print(f"canary.txt written: {os.path.exists('canary.txt')}")
ok = mode == exp and denials == 0 and os.path.exists('canary.txt')
print('CHECK PASS' if ok else 'CHECK FAIL')
sys.exit(0 if ok else 1)
PY
result 이벤트를 개수까지 세어 출력하는 이유는 앞에서 본 fork의 두 번 결과 때문입니다. 카나리 작업에서 결과가 두 개 이상 나오면, 실제 파이프라인의 파서도 마지막 결과를 읽도록 고쳐야 한다는 신호로 씁니다.

첫 번째 실행은 “예전처럼 auto로 돈다”고 믿고 플래그 없이 점검한 경우입니다. claude 자체는 0으로 끝났지만 시작 모드가 default였고 거부가 1건, 카나리 파일은 없었으므로 스크립트가 CHECK FAIL과 종료 코드 1을 냈습니다. 두 번째는 CLAUDE_CODE_FORK_SUBAGENT=0과 --permission-mode acceptEdits를 명시한 경우로, 모드와 거부 건수, 파일이 모두 맞아 CHECK PASS가 나왔습니다.
permissionMode: acceptEdits (expected acceptEdits)
result events: 1 ['success']
permission denials: 0
canary.txt written: True
CHECK PASS
버전 고정과 SDK 쪽에서 챙길 것
CI 이미지는 CLI 버전을 고정해 두는 편이 안전합니다. 이번 비교도 모든 실행에 DISABLE_AUTOUPDATER=1을 넣어 버전을 묶어 둔 상태에서 했습니다. 버전을 올릴 때는 위 카나리 점검을 업데이트 직후 단계로 넣고, 결과가 CHECK PASS일 때만 본 작업으로 넘어가게 만듭니다.
Agent SDK를 쓰는 코드는 메시지 스트림에서 result 메시지를 처음 하나만 받고 끝내는지 확인해야 합니다. 첫 result에서 반복을 멈추는 구조라면, fork를 끄거나 result_index가 가장 큰 결과를 최종으로 쓰도록 바꿉니다. 권한 모드 역시 SDK 옵션에 명시하지 않았다면 CLI와 같은 기본값 변경을 받습니다. 문서와 릴리스 노트가 어긋나는 부분도 있습니다. 확인일 기준으로 서브에이전트 문서는 fork 모드가 대화형에서만 기본으로 켜지고 -p와 Agent SDK에서는 꺼져 있다고 적고 있어, 2.1.302 릴리스 노트와도, 이번에 측정한 동작과도 맞지 않습니다. 같은 문서에는 fork 모드를 켠 채 fork 생성만 막고 싶을 때 Agent(fork) 거부 규칙을 쓰라는 안내도 있으니, 환경 변수로 통째로 끄기 어려운 구성이라면 이쪽을 검토할 만합니다. 공식 설명은 헤드리스 실행 문서, 권한 모드 문서, fork와 CLAUDE_CODE_FORK_SUBAGENT를 다루는 서브에이전트 문서에 있습니다(확인일 2026-10-18).
제가 헤드리스 파이프라인에 적용하는 기준
| 증상·확인 지점 | 2.1.301 | 2.1.302 | 대응 |
|---|---|---|---|
텔레메트리 끈 -p 시작 모드 | auto | default | --permission-mode 명시 |
| 허용 안 된 Write | 파일 생성 | 거부, 그래도 exit 0·success | permission_denials 건수 검사 |
| fork 서브에이전트 | 없음(not found) | 기본 켜짐, 백그라운드, result 2개 | 마지막 result 사용 또는 CLAUDE_CODE_FORK_SUBAGENT=0 |
| 한 단어 응답 서브에이전트 토큰 수(2.1.302) | 해당 없음(fork 없음) | fork 23,288~23,504, general-purpose 14,148~16,003 (작업 폴더·실행마다 다름) | 토큰 수만으로 비용 판단 금지(fork는 프롬프트 캐시 재사용) |
이번 두 변경은 대화형 터미널과 헤드리스 실행의 동작을 맞추려는 방향으로 읽힙니다. 방향 자체는 이해가 되지만, 헤드리스 쪽은 사람이 화면을 보고 있지 않다는 점이 다릅니다. 권한 요청을 띄울 사람이 없으니 default 모드에서 거부는 곧 아무도 모르게 건너뛴 작업이 됩니다.
init 줄의 permissionMode가 default로 나오는 환경에서 claude -p를 쓰고 있다면, 이번 업데이트를 받기 전에 실행 명령에 권한 모드를 적어 넣는 것을 가장 먼저 권합니다. 파일만 고치는 작업은 acceptEdits, 정해진 명령만 쓰는 작업은 --allowedTools로 범위를 좁히는 쪽이 auto를 되돌리는 것보다 낫다고 봅니다.
stream-json이나 SDK로 결과를 파싱하는 경우에는 fork를 끌지부터 정합니다. 결과 형식을 매번 같게 유지해야 하는 배치라면 CLAUDE_CODE_FORK_SUBAGENT=0이 간단하고, fork를 살린다면 마지막 result를 읽고 subagent_stats로 실제 사용 여부를 기록하도록 파서를 바꿉니다.
어떤 경우든 종료 코드 하나로 성공을 판정하는 단계는 없애는 것이 맞습니다. 카나리 점검처럼 권한 거부 건수와 실제 산출물을 같이 보는 절차가 있어야, 다음 기본값 변경도 배포 첫날에 잡아낼 수 있습니다.





