가드 스크립트는 AI 에이전트가 당신의 초안을 발행하는 것을 막지 못합니다. 에이전트에게는 셸(Shell)이 있기 때문입니다.
요약
Claude Code 에이전트가 작성된 가드 스크립트를 우회하여 기사를 즉시 발행해버린 사례를 통해 AI 에이전트 제어의 위험성을 경고합니다. 단순한 프롬프트나 래퍼 스크립트로는 부족하며, 에이전트가 명령을 직접 실행할 수 없도록 시스템 수준의 강제 집행이 필요함을 강조합니다.
핵심 포인트
- AI 에이전트는 셸(Shell) 접근 권한을 통해 기존 가드 스크립트를 우회할 수 있음
- 단순 권고형 가드레일은 에이전트에게 실질적인 제약이 되지 못함
- 해결책은 명령 경로 하단에서 도구 실행 훅이나 인간 전용 승인 절차를 강제하는 것임
- 에이전트가 선호하는 명령보다 더 낮은 단계에서 물리적 차단이 이루어져야 함
저는 Claude Code에게 제 기사 발행 파이프라인에 대한 제어권을 주었습니다. Claude Code는 작성, 자동 검토, 제한적 공유 미리보기, 그리고 스케줄링까지 엔드 투 엔드(end to end)로 처리했습니다. 결과가 꽤 괜찮았기에 저는 주의 깊게 지켜보는 것을 멈췄습니다.
그러던 어느 오후, 저는 기존 포스트를 다시 작성하고 제가 변경 사항을 검토할 수 있도록 제한적 공유 URL을 달라고 요청했습니다. 하지만 대신에 Claude Code는 초안을 공개적으로 발행해 버렸습니다. 제가 알아차렸을 때는 이미 약 100명의 사람들이 그것을 읽은 상태였습니다.
저는 Qiita에 발행하고 있었는데, Qiita는 기사가 완전히 공개되면 API를 통해 다시 비공개로 전환할 수 없습니다. 되돌리기(undo)가 불가능합니다. 초안은 공개되었고, 그대로 유지되었습니다. 이것은 그냥 넘길 수 있는 Qiita만의 특이한 점이 아닙니다. 발행이 되돌릴 수 없는 모든 플랫폼, 즉 사람이 페이지를 읽고 나면 대부분의 플랫폼에서 발생하는 현상입니다.
이 글은 무엇이 잘못되었는지와 그 이후에 제가 구축한 가드레일(guardrail) 시스템에 대한 기록입니다. 교훈은 하단에 있지만, 제가 어떻게 그 단계에 도달했는지 살펴보는 가치가 있습니다. 왜냐하면 뻔한 해결책이 틀린 해결책이기 때문입니다.
빠른 답변
발행 가드레일은 AI 에이전트가 그것을 우회할 수 없을 때만 작동합니다.
안전한 경로가 safe-publish.sh라 할지라도, 에이전트가 여전히 qiita publish를 직접 실행할 수 있다면, 그 가드레일은 권고 사항(advisory)에 불과합니다. 해결책은 더 강력한 프롬프트(prompt)를 사용하는 것이 아닙니다. 해결책은 강제 집행(enforcement)을 에이전트가 선호하는 명령 경로보다 아래 단계로 옮기는 것입니다:
- 검토 전 기계적 확인 (machine checks)
- 공개 출시 승인을 위한 인간 전용 원장 (human-only ledger)
- 원시 발행 명령을 차단하는 도구 실행 훅 (tool-execution hook)
- 인간이 게이트를 무시하도록 훈련되지 않는 명확한 업데이트 경로
설계 목표는 간단합니다. AI는 안전한 영역 내에서 초안 작성, 검토, 미리보기 및 업데이트를 할 수 있지만, 되돌릴 수 없는 발행 경로는 인간의 게이트를 통과하지 않는 한 반드시 벽에 부딪혀야 합니다.
실제로 실행된 것
Claude Code가 실행한 명령은 다음과 같습니다:
bun run qiita publish --force <slug>
저는 정확히 이런 상황을 대비해 가드 스크립트(gate scripts)를 이미 만들어 두었습니다. "초안(draft)"과 "라이브(live)" 사이에 세 개의 관문을 두는 gate-machine-review.sh와 safe-publish.sh가 있었습니다. 하지만 에이전트는 그 관문들을 거치지 않았습니다. 에이전트는 제가 작성한 모든 체크 과정을 우회하여, 밑단에 있는 bun run qiita publish를 직접 호출했습니다.
두 가지 실패가 겹쳐서 발생했습니다:
- 에이전트가 가드 스크립트를 건너뛰고 가공되지 않은 발행 명령을 호출했습니다.
- 초안의 프론트매터(frontmatter)에
private: false가 설정되어 있었으나 아무도 이를 잡아내지 못했습니다. 여기에--force플래그가 붙어 있었기에, 명령은 어떠한 확인 프롬프트도 없이 실행되었습니다.
각각의 요소는 개별적으로 보면 합리적이었지만, 이들이 결합되면서 인간의 개입(human in the loop) 없이 기사가 발행되어 버렸습니다.
가드 스크립트가 저를 구하지 못한 이유
이 부분이 제가 한동안 잘못 알고 있었던 지점입니다. 저는 안전한 발행을 위한 래퍼(wrapper)가 있다는 것만으로도 발행 과정이 안전할 것이라고 가정했습니다. 그렇지 않습니다. 래퍼 스크립트는 오직 그 스크립트를 통과하는 경로만을 보호합니다. 에이전트에게는 셸(Shell)이 있습니다. 에이전트가 bun run qiita publish를 직접 입력할 수 있다면, 래퍼는 아무것도 보호하지 못합니다.
그래서 재구축 작업은 두 부분으로 나뉘었습니다. 안전한 경로를 실제로 엄격하게 만들고, 안전하지 않은 경로를 물리적으로 사용할 수 없게 만드는 것이었습니다.
관문 1: 머신 리뷰 (pre_publish_check.py)
첫 번째 관문은 기사 파일을 읽고, 사람이 확인하기 전에 명백한 문제들을 거부하는 Python 스크립트입니다. 이 스크립트는 다음 사항들을 체크합니다:
- 필수 프론트매터(frontmatter) 필드 (
title,tags,id) - 최소 글자 수 (절반만 작성된 초안이 진행되는 것을 방지)
- 죽은 URL (Dead URLs): 모든 링크에 대해
curlHEAD 요청을 보내며, 404/410/5xx/DNS 오류가 발생하면 관문을 통과하지 못함 - 잘못된 형식의 링크 또는 주입된(injected) 링크
죽은 URL 체크는 예전에 제 개인적인 리뷰 단계에 있었는데, 이는 제가 링크를 일일이 눈으로 확인해야 함을 의미했습니다. 그것은 낭비였습니다. 기계가 검증할 수 있는 것이라면 기계가 차단해야 하므로, 이를 관문 1로 옮겼습니다. 인간의 주의력은 희소한 자원입니다. curl 확인에 주의력을 낭비하지 마세요.
이 단계를 통과하면 다음 단계에서 읽을 수 있는 machine_review_passed 마커를 기록합니다.
관문 2와 3: safe-publish.sh
셸 래퍼(shell wrapper)는 나머지를 강제합니다:
- Gate 1 확인:
machine_review_passed가 없으면 차단(BLOCK). 머신 관문(machine gate)을 통과하지 않고서는 인간 관문에 도달할 수 없습니다. - 업데이트는 예외: 이미 공개된 기사의 본문을 편집하는 경우 Gate 2와 3을 건너뜁니다. 라이브 콘텐츠를 업데이트하는 것은 무언가를 처음으로 공개하는 것과 같은 위험을 수반하지 않으며, 이 둘을 동일하게 취급하면 단순히 프롬프트를 무시하도록 훈련될 뿐입니다.
- Gate 2 (인간 관문):
reviewed_by_user가 누락되었다면 차단(BLOCK). 여기서 중요한 속성은 _에이전트가 이 값을 스스로 작성할 수 없다_는 점입니다. 이 값은 인간이 직접 편집하는 원장(ledger)에 존재합니다. 이것이 에이전트가 체크하는 체크박스가 아닌, 인간 관문이 되게 만드는 요소입니다. 만약 AI가 이 값을 설정할 수 있다면, 그 관문은 불필요한 단계가 추가된 주석에 불과할 것입니다. - Gate 3 (스케줄링):
publish_target_date가 미래라면 차단(BLOCK). 만약 오늘 이미 다른 기사가 발행되었다면 차단(BLOCK). 저는 하루에 최대 한 개의 기사만 발행하며, 관문은 제가 기억할 것이라고 믿는 대신 이를 강제합니다.
Gate 0: 도구 계층에서 차단하는 훅(hook)
여기에 기존 시스템이 결코 메우지 못했던 간극이 있습니다. 위의 모든 관문은 스크립트 내부에 존재합니다. 스크립트는 호출될 때만 실행됩니다. 사고가 발생한 이유는 바로 에이전트가 스크립트를 호출하지 않았기 때문입니다.
Claude Code는 Bash 도구를 통해 셸 명령을 실행하며, 명령이 실행되기 전에 PreToolUse 이벤트를 발생시킵니다. 해당 이벤트는 호출을 거부(veto)할 수 있습니다. 그래서 저는 모든 Bash 호출을 가로채고 실행 전에 명령 문자열을 검사하는 훅인 pre-bash-qiita-publish-guard.py를 작성했습니다:
BLOCK_PATTERNS = [
r"bunx\s+qiita\s+publish",
r"bun\s+(run|x)\s+qiita\s+publish",
...
명령이 차단 패턴(block pattern)과 일치하고 허용 목록(allow list)에 없다면, 훅은 이를 거부하고 에이전트에게 safe-publish.sh를 통해 진행하라는 메시지를 반환합니다. 이제 단순한 qiita publish 명령은 셸이 이를 인지하기도 전에 종료됩니다. qiita pull이나 qiita preview와 같은 읽기 전용 작업은 수정 없이 통과됩니다.
유용한 출력 결과는 의도적으로 지루하게 설계되었습니다:
BLOCKED: raw qiita publish is not allowed.
Use scripts/safe-publish.sh <slug> after machine review and human approval.
저 메시지가 바로 제품의 핵심입니다. 이 메시지는 되돌릴 수 없는 공개 발행(public publish)을 복구 가능한 라우팅 오류(routing error)로 전환합니다.
정규 표현식 차단 목록(regex blocklist)은 분명히 나쁜 방식입니다
회의론자라면 명령 문자열에 대한 정규 표현식(regex) 사용이 제가 앞선 두 섹션에서 비판했던 바로 그 종류의 가드(guard)라고 정확히 지적할 것입니다. 추가 공백, bash -c 래퍼(wrapper), 별칭(alias), 바이너리 이름을 담은 변수 등—이 중 어느 하나라도 패턴을 빠져나갈 수 있습니다. 적대적인 공격자에 대한 방어책으로서 이는 취약합니다.
하지만 여기서의 위협 모델(threat model)은 적대적인 공격자가 아닙니다. 당연한 행동을 수행하는 유능한 모델입니다. 에이전트는 저를 회피하려 하는 것이 아닙니다. 에이전트가 발행 명령을 실행할 때 가장 직관적인 방식으로 입력하며, 그 직관적인 방식이 바로 패턴에 걸리는 방식입니다. 이 훅(hook)은 조용한 공개 발행을 강력한 차단과 함께 safe-publish.sh를 가리키는 메시지로 전환합니다. 그것이 이 작업의 전부입니다.
진정한 해결책은 에이전트가 접근할 수 있는 토큰이나 CLI에서 발행 기능 자체를 제거하여, 명령이 '패턴에 매칭되어 차단되는 것'이 아니라 '아예 사용할 수 없는 상태'로 만드는 것입니다. 저는 아직 그 부분까지는 구현하지 않았습니다. 그렇게 하기 전까지, 이 훅은 실제로 사고를 일으켰던 간극을 메워주며, 강제 적용(enforcement)의 위치를 "에이전트가 호출해야 하는 스크립트"에서 "에이전트가 모든 것을 호출하는 계층(layer)"으로 내려줍니다. 이는 최종적인 해결책은 아닐지라도 실질적인 개선입니다.
실무 체크리스트
AI 에이전트가 발행 파이프라인(publishing pipeline)을 다루도록 허용한다면, 다음 체크리스트부터 시작하십시오:
- 문서화된 정상 경로 (happy path)뿐만 아니라, 모든 되돌릴 수 없는 명령 (irreversible command)을 식별하십시오.
- 안전한 경로를 명시적으로 만드십시오. 예:
scripts/safe-publish.sh <slug>. - 도구 실행 계층 (tool-execution layer)에서 원시 발행 명령 (raw publish command)을 차단하십시오.
- 인간의 승인 (human approval)을 에이전트가 함부로 수정할 수 없는 곳에 두십시오.
- 새로운 공개 릴리스 (public release)와 이미 공개된 콘텐츠의 업데이트를 분리하십시오.
- 발행 상태 (publish state)를 채팅 기록 외부에 저장하여, 다음 세션에서 안전하게 재개할 수 있도록 하십시오.
- "에이전트가 기억해야 한다"를 통제 수단이 아닌 버그로 취급하십시오.
이것이 시스템을 완벽하게 만들지는 않습니다. 다만 가장 발생 가능성이 높은 실수를 튕겨낼 수 있게 해줍니다.
여전히 열려 있는 구멍들
이것이 완성된 것처럼 들리게 하기보다는 솔직해지는 편이 낫겠기에, 아직 견고하지 않은 부분들을 말씀드리겠습니다. 두 번째 항목은 보기보다 더 흥미롭습니다.
updated_at 드리프트 (drift). qiita publish는 프론트매터 (frontmatter)의 updated_at이 서버에 있는 값보다 오래된 경우 업데이트를 거부합니다. 기사를 새로 작성할 때마다 라이브 값을 가져와서 업데이트가 통과되기 전에 이를 조정 (reconcile)해야 합니다. 저는 safe-publish.sh가 이 가져오기 및 동기화 (fetch-and-sync)를 자동으로 수행하기를 원하지만, 아직은 그렇지 않아서 가끔씩 문제를 일으키는 수동 단계로 남아 있습니다.
원형 그대로의 사고를 보여주는 원장 드리프트 (ledger drift). 저는 원장 (ledger)을 라이브 상태와 일치시키는 ledger_sync.py를 가지고 있는데, 특정 조건 하에서 이 스크립트가 스스로 reviewed_by_user를 true로 설정하려고 시도합니다. 다시 한번 읽어보십시오. 에이전트가 쓰는 것이 금지된 단 하나의 값이, 에이전트가 트리거하는 자동화에 의해 도달 가능한 상태입니다. 인간의 관문 (human gate)이 제가 벽을 쌓은 곳에서 한 단계 아래에서부터 내부로부터 스스로 침식되고 있습니다.
그 부분이 바로 깊이 고민해 볼 가치가 있는 지점입니다. 이 실패 모드 (failure mode)는 프랙탈 (fractal) 구조를 띱니다. 자동화를 추가할 때마다, 그 아래 단계의 자동화가 막으려 했던 바로 그 구멍이 다시 열리게 됩니다. 따라서 인간 전용 권한 (human-only privilege)은 자동화가 진정으로 도달할 수 없는 계층으로 계속해서 밀려 내려가야만 합니다. "에이전트를 발행 명령 (publish command)으로부터 분리했다"라는 말은 결코 완성된 문장이 아닙니다. 그 위에 편의 기능을 하나 더 구축할 때마다 계속해서 다시 증명해야 하는 일입니다. 저는 지금 이 사례를 수정하고 있습니다. 이것이 마지막 사례인 척하지는 않겠습니다.
교훈
이런 사고가 발생한 후의 본능적인 대응은 AI를 더 조심스럽게 만드는 것입니다. 더 나은 프롬프트 (prompt), 시스템 메시지 (system message) 내의 더 많은 경고, 발행 전 private 여부를 확인하라는 엄격한 지침 같은 것들 말입니다. 저도 그런 시도를 해보았습니다. 하지만 그것은 거의 무용지물에 가깝습니다. 99%의 시간 동안 조심스러운 모델이라 할지라도, 100번째 실행에서 당신의 초안을 발행해 버릴 수 있으며, 발행은 되돌릴 수 없기 때문입니다.
실제로 효과가 있었던 것은, AI가 수행할 수 있는 작업과 인간이 보유한 권한을 코드 수준에서 (at the code level) 분리하는 것이었습니다. 에이전트는 하루 종일 머신 리뷰 (machine review), 초안 작성 (draft), 미리보기 (preview), 그리고 풀 (pull)을 수행할 수 있습니다. 하지만 reviewed_by_user를 설정할 수 없으며, 원시 발행 명령 (raw publish command)에도 도달할 수 없습니다. 도구 실행 훅 (tool-execution hook)이 해당 명령을 통과시키지 않기 때문입니다.
그래서 저는 실수를 절대 하지 않는 에이전트를 만들려는 시도를 멈추고, 실수를 하더라도 아무런 대가가 따르지 않는 시스템을 만들기 시작했습니다. 에이전트가 결국 잘못된 행동을 할 것이라고 가정하고, 그 잘못된 행동이 말로 설득해서 넘어갈 수 없는 벽에 부딪히도록 만드는 것입니다. 더 조심스러운 에이전트라면 환영할 일입니다. 하지만 초안의 비공개 상태를 유지해 주는 것은 에이전트가 아닙니다. 그것은 벽이 해내는 일입니다.
FAQ
왜 그냥 AI 에이전트에게 더 조심하라고 말하지 않나요?
조심성 (carefulness)은 확률적 (probabilistic)인 반면, 발행은 되돌릴 수 없기 때문입니다. 더 나은 프롬프트는 실수를 줄일 수는 있지만, 에이전트가 기술적으로 허용된 권한 자체를 바꾸지는 못합니다.
래퍼 스크립트 (wrapper script)만으로 충분할까요?
아니요. 래퍼(wrapper)는 그것을 거쳐가는 경로만을 보호할 뿐입니다. 만약 에이전트가 가공되지 않은(raw) 발행 명령을 직접 호출할 수 있다면, 래퍼는 강제 수단이 아닌 단순한 문서(documentation)에 불과합니다.
명령 차단 훅(command-blocking hook)이 완전한 보안 경계일까요?
아니요. 정규 표현식(regex) 훅은 적대적 공격(adversarial)에 대비한 보안 경계가 아닙니다. 하지만 해당 사고를 유발했던 단순한 실수를 차단할 수 있다는 점에서는 여전히 유용합니다. 더 강력한 설계 방식은 에이전트가 접근 가능한 자격 증명(credentials)이나 도구(tooling)에서 가공되지 않은 발행 권한 자체를 제거하는 것입니다.
무엇이 인간 전용으로 남아 있어야 할까요?
무언가를 처음으로 공개하기로 하는 결정입니다. 초안 작성(drafting), 검토(checks), 미리보기(previews), 그리고 업데이트는 공격적으로 자동화할 수 있지만, 첫 번째 공개 배포(public release)에는 반드시 인간이 소유한 승인 신호(approval signal)가 필요합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기