AI 에이전트가 셸로 파일을 쓰거나 프롬프트를 만들 때 heredoc을 쓰는 일은 흔하다. 문제는 구분자를 따옴표로 감싸지 않으면, 본문 안의 백틱과 $(...)가 본문을 글로 두지 않고 실제 명령으로 실행된다는 점이다. 볼트에 남은 하네스 개발 기록에는 이런 사고가 두 건 있다. 한 건은 본문 속 백틱 표기가 실제로 실행된 경우이고, 다른 한 건은 본문 속 백틱 명령이 실행되어 입력을 기다리는 명령이 멈춘 채 프로세스가 남은 경우다.

표지 이미지는 글의 요점을 시각화한 개념 이미지(AI 생성)다.

결론부터 적는다. 셸이 파일 내용이나 프롬프트를 쓰는 명령에서는 구분자를 <<'EOF'처럼 인용한다. 인용하지 않은 구분자는 본문 확장이 의도된 경우에만 쓴다. 모델이 쓴 자료나 인용한 자료는 명령 문자열에 끼워 넣지 않고, 파일이나 표준 입력으로만 넘긴다. 이 글은 두 사고를 기록 그대로 정리하고, 원인을 셸 명세에서 확인한 뒤, 규칙을 지침에 넣으면서 어떻게 시험으로 고정했는지, 그리고 아직 남은 한계가 무엇인지 적는다.

범위는 heredoc 구분자 한 줄이 만드는 문제로 한정한다. 셸 전반의 권한 설계나 훅의 명령 검사는 다루지 않는다. 훅이 명령을 해석할 때 생기는 다른 문제는 PreToolUse git 훅이 복합 명령과 cwd에서 무력해진 두 경우 글에서 따로 다뤘다.

heredoc은 본문을 어떻게 읽는가

heredoc은 명령 뒤에 적은 여러 줄을 그 명령의 표준 입력으로 넘기는 문법이다. 본문은 구분자 단어만 단독으로 적힌 줄을 만날 때까지 이어진다. 중요한 것은 셸이 그 본문을 어떤 문자열로 취급하느냐다. POSIX 셸 명령 언어 명세의 2.7.4절은 두 경우를 나눠 적는다. 구분자 단어의 어느 부분이든 인용되면 본문 줄은 확장되지 않는다. 인용되지 않으면 모든 본문 줄에 매개변수 확장, 명령 치환, 산술 확장이 적용되고, 백슬래시는 큰따옴표 안에서처럼 동작한다.

GNU Bash 매뉴얼의 리다이렉션 절도 같은 내용을 적는다. 인용되지 않은 단어에서는 본문이 큰따옴표 문자열처럼 다뤄지며, 백슬래시는 \, $, 백틱을 인용할 때만 특별하다. zsh 매뉴얼의 리다이렉션 절도 방향이 같다. 구분자의 문자 하나라도 따옴표나 백슬래시로 인용되면 문서 내용에 해석을 적용하지 않고, 그렇지 않으면 매개변수 치환과 명령 치환이 일어난다. 세 문서가 같은 방향을 말하므로, 이 규칙은 Bash와 zsh 어느 쪽에서도 같은 방식으로 읽을 수 있다.

표 1. 구분자 인용 여부에 따른 heredoc 본문 처리 (POSIX 명세, Bash 매뉴얼, zsh 매뉴얼 기준)
구분자 표기매개변수·산술 확장명령 치환(백틱, $(…))백슬래시근거
<<EOF (인용 없음)수행수행큰따옴표 안과 같아, 특정 문자 앞에서만 인용 역할을 함POSIX 2.7.4, Bash 매뉴얼 Here-Documents
<<'EOF', <<"EOF" (인용 있음)수행 안 함수행 안 함그대로 글자로 남음POSIX 2.7.4, Bash 매뉴얼, zsh 매뉴얼 Redirection

인용하지 않은 구분자는 본문을 셸 문법으로 읽는다. 셸은 본문이 글인지 명령인지 묻지 않고, 따옴표가 없으면 백틱을 명령 치환으로 실행한다.

