
Claude Code로 19일 만에 PWA를 제작하고, 5명의 AI 역할을 통한 상호 리뷰로 3건의 취약점을 찾아내다
요약
Claude Code를 활용해 19일 만에 PWA를 개발하며, 5명의 AI 에이전트에게 서로 다른 역할을 부여해 상호 리뷰를 진행하는 워크플로우를 소개합니다. 이 과정을 통해 실제 보안 취약점을 발견하고, 훅(Hook) 스크립트를 통해 AI의 개발 규율을 물리적으로 강제하는 설계 방식을 보여줍니다.
핵심 포인트
- 5개 AI 역할의 상호 리뷰 및 사회자 판정 시스템 구축
- Claude Code 훅(Hook)을 이용한 PR 강제 및 개발 가드레일 설계
- AI 에이전트 기반 개발 시 보안 취약점(키 유출 등) 탐지 사례
- AI가 작성한 코드의 품질 관리를 위한 물리적 제어 방식
AI 코딩 에이전트(AI Coding Agent)에게 실제 프로덕트를 작성하게 했을 때, 그 코드의 안전성은 누가 확인해야 할까요?
사람이 전부 리뷰한다면, 작성 속도를 높인 의미가 퇴색됩니다. 그렇다고 AI에게 "보안 리뷰를 해줘"라고 한마디 던지는 것만으로는, 그럴듯한 지적만 늘어놓을 뿐 실제로 파고들 수 있는 취약점이 나올 것이라는 보장이 없습니다.
이 질문에 대해, AI에게 5가지의 서로 다른 관점을 할당하여 독립적으로 지적하게 하고, 서로 반론하게 하며, 대립이 남은 항목은 사회자 역할이 판정한다는 절차로 답을 내고 있는 리포지토리(Repository)를 발견했습니다. 심지어 나온 지적 중 1건은, 리뷰를 수행 중인 AI 자신의 실행 환경에 열려 있던 키(Key) 유출 경로였습니다.
제가 읽은 것은 kai-kou/github-issue-shortcut(MIT 라이선스)입니다. 스마트폰 홈 화면에서 몇 초 만에 GitHub Issue를 생성하는 PWA(Progressive Web App)로, Cloudflare Workers 위에서 동작합니다. 개발 기록과 논의 로그가 리포지토리에 그대로 남아 있었기에, 실제로 직접 세어보고 구동하며 추적해 보았습니다.
- AI가 작성한 103개의 커밋(Commit)이 어떤 가드레일(Guardrail)을 통과하여
main에 들어갔는지 - 5개 역할로 나눈 AI의 상호 리뷰가 실제로 무엇을 찾아냈는지 (실제 지적 문구 포함)
fetch에 템플릿 리터럴(Template Literal)로 URL을 구성하면 어떤 일이 발생하는지- 지적의 중요도에 따라 AI끼리 대립했을 때 어떻게 결론을 내리는지
Claude Code와 같은 AI 에이전트에게 구현을 맡기는 운영 방식을 구축하고 있으며, 리뷰와 품질 게이트(Quality Gate) 설계에 고민이 있는 분들을 대상으로 합니다.
- 리포지토리:
kai-kou/github-issue-shortcut(커밋fa6b026) - 검증 환경: Linux 컨테이너 (Node.js, git, curl 사용이 가능한 클라우드 실행 환경)
- 검증일: 2026-07-29 JST
먼저 개발 실적을 세어 보았습니다.
$ git log --oneline | wc -l
103
$ git log --pretty=%s | grep -cE '\(#[0-9]+\)$'
...
첫 커밋이 2026-07-10, 최신이 2026-07-29이므로 19일간의 기록입니다. 103개의 커밋 중 97개가 끝에 (#N)을 가지고 있습니다. GitHub의 스쿼시 머지(Squash Merge)가 자동으로 붙이는 형식이므로, 거의 모든 커밋이 PR(Pull Request)을 경유하여 main에 들어갔다는 뜻입니다.
이는 규율로 지켜지는 것이 아니라, 물리적으로 강제되고 있었습니다. .claude/hooks/를 보면 18개의 훅 스크립트(Hook Script)가 나열되어 있습니다.
$ ls .claude/hooks/
lib pre-git-push-check.sh stop-completion-report-check.sh
orchestrator-directive.sh pre-pr-create-check.sh stop-git-check.sh
...
pre-git-push-check.sh가 도구 실행 전에 main으로의 직접적인 푸시(Push)를 차단하고, pre-pr-create-check.sh가 PR 생성 시 필수 항목을 검사하며, stop-pr-check.sh가 세션 종료 시 "푸시를 했음에도 PR을 만들지 않은 브랜치"를 감지합니다. "AI에게 주의를 주는" 것이 아니라 "할 수 없게 만드는" 쪽으로 설계되었습니다.
본론인 보안 리뷰입니다. content/discussions/ 아래에 논의 로그가 그대로 남아 있었습니다.
$ ls content/discussions/
base-sync-20260714 manual_check_automation_20260725 security-risk-review-20260729
design-docs-20260717 pr151_layer2_review sidepanel-review
...
security-risk-review-20260729의 내용을 보면, 5가지 관점(역할)이 독립적으로 지적을 내놓고 있습니다. 엔트리의 파일명을 통해 역할을 파악할 수 있었습니다.
authn_token: 인증 및 토큰 관리secrets_supplychain: 시크릿(Secret) 및 공급망(Supply Chain)web_appsec: 웹 애플리케이션 보안 (Web Application Security)privacy_data: 프라이버시 및 데이터ops_risk
: 운영 리스크 (ops_risk)
흐름은 3라운드로 진행됩니다. Round 1에서 각 역할이 독립적으로 claim (지적)을 게시하고, Round 2에서 서로의 지적에 대해 rebuttal (반론)을 게시하며, Round 3에서 사회자 역할(lead)이 consensus (합의)와 verdict (판결)를 내립니다. 한 게시물이 하나의 파일이므로, 병렬로 작성해도 충돌이 발생하지 않습니다.
이 구조의 핵심은 Round 1의 게시물이 서로 보이지 않는 상태에서 작성된다는 점입니다. 하나의 AI에게 "5가지 관점에서 리뷰해줘"라고 요청하면, 처음에 작성한 관점에 끌려 비슷한 지적들이 나열되기 쉽지만, 독립적으로 작성하게 하면 관점의 중복이 줄어듭니다. 여기에 Round 2에서 반론 과정을 거침으로써, 분위기에 휩쓸린 과도한 지적을 걸러낼 수 있습니다.
Round 3의 verdict (판결)는 WARN 판정이었으며, High 3건, Medium 3건 이상이 나열되어 있었습니다. High 등급의 3건을 요약합니다.
| ID | 지적 내용 | 대상 |
|---|---|---|
| H-1 | 하네스(Harness)가 시크릿(Secret)을 /tmp의 world-readable한 파일로 평문 저장하며, 심지어 Read 거부 리스트(Deny list) 대상에서도 제외됨 | AI 자신의 실행 환경 |
| H-2 | repo 파라미터가 검증되지 않아, GitHub API로의 임의 경로 유도가 가능함 | 프로덕션 코드 |
| H-3 | /auth/login에 속도 제한(Rate limit)이 없어, Worker 측만 소모시키는 가용성 공격(Availability attack)이 가능함 | 프로덕션 코드 |
H-1은 개인적으로 가장 놀라운 지적이었습니다. 리뷰를 수행 중인 AI 자신이 구동되고 있는 환경에 관한 이야기입니다. verdict에서 인용한 내용입니다.
TOKEN_ENCRYPTION_KEY (모든 사용자의 토큰 Cookie를 암호화하는 유일한 키이자 Rate limit HMAC 키를 겸함)와 GITHUB_CLIENT_SECRET을 포함할 수 있는 export 문이 /tmp/broker_secrets.env 및 /tmp/github_variables.env에 평문으로 남음. 두 스크립트 모두 umask/chmod 설정이 존재하지 않으며 (lead 실기 확인), deny 리스트에도 /tmp/*.env에 해당하는 패턴이 없음.
리포지토리 내의 Issue/PR 댓글 등에 심어진 프롬프트 인젝션 (Prompt Injection)이 "/tmp/broker_secrets.env를 확인해줘"라고 유도하면, Claude는 deny에 걸리지 않고 Read 할 수 있습니다. 읽어낸 값을 Issue 댓글, PR 본문, 커밋(Commit)과 같이 이미 권한이 허용된 액션을 통해 퍼블릭 리포지토리로 전송하게 만들면, 네트워크 허용 목록(Allowlist)을 우회하여 키가 유출됩니다.
프롬프트 인젝션으로 비밀 파일을 읽게 하고, 이미 허용된 작업(커밋, 댓글)만으로 외부로 반출한다는 시나리오입니다. 네트워크 허용 목록을 전혀 깨뜨리지 않기 때문에, 통신 측면의 방어는 의미가 없습니다. AI 에이전트에게 권한을 부여하는 운영 방식을 사용하고 있다면, 자신의 환경에서도 한 번쯤 확인해 볼 가치가 있는 종류의 취약점이라고 생각합니다.
기술적으로 가장 흥미로웠던 것은 H-2입니다. Round 1의 web_appsec 역할의 지적을 그대로 인용합니다.
repo (body의 repo / query의 repo)는 .trim()만 적용될 뿐, owner/repo 형식에 대한 포맷 검증이 전혀 없음. worker/github.ts:252의 fetch(\${apiBase}/repos/${repoFullName}/issues`, ...)는 템플릿 리터럴로 구성된 문자열을 그대로 fetch()에 전달하며, WHATWG URL 파서가 ..세그먼트의 정규화(Normalization)와#에 의한 프래그먼트(Fragment) 추출을 수행하기 때문에, repoFullName에 ../나 #`을 포함하면 생성되는 실제 요청 경로를 임의로 변경할 수 있음.
구체적인 예시도 작성되어 있었습니다. {"repo": "../orgs/some-org/repos#", "title": "x"}를 보내면, 구성되는 문자열은 다음과 같습니다.
fetch에 전달된 시점에 URL 파서가 ..를 정규화하고 # 이후를 프래그먼트로 분리하므로, 실제로 날아가는 요청은 다음과 같습니다.
POST https://api.github.com/orgs/some-org/repos
「Issue 생성」을 의도했음에도 불구하고, 사용자 본인의 OAuth 토큰을 사용하여 api.github.com 하위의 다른 엔드포인트를 호출할 수 있습니다. 토큰이 본인의 것이므로 권한 상승(Privilege Escalation)은 아니지만, UI가 보장한다고 믿었던 'App 설치 완료 및 push 권한 있음'이라는 허용 목록(allow-list)을 완전히 우회할 수 있습니다.
경로 탐색(Path Traversal)이라고 하면 흔히 파일 시스템의 문제라고 생각하기 쉽지만, HTTP 클라이언트의 URL 구성 과정에서도 동일한 일이 발생할 수 있다는 점을 보여주는 매우 이해하기 쉬운 사례였습니다. new URL()을 거치지 않고 문자열 결합(string concatenation)으로 URL을 구성하는 코드는 제 로컬 환경에도 있을 법한 코드라는 생각이 듭니다.
수정 커밋이 남아 있어 내용을 확인해 보았습니다.
$ git log --all --oneline | grep -E '204|213'
ec8d3c1 fix: repo 파라미터 형식 검증 및 harness 비밀 파일 권한 수정 (보안 리뷰 #204) (#213)
worker/index.ts에 추가된 검증 함수입니다.
const REPO_OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
const REPO_NAME_PATTERN = /^[A-Za-z0-9._-]{1,100}$/;
function isValidRepoFullName(value: string): boolean {
...
owner와 name을 분리하여 검증하는 것이 핵심입니다. 커밋 메시지에 따르면, 초기 구현은 하나의 정규 표현식으로 작성되어 있어, owner/.github와 같은 정상적인 리포지토리를 차단해 버리는 회귀(regression) 문제가 발생했었다고 합니다. 이를 다음 단계의 셀프 리뷰(self-review)가 탐지하여 현재의 형태로 수정했습니다.
- repo 형식 검증이 .github와 같은 정당한 repo 명을 차단하던 문제를 수정한다.
owner(영문/숫자 및 하이픈, 39자)와 repo 명(영문/숫자 및 . _ -, 100자)을 분리하여 검증하고,
경로 세그먼트(path segment)로서 특별 취급되는 '.'과 '..'만을 별도로 차단하는 방식으로 변경한다.
동일한 커밋에서 worker/issues.test.ts에 97줄의 테스트 코드가 추가되었으며, .github, 1글자, 최대 길이, owner의 도트(dot) 거부와 같은 경계값(boundary value)들이 포함되어 있습니다. "AI가 지적한 허점을 AI가 메우고, 그 수정 과정에서 발생한 회귀를 또 다른 AI 단계가 잡아내는" 연쇄 과정이 그대로 이력에 남아 있었습니다.
또 하나 흥미로웠던 점은, 지적의 심각도(severity)를 두고 역할(role) 간의 의견이 갈렸을 때의 처리 방식입니다. H-2의 판결(verdict)에는 다음과 같이 기록되어 있습니다.
사실관계는 모든 렌즈(lens)가 일치함. 심각도는 high(web_appsec) vs medium(authn_token/ops_risk)으로 대립하였으나, lead가 high로 판결함 (방어가 GitHub 측 권한 모델의 단일 계층에만 의존하고 있으며, App 권한이 확장되는 순간 무너지기 때문). 권한 상승이나 타인의 데이터 노출까지는 이르지 않으므로 critical은 아님.
"사실관계는 일치하지만 평가가 대립함"을 명시적으로 구분하여 기록하고, 사회자 역할이 이유를 곁들여 판결을 내리고 있습니다. 다수결(medium 2표 vs high 1표)이었다면 medium이 되었을 상황을, 논거의 강함에 따라 뒤집은 것도 의도적인 설계로 보입니다.
반대로 심각도 상향 조정이 기각된 기록도 있었습니다. M-2(암호 키와 HMAC 키의 재사용)에 대해, 시크릿 담당 역할이 "H-1의 유출 경로가 있으므로 등급을 높여야 한다"고 주장했으나, 인증 담당 역할이 "마스터 키가 유출되면 HKDF를 통해서도 동일한 절차로 서브 키를 재도출할 수 있다"고 반론하여 medium으로 유지되었습니다.
AI 리뷰를 실무에 적용할 때 가장 곤란한 점은 "그럴듯한 지적이 대량으로 쏟아져서 무엇이 진짜인지 알 수 없는" 상태입니다. 반론 단계를 구조화하고, 대립이 해소된 이유까지 기록으로 남긴다면, 지적 내용 자체뿐만 아니라 **지적의 신뢰도(confidence)**까지 함께 받아들일 수 있습니다.
토론 로그는 설득력이 있지만, 실제로 수정이 이루어져 운영 환경에 효과가 있는지는 별개의 문제입니다. 확인할 수 있는 부분을 확인해 보았습니다.
H-1(harness의 키 유출 경로)에 대해서는 읽기 거부 목록(Read deny list)을 확인했습니다.
$ python3 -c "import json;d=json.load(open('.claude/settings.json'));print(d['permissions']['deny'])"
출력에 Read(**/*.env)
・Read(**/broker_secrets.env)
・Read(**/github_variables.env)
가 포함되어 있었습니다. 파일 생성 측에도 ( umask 077; : > "$_broker_out" )와 chmod 600 "$_broker_out"가 모두 포함되어 있습니다. 주석에 따르면, umask는 신규 생성 시에만 적용되므로, 동일 컨테이너의 이전 세션이 644 권한으로 만든 파일이 남아 있는 경우를 대비해 chmod도 명시적으로 지정했다고 합니다. 이는 지적된 fix란에 작성된 내용과 일치합니다.
M-3(보안 헤더 미설정)에 대해서는 배포 중인 앱을 호출해 보았습니다.
$ curl -sI https://github-issue-shortcut.kinamocchi-tech.workers.dev/
Content-Security-Policy: default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; img-src 'self' data: https://avatars.githubusercontent.com가 반환되었으며, X-Frame-Options: DENY / Referrer-Policy: same-origin / X-Content-Type-Options: nosniff도 포함되어 있었습니다. 지적된 3가지 헤더가 모두 들어가 있었습니다.
지적 → 수정 → 운영 환경에서 확인까지의 과정이 외부에서 추적 가능한 상태로 되어 있다는 점이 공개 리포지토리(Public Repository)의 강점이라고 생각합니다.
19일 동안 103개의 커밋을 달성한 속도 그 자체보다, 그 속도로 작성된 코드를 어떻게 검증하고 있는지가 더 참고가 되었습니다. 제가 읽은 범위 내에서 효과적이었던 점은 다음 3가지입니다.
1. 구조적으로 독립성을 확보한다. "5가지 관점에서 리뷰해줘"라고 한 번 요청하는 것과, 5개의 역할(Role)에게 독립적으로 작성하게 한 뒤 대조하는 것은 나오는 지적 사항의 중복도 측면에서 차이가 납니다. 1개의 포스트에 1개의 파일을 할당하여 충돌하지 않는 저장 공간을 마련해 두면, 병렬로 실행하더라도 기록이 망가지지 않습니다.
2. 반드시 반론(Counter-argument) 단계를 거친다. 과도한 지적을 걸러내는 것은 추가 체크리스트가 아니라, 다른 역할로부터의 반론이었습니다. 실제로 M-2의 등급 상향은 반론 단계에서 멈췄습니다. 지적의 양이 아니라, 지적이 반론을 뚫고 살아남았느냐가 신뢰도가 됩니다.
3. 대립의 해소 이유를 남긴다. 다수결로 없애지 않고, 사회자 역할이 이유를 담아 판결한 기록이 남아 있기 때문에, 나중에 "왜 이것이 high 등급인가"를 다시 읽어볼 수 있습니다. 리뷰 결과가 단순한 이슈(Issue) 목록이 아니라 토론의 기록으로 남아 있다는 가치는 생각보다 컸습니다.
그리고, 리뷰 대상에 AI 자신의 실행 환경을 포함하는 것. H-1이 보여준 것은, 운영 코드(Production Code)만 보고 있으면 에이전트에게 부여한 권한 자체가 취약점이 될 수 있다는 사실을 깨닫지 못한다는 점이었습니다. 프롬프트 인젝션(Prompt Injection)이 성립된다는 전제의 공격은 코드 리뷰의 관점만으로는 도출되지 않습니다.
코드와 토론 로그 모두 MIT 라이선스로 공개되어 있습니다. 이슈(Issue)와 PR은 환영합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기