구분자 인용 여부에 따라 heredoc 본문이 어떻게 처리되는지 나란히 비교한 그림이다. 왼쪽 인용하지 않은 구분자는 매개변수와 산술 확장을 하고 백틱과 $() 명령 치환을 실행한다. 오른쪽 인용한 구분자는 확장을 하지 않아 백슬래시를 포함한 본문이 글자 그대로 남는다. 명령 치환 실행 칸이 강조되어 있다.
heredoc 본문이 파일에 글자 그대로 남는지, 셸 명령으로 실행되는지는 구분자를 인용했는지에 따라 정해진다.

무해한 명령으로 다시 확인하기

명세대로라면 같은 차이는 다음과 같이 나타난다. 실행한 명령은 echo뿐이고, 본문도 설명을 위해 만든 합성 예시다. 같은 세 줄을 인용하지 않은 구분자와 인용한 구분자로 각각 실행했다.

인용하지 않은 구분자 쪽은 다음과 같이 쓰면 된다.

cat <<EOF
line-a: `echo backtick-ran`
line-b: $(echo subst-ran)
line-c: \$(echo escaped-literal)
EOF

출력은 다음과 같았다.

line-a: backtick-ran
line-b: subst-ran
line-c: $(echo escaped-literal)

구분자를 작은따옴표로 감싸면 같은 세 줄이 글자 그대로 남는다.

cat <<'EOF'
line-a: `echo backtick-ran`
line-b: $(echo subst-ran)
line-c: \$(echo escaped-literal)
EOF
line-a: `echo backtick-ran`
line-b: $(echo subst-ran)
line-c: \$(echo escaped-literal)

구분자의 따옴표 하나로 파일에 들어가는 내용이 달라진다. 인용하지 않은 쪽에서는 백틱 안의 명령과 $() 안의 명령이 실행되어 그 자리가 결과 문자열로 바뀌었다. 사람이 적은 글을 파일에 옮기려던 의도와 실제 결과가 어긋나는 지점이 바로 여기다.

첫 사고: 본문 속 백틱 표기가 실행됐다

첫 번째 사고는 하네스 개발 기록의 한 실행 단계에서 났다. 그 단계의 인용하지 않은 heredoc 본문에는 셸 함수 이름 하나와 다른 셸을 여는 명령 하나가 백틱으로 감싸여 있었다. 기록에 따르면 두 표기는 모두 실제로 실행됐다. 기록은 실행 사실까지만 남기며, 이 기록만으로는 그 실행이 어떤 상태 변화를 냈는지 알 수 없다.

이 사고에서 먼저 봐야 할 것은 누가 실행을 원했느냐가 아니다. 본문은 글이었지만, 인용되지 않은 heredoc에서 셸은 그것을 글로 두지 않는다. 백틱은 명령 치환의 문법이므로, 강조나 참조를 위해 넣은 백틱도 실행 문법으로 해석된다. 에이전트가 쓴 글에는 명령 이름을 백틱으로 감싸는 표기가 자연스럽게 들어가므로, 이 점을 전제로 두어야 한다.

기록에서 확인되지 않는 것도 적어 둔다. 그 실행이 최종적으로 어떤 파일을 남겼는지, 실행된 함수가 환경을 얼마나 바꿨는지는 기록에 없다. 이런 부분을 추정으로 채우면 사고 기록이 아니라 소설이 된다. 그래서 이 글은 실행 사실만 사고로 적고, 결과는 확인되지 않음으로 둔다.

두 번째 사고: 입력을 기다리는 명령이 본문에 섞였다

두 번째 사고는 2026년 10월 17일 16시 45분 KST 무렵 일어났다. 계획 본문을 인용되지 않은 heredoc으로 파일에 쓰던 실행이었고, 그 본문에 백틱으로 감싼 명령 세 개가 있었다. 기록된 순서는 다음과 같다.

  1. 대화형 설정 명령이 실행됐다. 확인 플래그가 없어서 입력을 기다렸고, 출력은 설정 적용으로 이어지지 않은 채 계획 본문 쪽에 섞였다.
  2. 읽기 전용 목록 명령이 실행됐다.
  3. 엄격 설정 검사 명령이 실행됐다. 표준 입력을 기다리며 멈췄고, 프로세스가 남았다.
2026년 10월 17일 실행에서 인용 없는 heredoc 본문의 명령이 차례로 실행된 과정을 시간 순으로 보여 준다. 대화형 설정 명령이 입력을 기다리며 출력이 계획 본문에 섞였고, 읽기 전용 목록 명령이 이어졌다. 엄격 설정 검사 명령은 표준 입력을 기다리며 멈췄고, 남은 프로세스 칸이 강조되어 있다. 이 프로세스는 사용자가 그대로 두기로 했다.
인용하지 않은 구분자 하나가 파일 내용뿐 아니라 실행 흐름까지 바꾼 순서다.

기록의 첫 줄은 이 밖에도 다른 명령이 있었다고 적는다. 그래서 이 세 개가 실행된 명령의 전부인지는 확인되지 않는다.

기록된 영향은 제한적이었다. 설정 파일의 해시는 저장소에 커밋된 내용과 같았고, 도구 등록 항목도 바뀌지 않았다고 기록됐다. 계획 파일은 비어 있었고, 같은 내용을 인용된 heredoc으로 다시 썼다. 멈춰 있던 프로세스는 사용자가 그대로 두기로 정했다. 기록에는 그 뒤 정리됐는지 나오지 않는다.

이 사고가 주는 교훈은 두 갈래다. 하나는 인용 누락이 파일 내용뿐 아니라 실행 흐름까지 바꾼다는 것이다. 다른 하나는 대화형 명령이 본문 실행 중에 들어가면, 본문이 깨지는 것과 프로세스가 멈추는 것이 같은 원인에서 나온다는 것이다. 두 번째 교훈은 추정이다. 기록은 두 현상이 같은 실행에서 함께 나왔다는 것까지만 보여 준다.

원인은 인용의 기본값과 모델이 쓴 본문의 결합이다

첫째 원인은 셸 문법이다. 인용하지 않은 구분자는 확장을 켠다. 본문을 정적인 글로 여기는 기대와 셸의 해석이 어긋나는 것이 출발점이다. 위에서 본 재현처럼 같은 세 줄이 구분자의 따옴표 하나에 따라 실행되거나 글로 남는다.

둘째 원인은 입력의 출처다. 둘째 사고는 에이전트가 셸로 쓰던 본문이었다. 첫째 사고의 작성 주체는 기록에 없다. 모델이 쓴 계획과 프롬프트에는 명령 이름을 백틱으로 감싸는 마크다운 표기가 흔하다. 사람이 쓴 셸 스크립트보다 백틱이 많이 들어갈 조건이라는 뜻이다. 이것이 사고 빈도를 얼마나 높였는지는 측정하지 않았으므로 추정으로 남긴다.

셋째 원인은 설계다. 명령 문자열 안에 본문을 끼워 넣는 방식은, 구분자를 인용하더라도 본문이 명령의 일부가 되는 경로를 남긴다. 그래서 인용 규칙 하나만으로는 충분하지 않다. 본문을 명령의 일부가 아니라 데이터로 넘기는 경로가 함께 있어야 한다.

규칙: 구분자를 인용하고, 모델이 쓴 자료는 데이터로만 넘긴다

규칙은 네 문장으로 정리했다. 하네스의 지침 문서에 들어간 내용과 같은 취지다.

  1. 셸 명령이 파일 내용이나 프롬프트를 쓸 때는 구분자를 인용한다. 예를 들면 <<'EOF'다.
  2. 인용하지 않은 구분자는 확장이 의도된 경우에만 쓴다. 그때도 의도한 변수는 본문 밖에 둔다.
  3. 생성되었거나 모델이 쓴 내용은 파일 쓰기 도구를 쓰거나, printf '%s'에 내용을 인수로 넘긴다.
  4. 모델이 쓴 자료나 인용한 자료는 파일이나 표준 입력으로만 전달하고, 셸 명령 문자열에 끼워 넣지 않는다.

두 규칙의 형태는 다음과 같다.

# 인용된 구분자: 백틱과 $()는 글자 그대로 파일에 남는다
cat <<'EOF' > plan.md
- 점검: `설정 명령`을 실행하지 않고 기록만 한다.
EOF

# 모델이 쓴 본문은 인수로만 넘긴다
printf '%s\n' "$BODY" > plan.md

변수 확장이 필요한 경우의 규칙은 단순하다. 값은 본문에 섞지 않고 명령의 인수로 넘긴다. 목적은 본문 속 문자가 명령으로 해석될 기회를 없애는 것이다.

구분자 인용에는 함정이 하나 더 있다. POSIX 명세는 본문 안에 구분자 단어만으로 이뤄진 줄이 있으면 본문이 그 줄에서 끝난다고 정의한다. 모델이 쓴 긴 글에 구분자와 같은 줄이 우연히 들어가면, 인용 여부와 관계없이 본문이 잘린다. 그래서 모델이 쓴 긴 본문은 파일 쓰기 도구나 인수 전달로 옮기는 것이 더 안전하다. 규칙 문구에 이 내용을 넣었지만, 이것을 검사하는 시험은 따로 없다.

규칙을 시험으로 고정하고 적용한 과정

규칙은 두 하네스 설정 저장소에 반영했다. 두 저장소 모두 에이전트가 따르는 지침 문서를 가진다. 적용 위치는 저장소마다 한두 문장 또는 짧은 문단으로 나눴다. 관계없는 문장은 바꾸지 않았다(한 곳은 기존 문장에 이어 붙임). 한 저장소의 지침 파일에는 세 문구를 모두 넣었고, 다른 저장소의 세 파일에는 인용 구분자 표기와 구분자를 인용하라는 두 문구를 넣었다. 그중 한 파일에만 printf '%s' 문장과 명령 문자열에 넣지 말라는 문장을 더 넣었다.

절차는 시험부터 시작했다. 각 지침 파일이 필수 문구를 담고 있는지 고정 문자열로 확인하는 시험을 먼저 썼고, 그 시험을 고치지 않은 상태로 실행했다. 한쪽 저장소에서는 세 줄, 다른 쪽에서는 여덟 줄의 단언이 실패했다. 구문 검사 bash -n은 한 저장소의 기록에서만 통과가 확인됐다. 구문 오류나 누락된 설정 때문에 실패한 것이 아니라 단언이 실패한 것이므로, 의도한 빨간 상태로 인정했다.

표 2. 규칙 반영 단계와 기록된 확인 결과
단계확인한 것기록된 결과
시험 먼저지침 파일이 필수 문구를 담는지 고정 문자열로 단언단언 실패 확인, 구문 검사는 한 저장소 기록에서만 통과해 의도한 빨간 상태로 인정
계획 검증계획의 시험 단계와 게이트시험 루프가 앞선 실패를 가린다는 문제 지적, 1회 보정
시험 리뷰단언의 범위와 격리를 다른 모델이 검토모든 차원 8점 이상, 막는 문제 없음
논리 적용필수 문구를 추가하고 같은 시험을 다시 실행고치지 않은 시험이 모두 통과, 전체 스위트 통과, 소유 파일만 변경
논리 리뷰문구의 내용과 범위를 다른 모델이 검토모든 차원 8점 이상, 막는 문제 없음
활성화백업, 해시, 열린 프로세스 확인 후 원자적 교체, 변경 기록활성 파일 해시가 병합된 내용과 일치
승격개발 브랜치와 리뷰된 PR 머리의 파일 블롭 비교, 메인 fast-forward 확인두 저장소 모두 메인이 같은 내용을 가리킴

시험 루프가 실패를 가리던 문제

계획 검증 단계에서 한 가지 결함이 나왔다. 전체 시험 스위트를 도는 루프가 시험 실패를 누적하지 않아서, 마지막 시험이 통과하면 앞선 시험의 실패가 종료 코드에서 사라졌다. 이 결함을 고친 루프는 다음 형태다.

rc=0
for f in tests/*.test.sh; do bash "$f" || rc=1; done
exit $rc

이 루프는 시험이 하나라도 실패하면 종료 코드를 1로 남긴다. 앞선 실패가 뒤의 통과에 가려지지 않는다. 보정 뒤의 모든 스위트 점검은 이 루프로 돌렸다. 이 하네스의 규칙은 계획 검증 실패를 한 번만 고치고 재검증 없이 진행하도록 되어 있어서, 보정은 루프 한 곳만 바꾸고 다시 검증하지 않았다.

리뷰와 활성화에서 확인한 것

시험과 논리 변경은 작성과 다른 모델이 각각 리뷰했다. 기록된 점수는 모든 차원에서 8점 이상이었고, 막는 문제는 비어 있었다. 점수는 기록이 준 값이며, 이 글에서 독립적으로 다시 매긴 것이 아니다.

활성화는 파일을 바로 덮어쓰지 않았다. 백업을 만들고, 대상 파일을 연 프로세스가 없는지 확인한 뒤 원자적으로 교체했다. 변경 기록에는 전후 해시와 되돌리는 방법을 남겼다. 되돌림 절차는 활성 파일의 해시가 교체 후 값과 같은지 먼저 확인하고, 백업에서 복원한 뒤 해시를 다시 확인하는 순서다. 기록상 두 저장소 모두 활성 파일 해시가 병합된 내용과 일치했다.

남은 한계

  • 시험이 보는 것은 문구의 존재다. 지침 파일에 필수 문구가 있는지 확인할 뿐, 실행 에이전트가 실제로 인용해서 쓰는지는 이 시험으로 확인되지 않는다.
  • 규칙은 지침 텍스트이며 기계적으로 차단하지 않는다. 지침을 무시하고 인용 없는 heredoc을 쓰는 명령을 막는 장치는 이 변경에 없다.
  • 기록된 사고는 두 건이다. 두 번째 사고 기록의 첫 줄은 다른 명령도 있었다고 적어, 목록이 전부인지 확인되지 않는다.
  • 본문에 구분자와 같은 줄이 들어가면 본문이 거기서 끝난다. 규칙은 파일 쓰기 도구나 인수 전달을 권하지만, 이를 검사하는 시험은 없다.
  • 이 규칙은 heredoc 구분자와 모델 자료의 전달 경로만 다룬다. 문자열을 다시 셸 명령으로 해석하는 호출이나 형식 문자열에 변수를 넣는 경로는 범위 밖이다. 이 경로들이 같은 위험을 가지는지는 추정이며, 별도 점검이 필요하다.
  • 두 번째 사고에서 멈춘 프로세스는 사용자의 결정으로 그대로 두었다. 기록에는 그 뒤의 정리 상태가 없다.

정리

두 사고의 공통 원인은 인용되지 않은 구분자였다. 셸은 그 본문에서 백틱과 $()를 명령으로 해석했고, 모델이 쓴 글에는 그 표기가 들어가기 쉬웠다. 규칙은 인용 구분자를 기본으로 두고, 모델이 쓴 자료를 명령 문자열이 아니라 파일이나 표준 입력으로만 지나가게 했다. 다만 이것은 heredoc 한 경로를 막은 것이지, 셸 명령의 모든 위험을 없앤 것은 아니다. 규칙이 지침에 있다는 사실과 실제 실행이 규칙을 따랐다는 사실은 다르며, 이 글은 앞의 것만 확인했다.

자주 묻는 질문

구분자를 큰따옴표로 감싸도 같은가?

POSIX 명세와 Bash 매뉴얼은 구분자 단어의 어느 부분이든 인용되면 본문을 확장하지 않는다고 적는다. 따라서 큰따옴표로 감싸도 같은 효과다. 이 규칙은 읽는 사람이 바로 알아볼 수 있도록 작은따옴표 형식을 정했다.

본문에 변수 값을 넣어야 하면 어떻게 하나?

구분자를 인용한 채 본문을 쓰고, 값은 명령의 인수로 넘긴다. 예를 들어 printf '%s\n' "$BODY"처럼 쓰면 값은 문자열로만 전달되어, 본문 속 문자가 다시 해석되지 않는다. 인용하지 않은 구분자는 확장 자체가 목적인 짧은 명령에만 쓰고, 본문 안에 외부 입력을 넣지 않는다.

모델이 쓴 긴 본문은 어떻게 쓰는 것이 안전한가?

파일 쓰기 도구로 직접 쓰는 것이 가장 단순하다. 셸을 거쳐야 한다면 본문을 파일이나 표준 입력으로 전달한다. 명령 문자열 안에 본문을 넣는 방식은 피한다.

이 규칙이면 셸 명령 주입이 모두 막히나?

아니다. 이 규칙은 heredoc 구분자와 모델이 쓴 자료의 전달 경로만 다룬다. 한계 절에 적은 것처럼 시험은 문구가 있는지만 확인하고, 다른 경로는 이 글의 범위 밖이다.

출처와 확인 범위

이 글의 사고 기록은 하네스 개발 기록에서 가져왔다. 기록의 세부 명령과 경로는 일반화했다. 위 출력 예시는 POSIX·Bash 명세에 따른 예상 출력이며, 무해한 echo 명령으로 짠 설명용 합성 예시다.

관련 글은 Claude Code 도구 하나가 갖춰야 할 6가지 계약과 멀티에이전트 코딩에서 실제로 사고 난 것들이다. 분류는 프로그래밍 카테고리에 있다.